import pyray as rl import qrcode import numpy as np import time from openpilot.common.api import Api from openpilot.common.swaglog import cloudlog from openpilot.common.params import Params from openpilot.system.ui.lib.application import FontWeight, gui_app from openpilot.system.ui.lib.wrap_text import wrap_text from openpilot.system.ui.lib.text_measure import measure_text_cached class PairingDialog: """Dialog for device pairing with QR code.""" QR_REFRESH_INTERVAL = 300 # 5 minutes in seconds def __init__(self): self.params = Params() self.qr_texture: rl.Texture | None = None self.last_qr_generation = 0 def _get_pairing_url(self) -> str: try: dongle_id = self.params.get("DongleId", encoding='utf8') or "" token = Api(dongle_id).get_token() except Exception as e: cloudlog.warning(f"Failed to get pairing token: {e}") token = "" return f"https://connect.comma.ai/setup?token={token}" def _generate_qr_code(self) -> None: try: qr = qrcode.QRCode(version=1, error_correction=qrcode.constants.ERROR_CORRECT_L, box_size=10, border=4) qr.add_data(self._get_pairing_url()) qr.make(fit=True) pil_img = qr.make_image(fill_color="black", back_color="white").convert('RGBA') img_array = np.array(pil_img, dtype=np.uint8) if self.qr_texture and self.qr_texture.id != 0: rl.unload_texture(self.qr_texture) rl_image = rl.Image() rl_image.data = rl.ffi.cast("void *", img_array.ctypes.data) rl_image.width = pil_img.width rl_image.height = pil_img.height rl_image.mipmaps = 1 rl_image.format = rl.PixelFormat.PIXELFORMAT_UNCOMPRESSED_R8G8B8A8 self.qr_texture = rl.load_texture_from_image(rl_image) 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.time() if current_time - self.last_qr_generation >= self.QR_REFRESH_INTERVAL: self._generate_qr_code() 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 close_icon = gui_app.texture("icons/close.png", close_size, close_size) close_rect = rl.Rectangle(content_rect.x, y, close_size, close_size) mouse_pos = rl.get_mouse_position() is_hover = rl.check_collision_point_rec(mouse_pos, close_rect) is_pressed = rl.is_mouse_button_down(rl.MouseButton.MOUSE_BUTTON_LEFT) is_released = rl.is_mouse_button_released(rl.MouseButton.MOUSE_BUTTON_LEFT) color = rl.Color(180, 180, 180, 150) if (is_hover and is_pressed) else rl.WHITE rl.draw_texture(close_icon, int(content_rect.x), int(y), color) if (is_hover and is_released) or rl.is_key_pressed(rl.KeyboardKey.KEY_ESCAPE): return 1 y += close_size + 40 # Title title = "Pair your device to your comma account" 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: instructions = [ "Go to https://connect.comma.ai on your phone", "Click \"add new device\" and scan the QR code on the right", "Bookmark connect.comma.ai to your home screen to use it like an app", ] 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_width = measure_text_cached(font, number, 30).x rl.draw_text(number, int(circle_x - number_width // 2), int(circle_y - 15), 30, 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 def _render_qr_code(self, rect: rl.Rectangle) -> None: if not self.qr_texture: rl.draw_rectangle_rounded(rect, 0.1, 20, rl.Color(240, 240, 240, 255)) error_font = gui_app.font(FontWeight.BOLD) rl.draw_text_ex( error_font, "QR Code Error", rl.Vector2(rect.x + 20, rect.y + rect.height // 2 - 15), 30, 0.0, rl.RED ) return source = rl.Rectangle(0, 0, self.qr_texture.width, self.qr_texture.height) rl.draw_texture_pro(self.qr_texture, source, rect, rl.Vector2(0, 0), 0, 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() 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