""" 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 pyray as rl from openpilot.cereal import custom from openpilot.selfdrive.ui.sunnypilot.layouts.onboarding import SunnylinkConsentPage 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.list_view import button_item_sp from openpilot.system.ui.sunnypilot.widgets.list_view import toggle_item_sp from openpilot.system.ui.sunnypilot.widgets.sunnylink_pairing_dialog import SunnylinkPairingDialog from openpilot.system.ui.widgets import Widget, DialogResult 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 dual_button_item from openpilot.system.ui.widgets.scroller_tici import Scroller, LineSeparator from openpilot.common.version import sunnylink_consent_version 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.NORMAL, 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.NORMAL, 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.NORMAL, 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._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=False, spacing=0) def _initialize_items(self): 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_sp( 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_sp( 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: return ui_state.params.get("SunnylinkDongleId") or tr("N/A") def _handle_pair_btn(self, sponsor_pairing: bool = False): sunnylink_dongle_id = self._get_sunnylink_dongle_id() if sunnylink_dongle_id == UNREGISTERED_SUNNYLINK_DONGLE_ID: gui_app.push_widget(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.push_widget(self._sunnylink_pairing_dialog) def _handle_backup_btn(self): backup_dialog = ConfirmDialog(text=tr("Are you sure you want to backup your current sunnypilot settings?"), confirm_text="Backup", callback=self._backup_handler) gui_app.push_widget(backup_dialog) 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", callback=self._restore_handler) gui_app.push_widget(restore_dialog) 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.push_widget(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.push_widget(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 = ConfirmDialog(tr("Settings restored. Confirm to restart the interface."), tr("OK"), cancel_text="", callback=lambda _: gui_app.request_close()) gui_app.push_widget(dialog) 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): sl_consent: bool = ui_state.params.get("CompletedSunnylinkConsentVersion") == sunnylink_consent_version sl_enabled: bool = ui_state.params.get_bool("SunnylinkEnabled") if state and not sl_consent and not sl_enabled: def on_consent_done(): enabled = ui_state.params.get_bool("SunnylinkEnabled") self._update_description(enabled) gui_app.pop_widget() sl_terms_dlg = SunnylinkConsentPage(done_callback=on_consent_done) gui_app.push_widget(sl_terms_dlg) else: ui_state.params.put_bool("SunnylinkEnabled", state) self._update_description(state) def _update_description(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() ui_state.sunnylink_state.set_settings_open(True) self._scroller.show_event() self._sunnylink_description.set_visible(False) def hide_event(self): super().hide_event() ui_state.sunnylink_state.set_settings_open(False)