diff --git a/selfdrive/ui/mici/layouts/main.py b/selfdrive/ui/mici/layouts/main.py index b52f9ed39..a83ebd196 100644 --- a/selfdrive/ui/mici/layouts/main.py +++ b/selfdrive/ui/mici/layouts/main.py @@ -11,6 +11,9 @@ from openpilot.system.ui.widgets import Widget from openpilot.system.ui.widgets.scroller import Scroller from openpilot.system.ui.lib.application import gui_app +if gui_app.sunnypilot_ui(): + from openpilot.selfdrive.ui.sunnypilot.mici.layouts.settings import SettingsLayoutSP as SettingsLayout + ONROAD_DELAY = 2.5 # seconds diff --git a/selfdrive/ui/sunnypilot/mici/layouts/__init__.py b/selfdrive/ui/sunnypilot/mici/layouts/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/selfdrive/ui/sunnypilot/mici/layouts/settings.py b/selfdrive/ui/sunnypilot/mici/layouts/settings.py new file mode 100644 index 000000000..c6a2d5825 --- /dev/null +++ b/selfdrive/ui/sunnypilot/mici/layouts/settings.py @@ -0,0 +1,39 @@ +""" +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 + +from openpilot.selfdrive.ui.mici.layouts.settings import settings as OP +from openpilot.selfdrive.ui.mici.widgets.button import BigButton +from openpilot.selfdrive.ui.sunnypilot.mici.layouts.sunnylink import SunnylinkLayoutMici + +ICON_SIZE = 70 + +OP.PanelType = IntEnum( # type: ignore + "PanelType", + [es.name for es in OP.PanelType] + [ + "SUNNYLINK", + ], + start=0, +) + + +class SettingsLayoutSP(OP.SettingsLayout): + def __init__(self): + OP.SettingsLayout.__init__(self) + + sunnylink_btn = BigButton("sunnylink", "", "icons_mici/settings/developer/ssh.png") + sunnylink_btn.set_click_callback(lambda: self._set_current_panel(OP.PanelType.SUNNYLINK)) + self._panels.update({ + OP.PanelType.SUNNYLINK: OP.PanelInfo("sunnylink", SunnylinkLayoutMici(back_callback=lambda: self._set_current_panel(None))), + }) + + items = self._scroller._items.copy() + + items.insert(1, sunnylink_btn) + self._scroller._items.clear() + for item in items: + self._scroller.add_widget(item) diff --git a/selfdrive/ui/sunnypilot/mici/layouts/sunnylink.py b/selfdrive/ui/sunnypilot/mici/layouts/sunnylink.py new file mode 100644 index 000000000..2ab035c1c --- /dev/null +++ b/selfdrive/ui/sunnypilot/mici/layouts/sunnylink.py @@ -0,0 +1,192 @@ +""" +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 collections.abc import Callable + +import pyray as rl +from cereal import custom +from openpilot.selfdrive.ui.mici.widgets.dialog import BigDialog, BigConfirmationDialogV2 +from openpilot.selfdrive.ui.sunnypilot.mici.widgets.sunnylink_pairing_dialog import SunnylinkPairingDialog +from openpilot.sunnypilot.sunnylink.api import UNREGISTERED_SUNNYLINK_DONGLE_ID +from openpilot.system.ui.lib.multilang import tr + +from openpilot.system.ui.widgets.scroller import Scroller +from openpilot.selfdrive.ui.mici.widgets.button import BigButton, BigToggle +from openpilot.system.ui.lib.application import gui_app, MousePos +from openpilot.system.ui.widgets import NavWidget +from openpilot.selfdrive.ui.ui_state import ui_state + + +class SunnylinkLayoutMici(NavWidget): + def __init__(self, back_callback: Callable): + super().__init__() + self.set_back_callback(back_callback) + self._restore_in_progress = False + self._backup_in_progress = False + self._sunnylink_enabled = ui_state.params.get("SunnylinkEnabled") + + self._sunnylink_toggle = BigToggle(text="", + initial_state=self._sunnylink_enabled, + toggle_callback=SunnylinkLayoutMici._sunnylink_toggle_callback) + self._sunnylink_sponsor_button = SunnylinkPairBigButton(sponsor_pairing=False) + self._sunnylink_pair_button = SunnylinkPairBigButton(sponsor_pairing=True) + self._backup_btn = BigButton(tr("backup settings"), "", "") + self._backup_btn.set_click_callback(lambda: self._handle_backup_restore_btn(restore=False)) + self._restore_btn = BigButton(tr("restore settings"), "", "") + self._restore_btn.set_click_callback(lambda: self._handle_backup_restore_btn(restore=True)) + self._sunnylink_uploader_toggle = BigToggle(text=tr("sunnylink uploader"), initial_state=False, + toggle_callback=SunnylinkLayoutMici._sunnylink_uploader_callback) + + self._scroller = Scroller([ + self._sunnylink_toggle, + self._sunnylink_sponsor_button, + self._sunnylink_pair_button, + self._backup_btn, + self._restore_btn, + self._sunnylink_uploader_toggle + ], snap_items=False) + + def _update_state(self): + super()._update_state() + self._sunnylink_enabled = ui_state.sunnylink_enabled + self._sunnylink_toggle.set_text(tr("enable sunnylink")) + self._sunnylink_pair_button.set_visible(self._sunnylink_enabled) + self._sunnylink_sponsor_button.set_visible(self._sunnylink_enabled) + self._backup_btn.set_visible(self._sunnylink_enabled) + self._restore_btn.set_visible(self._sunnylink_enabled) + self._sunnylink_uploader_toggle.set_visible(self._sunnylink_enabled) + self.handle_backup_restore_progress() + + if ui_state.sunnylink_state.is_sponsor(): + self._sunnylink_sponsor_button.set_text(tr("thanks")) + self._sunnylink_sponsor_button.set_value(ui_state.sunnylink_state.get_sponsor_tier().name.lower()) + self._sunnylink_sponsor_button.set_enabled(False) + else: + self._sunnylink_sponsor_button.set_text(tr("sponsor")) + self._sunnylink_sponsor_button.set_value("") + + if ui_state.sunnylink_state.is_paired(): + self._sunnylink_pair_button.set_text(tr("paired")) + else: + self._sunnylink_pair_button.set_text(tr("pair")) + + def show_event(self): + super().show_event() + self._scroller.show_event() + ui_state.update_params() + + def _render(self, rect: rl.Rectangle): + self._scroller.render(rect) + + @staticmethod + def _sunnylink_toggle_callback(state: bool): + ui_state.params.put_bool("SunnylinkEnabled", state) + ui_state.update_params() + + @staticmethod + def _sunnylink_uploader_callback(state: bool): + ui_state.params.put_bool("EnableSunnylinkUploader", state) + + def _handle_backup_restore_btn(self, restore: bool = False): + lbl = tr("slide to restore") if restore else tr("slide to backup") + icon = "icons_mici/settings/device/update.png" + dlg = BigConfirmationDialogV2(lbl, icon, confirm_callback=self._restore_handler if restore else self._backup_handler) + gui_app.set_modal_overlay(dlg) + + def _backup_handler(self): + self._backup_in_progress = True + self._backup_btn.set_enabled(False) + ui_state.params.put_bool("BackupManager_CreateBackup", True) + + def _restore_handler(self): + 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 + self._backup_btn.set_text(tr("backing up")) + text = tr(f"{backup_progress}%") + self._backup_btn.set_value(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")) + self._backup_btn.set_value(tr("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 + gui_app.set_modal_overlay(BigDialog(title=tr("settings backed up"), description="")) + 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 + self._restore_btn.set_text(tr("restoring")) + text = tr(f"{restore_progress}%") + self._restore_btn.set_value(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")) + self._restore_btn.set_value(tr("failed")) + gui_app.set_modal_overlay(BigDialog(title=tr("unable to restore"), description="try again later.")) + + elif (restore_status == custom.BackupManagerSP.Status.completed or + (restore_status == custom.BackupManagerSP.Status.idle and restore_progress == 100.0)): + self._restore_in_progress = False + gui_app.set_modal_overlay(BigConfirmationDialogV2( + title="slide to restart", icon="icons_mici/settings/device/reboot.png", + confirm_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._backup_btn.set_value("") + self._restore_btn.set_enabled(can_enable) + self._restore_btn.set_text(tr("restore settings")) + self._restore_btn.set_value("") + + +class SunnylinkPairBigButton(BigButton): + def __init__(self, sponsor_pairing: bool = False): + self.sponsor_pairing = sponsor_pairing + super().__init__("", "", "") + + def _update_state(self): + super()._update_state() + + def _handle_mouse_release(self, mouse_pos: MousePos): + super()._handle_mouse_release(mouse_pos) + + dlg: BigDialog | SunnylinkPairingDialog | None = None + if UNREGISTERED_SUNNYLINK_DONGLE_ID == (ui_state.params.get("SunnylinkDongleId") or UNREGISTERED_SUNNYLINK_DONGLE_ID): + dlg = BigDialog(tr("sunnylink Dongle ID not found. Please reboot & try again."), "") + elif self.sponsor_pairing: + dlg = SunnylinkPairingDialog(sponsor_pairing=True) + elif not self.sponsor_pairing: + dlg = SunnylinkPairingDialog(sponsor_pairing=False) + if dlg: + gui_app.set_modal_overlay(dlg) diff --git a/selfdrive/ui/sunnypilot/mici/widgets/__init__.py b/selfdrive/ui/sunnypilot/mici/widgets/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/selfdrive/ui/sunnypilot/mici/widgets/sunnylink_pairing_dialog.py b/selfdrive/ui/sunnypilot/mici/widgets/sunnylink_pairing_dialog.py new file mode 100644 index 000000000..e2cef2fa0 --- /dev/null +++ b/selfdrive/ui/sunnypilot/mici/widgets/sunnylink_pairing_dialog.py @@ -0,0 +1,57 @@ +""" +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.mici.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.widgets import NavWidget +from openpilot.system.ui.widgets.label import MiciLabel + + +class SunnylinkPairingDialog(PairingDialog): + """Dialog for device pairing with QR code.""" + + def __init__(self, sponsor_pairing: bool = False): + PairingDialog.__init__(self) + self._sponsor_pairing = sponsor_pairing + label_text = tr("pair with sunnylink") if sponsor_pairing else tr("become a sunnypilot sponsor") + self._pair_label = MiciLabel(label_text, 48, font_weight=FontWeight.BOLD, + color=rl.Color(255, 255, 255, int(255 * 0.9)), line_height=40, wrap_text=True) + + 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): + NavWidget._update_state(self) + + +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