From 1f967668a545b34e2a3ffc85d5e56afdd837efd7 Mon Sep 17 00:00:00 2001 From: Nayan Date: Thu, 4 Dec 2025 01:51:08 -0500 Subject: [PATCH 1/5] ui: add sunnypilot font (#1552) add sp font --- selfdrive/assets/fonts/Audiowide-Regular.ttf | 3 +++ system/ui/lib/application.py | 1 + 2 files changed, 4 insertions(+) create mode 100644 selfdrive/assets/fonts/Audiowide-Regular.ttf diff --git a/selfdrive/assets/fonts/Audiowide-Regular.ttf b/selfdrive/assets/fonts/Audiowide-Regular.ttf new file mode 100644 index 0000000000..1b6913947b --- /dev/null +++ b/selfdrive/assets/fonts/Audiowide-Regular.ttf @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:434a720871336d359378beff5ebff3f9fd654d958693d272c7c6f2e271c7e41c +size 47676 diff --git a/system/ui/lib/application.py b/system/ui/lib/application.py index e4850e9cbc..60a110293d 100644 --- a/system/ui/lib/application.py +++ b/system/ui/lib/application.py @@ -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" From d75d80b885de1e2f7339f29135aa86e444fdbeda Mon Sep 17 00:00:00 2001 From: Jason Wen Date: Thu, 4 Dec 2025 17:53:33 -0500 Subject: [PATCH 2/5] ui: add right-aligned value display support in `ListItemSP` (#1554) * ui: add right-aligned value display support in `ListItemSP` * lint * styles --- system/ui/sunnypilot/lib/styles.py | 1 + system/ui/sunnypilot/widgets/list_view.py | 27 ++++++++++++++++++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/system/ui/sunnypilot/lib/styles.py b/system/ui/sunnypilot/lib/styles.py index 3597509dc5..c1f8c3926e 100644 --- a/system/ui/sunnypilot/lib/styles.py +++ b/system/ui/sunnypilot/lib/styles.py @@ -17,6 +17,7 @@ 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 # Toggle Control diff --git a/system/ui/sunnypilot/widgets/list_view.py b/system/ui/sunnypilot/widgets/list_view.py index f05de81397..315840f7ea 100644 --- a/system/ui/sunnypilot/widgets/list_view.py +++ b/system/ui/sunnypilot/widgets/list_view.py @@ -8,9 +8,10 @@ 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.label import gui_label from openpilot.system.ui.widgets.list_view import ListItem, ToggleAction, ItemAction, MultipleButtonAction, _resolve_value from openpilot.system.ui.sunnypilot.lib.styles import style from openpilot.system.ui.sunnypilot.widgets.option_control import OptionControlSP, LABEL_WIDTH @@ -86,6 +87,17 @@ class ListItemSP(ListItem): 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) + + def set_right_value(self, value: str | Callable[[], str]): + self._right_value_source = value + + @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) @@ -154,6 +166,19 @@ class ListItemSP(ListItem): 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, 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=style.ITEM_TEXT_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: if self.callback: From c6818bd07f8cadb7a42880f4a814442575875c24 Mon Sep 17 00:00:00 2001 From: Jason Wen Date: Thu, 4 Dec 2025 21:41:58 -0500 Subject: [PATCH 3/5] ui: customizable value colors for `ButtonActionSP` and `ListViewSP` (#1555) ui: introduce customizable value colors for `ButtonActionSP` and `ListViewSP` --- selfdrive/ui/layouts/settings/device.py | 3 ++ selfdrive/ui/layouts/settings/software.py | 3 ++ system/ui/sunnypilot/lib/styles.py | 2 + system/ui/sunnypilot/widgets/list_view.py | 57 +++++++++++++++++++---- system/ui/widgets/network.py | 1 + 5 files changed, 56 insertions(+), 10 deletions(-) diff --git a/selfdrive/ui/layouts/settings/device.py b/selfdrive/ui/layouts/settings/device.py index 078623c882..8830ef946f 100644 --- a/selfdrive/ui/layouts/settings/device.py +++ b/selfdrive/ui/layouts/settings/device.py @@ -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."), diff --git a/selfdrive/ui/layouts/settings/software.py b/selfdrive/ui/layouts/settings/software.py index 4b8b7015f8..c4f899d798 100644 --- a/selfdrive/ui/layouts/settings/software.py +++ b/selfdrive/ui/layouts/settings/software.py @@ -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 diff --git a/system/ui/sunnypilot/lib/styles.py b/system/ui/sunnypilot/lib/styles.py index c1f8c3926e..3e1d2dc408 100644 --- a/system/ui/sunnypilot/lib/styles.py +++ b/system/ui/sunnypilot/lib/styles.py @@ -20,6 +20,8 @@ class Base: 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) diff --git a/system/ui/sunnypilot/widgets/list_view.py b/system/ui/sunnypilot/widgets/list_view.py index 315840f7ea..bd68d35a48 100644 --- a/system/ui/sunnypilot/widgets/list_view.py +++ b/system/ui/sunnypilot/widgets/list_view.py @@ -12,7 +12,8 @@ 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.label import gui_label -from openpilot.system.ui.widgets.list_view import ListItem, ToggleAction, ItemAction, MultipleButtonAction, _resolve_value +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 @@ -24,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): @@ -89,9 +118,11 @@ class ListItemSP(ListItem): 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]): + 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: @@ -127,17 +158,17 @@ class ListItemSP(ListItem): 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 + 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 - if isinstance(self.action_item, ToggleAction): - action_x = item_rect.x - else: - action_x = item_rect.x + item_rect.width - action_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) + + 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: @@ -176,7 +207,7 @@ class ListItemSP(ListItem): style.ITEM_BASE_HEIGHT, ) if value_rect.width > 0: - gui_label(value_rect, value_text, font_size=style.ITEM_TEXT_FONT_SIZE, color=style.ITEM_TEXT_VALUE_COLOR, font_weight=FontWeight.NORMAL, + 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 @@ -236,3 +267,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) diff --git a/system/ui/widgets/network.py b/system/ui/widgets/network.py index 71755cc284..2aeefd5444 100644 --- a/system/ui/widgets/network.py +++ b/system/ui/widgets/network.py @@ -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 From b5b170b65a09b0725bca77fa0775e27059c2bc7e Mon Sep 17 00:00:00 2001 From: Jason Wen Date: Fri, 5 Dec 2025 00:30:48 -0500 Subject: [PATCH 4/5] ui: sunnypilot sponsor tier color mapping (#1556) * ui: sunnypilot sponsor tier color mapping * lint --- sunnypilot/sunnylink/sunnylink_state.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/sunnypilot/sunnylink/sunnylink_state.py b/sunnypilot/sunnylink/sunnylink_state.py index 4d0b397e03..13b5ad81ec 100644 --- a/sunnypilot/sunnylink/sunnylink_state.py +++ b/sunnypilot/sunnylink/sunnylink_state.py @@ -8,11 +8,13 @@ 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): @@ -201,5 +203,19 @@ class SunnylinkState: 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() From 1d9bda65fea8da24a2804a5a3605fe924eeaed18 Mon Sep 17 00:00:00 2001 From: Nayan Date: Fri, 5 Dec 2025 01:01:55 -0500 Subject: [PATCH 5/5] ui: sunnylink panel (#1494) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * param to control stock vs sp ui * init styles * SP Toggles * Lint * optimizations * sp raylib preview * fix callback * fix ui preview * sunnylink state * introducing ui_state_sp for py * poll from ui_state_sp * cloudlog & ruff * param to control stock vs sp ui * better * better padding * this * listitem -> listitemsp * add show_description method * remove padding from line separator. like, WHY? 😩😩 * ui: `GuiApplicationExt` * add to readme * use gui_app.sunnypilot_ui() * use gui_app.sunnypilot_ui() * fetch only when connected to network * sponsor & pairing qr * init panel elements * backup & restore * fruit loops * update * enable, disable, enable, disable * handle layout updates * not needed * change it up * better * scroller -> scroller_tici * optimizations * remove Params * fix button disablement * ui_state_sp changes * keep enabled * add header text * dad jokes? * no * lint? Lint! * final touches * add sp font * use sp font * some * ui: add right-aligned value display support in `ListItem` (in another pr) * display sunnylink device id * display sunnylink device id and sponsor tiers * ui: add right-aligned value display support in `ListItemSP` * lint * styles * lint * ui: introduce customizable value colors for `ButtonActionSP` and `ListViewSP` * support * convert to str * disable if paired * colored sponsors * hide and disable pairing button if paired * texts * ui: sunnypilot sponsor tier color mapping * lint * dongle id for ui preview --------- Co-authored-by: Jason Wen Co-authored-by: James Vecellio-Grant <159560811+Discountchubbs@users.noreply.github.com> --- .../sunnypilot/layouts/settings/settings.py | 2 +- .../sunnypilot/layouts/settings/sunnylink.py | 326 +++++++++++++++++- .../ui/tests/test_ui/raylib_screenshots.py | 1 + .../widgets/sunnylink_pairing_dialog.py | 137 ++++++++ 4 files changed, 459 insertions(+), 7 deletions(-) create mode 100644 system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py diff --git a/selfdrive/ui/sunnypilot/layouts/settings/settings.py b/selfdrive/ui/sunnypilot/layouts/settings/settings.py index 905338552f..45c9b93483 100644 --- a/selfdrive/ui/sunnypilot/layouts/settings/settings.py +++ b/selfdrive/ui/sunnypilot/layouts/settings/settings.py @@ -112,7 +112,7 @@ 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"), NetworkUISP(wifi_manager), icon="icons/network.png"), - OP.PanelType.SUNNYLINK: PanelInfo(tr_noop("sunnylink"), SunnylinkLayout(), icon="icons/shell.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"), diff --git a/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py b/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py index b1e12c17ab..7125fdf3f3 100644 --- a/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py +++ b/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py @@ -4,27 +4,341 @@ 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 and not ui_state.sunnylink_state.is_sponsor()) + + 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.set_visible(lambda: self._sunnylink_enabled and not ui_state.sunnylink_state.is_paired()) + self._pair_btn.action_item.set_enabled(self._sunnylink_enabled and not ui_state.sunnylink_state.is_paired()) + 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) diff --git a/selfdrive/ui/tests/test_ui/raylib_screenshots.py b/selfdrive/ui/tests/test_ui/raylib_screenshots.py index 164358bfce..e209ab8060 100755 --- a/selfdrive/ui/tests/test_ui/raylib_screenshots.py +++ b/selfdrive/ui/tests/test_ui/raylib_screenshots.py @@ -369,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) diff --git a/system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py b/system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py new file mode 100644 index 0000000000..4fab79caf1 --- /dev/null +++ b/system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py @@ -0,0 +1,137 @@ +""" +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 + + 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): + if ui_state.sunnylink_state.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