Files
sunnypilot/openpilot/selfdrive/ui/widgets/pairing_dialog.py
T
2026-08-07 15:41:28 -07:00

155 lines
5.3 KiB
Python

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.system.ui.widgets import Widget
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
from openpilot.system.ui.widgets.button import IconButton
from openpilot.selfdrive.ui.ui_state import ui_state
class PairingDialog(Widget):
"""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._close_btn = IconButton(gui_app.texture("icons/close.png", 80, 80))
self._close_btn.set_click_callback(gui_app.pop_widget)
def _get_pairing_url(self) -> str:
try:
dongle_id = self.params.get("DongleId") or ""
token = Api(dongle_id).get_token({'pair': True})
except Exception:
cloudlog.exception("Failed to get pairing token")
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())
except Exception:
cloudlog.exception("QR code generation failed")
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):
if ui_state.prime_state.is_paired():
gui_app.pop_widget()
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 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 = [
tr("Go to https://connect.comma.ai on your phone"),
tr("Click \"add new device\" and scan the QR code on the right"),
tr("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_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
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, tr("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()
gui_app.push_widget(pairing)
try:
for _ in gui_app.render():
pass
finally:
del pairing