mirror of
https://github.com/sunnypilot/sunnypilot.git
synced 2026-08-05 02:35:41 +08:00
Merge remote-tracking branch 'sunnypilot/sunnypilot/master' into hkg-angle-steering-2025
This commit is contained in:
@@ -107,7 +107,6 @@ jobs:
|
||||
|
||||
build_mac:
|
||||
name: build macOS
|
||||
if: false # temp disable since gcc-arm-embedded install is getting stuck due to checksum mismatch
|
||||
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' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:434a720871336d359378beff5ebff3f9fd654d958693d272c7c6f2e271c7e41c
|
||||
size 47676
|
||||
@@ -19,6 +19,9 @@ from openpilot.system.ui.widgets.list_view import text_item, button_item, dual_b
|
||||
from openpilot.system.ui.widgets.option_dialog import MultiOptionDialog
|
||||
from openpilot.system.ui.widgets.scroller_tici import Scroller
|
||||
|
||||
if gui_app.sunnypilot_ui():
|
||||
from openpilot.system.ui.sunnypilot.widgets.list_view import button_item_sp as button_item
|
||||
|
||||
# Description constants
|
||||
DESCRIPTIONS = {
|
||||
'pair_device': tr_noop("Pair your device with comma connect (connect.comma.ai) and claim your comma prime offer."),
|
||||
|
||||
@@ -11,6 +11,9 @@ from openpilot.system.ui.widgets.list_view import button_item, text_item, ListIt
|
||||
from openpilot.system.ui.widgets.option_dialog import MultiOptionDialog
|
||||
from openpilot.system.ui.widgets.scroller_tici import Scroller
|
||||
|
||||
if gui_app.sunnypilot_ui():
|
||||
from openpilot.system.ui.sunnypilot.widgets.list_view import button_item_sp as button_item
|
||||
|
||||
# TODO: remove this. updater fails to respond on startup if time is not correct
|
||||
UPDATED_TIMEOUT = 10 # seconds to wait for updated to respond
|
||||
|
||||
|
||||
@@ -4,24 +4,252 @@ 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.common.params import Params
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import pyray as rl
|
||||
|
||||
from cereal import custom
|
||||
from openpilot.common.constants import CV
|
||||
from openpilot.selfdrive.ui.ui_state import device, ui_state
|
||||
from openpilot.system.ui.lib.multilang import tr
|
||||
from openpilot.system.ui.lib.application import gui_app
|
||||
from openpilot.system.ui.widgets import DialogResult, Widget
|
||||
from openpilot.system.ui.widgets.confirm_dialog import alert_dialog, ConfirmDialog
|
||||
from openpilot.system.ui.widgets.scroller_tici import Scroller
|
||||
from openpilot.system.ui.widgets import Widget
|
||||
from openpilot.system.ui.widgets.toggle import ON_COLOR
|
||||
|
||||
from openpilot.sunnypilot.models.runners.constants import CUSTOM_MODEL_PATH
|
||||
from openpilot.system.ui.sunnypilot.lib.styles import style
|
||||
from openpilot.system.ui.sunnypilot.widgets.list_view import ButtonActionSP, ListItemSP, toggle_item_sp, option_item_sp
|
||||
from openpilot.system.ui.sunnypilot.widgets.progress_bar import progress_item
|
||||
from openpilot.system.ui.sunnypilot.widgets.tree_dialog import TreeOptionDialog, TreeNode, TreeFolder
|
||||
|
||||
if gui_app.sunnypilot_ui():
|
||||
from openpilot.system.ui.sunnypilot.widgets.list_view import button_item_sp as button_item
|
||||
|
||||
|
||||
class ModelAction(ButtonActionSP):
|
||||
def get_width_hint(self):
|
||||
return super().get_width_hint() + 1
|
||||
|
||||
|
||||
class ModelsLayout(Widget):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.model_manager = None
|
||||
self.download_status = None
|
||||
self.prev_download_status = None
|
||||
self.model_dialog = None
|
||||
self.last_cache_calc_time = 0
|
||||
|
||||
self._params = Params()
|
||||
items = self._initialize_items()
|
||||
self._scroller = Scroller(items, line_separator=True, spacing=0)
|
||||
self._initialize_items()
|
||||
|
||||
self.clear_cache_item.action_item.set_value(f"{self._calculate_cache_size():.2f} MB")
|
||||
for ctrl, key in [(self.lane_turn_value_control, "LaneTurnValue"), (self.delay_control, "LagdToggleDelay")]:
|
||||
ctrl.action_item.set_value(int(float(ui_state.params.get(key, return_default=True)) * 100))
|
||||
|
||||
self._scroller = Scroller(self.items, line_separator=True, spacing=0)
|
||||
|
||||
def _initialize_items(self):
|
||||
items = [
|
||||
self.current_model_item = ListItemSP(
|
||||
title=tr("Current Model"),
|
||||
description="",
|
||||
action_item=ModelAction(tr("SELECT")),
|
||||
callback=self._handle_current_model_clicked
|
||||
)
|
||||
|
||||
]
|
||||
return items
|
||||
self.supercombo_label = progress_item(tr("Driving Model"))
|
||||
self.vision_label = progress_item(tr("Vision Model"))
|
||||
self.policy_label = progress_item(tr("Policy Model"))
|
||||
|
||||
self.refresh_item = button_item(tr("Refresh Model List"), tr("REFRESH"), "",
|
||||
lambda: (ui_state.params.put("ModelManager_LastSyncTime", 0),
|
||||
gui_app.set_modal_overlay(alert_dialog(tr("Fetching Latest Models")))))
|
||||
|
||||
self.clear_cache_item = ListItemSP(
|
||||
title=tr("Clear Model Cache"),
|
||||
description="",
|
||||
action_item=ModelAction(tr("CLEAR")),
|
||||
callback=self._clear_cache
|
||||
)
|
||||
|
||||
self.cancel_download_item = button_item(tr("Cancel Download"), tr("Cancel"), "", lambda: ui_state.params.remove("ModelManager_DownloadIndex"))
|
||||
|
||||
self.lane_turn_value_control = option_item_sp(tr("Adjust Lane Turn Speed"), "LaneTurnValue", 500, 2000,
|
||||
tr("Set the maximum speed for lane turn desires. Default is 19 mph."),
|
||||
int(round(100 / CV.MPH_TO_KPH)), None, True, "", style.BUTTON_WIDTH, None, True,
|
||||
lambda v: f"{int(round(v / 100 * (CV.MPH_TO_KPH if ui_state.is_metric else 1)))}" +
|
||||
f" {'km/h' if ui_state.is_metric else 'mph'}")
|
||||
|
||||
self.lane_turn_desire_toggle = toggle_item_sp(tr("Use Lane Turn Desires"),
|
||||
tr("If you're driving at 20 mph (32 km/h) or below and have your blinker on," +
|
||||
" the car will plan a turn in that direction at the nearest drivable path. " +
|
||||
"This prevents situations (like at red lights) where the car might plan the wrong turn direction."),
|
||||
param="LaneTurnDesire")
|
||||
|
||||
self.delay_control = option_item_sp(tr("Adjust Software Delay"), "LagdToggleDelay", 5, 50,
|
||||
tr("Adjust the software delay when Live Learning Steer Delay is toggled off. The default software delay value is 0.2"),
|
||||
1, None, True, "", style.BUTTON_WIDTH, None, True, lambda v: f"{v / 100:.2f}s")
|
||||
|
||||
self.lagd_toggle = toggle_item_sp(tr("Live Learning Steer Delay"), "", param="LagdToggle")
|
||||
|
||||
self.items = [self.current_model_item, self.cancel_download_item, self.supercombo_label, self.vision_label,
|
||||
self.policy_label, self.refresh_item, self.clear_cache_item, self.lane_turn_desire_toggle,
|
||||
self.lane_turn_value_control, self.lagd_toggle, self.delay_control]
|
||||
|
||||
def _update_lagd_description(self, lagd_toggle: bool):
|
||||
desc = tr("Enable this for the car to learn and adapt its steering response time. Disable to use a fixed steering response time. " +
|
||||
"Keeping this on provides the stock openpilot experience.")
|
||||
if lagd_toggle:
|
||||
desc += f"<br>{tr('Live Steer Delay:')} {ui_state.sm['liveDelay'].lateralDelay:.3f} s"
|
||||
elif ui_state.CP:
|
||||
sw = float(ui_state.params.get("LagdToggleDelay", "0.2"))
|
||||
cp = ui_state.CP.steerActuatorDelay
|
||||
desc += f"<br>{tr('Actuator Delay:')} {cp:.2f} s + {tr('Software Delay:')} {sw:.2f} s = {tr('Total Delay:')} {cp + sw:.2f} s"
|
||||
self.lagd_toggle.set_description(desc)
|
||||
|
||||
def _is_downloading(self):
|
||||
return (self.model_manager and self.model_manager.selectedBundle and
|
||||
self.model_manager.selectedBundle.status == custom.ModelManagerSP.DownloadStatus.downloading)
|
||||
|
||||
@staticmethod
|
||||
def _calculate_cache_size():
|
||||
cache_size = 0.0
|
||||
if os.path.exists(CUSTOM_MODEL_PATH):
|
||||
cache_size = sum(os.path.getsize(os.path.join(CUSTOM_MODEL_PATH, file)) for file in os.listdir(CUSTOM_MODEL_PATH)) / (1024**2)
|
||||
return cache_size
|
||||
|
||||
def _clear_cache(self):
|
||||
def _callback(response):
|
||||
if response == DialogResult.CONFIRM:
|
||||
ui_state.params.put_bool("ModelManager_ClearCache", True)
|
||||
self.clear_cache_item.action_item.set_value(f"{self._calculate_cache_size():.2f} MB")
|
||||
|
||||
gui_app.set_modal_overlay(ConfirmDialog(tr("This will delete ALL downloaded models from the cache except the currently active model. Are you sure?"),
|
||||
tr("Clear Cache")), callback=_callback)
|
||||
|
||||
def _handle_bundle_download_progress(self):
|
||||
labels = {custom.ModelManagerSP.Model.Type.supercombo: self.supercombo_label,
|
||||
custom.ModelManagerSP.Model.Type.vision: self.vision_label,
|
||||
custom.ModelManagerSP.Model.Type.policy: self.policy_label}
|
||||
for label in labels.values():
|
||||
label.set_visible(False)
|
||||
self.cancel_download_item.set_visible(False)
|
||||
|
||||
if not self.model_manager or (not self.model_manager.selectedBundle and not self.model_manager.activeBundle):
|
||||
return
|
||||
|
||||
bundle = self.model_manager.selectedBundle if self._is_downloading() or (
|
||||
self.model_manager.selectedBundle and self.model_manager.selectedBundle.status == custom.ModelManagerSP.DownloadStatus.failed
|
||||
) else self.model_manager.activeBundle
|
||||
if not bundle:
|
||||
return
|
||||
|
||||
self.download_status = bundle.status
|
||||
status_changed = self.prev_download_status != self.download_status
|
||||
self.prev_download_status = self.download_status
|
||||
|
||||
self.cancel_download_item.set_visible(bool(self.model_manager.selectedBundle) and bool(ui_state.params.get("ModelManager_DownloadIndex")))
|
||||
|
||||
if (current_time := time.monotonic()) - self.last_cache_calc_time > 0.5:
|
||||
self.last_cache_calc_time = current_time
|
||||
self.clear_cache_item.action_item.set_value(f"{self._calculate_cache_size():.2f} MB")
|
||||
|
||||
if self.download_status == custom.ModelManagerSP.DownloadStatus.downloading:
|
||||
device.reset_interactive_timeout()
|
||||
|
||||
for model in bundle.models:
|
||||
if label := labels.get(getattr(model.type, 'raw', model.type)):
|
||||
label.set_visible(True)
|
||||
p = model.artifact.downloadProgress
|
||||
text, show, color = f"pending - {bundle.displayName}", False, rl.GRAY
|
||||
if p.status == custom.ModelManagerSP.DownloadStatus.downloading:
|
||||
text, show = f"{int(p.progress)}% - {bundle.displayName}", True
|
||||
elif p.status in (custom.ModelManagerSP.DownloadStatus.downloaded, custom.ModelManagerSP.DownloadStatus.cached):
|
||||
status_text = tr("from cache" if p.status == custom.ModelManagerSP.DownloadStatus.cached else "downloaded")
|
||||
text, color = f"{bundle.displayName} - {status_text if status_changed else tr('ready')}", ON_COLOR
|
||||
elif p.status == custom.ModelManagerSP.DownloadStatus.failed:
|
||||
text, color = f"download failed - {bundle.displayName}", rl.RED
|
||||
label.action_item.update(p.progress, text, show, color)
|
||||
|
||||
@staticmethod
|
||||
def _show_reset_params_dialog():
|
||||
def _callback(response):
|
||||
if response == DialogResult.CONFIRM:
|
||||
ui_state.params.remove("CalibrationParams")
|
||||
ui_state.params.remove("LiveTorqueParameters")
|
||||
msg = tr("Model download has started in the background. We suggest resetting calibration. Would you like to do that now?")
|
||||
gui_app.set_modal_overlay(ConfirmDialog(msg, tr("Reset Calibration")), callback=_callback)
|
||||
|
||||
def _on_model_selected(self, result):
|
||||
if result != DialogResult.CONFIRM:
|
||||
return
|
||||
selected_ref = self.model_dialog.selection_ref
|
||||
if selected_ref == "Default":
|
||||
ui_state.params.remove("ModelManager_ActiveBundle")
|
||||
self._show_reset_params_dialog()
|
||||
elif selected_bundle := next((bundle for bundle in self.model_manager.availableBundles if bundle.ref == selected_ref), None):
|
||||
ui_state.params.put("ModelManager_DownloadIndex", selected_bundle.index)
|
||||
if self.model_manager.activeBundle and selected_bundle.generation != self.model_manager.activeBundle.generation:
|
||||
self._show_reset_params_dialog()
|
||||
self.model_dialog = None
|
||||
|
||||
@staticmethod
|
||||
def _bundle_to_node(bundle):
|
||||
return TreeNode(bundle.ref, {'display_name': bundle.displayName, 'short_name': bundle.internalName})
|
||||
|
||||
def _get_folders(self, favorites):
|
||||
bundles = self.model_manager.availableBundles
|
||||
folders = {}
|
||||
for bundle in bundles:
|
||||
folders.setdefault(next((ov_ride.value for ov_ride in bundle.overrides if ov_ride.key == "folder"), ""), []).append(bundle)
|
||||
|
||||
folders_list = [TreeFolder("", [TreeNode("Default", {'display_name': tr("Default Model"), 'short_name': "Default"})])]
|
||||
for folder, folder_bundles in sorted(folders.items(), key=lambda x: max((bundle.index for bundle in x[1]), default=-1), reverse=True):
|
||||
folder_bundles.sort(key=lambda bundle: bundle.index, reverse=True)
|
||||
name = folder + (f" - (Updated: {m.group(1)})" if folder_bundles and (m := re.search(r'\(([^)]*)\)[^(]*$', folder_bundles[0].displayName)) else "")
|
||||
folders_list.append(TreeFolder(name, [self._bundle_to_node(bundle) for bundle in folder_bundles]))
|
||||
|
||||
if favorites and (fav_bundles := [bundle for bundle in bundles if bundle.ref in favorites]):
|
||||
folders_list.insert(1, TreeFolder("Favorites", [self._bundle_to_node(bundle) for bundle in fav_bundles]))
|
||||
return folders_list
|
||||
|
||||
def _handle_current_model_clicked(self):
|
||||
favs = ui_state.params.get("ModelManager_Favs")
|
||||
favorites = set(favs.split(';')) if favs else set()
|
||||
folders_list = self._get_folders(favorites)
|
||||
|
||||
active_ref = self.model_manager.activeBundle.ref if self.model_manager.activeBundle else "Default"
|
||||
self.model_dialog = TreeOptionDialog(tr("Select a Model"), folders_list, active_ref, "ModelManager_Favs",
|
||||
get_folders_fn=self._get_folders, on_exit=self._on_model_selected)
|
||||
gui_app.set_modal_overlay(self.model_dialog, callback=self._on_model_selected)
|
||||
|
||||
def _update_state(self):
|
||||
advanced_controls: bool = ui_state.params.get_bool("ShowAdvancedControls")
|
||||
turn_desire: bool = ui_state.params.get_bool("LaneTurnDesire")
|
||||
live_delay: bool = ui_state.params.get_bool("LagdToggle")
|
||||
|
||||
self.lane_turn_desire_toggle.action_item.set_state(turn_desire)
|
||||
self.lane_turn_value_control.set_visible(turn_desire and advanced_controls)
|
||||
self.lagd_toggle.action_item.set_state(live_delay)
|
||||
self.delay_control.set_visible(not live_delay and advanced_controls)
|
||||
new_step = int(round(100 / CV.MPH_TO_KPH)) if ui_state.is_metric else 100
|
||||
if self.lane_turn_value_control.action_item.value_change_step != new_step:
|
||||
self.lane_turn_value_control.action_item.value_change_step = new_step
|
||||
|
||||
self._update_lagd_description(live_delay)
|
||||
self.model_manager = ui_state.sm["modelManagerSP"]
|
||||
self._handle_bundle_download_progress()
|
||||
active_name = self.model_manager.activeBundle.internalName if self.model_manager and self.model_manager.activeBundle.ref else tr("Default Model")
|
||||
self.current_model_item.action_item.set_value(active_name)
|
||||
|
||||
if not ui_state.is_offroad():
|
||||
self.current_model_item.action_item.set_enabled(False)
|
||||
self.current_model_item.set_description(tr("Only available when vehicle is off, or always offroad mode is on"))
|
||||
else:
|
||||
self.current_model_item.action_item.set_enabled(True)
|
||||
self.current_model_item.set_description("")
|
||||
|
||||
def _render(self, rect):
|
||||
self._scroller.render(rect)
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
"""
|
||||
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 threading
|
||||
import time
|
||||
import pyray as rl
|
||||
|
||||
from openpilot.system.ui.lib.multilang import tr
|
||||
from openpilot.system.ui.widgets.button import Button, ButtonStyle
|
||||
from openpilot.system.ui.widgets.network import NetworkUI, PanelType
|
||||
|
||||
|
||||
class NetworkUISP(NetworkUI):
|
||||
def __init__(self, wifi_manager):
|
||||
super().__init__(wifi_manager)
|
||||
|
||||
self.scan_button = Button(tr("Scan"), self._scan_clicked, button_style=ButtonStyle.NORMAL, font_size=60, border_radius=30)
|
||||
self.scan_button.set_rect(rl.Rectangle(0, 0, 400, 100))
|
||||
|
||||
self._scanning = False
|
||||
self._wifi_manager.add_callbacks(networks_updated=self._on_networks_updated)
|
||||
|
||||
def _scan_clicked(self):
|
||||
self._scanning = True
|
||||
self.scan_button.set_text(tr("Scanning..."))
|
||||
self.scan_button.set_enabled(False)
|
||||
|
||||
threading.Thread(target=self._wifi_manager._update_networks, daemon=True).start()
|
||||
self._wifi_manager._request_scan()
|
||||
self._wifi_manager._last_network_update = time.monotonic()
|
||||
|
||||
def _on_networks_updated(self, networks):
|
||||
if self._scanning:
|
||||
self._scanning = False
|
||||
self.scan_button.set_text(tr("Scan"))
|
||||
self.scan_button.set_enabled(True)
|
||||
|
||||
def _render(self, rect: rl.Rectangle):
|
||||
super()._render(rect)
|
||||
|
||||
if self._current_panel == PanelType.WIFI:
|
||||
self.scan_button.set_position(self._rect.x, self._rect.y + 20)
|
||||
self.scan_button.render()
|
||||
@@ -19,10 +19,10 @@ 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.widgets.network import NetworkUI
|
||||
from openpilot.system.ui.lib.wifi_manager import WifiManager
|
||||
from openpilot.system.ui.widgets import Widget
|
||||
from openpilot.selfdrive.ui.sunnypilot.layouts.settings.models import ModelsLayout
|
||||
from openpilot.selfdrive.ui.sunnypilot.layouts.settings.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.trips import TripsLayout
|
||||
@@ -111,8 +111,8 @@ class SettingsLayoutSP(OP.SettingsLayout):
|
||||
|
||||
self._panels = {
|
||||
OP.PanelType.DEVICE: PanelInfo(tr_noop("Device"), DeviceLayoutSP(), icon="../../sunnypilot/selfdrive/assets/offroad/icon_home.png"),
|
||||
OP.PanelType.NETWORK: PanelInfo(tr_noop("Network"), NetworkUI(wifi_manager), icon="icons/network.png"),
|
||||
OP.PanelType.SUNNYLINK: PanelInfo(tr_noop("sunnylink"), SunnylinkLayout(), icon="icons/shell.png"),
|
||||
OP.PanelType.NETWORK: PanelInfo(tr_noop("Network"), NetworkUISP(wifi_manager), icon="icons/network.png"),
|
||||
OP.PanelType.SUNNYLINK: PanelInfo(tr_noop("sunnylink"), SunnylinkLayout(), icon="icons/wifi_strength_full.png"),
|
||||
OP.PanelType.TOGGLES: PanelInfo(tr_noop("Toggles"), TogglesLayout(), icon="../../sunnypilot/selfdrive/assets/offroad/icon_toggle.png"),
|
||||
OP.PanelType.SOFTWARE: PanelInfo(tr_noop("Software"), SoftwareLayout(), icon="../../sunnypilot/selfdrive/assets/offroad/icon_software.png"),
|
||||
OP.PanelType.MODELS: PanelInfo(tr_noop("Models"), ModelsLayout(), icon="../../sunnypilot/selfdrive/assets/offroad/icon_models.png"),
|
||||
|
||||
@@ -4,27 +4,340 @@ 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.common.params import Params
|
||||
from openpilot.system.ui.widgets.scroller_tici import Scroller
|
||||
from openpilot.system.ui.widgets import Widget
|
||||
from cereal import custom
|
||||
from openpilot.selfdrive.ui.ui_state import ui_state
|
||||
from openpilot.sunnypilot.sunnylink.api import UNREGISTERED_SUNNYLINK_DONGLE_ID
|
||||
from openpilot.system.ui.lib.application import gui_app, FontWeight
|
||||
from openpilot.system.ui.lib.multilang import tr
|
||||
from openpilot.system.ui.sunnypilot.widgets.sunnylink_pairing_dialog import SunnylinkPairingDialog
|
||||
from openpilot.system.ui.widgets.button import ButtonStyle, Button
|
||||
from openpilot.system.ui.widgets.confirm_dialog import alert_dialog, ConfirmDialog
|
||||
from openpilot.system.ui.widgets.label import UnifiedLabel
|
||||
from openpilot.system.ui.widgets.list_view import button_item, dual_button_item
|
||||
from openpilot.system.ui.widgets.scroller_tici import Scroller, LineSeparator
|
||||
from openpilot.system.ui.widgets import Widget, DialogResult
|
||||
from openpilot.system.ui.sunnypilot.widgets.list_view import toggle_item_sp
|
||||
import pyray as rl
|
||||
|
||||
if gui_app.sunnypilot_ui():
|
||||
from openpilot.system.ui.sunnypilot.widgets.list_view import button_item_sp as button_item
|
||||
|
||||
|
||||
class SunnylinkHeader(Widget):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
self._title = UnifiedLabel(
|
||||
text="🚀 sunnylink 🚀",
|
||||
font_size=90,
|
||||
font_weight=FontWeight.AUDIOWIDE,
|
||||
text_color=rl.WHITE,
|
||||
alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER,
|
||||
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP,
|
||||
wrap_text=False,
|
||||
elide=False
|
||||
)
|
||||
|
||||
self._description = UnifiedLabel(
|
||||
text=tr("For secure backup, restore, and remote configuration"),
|
||||
font_size=40,
|
||||
font_weight=FontWeight.LIGHT,
|
||||
text_color=rl.Color(0, 255, 0, 255), # Green
|
||||
alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER,
|
||||
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP,
|
||||
wrap_text=True,
|
||||
elide=False
|
||||
)
|
||||
|
||||
self._sponsor_msg = UnifiedLabel(
|
||||
text=tr("Sponsorship isn't required for basic backup/restore") + "\n" +
|
||||
tr("Click the Sponsor button for more details"),
|
||||
font_size=35,
|
||||
font_weight=FontWeight.LIGHT,
|
||||
text_color=rl.Color(255, 165, 0, 255), # Orange
|
||||
alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER,
|
||||
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP,
|
||||
wrap_text=True,
|
||||
elide=False
|
||||
)
|
||||
|
||||
self._padding = 20
|
||||
self._spacing = 10
|
||||
|
||||
def set_parent_rect(self, parent_rect: rl.Rectangle) -> None:
|
||||
super().set_parent_rect(parent_rect)
|
||||
|
||||
content_width = int(parent_rect.width - (self._padding * 2))
|
||||
|
||||
title_height = self._title.get_content_height(content_width)
|
||||
desc_height = self._description.get_content_height(content_width)
|
||||
sponsor_height = self._sponsor_msg.get_content_height(content_width)
|
||||
|
||||
total_height = (self._padding + title_height + self._spacing +
|
||||
desc_height + self._spacing + sponsor_height + self._padding)
|
||||
|
||||
self._rect.width = parent_rect.width
|
||||
self._rect.height = total_height
|
||||
|
||||
def _render(self, rect: rl.Rectangle):
|
||||
content_width = rect.width - (self._padding * 2)
|
||||
current_y = rect.y + self._padding
|
||||
|
||||
# Render title
|
||||
title_height = self._title.get_content_height(int(content_width))
|
||||
title_rect = rl.Rectangle(rect.x + self._padding, current_y, content_width, title_height)
|
||||
self._title.render(title_rect)
|
||||
current_y += title_height + self._spacing
|
||||
|
||||
# Render description
|
||||
desc_height = self._description.get_content_height(int(content_width))
|
||||
desc_rect = rl.Rectangle(rect.x + self._padding, current_y, content_width, desc_height)
|
||||
self._description.render(desc_rect)
|
||||
current_y += desc_height + self._spacing
|
||||
|
||||
# Render sponsor message
|
||||
sponsor_height = self._sponsor_msg.get_content_height(int(content_width))
|
||||
sponsor_rect = rl.Rectangle(rect.x + self._padding, current_y, content_width, sponsor_height)
|
||||
self._sponsor_msg.render(sponsor_rect)
|
||||
|
||||
|
||||
class SunnylinkDescriptionItem(Widget):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._description = UnifiedLabel(
|
||||
text="",
|
||||
font_size=40,
|
||||
font_weight=FontWeight.LIGHT,
|
||||
text_color=rl.WHITE,
|
||||
alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT,
|
||||
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP,
|
||||
wrap_text=True,
|
||||
elide=False,
|
||||
)
|
||||
self._padding = 20
|
||||
|
||||
def set_parent_rect(self, parent_rect: rl.Rectangle) -> None:
|
||||
super().set_parent_rect(parent_rect)
|
||||
desc_height = self._description.get_content_height(int(parent_rect.width)) + self._padding * 2
|
||||
|
||||
self._rect.width = parent_rect.width
|
||||
self._rect.height = desc_height
|
||||
|
||||
def set_text(self, text: str):
|
||||
self._description.set_text(text)
|
||||
|
||||
def set_color(self, color: rl.Color):
|
||||
self._description.set_text_color(color)
|
||||
|
||||
def _render(self, rect: rl.Rectangle):
|
||||
content_width = rect.width - (self._padding * 2)
|
||||
|
||||
desc_height = self._description.get_content_height(int(content_width))
|
||||
desc_rect = rl.Rectangle(rect.x + self._padding, rect.y, content_width, desc_height)
|
||||
self._description.render(desc_rect)
|
||||
|
||||
|
||||
class SunnylinkLayout(Widget):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
self._params = Params()
|
||||
self._sunnylink_pairing_dialog: SunnylinkPairingDialog | None = None
|
||||
self._restore_in_progress = False
|
||||
self._backup_in_progress = False
|
||||
self._sunnylink_enabled = ui_state.params.get("SunnylinkEnabled")
|
||||
|
||||
items = self._initialize_items()
|
||||
self._scroller = Scroller(items, line_separator=True, spacing=0)
|
||||
self._scroller = Scroller(items, line_separator=False, spacing=0)
|
||||
|
||||
def _initialize_items(self):
|
||||
items = [
|
||||
self._sunnylink_toggle = toggle_item_sp(
|
||||
title=tr("Enable sunnylink"),
|
||||
description=tr("This is the master switch, it will allow you to cutoff any sunnylink requests should you want to do that."),
|
||||
param="SunnylinkEnabled",
|
||||
callback=self._sunnylink_toggle_callback
|
||||
)
|
||||
|
||||
self._sunnylink_description = SunnylinkDescriptionItem()
|
||||
self._sunnylink_description.set_visible(False)
|
||||
|
||||
self._sponsor_btn = button_item(
|
||||
title=tr("Sponsor Status"),
|
||||
button_text=tr("SPONSOR"),
|
||||
description=tr(
|
||||
"Become a sponsor of sunnypilot to get early access to sunnylink features when they become available."),
|
||||
callback=lambda: self._handle_pair_btn(False)
|
||||
)
|
||||
self._pair_btn = button_item(
|
||||
title=tr("Pair GitHub Account"),
|
||||
button_text=tr("Not Paired"),
|
||||
description=tr(
|
||||
"Pair your GitHub account to grant your device sponsor benefits, including API access on sunnylink."),
|
||||
callback=lambda: self._handle_pair_btn(True)
|
||||
)
|
||||
self._sunnylink_uploader_toggle = toggle_item_sp(
|
||||
title=tr("Enable sunnylink uploader (infrastructure test)"),
|
||||
description=tr("Enable sunnylink uploader to allow sunnypilot to upload your driving data to sunnypilot servers. ") +
|
||||
tr("(Only for highest tiers, and does NOT bring ANY benefit to you yet. We are just testing data volume.)"),
|
||||
param="EnableSunnylinkUploader"
|
||||
)
|
||||
self._sunnylink_backup_restore_buttons = dual_button_item(
|
||||
description="",
|
||||
left_text=tr("Backup Settings"),
|
||||
right_text=tr("Restore Settings"),
|
||||
left_callback=self._handle_backup_btn,
|
||||
right_callback=self._handle_restore_btn
|
||||
)
|
||||
self._backup_btn: Button = self._sunnylink_backup_restore_buttons.action_item.left_button # store for easy individual access
|
||||
self._restore_btn: Button = self._sunnylink_backup_restore_buttons.action_item.right_button
|
||||
self._backup_btn.set_button_style(ButtonStyle.NORMAL)
|
||||
self._restore_btn.set_button_style(ButtonStyle.PRIMARY)
|
||||
|
||||
items = [
|
||||
SunnylinkHeader(),
|
||||
LineSeparator(),
|
||||
self._sunnylink_toggle,
|
||||
self._sunnylink_description,
|
||||
LineSeparator(),
|
||||
self._sponsor_btn,
|
||||
LineSeparator(),
|
||||
self._pair_btn,
|
||||
LineSeparator(),
|
||||
self._sunnylink_uploader_toggle,
|
||||
LineSeparator(),
|
||||
self._sunnylink_backup_restore_buttons
|
||||
]
|
||||
return items
|
||||
|
||||
@staticmethod
|
||||
def _get_sunnylink_dongle_id() -> str | None:
|
||||
return str(ui_state.params.get("SunnylinkDongleId") or (lambda: tr("N/A")))
|
||||
|
||||
def _handle_pair_btn(self, sponsor_pairing: bool = False):
|
||||
sunnylink_dongle_id = self._get_sunnylink_dongle_id()
|
||||
if sunnylink_dongle_id == UNREGISTERED_SUNNYLINK_DONGLE_ID:
|
||||
gui_app.set_modal_overlay(alert_dialog(message=tr("sunnylink Dongle ID not found. ") +
|
||||
tr("This may be due to weak internet connection or sunnylink registration issue. ") +
|
||||
tr("Please reboot and try again.")))
|
||||
elif not self._sunnylink_pairing_dialog:
|
||||
self._sunnylink_pairing_dialog = SunnylinkPairingDialog(sponsor_pairing)
|
||||
gui_app.set_modal_overlay(self._sunnylink_pairing_dialog, callback=lambda result: setattr(self, '_sunnylink_pairing_dialog', None))
|
||||
|
||||
def _handle_backup_btn(self):
|
||||
backup_dialog = ConfirmDialog(text=tr("Are you sure you want to backup your current sunnypilot settings?"), confirm_text="Backup")
|
||||
gui_app.set_modal_overlay(backup_dialog, callback=self._backup_handler)
|
||||
|
||||
def _handle_restore_btn(self):
|
||||
self._restore_btn.set_enabled(False)
|
||||
restore_dialog = ConfirmDialog(text=tr("Are you sure you want to restore the last backed up sunnypilot settings?"), confirm_text="Restore")
|
||||
gui_app.set_modal_overlay(restore_dialog, callback=self._restore_handler)
|
||||
|
||||
def _backup_handler(self, dialog_result: int):
|
||||
if dialog_result == DialogResult.CONFIRM:
|
||||
self._backup_in_progress = True
|
||||
self._backup_btn.set_enabled(False)
|
||||
ui_state.params.put_bool("BackupManager_CreateBackup", True)
|
||||
|
||||
def _restore_handler(self, dialog_result: int):
|
||||
if dialog_result == DialogResult.CONFIRM:
|
||||
self._restore_in_progress = True
|
||||
self._restore_btn.set_enabled(False)
|
||||
ui_state.params.put("BackupManager_RestoreVersion", "latest")
|
||||
|
||||
def handle_backup_restore_progress(self):
|
||||
sunnylink_backup_manager = ui_state.sm["backupManagerSP"]
|
||||
|
||||
backup_status = sunnylink_backup_manager.backupStatus
|
||||
restore_status = sunnylink_backup_manager.restoreStatus
|
||||
backup_progress = sunnylink_backup_manager.backupProgress
|
||||
restore_progress = sunnylink_backup_manager.restoreProgress
|
||||
|
||||
if self._backup_in_progress:
|
||||
self._restore_btn.set_enabled(False)
|
||||
self._backup_btn.set_enabled(False)
|
||||
|
||||
if backup_status == custom.BackupManagerSP.Status.inProgress:
|
||||
self._backup_in_progress = True
|
||||
text = tr(f"Backing up {backup_progress}%")
|
||||
self._backup_btn.set_text(text)
|
||||
|
||||
elif backup_status == custom.BackupManagerSP.Status.failed:
|
||||
self._backup_in_progress = False
|
||||
self._backup_btn.set_enabled(not ui_state.is_onroad())
|
||||
self._backup_btn.set_text(tr("Backup Failed"))
|
||||
|
||||
elif (backup_status == custom.BackupManagerSP.Status.completed or
|
||||
(backup_status == custom.BackupManagerSP.Status.idle and backup_progress == 100.0)):
|
||||
self._backup_in_progress = False
|
||||
dialog = alert_dialog(tr("Settings backup completed."))
|
||||
gui_app.set_modal_overlay(dialog)
|
||||
self._backup_btn.set_enabled(not ui_state.is_onroad())
|
||||
|
||||
elif self._restore_in_progress:
|
||||
self._restore_btn.set_enabled(False)
|
||||
self._backup_btn.set_enabled(False)
|
||||
|
||||
if restore_status == custom.BackupManagerSP.Status.inProgress:
|
||||
self._restore_in_progress = True
|
||||
text = tr(f"Restoring {restore_progress}%")
|
||||
self._restore_btn.set_text(text)
|
||||
|
||||
elif restore_status == custom.BackupManagerSP.Status.failed:
|
||||
self._restore_in_progress = False
|
||||
self._restore_btn.set_enabled(not ui_state.is_onroad())
|
||||
self._restore_btn.set_text(tr("Restore Failed"))
|
||||
dialog = alert_dialog(tr("Unable to restore the settings, try again later."))
|
||||
gui_app.set_modal_overlay(dialog)
|
||||
|
||||
elif (restore_status == custom.BackupManagerSP.Status.completed or
|
||||
(restore_status == custom.BackupManagerSP.Status.idle and restore_progress == 100.0)):
|
||||
self._restore_in_progress = False
|
||||
dialog = alert_dialog(tr("Settings restored. Confirm to restart the interface."))
|
||||
gui_app.set_modal_overlay(dialog, callback=lambda: gui_app.request_close())
|
||||
|
||||
else:
|
||||
can_enable = self._sunnylink_enabled and not ui_state.is_onroad()
|
||||
self._backup_btn.set_enabled(can_enable)
|
||||
self._backup_btn.set_text(tr("Backup Settings"))
|
||||
self._restore_btn.set_enabled(can_enable)
|
||||
self._restore_btn.set_text(tr("Restore Settings"))
|
||||
|
||||
def _sunnylink_toggle_callback(self, state: bool):
|
||||
if state:
|
||||
description = tr(
|
||||
"Welcome back!! We're excited to see you've enabled sunnylink again!")
|
||||
color = rl.Color(0, 255, 0, 255) # Green
|
||||
else:
|
||||
description = ("😢 " + tr("Not going to lie, it's sad to see you disabled sunnylink") +
|
||||
tr(", but we'll be here when you're ready to come back."))
|
||||
color = rl.Color(255, 165, 0, 255) # Orange
|
||||
self._sunnylink_description.set_text(description)
|
||||
self._sunnylink_description.set_color(color)
|
||||
self._sunnylink_description.set_visible(True)
|
||||
self._sunnylink_toggle.show_description(False)
|
||||
|
||||
def _update_state(self):
|
||||
super()._update_state()
|
||||
self._sunnylink_enabled = ui_state.params.get_bool("SunnylinkEnabled")
|
||||
self._sunnylink_toggle.set_right_value(tr("Dongle ID") + ": " + self._get_sunnylink_dongle_id())
|
||||
self._sunnylink_toggle.action_item.set_enabled(not ui_state.is_onroad())
|
||||
self._sunnylink_toggle.action_item.set_state(self._sunnylink_enabled)
|
||||
self._sunnylink_uploader_toggle.action_item.set_enabled(self._sunnylink_enabled)
|
||||
self.handle_backup_restore_progress()
|
||||
|
||||
sponsor_btn_text = tr("THANKS ♥") if ui_state.sunnylink_state.is_sponsor() else tr("SPONSOR")
|
||||
tier_name = ui_state.sunnylink_state.get_sponsor_tier().name.capitalize() or tr("Not Sponsor")
|
||||
self._sponsor_btn.action_item.set_text(sponsor_btn_text)
|
||||
self._sponsor_btn.action_item.set_value(tier_name, ui_state.sunnylink_state.get_sponsor_tier_color())
|
||||
self._sponsor_btn.action_item.set_enabled(self._sunnylink_enabled)
|
||||
|
||||
pair_btn_text = tr("Paired") if ui_state.sunnylink_state.is_paired() else tr("Not Paired")
|
||||
self._pair_btn.action_item.set_text(pair_btn_text)
|
||||
self._pair_btn.action_item.set_enabled(self._sunnylink_enabled)
|
||||
|
||||
def _render(self, rect):
|
||||
self._scroller.render(rect)
|
||||
|
||||
def show_event(self):
|
||||
super().show_event()
|
||||
self._scroller.show_event()
|
||||
self._sunnylink_description.set_visible(False)
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
"""
|
||||
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
|
||||
|
||||
This file is part of sunnypilot and is licensed under the MIT License.
|
||||
See the LICENSE.md file in the root directory for more details.
|
||||
"""
|
||||
from openpilot.common.params import Params
|
||||
from openpilot.system.ui.widgets.scroller_tici import Scroller
|
||||
from openpilot.system.ui.widgets import Widget
|
||||
|
||||
|
||||
class VehicleLayout(Widget):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
self._params = Params()
|
||||
items = self._initialize_items()
|
||||
self._scroller = Scroller(items, line_separator=True, spacing=0)
|
||||
|
||||
def _initialize_items(self):
|
||||
items = [
|
||||
|
||||
]
|
||||
return items
|
||||
|
||||
def _render(self, rect):
|
||||
self._scroller.render(rect)
|
||||
|
||||
def show_event(self):
|
||||
self._scroller.show_event()
|
||||
@@ -0,0 +1,67 @@
|
||||
"""
|
||||
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.system.ui.lib.multilang import tr
|
||||
from openpilot.system.ui.widgets import Widget
|
||||
from openpilot.system.ui.widgets.list_view import ButtonAction
|
||||
from openpilot.system.ui.widgets.scroller_tici import Scroller
|
||||
|
||||
from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.factory import BrandSettingsFactory
|
||||
from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.platform_selector import PlatformSelector, LegendWidget
|
||||
from openpilot.selfdrive.ui.ui_state import ui_state
|
||||
from openpilot.system.ui.sunnypilot.widgets.list_view import ListItemSP
|
||||
|
||||
|
||||
class VehicleLayout(Widget):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._brand_settings = None
|
||||
self._brand_items = []
|
||||
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")),
|
||||
callback=self._platform_selector._on_clicked)
|
||||
self._vehicle_item.title_color = self._platform_selector.color
|
||||
self._legend_widget = LegendWidget(self._platform_selector)
|
||||
|
||||
self.items = [self._vehicle_item, self._legend_widget]
|
||||
self._scroller = Scroller(self.items, line_separator=True, spacing=0)
|
||||
|
||||
@staticmethod
|
||||
def get_brand():
|
||||
if bundle := ui_state.params.get("CarPlatformBundle"):
|
||||
return bundle.get("brand", "")
|
||||
elif ui_state.CP and ui_state.CP.carFingerprint != "MOCK":
|
||||
return ui_state.CP.brand
|
||||
return ""
|
||||
|
||||
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")
|
||||
self._vehicle_item.action_item.set_text(vehicle_text)
|
||||
|
||||
brand = self.get_brand()
|
||||
if brand != self._current_brand:
|
||||
self._current_brand = brand
|
||||
self._brand_settings = BrandSettingsFactory.create_brand_settings(brand)
|
||||
self._brand_items = self._brand_settings.items if self._brand_settings else []
|
||||
|
||||
self.items = [self._vehicle_item, self._legend_widget] + self._brand_items
|
||||
self._scroller = Scroller(self.items, line_separator=True, spacing=0)
|
||||
|
||||
def _update_state(self):
|
||||
self._update_brand_settings()
|
||||
if self._brand_settings:
|
||||
self._brand_settings.update_settings()
|
||||
self._platform_selector.refresh()
|
||||
|
||||
def _render(self, rect):
|
||||
self._scroller.render(rect)
|
||||
|
||||
def show_event(self):
|
||||
self._scroller.show_event()
|
||||
@@ -0,0 +1,16 @@
|
||||
"""
|
||||
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 abc
|
||||
|
||||
|
||||
class BrandSettings(abc.ABC):
|
||||
def __init__(self):
|
||||
self.items = []
|
||||
|
||||
@abc.abstractmethod
|
||||
def update_settings(self) -> None:
|
||||
"""Update the settings based on the current vehicle brand."""
|
||||
@@ -0,0 +1,15 @@
|
||||
"""
|
||||
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.layouts.settings.vehicle.brands.base import BrandSettings
|
||||
|
||||
|
||||
class BodySettings(BrandSettings):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
def update_settings(self):
|
||||
pass
|
||||
@@ -0,0 +1,15 @@
|
||||
"""
|
||||
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.layouts.settings.vehicle.brands.base import BrandSettings
|
||||
|
||||
|
||||
class ChryslerSettings(BrandSettings):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
def update_settings(self):
|
||||
pass
|
||||
@@ -0,0 +1,45 @@
|
||||
"""
|
||||
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.layouts.settings.vehicle.brands.base import BrandSettings
|
||||
from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.body import BodySettings
|
||||
from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.chrysler import ChryslerSettings
|
||||
from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.ford import FordSettings
|
||||
from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.gm import GMSettings
|
||||
from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.honda import HondaSettings
|
||||
from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.hyundai import HyundaiSettings
|
||||
from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.mazda import MazdaSettings
|
||||
from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.nissan import NissanSettings
|
||||
from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.psa import PSASettings
|
||||
from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.rivian import RivianSettings
|
||||
from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.subaru import SubaruSettings
|
||||
from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.tesla import TeslaSettings
|
||||
from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.toyota import ToyotaSettings
|
||||
from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.volkswagen import VolkswagenSettings
|
||||
|
||||
|
||||
class BrandSettingsFactory:
|
||||
_BRAND_MAP: dict[str, type[BrandSettings]] = {
|
||||
"body": BodySettings,
|
||||
"chrysler": ChryslerSettings,
|
||||
"ford": FordSettings,
|
||||
"gm": GMSettings,
|
||||
"honda": HondaSettings,
|
||||
"hyundai": HyundaiSettings,
|
||||
"mazda": MazdaSettings,
|
||||
"nissan": NissanSettings,
|
||||
"psa": PSASettings,
|
||||
"rivian": RivianSettings,
|
||||
"subaru": SubaruSettings,
|
||||
"tesla": TeslaSettings,
|
||||
"toyota": ToyotaSettings,
|
||||
"volkswagen": VolkswagenSettings,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def create_brand_settings(brand: str) -> BrandSettings | None:
|
||||
cls = BrandSettingsFactory._BRAND_MAP.get(brand)
|
||||
return cls() if cls is not None else None
|
||||
@@ -0,0 +1,15 @@
|
||||
"""
|
||||
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.layouts.settings.vehicle.brands.base import BrandSettings
|
||||
|
||||
|
||||
class FordSettings(BrandSettings):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
def update_settings(self):
|
||||
pass
|
||||
@@ -0,0 +1,15 @@
|
||||
"""
|
||||
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.layouts.settings.vehicle.brands.base import BrandSettings
|
||||
|
||||
|
||||
class GMSettings(BrandSettings):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
def update_settings(self):
|
||||
pass
|
||||
@@ -0,0 +1,15 @@
|
||||
"""
|
||||
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.layouts.settings.vehicle.brands.base import BrandSettings
|
||||
|
||||
|
||||
class HondaSettings(BrandSettings):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
def update_settings(self):
|
||||
pass
|
||||
@@ -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.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.base import BrandSettings
|
||||
from openpilot.selfdrive.ui.ui_state import ui_state
|
||||
from openpilot.system.ui.lib.multilang import tr
|
||||
from openpilot.system.ui.sunnypilot.widgets.list_view import multiple_button_item_sp
|
||||
from opendbc.car.hyundai.values import CAR, CANFD_UNSUPPORTED_LONGITUDINAL_CAR, UNSUPPORTED_LONGITUDINAL_CAR
|
||||
|
||||
|
||||
class HyundaiSettings(BrandSettings):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.alpha_long_available = False
|
||||
|
||||
tuning_texts = [tr("Off"), tr("Dynamic"), tr("Predictive")]
|
||||
self.longitudinal_tuning_item = multiple_button_item_sp(tr("Custom Longitudinal Tuning"), "", tuning_texts,
|
||||
button_width=300, callback=self._on_tuning_selected,
|
||||
param="HyundaiLongitudinalTuning", inline=False)
|
||||
self.items = [self.longitudinal_tuning_item]
|
||||
|
||||
@staticmethod
|
||||
def _on_tuning_selected(index):
|
||||
ui_state.params.put("HyundaiLongitudinalTuning", index)
|
||||
|
||||
def update_settings(self):
|
||||
self.alpha_long_available = False
|
||||
bundle = ui_state.params.get("CarPlatformBundle")
|
||||
if bundle:
|
||||
platform = bundle.get("platform")
|
||||
self.alpha_long_available = CAR[platform] not in (UNSUPPORTED_LONGITUDINAL_CAR | CANFD_UNSUPPORTED_LONGITUDINAL_CAR)
|
||||
elif ui_state.CP:
|
||||
self.alpha_long_available = ui_state.CP.alphaLongitudinalAvailable
|
||||
|
||||
tuning_param = int(ui_state.params.get("HyundaiLongitudinalTuning") or "0")
|
||||
long_enabled = ui_state.has_longitudinal_control
|
||||
|
||||
long_tuning_descs = [
|
||||
tr("Your vehicle will use the Default longitudinal tuning."),
|
||||
tr("Your vehicle will use the Dynamic longitudinal tuning."),
|
||||
tr("Your vehicle will use the Predictive longitudinal tuning."),
|
||||
]
|
||||
long_tuning_desc = long_tuning_descs[tuning_param] if tuning_param < len(long_tuning_descs) else long_tuning_descs[0]
|
||||
|
||||
longitudinal_tuning_disabled = not ui_state.is_offroad() or not long_enabled
|
||||
if longitudinal_tuning_disabled:
|
||||
if not ui_state.is_offroad():
|
||||
long_tuning_desc = tr("This feature is unavailable while the car is onroad.")
|
||||
elif not long_enabled:
|
||||
long_tuning_desc = tr("This feature is unavailable because sunnypilot Longitudinal Control (Alpha) is not enabled.")
|
||||
|
||||
self.longitudinal_tuning_item.action_item.set_enabled(not longitudinal_tuning_disabled)
|
||||
self.longitudinal_tuning_item.set_description(long_tuning_desc)
|
||||
self.longitudinal_tuning_item.show_description(True)
|
||||
self.longitudinal_tuning_item.action_item.set_selected_button(tuning_param)
|
||||
self.longitudinal_tuning_item.set_visible(self.alpha_long_available)
|
||||
@@ -0,0 +1,15 @@
|
||||
"""
|
||||
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.layouts.settings.vehicle.brands.base import BrandSettings
|
||||
|
||||
|
||||
class MazdaSettings(BrandSettings):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
def update_settings(self):
|
||||
pass
|
||||
@@ -0,0 +1,15 @@
|
||||
"""
|
||||
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.layouts.settings.vehicle.brands.base import BrandSettings
|
||||
|
||||
|
||||
class NissanSettings(BrandSettings):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
def update_settings(self):
|
||||
pass
|
||||
@@ -0,0 +1,15 @@
|
||||
"""
|
||||
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.layouts.settings.vehicle.brands.base import BrandSettings
|
||||
|
||||
|
||||
class PSASettings(BrandSettings):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
def update_settings(self):
|
||||
pass
|
||||
@@ -0,0 +1,15 @@
|
||||
"""
|
||||
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.layouts.settings.vehicle.brands.base import BrandSettings
|
||||
|
||||
|
||||
class RivianSettings(BrandSettings):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
def update_settings(self):
|
||||
pass
|
||||
@@ -0,0 +1,54 @@
|
||||
"""
|
||||
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.layouts.settings.vehicle.brands.base import BrandSettings
|
||||
from openpilot.selfdrive.ui.ui_state import ui_state
|
||||
from openpilot.system.ui.lib.multilang import tr
|
||||
from openpilot.system.ui.sunnypilot.widgets.list_view import toggle_item_sp
|
||||
from opendbc.car.subaru.values import CAR, SubaruFlags
|
||||
|
||||
|
||||
class SubaruSettings(BrandSettings):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.has_stop_and_go = False
|
||||
|
||||
self.stop_and_go_toggle = toggle_item_sp(tr("Stop and Go (Beta)"), "", param="SubaruStopAndGo", callback=self._on_toggle_changed)
|
||||
|
||||
self.stop_and_go_manual_parking_brake_toggle = toggle_item_sp(tr("Stop and Go for Manual Parking Brake (Beta)"), "",
|
||||
param="SubaruStopAndGoManualParkingBrake", callback=self._on_toggle_changed)
|
||||
|
||||
self.items = [self.stop_and_go_toggle, self.stop_and_go_manual_parking_brake_toggle]
|
||||
|
||||
def _on_toggle_changed(self, _):
|
||||
self.update_settings()
|
||||
|
||||
def stop_and_go_disabled_msg(self):
|
||||
if not self.has_stop_and_go:
|
||||
return tr("This feature is currently not available on this platform.")
|
||||
elif not ui_state.is_offroad():
|
||||
return tr("Enable \"Always Offroad\" in Device panel, or turn vehicle off to toggle.")
|
||||
return ""
|
||||
|
||||
def update_settings(self):
|
||||
bundle = ui_state.params.get("CarPlatformBundle")
|
||||
if bundle:
|
||||
platform = bundle.get("platform")
|
||||
config = CAR[platform].config
|
||||
self.has_stop_and_go = not (config.flags & (SubaruFlags.GLOBAL_GEN2 | SubaruFlags.HYBRID))
|
||||
elif ui_state.CP:
|
||||
self.has_stop_and_go = not (ui_state.CP.flags & (SubaruFlags.GLOBAL_GEN2 | SubaruFlags.HYBRID))
|
||||
|
||||
disabled_msg = self.stop_and_go_disabled_msg()
|
||||
descriptions = [
|
||||
tr("Experimental feature to enable auto-resume during stop-and-go for certain supported Subaru platforms."),
|
||||
tr("Experimental feature to enable stop and go for Subaru Global models with manual handbrake. " +
|
||||
"Models with electric parking brake should keep this disabled. Thanks to martinl for this implementation!")
|
||||
]
|
||||
|
||||
for toggle, desc in zip([self.stop_and_go_toggle, self.stop_and_go_manual_parking_brake_toggle], descriptions, strict=True):
|
||||
toggle.action_item.set_enabled(self.has_stop_and_go and ui_state.is_offroad())
|
||||
toggle.set_description(f"<b>{disabled_msg}</b><br><br>{desc}" if disabled_msg else desc)
|
||||
@@ -0,0 +1,43 @@
|
||||
"""
|
||||
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.layouts.settings.vehicle.brands.base import BrandSettings
|
||||
from openpilot.selfdrive.ui.ui_state import ui_state
|
||||
from openpilot.system.ui.lib.multilang import tr
|
||||
from openpilot.system.ui.sunnypilot.widgets.list_view import toggle_item_sp
|
||||
|
||||
COOP_STEERING_MIN_KMH = 23
|
||||
OEM_STEERING_MIN_KMH = 48
|
||||
KM_TO_MILE = 0.621371
|
||||
|
||||
|
||||
class TeslaSettings(BrandSettings):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.coop_steering_toggle = toggle_item_sp(tr("Cooperative Steering (Beta)"), "", param="TeslaCoopSteering")
|
||||
self.items = [self.coop_steering_toggle]
|
||||
|
||||
def update_settings(self):
|
||||
is_metric = ui_state.is_metric
|
||||
unit = "km/h" if is_metric else "mph"
|
||||
|
||||
display_value_coop = COOP_STEERING_MIN_KMH if is_metric else round(COOP_STEERING_MIN_KMH * KM_TO_MILE)
|
||||
display_value_oem = OEM_STEERING_MIN_KMH if is_metric else round(OEM_STEERING_MIN_KMH * KM_TO_MILE)
|
||||
|
||||
coop_steering_disabled_msg = tr("Enable \"Always Offroad\" in Device panel, or turn vehicle off to toggle.")
|
||||
coop_steering_warning = tr(f"Warning: May experience steering oscillations below {display_value_oem} {unit} during turns, " +
|
||||
"recommend disabling this feature if you experience these.")
|
||||
coop_steering_desc = (
|
||||
f"<b>{coop_steering_warning}</b><br><br>" +
|
||||
f"{tr('Allows the driver to provide limited steering input while openpilot is engaged.')}<br>" +
|
||||
f"{tr(f'Only works above {display_value_coop} {unit}.')}"
|
||||
)
|
||||
|
||||
if not ui_state.is_offroad():
|
||||
coop_steering_desc = f"<b>{coop_steering_disabled_msg}</b><br><br>{coop_steering_desc}"
|
||||
|
||||
self.coop_steering_toggle.set_description(coop_steering_desc)
|
||||
self.coop_steering_toggle.action_item.set_enabled(ui_state.is_offroad())
|
||||
@@ -0,0 +1,15 @@
|
||||
"""
|
||||
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.layouts.settings.vehicle.brands.base import BrandSettings
|
||||
|
||||
|
||||
class ToyotaSettings(BrandSettings):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
def update_settings(self):
|
||||
pass
|
||||
@@ -0,0 +1,15 @@
|
||||
"""
|
||||
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.layouts.settings.vehicle.brands.base import BrandSettings
|
||||
|
||||
|
||||
class VolkswagenSettings(BrandSettings):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
def update_settings(self):
|
||||
pass
|
||||
@@ -0,0 +1,138 @@
|
||||
"""
|
||||
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
|
||||
|
||||
This file is part of sunnypilot and is licensed under the MIT License.
|
||||
See the LICENSE.md file in the root directory for more details.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import pyray as rl
|
||||
from collections.abc import Callable
|
||||
from functools import partial
|
||||
|
||||
from openpilot.common.basedir import BASEDIR
|
||||
from openpilot.system.ui.lib.application import gui_app, FontWeight
|
||||
from openpilot.system.ui.lib.multilang import tr
|
||||
from openpilot.system.ui.widgets import DialogResult, Widget
|
||||
from openpilot.system.ui.widgets.button import Button, ButtonStyle
|
||||
from openpilot.system.ui.widgets.confirm_dialog import ConfirmDialog
|
||||
|
||||
from openpilot.system.ui.sunnypilot.lib.styles import style
|
||||
from openpilot.system.ui.sunnypilot.widgets.tree_dialog import TreeOptionDialog, TreeNode, TreeFolder
|
||||
from openpilot.selfdrive.ui.ui_state import ui_state
|
||||
|
||||
CAR_LIST_JSON_OUT = os.path.join(BASEDIR, "sunnypilot", "selfdrive", "car", "car_list.json")
|
||||
|
||||
|
||||
class LegendWidget(Widget):
|
||||
def __init__(self, platform_selector):
|
||||
super().__init__()
|
||||
self.set_rect(rl.Rectangle(0, 0, 0, 350))
|
||||
self._platform_selector = platform_selector
|
||||
self._font = gui_app.font(FontWeight.NORMAL)
|
||||
self._bold_font = gui_app.font(FontWeight.BOLD)
|
||||
|
||||
def _render(self, rect):
|
||||
x = rect.x + 20
|
||||
y = rect.y + 20
|
||||
rl.draw_text_ex(self._font, tr("Select vehicle to force fingerprint manually."), rl.Vector2(x, y), 40, 0, style.ITEM_DESC_TEXT_COLOR)
|
||||
y += 80
|
||||
rl.draw_text_ex(self._font, tr("Colors represent vehicle fingerprint status:"), rl.Vector2(x, y), 40, 0, style.ITEM_DESC_TEXT_COLOR)
|
||||
y += 80
|
||||
|
||||
items = [
|
||||
(style.GREEN, tr("Fingerprinted automatically")),
|
||||
(style.BLUE, tr("Manually selected fingerprint")),
|
||||
(style.YELLOW, tr("Not fingerprinted or manually selected")),
|
||||
]
|
||||
for color, text in items:
|
||||
p_color = self._platform_selector.color
|
||||
is_active = p_color.r == color.r and p_color.g == color.g and p_color.b == color.b and p_color.a == color.a
|
||||
rl.draw_rectangle(int(x), int(y + 5), 30, 30, color)
|
||||
font = self._bold_font if is_active else self._font
|
||||
text_color = rl.WHITE if is_active else style.ITEM_DESC_TEXT_COLOR
|
||||
rl.draw_text_ex(font, f"- {text}", rl.Vector2(x + 50, y - 7), 40, 0, text_color)
|
||||
y += 50
|
||||
|
||||
|
||||
class PlatformSelector(Button):
|
||||
def __init__(self, on_platform_change: Callable[[], None] | None = None):
|
||||
super().__init__(tr("Vehicle"), self._on_clicked, button_style=ButtonStyle.NORMAL)
|
||||
self.set_rect(rl.Rectangle(0, 0, 0, 120))
|
||||
|
||||
with open(CAR_LIST_JSON_OUT) as car_list_json:
|
||||
self._platforms = json.load(car_list_json)
|
||||
|
||||
self._on_platform_change = on_platform_change
|
||||
self.refresh()
|
||||
|
||||
@property
|
||||
def text(self):
|
||||
return self._label._text
|
||||
|
||||
def set_parent_rect(self, parent_rect):
|
||||
super().set_parent_rect(parent_rect)
|
||||
self._rect.width = parent_rect.width
|
||||
|
||||
def _on_clicked(self):
|
||||
if ui_state.params.get("CarPlatformBundle"):
|
||||
ui_state.params.remove("CarPlatformBundle")
|
||||
self.refresh()
|
||||
if self._on_platform_change:
|
||||
self._on_platform_change()
|
||||
else:
|
||||
self._show_platform_dialog()
|
||||
|
||||
def _set_platform(self, platform_name):
|
||||
if data := self._platforms.get(platform_name):
|
||||
ui_state.params.put("CarPlatformBundle", {**data, "name": platform_name})
|
||||
self.refresh()
|
||||
if self._on_platform_change:
|
||||
self._on_platform_change()
|
||||
|
||||
def _on_platform_selected(self, dialog, res):
|
||||
if res == DialogResult.CONFIRM and dialog.selection_ref:
|
||||
offroad_msg = tr("This setting will take effect immediately.") if ui_state.is_offroad else \
|
||||
tr("This setting will take effect once the device enters offroad state.")
|
||||
|
||||
confirm_dialog = ConfirmDialog(offroad_msg, tr("Confirm"))
|
||||
|
||||
callback = partial(self._confirm_platform, dialog.selection_ref)
|
||||
gui_app.set_modal_overlay(confirm_dialog, callback=callback)
|
||||
|
||||
def _confirm_platform(self, platform_name, res):
|
||||
if res == DialogResult.CONFIRM:
|
||||
self._set_platform(platform_name)
|
||||
|
||||
def _show_platform_dialog(self):
|
||||
platforms = sorted(self._platforms.keys())
|
||||
makes = sorted({self._platforms[p].get('make') for p in platforms})
|
||||
folders = [TreeFolder(make, [TreeNode(p, {
|
||||
'display_name': p,
|
||||
'search_tags': f"{p} {self._platforms[p].get('make')} {' '.join(map(str, self._platforms[p].get('year', [])))} {self._platforms[p].get('model', p)}"
|
||||
}) for p in platforms if self._platforms[p].get('make') == make]) for make in makes]
|
||||
dialog = TreeOptionDialog(
|
||||
tr("Select a vehicle"),
|
||||
folders,
|
||||
search_title=tr("Search your vehicle"),
|
||||
search_subtitle=tr("Enter model year (e.g., 2021) and model (Toyota Corolla):"),
|
||||
search_funcs=[lambda node: node.data.get('display_name', ''), lambda node: node.data.get('search_tags', '')]
|
||||
)
|
||||
callback = partial(self._on_platform_selected, dialog)
|
||||
dialog.on_exit = callback
|
||||
gui_app.set_modal_overlay(dialog, callback=callback)
|
||||
|
||||
def refresh(self):
|
||||
self.color = style.YELLOW
|
||||
self._platform = tr("Unrecognized Vehicle")
|
||||
self.set_text(tr("No vehicle selected"))
|
||||
|
||||
if bundle := ui_state.params.get("CarPlatformBundle"):
|
||||
self._platform = bundle.get("name", "")
|
||||
self.set_text(self._platform)
|
||||
self.color = style.BLUE
|
||||
elif ui_state.CP and ui_state.CP.carFingerprint != "MOCK":
|
||||
self._platform = ui_state.CP.carFingerprint
|
||||
self.set_text(self._platform)
|
||||
self.color = style.GREEN
|
||||
self.set_enabled(True)
|
||||
@@ -0,0 +1,29 @@
|
||||
"""
|
||||
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 cereal import messaging, custom
|
||||
from openpilot.common.params import Params
|
||||
from openpilot.sunnypilot.sunnylink.sunnylink_state import SunnylinkState
|
||||
|
||||
|
||||
class UIStateSP:
|
||||
def __init__(self):
|
||||
self.params = Params()
|
||||
self.sm_services_ext = [
|
||||
"modelManagerSP", "selfdriveStateSP", "longitudinalPlanSP", "backupManagerSP",
|
||||
"gpsLocation", "liveTorqueParameters", "carStateSP", "liveMapDataSP", "carParamsSP", "liveDelay"
|
||||
]
|
||||
|
||||
self.sunnylink_state = SunnylinkState()
|
||||
|
||||
def update(self) -> None:
|
||||
self.sunnylink_state.start()
|
||||
|
||||
def update_params(self) -> None:
|
||||
CP_SP_bytes = self.params.get("CarParamsSPPersistent")
|
||||
if CP_SP_bytes is not None:
|
||||
self.CP_SP = messaging.log_from_bytes(CP_SP_bytes, custom.CarParamsSP)
|
||||
self.sunnylink_enabled = self.params.get_bool("SunnylinkEnabled")
|
||||
@@ -105,7 +105,7 @@ def setup_settings_software_branch_switcher(click, pm: PubMaster, scroll=None):
|
||||
|
||||
def setup_settings_firehose(click, pm: PubMaster, scroll=None):
|
||||
setup_settings(click, pm)
|
||||
scroll(-1000, 278, 950)
|
||||
scroll(-20, 278, 950)
|
||||
click(278, 850)
|
||||
|
||||
|
||||
@@ -115,7 +115,7 @@ def setup_settings_developer(click, pm: PubMaster, scroll=None):
|
||||
Params().put("CarParamsPersistent", CP.to_bytes())
|
||||
|
||||
setup_settings(click, pm)
|
||||
scroll(-1000, 278, 950)
|
||||
scroll(-20, 278, 950)
|
||||
click(278, 950)
|
||||
|
||||
|
||||
@@ -171,37 +171,37 @@ def setup_settings_steering(click, pm: PubMaster, scroll=None):
|
||||
|
||||
def setup_settings_cruise(click, pm: PubMaster, scroll=None):
|
||||
setup_settings(click, pm)
|
||||
click(278, 1017)
|
||||
scroll(-140, 278, 950)
|
||||
scroll(-4, 278, 950)
|
||||
click(278, 860)
|
||||
|
||||
|
||||
def setup_settings_visuals(click, pm: PubMaster, scroll=None):
|
||||
setup_settings(click, pm)
|
||||
scroll(-1000, 278, 950)
|
||||
scroll(-20, 278, 950)
|
||||
click(278, 330)
|
||||
|
||||
|
||||
def setup_settings_display(click, pm: PubMaster, scroll=None):
|
||||
setup_settings(click, pm)
|
||||
scroll(-1000, 278, 950)
|
||||
scroll(-20, 278, 950)
|
||||
click(278, 420)
|
||||
|
||||
|
||||
def setup_settings_osm(click, pm: PubMaster, scroll=None):
|
||||
setup_settings(click, pm)
|
||||
scroll(-1000, 278, 950)
|
||||
scroll(-20, 278, 950)
|
||||
click(278, 520)
|
||||
|
||||
|
||||
def setup_settings_trips(click, pm: PubMaster, scroll=None):
|
||||
setup_settings(click, pm)
|
||||
scroll(-1000, 278, 950)
|
||||
scroll(-20, 278, 950)
|
||||
click(278, 630)
|
||||
|
||||
|
||||
def setup_settings_vehicle(click, pm: PubMaster, scroll=None):
|
||||
setup_settings(click, pm)
|
||||
scroll(-1000, 278, 950)
|
||||
scroll(-20, 278, 950)
|
||||
click(278, 750)
|
||||
|
||||
|
||||
@@ -342,9 +342,14 @@ class TestUI:
|
||||
time.sleep(0.01)
|
||||
pyautogui.mouseUp(self.ui.left + x, self.ui.top + y, *args, **kwargs)
|
||||
|
||||
def scroll(self, clicks, x, y, *args, **kwargs):
|
||||
pyautogui.scroll(clicks, self.ui.left + x, self.ui.top + y, *args, **kwargs)
|
||||
time.sleep(UI_DELAY)
|
||||
def scroll(self, clicks: int, x, y, *args, **kwargs):
|
||||
if clicks == 0:
|
||||
return
|
||||
click = -1 if clicks < 0 else 1 # -1 = down, 1 = up
|
||||
for _ in range(abs(clicks)):
|
||||
pyautogui.scroll(click, self.ui.left + x, self.ui.top + y, *args, **kwargs) # scroll for individual clicks since we need to delay between clicks
|
||||
time.sleep(0.01) # small delay between scroll clicks to work properly
|
||||
time.sleep(2) # wait for scroll to fully settle
|
||||
|
||||
@with_processes(["ui"])
|
||||
def test_ui(self, name, setup_case):
|
||||
@@ -364,6 +369,7 @@ def create_screenshots():
|
||||
with OpenpilotPrefix():
|
||||
params = Params()
|
||||
params.put("DongleId", "123456789012345")
|
||||
params.put("SunnylinkDongleId", "123456789012345")
|
||||
|
||||
# Set branch name
|
||||
params.put("UpdaterCurrentDescription", VERSION)
|
||||
|
||||
@@ -12,6 +12,8 @@ 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
|
||||
|
||||
BACKLIGHT_OFFROAD = 65 if HARDWARE.get_device_type() == "mici" else 50
|
||||
|
||||
|
||||
@@ -21,7 +23,7 @@ class UIStatus(Enum):
|
||||
OVERRIDE = "override"
|
||||
|
||||
|
||||
class UIState:
|
||||
class UIState(UIStateSP):
|
||||
_instance: 'UIState | None' = None
|
||||
|
||||
def __new__(cls):
|
||||
@@ -31,6 +33,7 @@ class UIState:
|
||||
return cls._instance
|
||||
|
||||
def _initialize(self):
|
||||
UIStateSP.__init__(self)
|
||||
self.params = Params()
|
||||
self.sm = messaging.SubMaster(
|
||||
[
|
||||
@@ -55,7 +58,7 @@ class UIState:
|
||||
"carControl",
|
||||
"liveParameters",
|
||||
"rawAudioData",
|
||||
]
|
||||
] + self.sm_services_ext
|
||||
)
|
||||
|
||||
self.prime_state = PrimeState()
|
||||
@@ -111,6 +114,7 @@ class UIState:
|
||||
if time.monotonic() - self._param_update_time > 5.0:
|
||||
self.update_params()
|
||||
device.update()
|
||||
UIStateSP.update(self)
|
||||
|
||||
def _update_state(self) -> None:
|
||||
# Handle panda states updates
|
||||
@@ -180,6 +184,7 @@ class UIState:
|
||||
self.has_longitudinal_control = self.params.get_bool("AlphaLongitudinalEnabled")
|
||||
else:
|
||||
self.has_longitudinal_control = self.CP.openpilotLongitudinalControl
|
||||
UIStateSP.update_params(self)
|
||||
self._param_update_time = time.monotonic()
|
||||
|
||||
|
||||
|
||||
@@ -63,6 +63,9 @@ class ModelManagerSP:
|
||||
f.write(chunk)
|
||||
bytes_downloaded += len(chunk)
|
||||
|
||||
if not self.params.get("ModelManager_DownloadIndex"):
|
||||
raise Exception("Download cancelled")
|
||||
|
||||
if total_size > 0:
|
||||
progress = (bytes_downloaded / total_size) * 100
|
||||
model.downloadProgress.status = custom.ModelManagerSP.DownloadStatus.downloading
|
||||
@@ -176,6 +179,7 @@ class ModelManagerSP:
|
||||
cloudlog.exception(e)
|
||||
finally:
|
||||
self.params.remove("ModelManager_DownloadIndex")
|
||||
self.selected_bundle = None
|
||||
|
||||
if self.params.get("ModelManager_ClearCache"):
|
||||
self.clear_model_cache()
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
"""
|
||||
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 enum import IntEnum
|
||||
import threading
|
||||
import time
|
||||
import json
|
||||
import pyray as rl
|
||||
|
||||
from cereal import messaging
|
||||
from openpilot.common.params import Params
|
||||
from openpilot.common.swaglog import cloudlog
|
||||
from openpilot.sunnypilot.sunnylink.api import UNREGISTERED_SUNNYLINK_DONGLE_ID, SunnylinkApi
|
||||
from openpilot.system.ui.sunnypilot.lib.styles import style
|
||||
|
||||
|
||||
class RoleType(IntEnum):
|
||||
READONLY = 0
|
||||
SPONSOR = 1
|
||||
ADMIN = 2
|
||||
|
||||
|
||||
class SponsorTier(IntEnum):
|
||||
FREE = 0
|
||||
NOVICE = 1
|
||||
SUPPORTER = 2
|
||||
CONTRIBUTOR = 3
|
||||
BENEFACTOR = 4
|
||||
GUARDIAN = 5
|
||||
|
||||
|
||||
class User:
|
||||
device_id: str
|
||||
user_id: str
|
||||
created_at: int
|
||||
updated_at: int
|
||||
token_hash: str
|
||||
|
||||
def __init__(self, json_data):
|
||||
self.device_id = json_data.get("device_id")
|
||||
self.user_id = json_data.get("user_id")
|
||||
self.created_at = json_data.get("created_at")
|
||||
self.updated_at = json_data.get("updated_at")
|
||||
self.token_hash = json_data.get("token_hash")
|
||||
|
||||
|
||||
class Role:
|
||||
role_type: str
|
||||
role_tier: str
|
||||
|
||||
def __init__(self, json_data):
|
||||
self.role_type = json_data.get("role_type")
|
||||
self.role_tier = json_data.get("role_tier")
|
||||
|
||||
|
||||
def _parse_roles(roles: str) -> list[Role]:
|
||||
lst_roles = []
|
||||
try:
|
||||
roles_list = json.loads(roles)
|
||||
for r in roles_list:
|
||||
try:
|
||||
role = Role(r)
|
||||
lst_roles.append(role)
|
||||
except Exception as e:
|
||||
cloudlog.exception(f"Failed to parse role {r}: {e}")
|
||||
return lst_roles
|
||||
except Exception as e:
|
||||
cloudlog.exception(f"Error parsing roles: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def _parse_users(users: str) -> list[User]:
|
||||
lst_users = []
|
||||
try:
|
||||
users_list = json.loads(users)
|
||||
for u in users_list:
|
||||
try:
|
||||
user = User(u)
|
||||
lst_users.append(user)
|
||||
except Exception as e:
|
||||
cloudlog.exception(f"Failed to parse user {u}: {e}")
|
||||
return lst_users
|
||||
except Exception as e:
|
||||
cloudlog.exception(f"Error parsing users: {e}")
|
||||
return []
|
||||
|
||||
|
||||
class SunnylinkState:
|
||||
FETCH_INTERVAL = 5.0 # seconds between API calls
|
||||
API_TIMEOUT = 10.0 # seconds for API requests
|
||||
SLEEP_INTERVAL = 0.5 # seconds to sleep between checks in the worker thread
|
||||
NOT_PAIRED_USERNAMES = ["unregisteredsponsor", "temporarysponsor"]
|
||||
|
||||
def __init__(self):
|
||||
self._params = Params()
|
||||
self._lock = threading.Lock()
|
||||
self._running = False
|
||||
self._thread = None
|
||||
self._sm = messaging.SubMaster(['deviceState'])
|
||||
|
||||
self._roles: list[Role] = []
|
||||
self._users: list[User] = []
|
||||
self.sponsor_tier: SponsorTier = SponsorTier.FREE
|
||||
self.sunnylink_dongle_id = self._params.get("SunnylinkDongleId")
|
||||
self._api = SunnylinkApi(self.sunnylink_dongle_id)
|
||||
|
||||
self._load_initial_state()
|
||||
|
||||
def _load_initial_state(self) -> None:
|
||||
roles_cache = self._params.get("SunnylinkCache_Roles")
|
||||
users_cache = self._params.get("SunnylinkCache_Users")
|
||||
if roles_cache is not None:
|
||||
self._roles = _parse_roles(roles_cache)
|
||||
self.sponsor_tier = self._get_highest_tier()
|
||||
if users_cache is not None:
|
||||
self._users = _parse_users(users_cache)
|
||||
|
||||
def _get_highest_tier(self) -> SponsorTier:
|
||||
role_tier = SponsorTier.FREE
|
||||
for role in self._roles:
|
||||
try:
|
||||
if RoleType[role.role_type.upper()] == RoleType.SPONSOR:
|
||||
role_tier = max(role_tier, SponsorTier[role.role_tier.upper()])
|
||||
except Exception as e:
|
||||
cloudlog.exception(f"Error parsing role {role}: {e} for dongle id {self.sunnylink_dongle_id}")
|
||||
return role_tier
|
||||
|
||||
def _fetch_roles(self) -> None:
|
||||
if not self.sunnylink_dongle_id or self.sunnylink_dongle_id == UNREGISTERED_SUNNYLINK_DONGLE_ID:
|
||||
return
|
||||
|
||||
try:
|
||||
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()
|
||||
with self._lock:
|
||||
if sponsor_tier != self.sponsor_tier:
|
||||
self.sponsor_tier = sponsor_tier
|
||||
cloudlog.info(f"Sunnylink sponsor tier updated to {sponsor_tier.name}")
|
||||
except Exception as e:
|
||||
cloudlog.exception(f"Failed to fetch sunnylink roles: {e} for dongle id {self.sunnylink_dongle_id}")
|
||||
|
||||
def _fetch_users(self) -> None:
|
||||
if not self.sunnylink_dongle_id or self.sunnylink_dongle_id == UNREGISTERED_SUNNYLINK_DONGLE_ID:
|
||||
return
|
||||
|
||||
try:
|
||||
token = self._api.get_token()
|
||||
response = self._api.api_get(f"device/{self.sunnylink_dongle_id}/users", method='GET', access_token=token)
|
||||
if response.status_code == 200:
|
||||
users = response.text
|
||||
self._params.put("SunnylinkCache_Users", users)
|
||||
with self._lock:
|
||||
_parse_users(users)
|
||||
except Exception as e:
|
||||
cloudlog.exception(f"Failed to fetch sunnylink users: {e} for dongle id {self.sunnylink_dongle_id}")
|
||||
|
||||
def _worker_thread(self) -> None:
|
||||
while self._running:
|
||||
if self.is_connected():
|
||||
self._fetch_roles()
|
||||
self._fetch_users()
|
||||
|
||||
for _ in range(int(self.FETCH_INTERVAL / self.SLEEP_INTERVAL)):
|
||||
if not self._running:
|
||||
break
|
||||
time.sleep(self.SLEEP_INTERVAL)
|
||||
|
||||
def start(self) -> None:
|
||||
if self._thread and self._thread.is_alive():
|
||||
return
|
||||
self._running = True
|
||||
self._thread = threading.Thread(target=self._worker_thread, daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def stop(self) -> None:
|
||||
self._running = False
|
||||
if self._thread and self._thread.is_alive():
|
||||
self._thread.join(timeout=1.0)
|
||||
|
||||
def get_sponsor_tier(self) -> SponsorTier:
|
||||
with self._lock:
|
||||
return self.sponsor_tier
|
||||
|
||||
def is_sponsor(self) -> bool:
|
||||
with self._lock:
|
||||
is_sponsor = any(role.role_type.upper() == RoleType.SPONSOR.name and role.role_tier.upper() != SponsorTier.FREE.name
|
||||
for role in self._roles)
|
||||
return is_sponsor
|
||||
|
||||
def is_paired(self) -> bool:
|
||||
with self._lock:
|
||||
is_paired = any(user.user_id not in self.NOT_PAIRED_USERNAMES for user in self._users)
|
||||
return is_paired
|
||||
|
||||
def is_connected(self) -> bool:
|
||||
network_type = self._sm["deviceState"].networkType
|
||||
return bool(network_type != 0)
|
||||
|
||||
def get_sponsor_tier_color(self) -> rl.Color:
|
||||
tier = self.get_sponsor_tier()
|
||||
|
||||
if tier == SponsorTier.GUARDIAN:
|
||||
return rl.Color(255, 215, 0, 255)
|
||||
elif tier == SponsorTier.BENEFACTOR:
|
||||
return rl.Color(60, 179, 113, 255)
|
||||
elif tier == SponsorTier.CONTRIBUTOR:
|
||||
return rl.Color(70, 130, 180, 255)
|
||||
elif tier == SponsorTier.SUPPORTER:
|
||||
return rl.Color(147, 112, 219, 255)
|
||||
else:
|
||||
return style.ITEM_TEXT_VALUE_COLOR
|
||||
|
||||
def __del__(self):
|
||||
self.stop()
|
||||
@@ -94,6 +94,7 @@ class FontWeight(StrEnum):
|
||||
BOLD = "Inter-Bold.fnt"
|
||||
SEMI_BOLD = "Inter-SemiBold.fnt"
|
||||
UNIFONT = "unifont.fnt"
|
||||
AUDIOWIDE = "Audiowide-Regular.fnt"
|
||||
|
||||
# Small UI fonts
|
||||
DISPLAY_REGULAR = "Inter-Regular.fnt"
|
||||
|
||||
@@ -17,8 +17,11 @@ class Base:
|
||||
ITEM_TEXT_FONT_SIZE = 50
|
||||
ITEM_DESC_FONT_SIZE = 40
|
||||
ITEM_DESC_V_OFFSET = 150
|
||||
ITEM_TEXT_VALUE_COLOR = rl.Color(170, 170, 170, 255)
|
||||
CLOSE_BTN_SIZE = 160
|
||||
|
||||
TEXT_PADDING = 20
|
||||
|
||||
# Toggle Control
|
||||
TOGGLE_HEIGHT = 120
|
||||
TOGGLE_WIDTH = int(TOGGLE_HEIGHT * 1.75)
|
||||
@@ -65,5 +68,18 @@ class DefaultStyleSP(Base):
|
||||
OPTION_CONTROL_TEXT_PRESSED = rl.WHITE
|
||||
OPTION_CONTROL_TEXT_DISABLED = ITEM_DISABLED_TEXT_COLOR
|
||||
|
||||
# Tree Button Colors
|
||||
BUTTON_PRIMARY_COLOR = rl.Color(70, 91, 234, 255) # Royal Blue
|
||||
BUTTON_NEUTRAL_GRAY = rl.Color(51, 51, 51, 255)
|
||||
BUTTON_DISABLED_BG_COLOR = rl.Color(30, 30, 30, 255) # Very Dark Grey
|
||||
TREE_DIALOG_TRANSPARENT = rl.Color(0, 0, 0, 0)
|
||||
TREE_DIALOG_SEARCH_BUTTON_PRESSED = rl.Color(0x69, 0x68, 0x68, 0xFF)
|
||||
TREE_DIALOG_SEARCH_BUTTON_BORDER = rl.Color(150, 150, 150, 200)
|
||||
|
||||
# Vehicle Description Colors
|
||||
GREEN = rl.Color(0, 241, 0, 255)
|
||||
BLUE = rl.Color(0, 134, 233, 255)
|
||||
YELLOW = rl.Color(255, 213, 0, 255)
|
||||
|
||||
|
||||
style = DefaultStyleSP
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
"""
|
||||
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 re
|
||||
import unicodedata
|
||||
|
||||
|
||||
def normalize(text: str) -> str:
|
||||
return unicodedata.normalize('NFKD', text).encode('ascii', 'ignore').decode('utf-8').lower()
|
||||
|
||||
|
||||
def search_from_list(query: str, items: list[str]) -> list[str]:
|
||||
if not query:
|
||||
return items
|
||||
|
||||
normalized_query = normalize(query)
|
||||
search_terms = [re.sub(r'[^a-z0-9]', '', term) for term in normalized_query.split() if term.strip()]
|
||||
|
||||
results = []
|
||||
for item in items:
|
||||
normalized_item = normalize(item)
|
||||
item_with_spaces = re.sub(r'[^a-z0-9\s]', ' ', normalized_item)
|
||||
item_stripped = re.sub(r'[^a-z0-9]', '', normalized_item)
|
||||
|
||||
all_terms_match = True
|
||||
for term in search_terms:
|
||||
if not term:
|
||||
continue
|
||||
|
||||
if term not in item_with_spaces and term not in item_stripped:
|
||||
all_terms_match = False
|
||||
break
|
||||
|
||||
if all_terms_match:
|
||||
results.append(item)
|
||||
|
||||
return results
|
||||
@@ -0,0 +1,26 @@
|
||||
"""
|
||||
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 math
|
||||
|
||||
import pyray as rl
|
||||
|
||||
|
||||
def draw_star(center_x, center_y, radius, is_filled, color):
|
||||
center = rl.Vector2(center_x, center_y)
|
||||
points = []
|
||||
|
||||
for i in range(10):
|
||||
angle = -(i * 36 + 18) * math.pi / 180
|
||||
r = radius if i % 2 == 0 else radius / 2
|
||||
x = center_x + r * math.cos(angle)
|
||||
y = center_y + r * math.sin(angle)
|
||||
points.append(rl.Vector2(x, y))
|
||||
|
||||
for i in range(10):
|
||||
if is_filled:
|
||||
rl.draw_triangle(center, points[i], points[(i + 1) % 10], color)
|
||||
rl.draw_line_ex(points[i], points[(i + 1) % 10], 2, color)
|
||||
@@ -8,7 +8,6 @@ from collections.abc import Callable
|
||||
|
||||
from openpilot.common.params import Params
|
||||
from openpilot.system.ui.lib.application import gui_app
|
||||
from openpilot.system.ui.lib.multilang import tr
|
||||
from openpilot.system.ui.widgets import DialogResult
|
||||
from openpilot.system.ui.widgets.keyboard import Keyboard
|
||||
|
||||
@@ -27,14 +26,16 @@ class InputDialogSP:
|
||||
|
||||
def show(self):
|
||||
self.keyboard.reset(min_text_size=self.keyboard._min_text_size)
|
||||
self.keyboard.set_title(tr(self.title), *(tr(self.sub_title),) if self.sub_title else ())
|
||||
if self.sub_title:
|
||||
self.keyboard.set_title(self.title, self.sub_title)
|
||||
else:
|
||||
self.keyboard.set_title(self.title)
|
||||
self.keyboard.set_text(self.current_text)
|
||||
|
||||
def internal_callback(result: DialogResult):
|
||||
text = self.keyboard.text if result == DialogResult.CONFIRM else ""
|
||||
if result == DialogResult.CONFIRM:
|
||||
if self.param:
|
||||
self._params.put(self.param, text)
|
||||
if result == DialogResult.CONFIRM and self.param:
|
||||
self._params.put(self.param, text)
|
||||
if self.callback:
|
||||
self.callback(result, text)
|
||||
|
||||
|
||||
@@ -8,10 +8,12 @@ from collections.abc import Callable
|
||||
|
||||
import pyray as rl
|
||||
from openpilot.common.params import Params
|
||||
from openpilot.system.ui.lib.application import MousePos
|
||||
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.list_view import ListItem, ToggleAction, ItemAction, MultipleButtonAction, _resolve_value
|
||||
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
|
||||
from openpilot.system.ui.sunnypilot.lib.styles import style
|
||||
from openpilot.system.ui.sunnypilot.widgets.option_control import OptionControlSP, LABEL_WIDTH
|
||||
|
||||
@@ -23,6 +25,34 @@ class ToggleActionSP(ToggleAction):
|
||||
self.toggle = ToggleSP(initial_state=initial_state, callback=callback, param=param)
|
||||
|
||||
|
||||
class ButtonActionSP(ButtonAction):
|
||||
def __init__(self, text: str | Callable[[], str], width: int = style.BUTTON_WIDTH, enabled: bool | Callable[[], bool] = True):
|
||||
super().__init__(text=text, width=width, enabled=enabled)
|
||||
self._value_color: rl.Color = style.ITEM_TEXT_VALUE_COLOR
|
||||
|
||||
def set_value(self, value: str | Callable[[], str], color: rl.Color = style.ITEM_TEXT_VALUE_COLOR):
|
||||
self._value_source = value
|
||||
self._value_color = color
|
||||
|
||||
def _render(self, rect: rl.Rectangle) -> bool:
|
||||
"""Duplicate of ButtonAction._render, with additional value rendering"""
|
||||
self._button.set_text(self.text)
|
||||
self._button.set_enabled(_resolve_value(self.enabled))
|
||||
button_rect = rl.Rectangle(rect.x + rect.width - BUTTON_WIDTH, rect.y + (rect.height - BUTTON_HEIGHT) / 2, BUTTON_WIDTH, BUTTON_HEIGHT)
|
||||
self._button.render(button_rect)
|
||||
|
||||
value_text = self.value
|
||||
if value_text:
|
||||
value_rect = rl.Rectangle(rect.x, rect.y, rect.width - BUTTON_WIDTH - TEXT_PADDING, rect.height)
|
||||
gui_label(value_rect, value_text, font_size=style.ITEM_TEXT_FONT_SIZE, color=self._value_color,
|
||||
font_weight=FontWeight.NORMAL, alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT,
|
||||
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE)
|
||||
|
||||
pressed = self._pressed
|
||||
self._pressed = False
|
||||
return pressed
|
||||
|
||||
|
||||
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):
|
||||
@@ -80,16 +110,35 @@ class MultipleButtonActionSP(MultipleButtonAction):
|
||||
class ListItemSP(ListItem):
|
||||
def __init__(self, title: str | Callable[[], str] = "", icon: str | None = None, description: str | Callable[[], str] | None = None,
|
||||
description_visible: bool = False, callback: Callable | None = None,
|
||||
action_item: ItemAction | None = None, inline: bool = True):
|
||||
action_item: ItemAction | None = None, inline: bool = True, title_color: rl.Color = style.ITEM_TEXT_COLOR):
|
||||
ListItem.__init__(self, title, icon, description, description_visible, callback, action_item)
|
||||
self.title_color = title_color
|
||||
self.inline = inline
|
||||
if not self.inline:
|
||||
self._rect.height += style.ITEM_BASE_HEIGHT/1.75
|
||||
self._right_value_source: str | Callable[[], str] | None = None
|
||||
self._right_value_font = gui_app.font(FontWeight.NORMAL)
|
||||
self._right_value_color: rl.Color = style.ITEM_TEXT_VALUE_COLOR
|
||||
|
||||
def set_right_value(self, value: str | Callable[[], str], color: rl.Color = style.ITEM_TEXT_VALUE_COLOR):
|
||||
self._right_value_source = value
|
||||
self._right_value_color = color
|
||||
|
||||
@property
|
||||
def right_value(self) -> str:
|
||||
if self._right_value_source is None:
|
||||
return ""
|
||||
return str(_resolve_value(self._right_value_source, ""))
|
||||
|
||||
def get_item_height(self, font: rl.Font, max_width: int) -> float:
|
||||
height = super().get_item_height(font, max_width)
|
||||
|
||||
if self.description_visible:
|
||||
height += style.ITEM_PADDING * 1.5
|
||||
|
||||
if not self.inline:
|
||||
height = height + style.ITEM_BASE_HEIGHT/1.75
|
||||
height += style.ITEM_BASE_HEIGHT / 1.75
|
||||
|
||||
return height
|
||||
|
||||
def show_description(self, show: bool):
|
||||
@@ -100,21 +149,28 @@ class ListItemSP(ListItem):
|
||||
return rl.Rectangle(0, 0, 0, 0)
|
||||
|
||||
if not self.inline:
|
||||
action_y = item_rect.y + self._text_size.y + style.ITEM_PADDING * 3
|
||||
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
|
||||
|
||||
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.rect.width
|
||||
if right_width == 0: # Full width action (like DualButtonAction)
|
||||
return rl.Rectangle(item_rect.x + style.ITEM_PADDING, item_rect.y,
|
||||
item_rect.width - (style.ITEM_PADDING * 2), style.ITEM_BASE_HEIGHT)
|
||||
right_width = self.action_item.get_width_hint()
|
||||
if right_width == 0:
|
||||
return rl.Rectangle(item_rect.x + style.ITEM_PADDING, item_rect.y, item_rect.width - (style.ITEM_PADDING * 2), style.ITEM_BASE_HEIGHT)
|
||||
|
||||
action_width = self.action_item.rect.width
|
||||
content_width = item_rect.width - (style.ITEM_PADDING * 2)
|
||||
title_width = measure_text_cached(self._font, self.title, style.ITEM_TEXT_FONT_SIZE).x
|
||||
right_width = min(content_width - title_width, right_width)
|
||||
if isinstance(self.action_item, ToggleAction):
|
||||
action_x = item_rect.x
|
||||
else:
|
||||
action_x = item_rect.x + item_rect.width - action_width
|
||||
action_x = item_rect.x + item_rect.width - right_width
|
||||
action_y = item_rect.y
|
||||
return rl.Rectangle(action_x, action_y, action_width, style.ITEM_BASE_HEIGHT)
|
||||
return rl.Rectangle(action_x, action_y, right_width, style.ITEM_BASE_HEIGHT)
|
||||
|
||||
def _render(self, _):
|
||||
if not self.is_visible:
|
||||
@@ -141,7 +197,20 @@ class ListItemSP(ListItem):
|
||||
if self.title:
|
||||
self._text_size = measure_text_cached(self._font, self.title, style.ITEM_TEXT_FONT_SIZE)
|
||||
item_y = self._rect.y + (style.ITEM_BASE_HEIGHT - self._text_size.y) // 2
|
||||
rl.draw_text_ex(self._font, self.title, rl.Vector2(text_x, item_y), style.ITEM_TEXT_FONT_SIZE, 0, style.ITEM_TEXT_COLOR)
|
||||
rl.draw_text_ex(self._font, self.title, rl.Vector2(text_x, item_y), style.ITEM_TEXT_FONT_SIZE, 0, self.title_color)
|
||||
|
||||
value_text = self.right_value
|
||||
if value_text:
|
||||
# area from after the title to the right edge of the row
|
||||
value_rect = rl.Rectangle(
|
||||
text_x, # start at the beginning of the text area
|
||||
self._rect.y,
|
||||
self._rect.width - (text_x - self._rect.x) - style.ITEM_PADDING,
|
||||
style.ITEM_BASE_HEIGHT,
|
||||
)
|
||||
if value_rect.width > 0:
|
||||
gui_label(value_rect, value_text, font_size=style.ITEM_TEXT_FONT_SIZE, color=self._right_value_color, font_weight=FontWeight.NORMAL,
|
||||
alignment=rl.GuiTextAlignment.TEXT_ALIGN_RIGHT, alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE)
|
||||
|
||||
# Render toggle and handle callback
|
||||
if self.action_item.render(left_rect) and self.action_item.enabled:
|
||||
@@ -153,7 +222,7 @@ class ListItemSP(ListItem):
|
||||
# Draw main text
|
||||
self._text_size = measure_text_cached(self._font, self.title, style.ITEM_TEXT_FONT_SIZE)
|
||||
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, style.ITEM_TEXT_COLOR)
|
||||
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:
|
||||
@@ -170,7 +239,7 @@ class ListItemSP(ListItem):
|
||||
|
||||
desc_y = self._rect.y + style.ITEM_DESC_V_OFFSET
|
||||
if not self.inline and self.action_item:
|
||||
desc_y = self.action_item.rect.y + style.ITEM_DESC_V_OFFSET - style.ITEM_PADDING * 1.75
|
||||
desc_y = self.action_item.rect.y + style.ITEM_DESC_V_OFFSET - style.ITEM_PADDING * 0.5
|
||||
|
||||
description_rect = rl.Rectangle(self._rect.x + style.ITEM_PADDING, desc_y, content_width, description_height)
|
||||
self._html_renderer.render(description_rect)
|
||||
@@ -200,3 +269,9 @@ def option_item_sp(title: str | Callable[[], str], param: str,
|
||||
enabled, on_value_changed, value_map, label_width, use_float_scaling, label_callback
|
||||
)
|
||||
return ListItemSP(title=title, description=description, action_item=action, icon=icon)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
"""
|
||||
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 pyray as rl
|
||||
from openpilot.common.swaglog import cloudlog
|
||||
from openpilot.selfdrive.ui.ui_state import ui_state
|
||||
from openpilot.selfdrive.ui.widgets.pairing_dialog import PairingDialog
|
||||
from openpilot.sunnypilot.sunnylink.api import SunnylinkApi, UNREGISTERED_SUNNYLINK_DONGLE_ID, API_HOST
|
||||
from openpilot.system.ui.lib.application import FontWeight, gui_app
|
||||
from openpilot.system.ui.lib.multilang import tr
|
||||
from openpilot.system.ui.lib.wrap_text import wrap_text
|
||||
from openpilot.system.ui.lib.text_measure import measure_text_cached
|
||||
|
||||
|
||||
class SunnylinkPairingDialog(PairingDialog):
|
||||
"""Dialog for device pairing with QR code."""
|
||||
|
||||
QR_REFRESH_INTERVAL = 300 # 5 minutes in seconds
|
||||
|
||||
def __init__(self, sponsor_pairing: bool = False):
|
||||
PairingDialog.__init__(self)
|
||||
self._sponsor_pairing = sponsor_pairing
|
||||
self._is_paired_prev = ui_state.sunnylink_state.is_paired()
|
||||
|
||||
def _get_pairing_url(self) -> str:
|
||||
qr_string = "https://github.com/sponsors/sunnyhaibin"
|
||||
|
||||
if self._sponsor_pairing:
|
||||
try:
|
||||
sl_dongle_id = self.params.get("SunnylinkDongleId") or UNREGISTERED_SUNNYLINK_DONGLE_ID
|
||||
token = SunnylinkApi(sl_dongle_id).get_token()
|
||||
inner_string = f"1|{sl_dongle_id}|{token}"
|
||||
payload_bytes = base64.b64encode(inner_string.encode('utf-8')).decode('utf-8')
|
||||
qr_string = f"{API_HOST}/sso?state={payload_bytes}"
|
||||
except Exception:
|
||||
cloudlog.exception("Failed to get pairing token")
|
||||
|
||||
return qr_string
|
||||
|
||||
def _update_state(self):
|
||||
is_paired = ui_state.sunnylink_state.is_paired()
|
||||
if not self._is_paired_prev and is_paired:
|
||||
gui_app.set_modal_overlay(None)
|
||||
|
||||
def _render(self, rect: rl.Rectangle) -> int:
|
||||
rl.clear_background(rl.Color(224, 224, 224, 255))
|
||||
|
||||
self._check_qr_refresh()
|
||||
|
||||
margin = 70
|
||||
content_rect = rl.Rectangle(rect.x + margin, rect.y + margin, rect.width - 2 * margin, rect.height - 2 * margin)
|
||||
y = content_rect.y
|
||||
|
||||
# Close button
|
||||
close_size = 80
|
||||
pad = 20
|
||||
close_rect = rl.Rectangle(content_rect.x - pad, y - pad, close_size + pad * 2, close_size + pad * 2)
|
||||
self._close_btn.render(close_rect)
|
||||
|
||||
y += close_size + 40
|
||||
|
||||
# Title
|
||||
title = tr("Pair your GitHub account") if self._sponsor_pairing else tr("Early Access: Become a sunnypilot Sponsor")
|
||||
title_font = gui_app.font(FontWeight.NORMAL)
|
||||
left_width = int(content_rect.width * 0.5 - 15)
|
||||
|
||||
title_wrapped = wrap_text(title_font, title, 75, left_width)
|
||||
rl.draw_text_ex(title_font, "\n".join(title_wrapped), rl.Vector2(content_rect.x, y), 75, 0.0, rl.BLACK)
|
||||
y += len(title_wrapped) * 75 + 60
|
||||
|
||||
# Two columns: instructions and QR code
|
||||
remaining_height = content_rect.height - (y - content_rect.y)
|
||||
right_width = content_rect.width // 2 - 20
|
||||
|
||||
# Instructions
|
||||
self._render_instructions(rl.Rectangle(content_rect.x, y, left_width, remaining_height))
|
||||
|
||||
# QR code
|
||||
qr_size = min(right_width, content_rect.height) - 40
|
||||
qr_x = content_rect.x + left_width + 40 + (right_width - qr_size) // 2
|
||||
qr_y = content_rect.y
|
||||
self._render_qr_code(rl.Rectangle(qr_x, qr_y, qr_size, qr_size))
|
||||
|
||||
return -1
|
||||
|
||||
def _render_instructions(self, rect: rl.Rectangle) -> None:
|
||||
if self._sponsor_pairing:
|
||||
instructions = [
|
||||
tr("Scan the QR code to login to your GitHub account"),
|
||||
tr("Follow the prompts to complete the pairing process"),
|
||||
tr("Re-enter the \"sunnylink\" panel to verify sponsorship status"),
|
||||
tr("If sponsorship status was not updated, please contact a moderator on the community forum at https://community.sunnypilot.ai")
|
||||
]
|
||||
else:
|
||||
instructions = [
|
||||
tr("Scan the QR code to visit sunnyhaibin's GitHub Sponsors page"),
|
||||
tr("Choose your sponsorship tier and confirm your support"),
|
||||
tr("Join our Community Forum at https://community.sunnypilot.ai and reach out to a moderator if you have issues")
|
||||
]
|
||||
|
||||
font = gui_app.font(FontWeight.BOLD)
|
||||
y = rect.y
|
||||
|
||||
for i, text in enumerate(instructions):
|
||||
circle_radius = 25
|
||||
circle_x = rect.x + circle_radius + 15
|
||||
text_x = rect.x + circle_radius * 2 + 40
|
||||
text_width = rect.width - (circle_radius * 2 + 40)
|
||||
|
||||
wrapped = wrap_text(font, text, 47, int(text_width))
|
||||
text_height = len(wrapped) * 47
|
||||
circle_y = y + text_height // 2
|
||||
|
||||
# Circle and number
|
||||
rl.draw_circle(int(circle_x), int(circle_y), circle_radius, rl.Color(70, 70, 70, 255))
|
||||
number = str(i + 1)
|
||||
number_size = measure_text_cached(font, number, 30)
|
||||
rl.draw_text_ex(font, number, (int(circle_x - number_size.x // 2), int(circle_y - number_size.y // 2)), 30, 0, rl.WHITE)
|
||||
|
||||
# Text
|
||||
rl.draw_text_ex(font, "\n".join(wrapped), rl.Vector2(text_x, y), 47, 0.0, rl.BLACK)
|
||||
y += text_height + 50
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
gui_app.init_window("pairing device")
|
||||
pairing = SunnylinkPairingDialog(sponsor_pairing=True)
|
||||
try:
|
||||
for _ in gui_app.render():
|
||||
result = pairing.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
|
||||
if result != -1:
|
||||
break
|
||||
finally:
|
||||
del pairing
|
||||
@@ -24,6 +24,9 @@ class ToggleSP(Toggle):
|
||||
initial_state = self.params.get_bool(self.param_key)
|
||||
Toggle.__init__(self, initial_state, callback)
|
||||
|
||||
def set_rect(self, rect: rl.Rectangle):
|
||||
self._rect = rl.Rectangle(rect.x, rect.y, style.TOGGLE_WIDTH, style.TOGGLE_HEIGHT)
|
||||
|
||||
def _handle_mouse_release(self, mouse_pos: MousePos):
|
||||
super()._handle_mouse_release(mouse_pos)
|
||||
if self._enabled and self.param_key:
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
"""
|
||||
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 dataclasses import dataclass, field
|
||||
|
||||
import pyray as rl
|
||||
from openpilot.common.params import Params
|
||||
from openpilot.system.ui.lib.application import FontWeight, gui_app
|
||||
from openpilot.system.ui.lib.multilang import tr
|
||||
from openpilot.system.ui.widgets import DialogResult
|
||||
from openpilot.system.ui.widgets.button import Button, ButtonStyle, BUTTON_PRESSED_BACKGROUND_COLORS
|
||||
from openpilot.system.ui.widgets.label import gui_label
|
||||
from openpilot.system.ui.widgets.option_dialog import MultiOptionDialog
|
||||
|
||||
from openpilot.system.ui.sunnypilot.lib.styles import style
|
||||
from openpilot.system.ui.sunnypilot.widgets.helpers.fuzzy_search import search_from_list
|
||||
from openpilot.system.ui.sunnypilot.widgets.helpers.star_icon import draw_star
|
||||
from openpilot.system.ui.sunnypilot.widgets.input_dialog import InputDialogSP
|
||||
|
||||
|
||||
@dataclass
|
||||
class TreeNode:
|
||||
ref: str
|
||||
data: dict = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class TreeFolder:
|
||||
folder: str
|
||||
nodes: list
|
||||
|
||||
|
||||
class TreeItemWidget(Button):
|
||||
def __init__(self, text, ref, is_folder=False, indent_level=0, click_callback=None, favorite_callback=None, is_favorite=False, is_expanded=False):
|
||||
super().__init__(text, click_callback, button_style=ButtonStyle.NORMAL, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT,
|
||||
text_padding=20 + indent_level * 30, elide_right=True)
|
||||
self.text = text
|
||||
self.ref = ref
|
||||
self.is_folder = is_folder
|
||||
self.indent_level = indent_level
|
||||
self.is_favorite = is_favorite
|
||||
self.selected = False
|
||||
self._favorite_callback = favorite_callback
|
||||
self.text_padding = 20 + indent_level * 30
|
||||
self.border_radius = 10
|
||||
self.is_expanded = is_expanded
|
||||
|
||||
def _render(self, rect):
|
||||
indent = 60 * self.indent_level
|
||||
self._rect = rl.Rectangle(rect.x + indent, rect.y, rect.width - indent, rect.height)
|
||||
if self.is_pressed:
|
||||
color = BUTTON_PRESSED_BACKGROUND_COLORS[self._button_style]
|
||||
elif self.selected and self.ref != "search_bar":
|
||||
color = style.BUTTON_PRIMARY_COLOR
|
||||
else:
|
||||
color = style.BUTTON_DISABLED_BG_COLOR
|
||||
roundness = self.border_radius / (min(self._rect.width, self._rect.height) / 2)
|
||||
rl.draw_rectangle_rounded(self._rect, roundness, 10, color)
|
||||
text_offset = self.text_padding + 20 - 15 if self.is_expanded and not self.is_folder and self.indent_level > 0 else self.text_padding + 20
|
||||
text_rect = rl.Rectangle(self._rect.x + text_offset, self._rect.y, self._rect.width - self.text_padding - 20 - 90, self._rect.height)
|
||||
self._label.render(text_rect)
|
||||
|
||||
if not self.is_folder and self._favorite_callback:
|
||||
draw_star(self._rect.x + self._rect.width - 90, self._rect.y + self._rect.height / 2, 40, self.is_favorite,
|
||||
style.ON_BG_COLOR if self.is_favorite else rl.GRAY)
|
||||
|
||||
def _handle_mouse_release(self, mouse_pos):
|
||||
star_rect = rl.Rectangle(self._rect.x + self._rect.width - 90 - 40, self._rect.y + self._rect.height / 2 - 40, 80, 80)
|
||||
if not self.is_folder and self._favorite_callback and rl.check_collision_point_rec(mouse_pos, star_rect):
|
||||
self._favorite_callback()
|
||||
return True
|
||||
return super()._handle_mouse_release(mouse_pos)
|
||||
|
||||
|
||||
class TreeOptionDialog(MultiOptionDialog):
|
||||
def __init__(self, title, folders, current_ref="", fav_param="", option_font_weight=FontWeight.MEDIUM, search_prompt=None,
|
||||
get_folders_fn=None, on_exit=None, display_func=None, search_funcs=None, search_title=None, search_subtitle=None):
|
||||
super().__init__(title, [], "", option_font_weight)
|
||||
self.folders = folders
|
||||
self.selection_ref = current_ref
|
||||
self.fav_param = fav_param
|
||||
self.expanded = set()
|
||||
self.params = Params()
|
||||
val = self.params.get(fav_param) if fav_param else None
|
||||
self.favorites = set(val.split(';')) if val else set()
|
||||
self.query = ""
|
||||
self.search_prompt = search_prompt or tr("Search")
|
||||
self.get_folders_fn = get_folders_fn
|
||||
self.on_exit = on_exit
|
||||
self.display_func = display_func or (lambda node: node.data.get('display_name', node.ref))
|
||||
self.search_funcs = search_funcs or [lambda node: node.data.get('display_name', ''), lambda node: node.data.get('short_name', '')]
|
||||
self._search_rect = None
|
||||
self._search_width = 0.475
|
||||
|
||||
# Default title & overridable subtitle for InputDialogSP
|
||||
self.search_title = search_title or tr("Enter search query")
|
||||
self.search_subtitle = search_subtitle
|
||||
self.search_dialog = None
|
||||
self._search_pressed = False
|
||||
|
||||
self._build_visible_items()
|
||||
|
||||
def _on_search_confirm(self, result, text):
|
||||
if result == DialogResult.CONFIRM:
|
||||
self.query = text
|
||||
self._build_visible_items()
|
||||
gui_app.set_modal_overlay(self, callback=self.on_exit)
|
||||
|
||||
def _on_search_clicked(self):
|
||||
self.search_dialog = InputDialogSP(
|
||||
self.search_title,
|
||||
self.search_subtitle,
|
||||
current_text=self.query,
|
||||
callback=self._on_search_confirm,
|
||||
)
|
||||
self.search_dialog.show()
|
||||
|
||||
def _toggle_folder(self, folder):
|
||||
if folder.folder:
|
||||
if folder.folder in self.expanded:
|
||||
self.expanded.remove(folder.folder)
|
||||
else:
|
||||
self.expanded.add(folder.folder)
|
||||
if folder == self.folders[-1] and folder.folder in self.expanded:
|
||||
self.scroller.scroll_panel.set_offset(self.scroller.scroll_panel.offset - 200)
|
||||
self._build_visible_items(reset_scroll=False)
|
||||
|
||||
def _select_node(self, node):
|
||||
self.selection = self.display_func(node)
|
||||
self.selection_ref = node.ref
|
||||
|
||||
def _toggle_favorite(self, node):
|
||||
self.favorites.remove(node.ref) if node.ref in self.favorites else self.favorites.add(node.ref)
|
||||
if self.fav_param:
|
||||
self.params.put(self.fav_param, ';'.join(self.favorites))
|
||||
if self.get_folders_fn:
|
||||
self.folders = self.get_folders_fn(self.favorites)
|
||||
self._build_visible_items(reset_scroll=False)
|
||||
|
||||
def _build_visible_items(self, reset_scroll=True):
|
||||
self.visible_items = []
|
||||
for folder in self.folders:
|
||||
nodes = [node for node in folder.nodes if not self.query or search_from_list(self.query, [search_func(node) for search_func in self.search_funcs])]
|
||||
if not nodes and self.query:
|
||||
continue
|
||||
expanded = folder.folder in self.expanded or not folder.folder or bool(self.query)
|
||||
if folder.folder:
|
||||
self.visible_items.append(TreeItemWidget(f"{'-' if expanded else '+'} {folder.folder}", "", True, 0,
|
||||
lambda folder_ref=folder: self._toggle_folder(folder_ref)))
|
||||
if expanded:
|
||||
for node in nodes:
|
||||
favorite_cb = (lambda node_ref=node: self._toggle_favorite(node_ref)) if self.fav_param and node.ref != "Default" else None
|
||||
self.visible_items.append(TreeItemWidget(self.display_func(node), node.ref, False, 1 if folder.folder else 0,
|
||||
lambda node_ref=node: self._select_node(node_ref),
|
||||
favorite_cb, node.ref in self.favorites, is_expanded=expanded))
|
||||
self.option_buttons = self.visible_items
|
||||
self.options = [item.text for item in self.visible_items]
|
||||
self.scroller._items = self.visible_items
|
||||
if reset_scroll:
|
||||
self.scroller.scroll_panel.set_offset(0)
|
||||
|
||||
def _render(self, rect):
|
||||
dialog_content_rect = rl.Rectangle(rect.x + 50, rect.y + 50, rect.width - 100, rect.height - 100)
|
||||
rl.draw_rectangle_rounded(dialog_content_rect, 0.02, 20, rl.BLACK)
|
||||
|
||||
# Title on the left
|
||||
title_rect = rl.Rectangle(dialog_content_rect.x + 50, dialog_content_rect.y + 50, dialog_content_rect.width * 0.5, 70)
|
||||
gui_label(title_rect, self.title, 70, font_weight=FontWeight.BOLD)
|
||||
|
||||
# Search bar on the top right
|
||||
search_width = dialog_content_rect.width * self._search_width
|
||||
search_height = 110
|
||||
search_x = dialog_content_rect.x + dialog_content_rect.width - 50 - search_width
|
||||
search_y = dialog_content_rect.y + 40 # align roughly with title
|
||||
|
||||
self._search_rect = rl.Rectangle(search_x, search_y, search_width, search_height)
|
||||
|
||||
# Draw search field
|
||||
inset = 4
|
||||
roundness = 0.3
|
||||
input_rect = rl.Rectangle(self._search_rect.x + inset, self._search_rect.y + inset,
|
||||
self._search_rect.width - inset * 2, self._search_rect.height - inset * 2)
|
||||
|
||||
# Transparent fill (unpressed), white fill (pressed), border
|
||||
fill_color = style.TREE_DIALOG_SEARCH_BUTTON_PRESSED if self._search_pressed else style.TREE_DIALOG_TRANSPARENT
|
||||
rl.draw_rectangle_rounded(input_rect, roundness, 10, fill_color)
|
||||
rl.draw_rectangle_rounded_lines_ex(input_rect, roundness, 10, 3, style.TREE_DIALOG_SEARCH_BUTTON_BORDER)
|
||||
|
||||
# Magnifying glass icon
|
||||
icon_color = rl.Color(180, 180, 180, 240)
|
||||
cx = input_rect.x + 60
|
||||
cy = input_rect.y + input_rect.height / 2 - 5
|
||||
radius = min(input_rect.height * 0.28, 26)
|
||||
|
||||
circle_thickness = 4
|
||||
for i in range(circle_thickness):
|
||||
rl.draw_circle_lines(int(cx), int(cy), radius - i, icon_color)
|
||||
|
||||
handle_thickness = 5
|
||||
inner_x = cx + radius * 0.65
|
||||
inner_y = cy + radius * 0.65
|
||||
outer_x = cx + radius * 1.45
|
||||
outer_y = cy + radius * 1.45
|
||||
|
||||
rl.draw_line_ex(rl.Vector2(inner_x, inner_y), rl.Vector2(outer_x, outer_y), handle_thickness, icon_color)
|
||||
|
||||
# User text (query), placed after the icon if present
|
||||
if self.query:
|
||||
text_start_x = outer_x + 45
|
||||
text_rect = rl.Rectangle(text_start_x, input_rect.y, input_rect.x + input_rect.width - text_start_x - 10, input_rect.height)
|
||||
gui_label(text_rect, self.query, 70, font_weight=FontWeight.MEDIUM)
|
||||
|
||||
options_top = self._search_rect.y + self._search_rect.height + 40
|
||||
options_area_rect = rl.Rectangle(dialog_content_rect.x + 50, options_top, dialog_content_rect.width - 100,
|
||||
dialog_content_rect.height - (options_top - dialog_content_rect.y) - 210)
|
||||
|
||||
for index, option_text in enumerate(self.options):
|
||||
self.option_buttons[index].selected = (option_text == self.selection)
|
||||
self.option_buttons[index].set_button_style(ButtonStyle.PRIMARY if option_text == self.selection else ButtonStyle.NORMAL)
|
||||
self.option_buttons[index].set_rect(rl.Rectangle(0, 0, options_area_rect.width, 135))
|
||||
self.scroller.render(options_area_rect)
|
||||
|
||||
button_width = (dialog_content_rect.width - 150) / 2
|
||||
button_y_position = dialog_content_rect.y + dialog_content_rect.height - 160
|
||||
|
||||
cancel_rect = rl.Rectangle(dialog_content_rect.x + 50, button_y_position, button_width, 160)
|
||||
self.cancel_button.render(cancel_rect)
|
||||
|
||||
select_rect = rl.Rectangle(dialog_content_rect.x + 100 + button_width, button_y_position, button_width, 160)
|
||||
self.select_button.set_enabled(self.selection != self.current)
|
||||
self.select_button.render(select_rect)
|
||||
|
||||
return self._result
|
||||
|
||||
def _handle_mouse_press(self, mouse_pos):
|
||||
if self._search_rect and rl.check_collision_point_rec(mouse_pos, self._search_rect):
|
||||
self._search_pressed = True
|
||||
return True
|
||||
return super()._handle_mouse_press(mouse_pos)
|
||||
|
||||
def _handle_mouse_release(self, mouse_pos):
|
||||
clicked_search = False
|
||||
if self._search_rect and rl.check_collision_point_rec(mouse_pos, self._search_rect):
|
||||
clicked_search = self._search_pressed
|
||||
|
||||
self._search_pressed = False
|
||||
|
||||
if clicked_search:
|
||||
self._on_search_clicked()
|
||||
return True
|
||||
|
||||
return super()._handle_mouse_release(mouse_pos)
|
||||
@@ -16,6 +16,7 @@ from openpilot.system.ui.widgets.scroller_tici import Scroller
|
||||
from openpilot.system.ui.widgets.list_view import ButtonAction, ListItem, MultipleButtonAction, ToggleAction, button_item, text_item
|
||||
|
||||
if gui_app.sunnypilot_ui():
|
||||
from openpilot.system.ui.sunnypilot.widgets.list_view import button_item_sp as button_item
|
||||
from openpilot.system.ui.sunnypilot.widgets.list_view import ListItemSP as ListItem
|
||||
from openpilot.system.ui.sunnypilot.widgets.list_view import ToggleActionSP as ToggleAction
|
||||
from openpilot.system.ui.sunnypilot.widgets.list_view import MultipleButtonActionSP as MultipleButtonAction
|
||||
|
||||
Reference in New Issue
Block a user