From 7635dec6182705b9516b400521eac447f3eb3fed Mon Sep 17 00:00:00 2001 From: stef <19478336+stefpi@users.noreply.github.com> Date: Fri, 18 Sep 2026 00:53:39 -0400 Subject: [PATCH] ui: qr widget (#38951) * rewrite pairing dialog into nice qr widget * simplify, move refresh to device pairing dialog * fix * refactor --- .../settings/device/green_settings.png | 3 + .../selfdrive/ui/mici/layouts/onboarding.py | 25 +---- .../ui/mici/layouts/settings/device.py | 54 ++++++++++- .../ui/mici/tests/test_widget_leaks.py | 2 +- .../ui/mici/widgets/pairing_dialog.py | 95 ------------------- openpilot/selfdrive/ui/mici/widgets/qr.py | 37 ++++++++ 6 files changed, 95 insertions(+), 121 deletions(-) create mode 100644 openpilot/selfdrive/assets/icons_mici/settings/device/green_settings.png delete mode 100644 openpilot/selfdrive/ui/mici/widgets/pairing_dialog.py create mode 100644 openpilot/selfdrive/ui/mici/widgets/qr.py diff --git a/openpilot/selfdrive/assets/icons_mici/settings/device/green_settings.png b/openpilot/selfdrive/assets/icons_mici/settings/device/green_settings.png new file mode 100644 index 0000000000..230beb85dd --- /dev/null +++ b/openpilot/selfdrive/assets/icons_mici/settings/device/green_settings.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:25db08a9038b5faa3b200ef5625cb216d7f4b31ea3d49fb0395f9b726bfaf8e6 +size 13135 diff --git a/openpilot/selfdrive/ui/mici/layouts/onboarding.py b/openpilot/selfdrive/ui/mici/layouts/onboarding.py index 0590440c6b..7cfba8f541 100644 --- a/openpilot/selfdrive/ui/mici/layouts/onboarding.py +++ b/openpilot/selfdrive/ui/mici/layouts/onboarding.py @@ -3,7 +3,7 @@ import numpy as np import pyray as rl from collections.abc import Callable from openpilot.common.filter_simple import FirstOrderFilter -from openpilot.common.qrcode import make_texture +from openpilot.selfdrive.ui.mici.widgets.qr import QR from openpilot.system.ui.lib.application import FontWeight, gui_app, TextAlignment from openpilot.system.ui.widgets import Widget from openpilot.system.ui.widgets.button import SmallCircleIconButton @@ -275,27 +275,6 @@ class TrainingGuide(NavWidget): self._steps[0].render(self._rect) -class QRCodeWidget(Widget): - def __init__(self, url: str, size: int = 170): - super().__init__() - self.set_rect(rl.Rectangle(0, 0, size, size)) - self._size = size - self._qr_texture: rl.Texture | None = None - self._generate_qr(url) - - def _generate_qr(self, url: str): - self._qr_texture = make_texture(url, inverted=True) - - def _render(self, _): - if self._qr_texture: - scale = self._size / self._qr_texture.height - rl.draw_texture_ex(self._qr_texture, rl.Vector2(round(self._rect.x), round(self._rect.y)), 0.0, scale, rl.WHITE) - - def __del__(self): - if self._qr_texture and self._qr_texture.id != 0: - rl.unload_texture(self._qr_texture) - - class TermsPage(Scroller): def __init__(self, on_accept, on_decline): super().__init__() @@ -312,7 +291,7 @@ class TermsPage(Scroller): self._terms_header, GreyBigButton("swipe for QR code", "or go to https://comma.ai/terms", gui_app.texture("icons_mici/setup/small_slider/slider_arrow.png", 64, 56, flip_x=True)), - QRCodeWidget("https://comma.ai/terms"), + QR("https://comma.ai/terms"), self._must_accept_card, self._accept_button, self._decline_button, diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/device.py b/openpilot/selfdrive/ui/mici/layouts/settings/device.py index c85e484376..0a665f7a2d 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/device.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/device.py @@ -1,16 +1,19 @@ import os import pyray as rl +import time from collections.abc import Callable from typing import Union +from openpilot.common.api import Api +from openpilot.common.swaglog import cloudlog from openpilot.common.basedir import BASEDIR from openpilot.common.params import Params from openpilot.common.time_helpers import system_time_valid from openpilot.system.ui.widgets.scroller import NavRawScrollPanel, NavScroller from openpilot.selfdrive.ui.mici.widgets.info import InfoLayoutMici -from openpilot.selfdrive.ui.mici.widgets.button import BigButton, BigCircleButton +from openpilot.selfdrive.ui.mici.widgets.button import BigButton, BigCircleButton, GreyBigButton from openpilot.selfdrive.ui.mici.widgets.dialog import BigDialog, BigConfirmationDialog -from openpilot.selfdrive.ui.mici.widgets.pairing_dialog import PairingDialog +from openpilot.selfdrive.ui.mici.widgets.qr import QR from openpilot.selfdrive.ui.mici.onroad.cabin_camera_dialog import CabinCameraDialog from openpilot.selfdrive.ui.mici.layouts.onboarding import TrainingGuide, TermsPage from openpilot.system.ui.lib.application import gui_app, MousePos @@ -96,6 +99,53 @@ class DeviceInfoLayoutMici(InfoLayoutMici): super().__init__("device ID", params.get("DongleId") or 'N/A', "serial", params.get("HardwareSerial") or 'N/A', width=380) +class PairingDialog(NavScroller): + """Pair the device with comma connect.""" + + QR_REFRESH_INTERVAL = 300 # 5 minutes in seconds + + def __init__(self): + super().__init__() + self._params = Params() + self._last_pairing_qr_generation = float("-inf") + self._qr = QR(self._get_pairing_url()) + self._scroller._show_scroll_indicator = False + self._scroller.add_widgets([ + self._qr, + GreyBigButton("finish setup", "scan to pair device\nwith connect", + gui_app.texture("icons_mici/settings/device/green_settings.png", 64, 64)), + GreyBigButton("", "connect lets you review recent driving footage and bookmark events."), + ]) + + def _get_pairing_url(self) -> str: + try: + dongle_id = self._params.get("DongleId") or "" + token = Api(dongle_id).get_token({'pair': True}) + except Exception as e: + cloudlog.warning(f"Failed to get pairing token: {e}") + token = "" + return f"https://connect.comma.ai/?pair={token}" + + def _update_layout_rects(self): + super()._update_layout_rects() + self._qr.set_rect(rl.Rectangle(self._qr.rect.x, self._qr.rect.y, self._rect.height, self._rect.height)) + + def _render(self, rect: rl.Rectangle): + current_time = time.monotonic() + if current_time - self._last_pairing_qr_generation >= self.QR_REFRESH_INTERVAL: + self._qr._url = self._get_pairing_url() + if self._qr._texture and self._qr._texture.id != 0: + rl.unload_texture(self._qr._texture) + self._qr._texture = self._qr._generate_qr_code() + self._last_pairing_qr_generation = current_time + super()._render(rect) + + def _update_state(self): + super()._update_state() + if ui_state.prime_state.is_paired() and not self.is_dismissing: + self.dismiss() + + class PairBigButton(BigButton): def __init__(self): super().__init__("pair", "connect.comma.ai", gui_app.texture("icons_mici/settings/comma_icon.png", 33, 60)) diff --git a/openpilot/selfdrive/ui/mici/tests/test_widget_leaks.py b/openpilot/selfdrive/ui/mici/tests/test_widget_leaks.py index c0f1ac1510..a329a00e6b 100755 --- a/openpilot/selfdrive/ui/mici/tests/test_widget_leaks.py +++ b/openpilot/selfdrive/ui/mici/tests/test_widget_leaks.py @@ -52,7 +52,7 @@ class TestWidgetLeaks(OpenpilotTestCase): # mici dialogs from openpilot.selfdrive.ui.mici.layouts.onboarding import TrainingGuide as MiciTrainingGuide, OnboardingWindow as MiciOnboardingWindow from openpilot.selfdrive.ui.mici.onroad.cabin_camera_dialog import CabinCameraDialog as MiciCabinCameraDialog - from openpilot.selfdrive.ui.mici.widgets.pairing_dialog import PairingDialog as MiciPairingDialog + from openpilot.selfdrive.ui.mici.layouts.settings.device import PairingDialog as MiciPairingDialog from openpilot.selfdrive.ui.mici.widgets.dialog import BigDialog, BigConfirmationDialog, BigInputDialog from openpilot.selfdrive.ui.mici.layouts.settings.device import MiciFccModal diff --git a/openpilot/selfdrive/ui/mici/widgets/pairing_dialog.py b/openpilot/selfdrive/ui/mici/widgets/pairing_dialog.py deleted file mode 100644 index 7e05bc2fa0..0000000000 --- a/openpilot/selfdrive/ui/mici/widgets/pairing_dialog.py +++ /dev/null @@ -1,95 +0,0 @@ -import pyray as rl -import time - -from openpilot.common.api import Api -from openpilot.common.qrcode import make_texture -from openpilot.common.swaglog import cloudlog -from openpilot.common.params import Params -from openpilot.selfdrive.ui.ui_state import ui_state -from openpilot.system.ui.widgets.nav_widget import NavWidget -from openpilot.system.ui.lib.application import FontWeight, gui_app -from openpilot.system.ui.widgets.label import UnifiedLabel - - -class PairingDialog(NavWidget): - """Dialog for device pairing with QR code.""" - - QR_REFRESH_INTERVAL = 300 # 5 minutes in seconds - - def __init__(self): - super().__init__() - self._params = Params() - self._qr_texture: rl.Texture | None = None - self._last_qr_generation = float("-inf") - - self._txt_pair = gui_app.texture("icons_mici/settings/device/pair.png", 33, 60) - self._pair_label = UnifiedLabel("pair with comma connect", font_size=48, font_weight=FontWeight.BOLD, line_height=0.8) - - def _get_pairing_url(self) -> str: - try: - dongle_id = self._params.get("DongleId") or "" - token = Api(dongle_id).get_token({'pair': True}) - except Exception as e: - cloudlog.warning(f"Failed to get pairing token: {e}") - token = "" - return f"https://connect.comma.ai/?pair={token}" - - def _generate_qr_code(self) -> None: - try: - if self._qr_texture and self._qr_texture.id != 0: - rl.unload_texture(self._qr_texture) - self._qr_texture = make_texture(self._get_pairing_url(), inverted=True) - except Exception as e: - cloudlog.warning(f"QR code generation failed: {e}") - self._qr_texture = None - - def _check_qr_refresh(self) -> None: - current_time = time.monotonic() - if current_time - self._last_qr_generation >= self.QR_REFRESH_INTERVAL: - self._generate_qr_code() - self._last_qr_generation = current_time - - def _update_state(self): - super()._update_state() - if ui_state.prime_state.is_paired() and not self.is_dismissing: - self.dismiss() - - def _render(self, rect: rl.Rectangle): - self._check_qr_refresh() - - self._render_qr_code() - - label_x = self._rect.x + 8 + self._rect.height + 24 - self._pair_label.set_max_width(int(self._rect.width - label_x)) - self._pair_label.set_position(label_x, self._rect.y + 16) - self._pair_label.render() - - rl.draw_texture_ex(self._txt_pair, rl.Vector2(label_x, self._rect.y + self._rect.height - self._txt_pair.height - 16), - 0.0, 1.0, rl.Color(255, 255, 255, int(255 * 0.35))) - - def _render_qr_code(self) -> None: - if not self._qr_texture: - error_font = gui_app.font(FontWeight.BOLD) - rl.draw_text_ex( - error_font, "QR Code Error", rl.Vector2(self._rect.x + 20, self._rect.y + self._rect.height // 2 - 15), 30, 0.0, rl.RED - ) - return - - scale = self._rect.height / self._qr_texture.height - pos = rl.Vector2(round(self._rect.x + 8), round(self._rect.y)) - rl.draw_texture_ex(self._qr_texture, pos, 0.0, scale, rl.WHITE) - - def __del__(self): - if self._qr_texture and self._qr_texture.id != 0: - rl.unload_texture(self._qr_texture) - - -if __name__ == "__main__": - gui_app.init_window("pairing device") - pairing = PairingDialog() - gui_app.push_widget(pairing) - try: - for _ in gui_app.render(): - pass - finally: - del pairing diff --git a/openpilot/selfdrive/ui/mici/widgets/qr.py b/openpilot/selfdrive/ui/mici/widgets/qr.py new file mode 100644 index 0000000000..f0ff0d0ed9 --- /dev/null +++ b/openpilot/selfdrive/ui/mici/widgets/qr.py @@ -0,0 +1,37 @@ +import pyray as rl + +from openpilot.common.swaglog import cloudlog +from openpilot.system.ui.lib.application import FontWeight +from openpilot.system.ui.widgets import Widget +from openpilot.system.ui.widgets.label import Label +from openpilot.common.qrcode import make_texture + + +class QR(Widget): + def __init__(self, url: str, width: int = 170): + super().__init__() + self._url = url + + self._texture = self._generate_qr_code() + + self.set_rect(rl.Rectangle(0, 0, width, width)) + self._error = Label("QR Code Error", font_size=30, font_weight=FontWeight.BOLD, text_color=rl.RED) + + def _generate_qr_code(self): + try: + return make_texture(self._url, inverted=True) + except Exception as e: + cloudlog.warning(f"QR code generation failed: {e}") + return None + + def _render(self, rect: rl.Rectangle): + if not self._texture: + self._error.render(rect) + return + scale = rect.height / self._texture.height + pos = rl.Vector2(round(rect.x), round(rect.y)) + rl.draw_texture_ex(self._texture, pos, 0.0, scale, rl.WHITE) + + def __del__(self): + if self._texture and self._texture.id != 0: + rl.unload_texture(self._texture)