sunnypilot v2026.002.000

version: sunnypilot v2026.002.000 (staging)
date: 2026-07-21T07:44:02
master commit: 2c334ede443d7391d27575af8c854a095ba702a8
This commit is contained in:
github-actions[bot] committed 2026-07-21 07:44:02 +00:00
commit de77c89cf6
3995 files changed
+1260688

No files matched your search

Whitespace-only changes.
@@ -0,0 +1,65 @@
import pyray as rl
from openpilot.common.params import Params
from openpilot.system.ui.lib.application import gui_app, FontWeight, FONT_SCALE
from openpilot.system.ui.lib.multilang import tr
from openpilot.system.ui.widgets import Widget
class ExperimentalModeButton(Widget):
def __init__(self):
super().__init__()
self.img_width = 80
self.horizontal_padding = 25
self.button_height = 125
self.params = Params()
self.experimental_mode = self.params.get_bool("ExperimentalMode")
self.chill_pixmap = gui_app.texture("icons/couch.png", self.img_width, self.img_width)
self.experimental_pixmap = gui_app.texture("icons/experimental_grey.png", self.img_width, self.img_width)
def show_event(self):
super().show_event()
self.experimental_mode = self.params.get_bool("ExperimentalMode")
def _get_gradient_colors(self):
alpha = 0xCC if self.is_pressed else 0xFF
if self.experimental_mode:
return rl.Color(255, 155, 63, alpha), rl.Color(219, 56, 34, alpha)
else:
return rl.Color(20, 255, 171, alpha), rl.Color(35, 149, 255, alpha)
def _draw_gradient_background(self, rect):
start_color, end_color = self._get_gradient_colors()
rl.draw_rectangle_gradient_h(int(rect.x), int(rect.y), int(rect.width), int(rect.height),
start_color, end_color)
def _render(self, rect):
rl.begin_scissor_mode(int(rect.x), int(rect.y), int(rect.width), int(rect.height))
self._draw_gradient_background(rect)
rl.draw_rectangle_rounded_lines_ex(self._rect, 0.19, 10, 5, rl.BLACK)
rl.end_scissor_mode()
# Draw vertical separator line
line_x = rect.x + rect.width - self.img_width - (2 * self.horizontal_padding)
separator_color = rl.Color(0, 0, 0, 77) # 0x4d = 77
rl.draw_line_ex(rl.Vector2(line_x, rect.y), rl.Vector2(line_x, rect.y + rect.height), 3, separator_color)
# Draw text label (left aligned)
text = tr("EXPERIMENTAL MODE ON") if self.experimental_mode else tr("CHILL MODE ON")
text_x = rect.x + self.horizontal_padding
text_y = rect.y + rect.height / 2 - 45 * FONT_SCALE // 2 # Center vertically
rl.draw_text_ex(gui_app.font(FontWeight.NORMAL), text, rl.Vector2(int(text_x), int(text_y)), 45, 0, rl.BLACK)
# Draw icon (right aligned)
icon_x = rect.x + rect.width - self.horizontal_padding - self.img_width
icon_y = rect.y + (rect.height - self.img_width) / 2
icon_rect = rl.Rectangle(icon_x, icon_y, self.img_width, self.img_width)
# Draw current mode icon
current_icon = self.experimental_pixmap if self.experimental_mode else self.chill_pixmap
source_rect = rl.Rectangle(0, 0, current_icon.width, current_icon.height)
rl.draw_texture_pro(current_icon, source_rect, icon_rect, rl.Vector2(0, 0), 0, rl.WHITE)
@@ -0,0 +1,345 @@
import pyray as rl
from enum import IntEnum
from abc import ABC, abstractmethod
from collections.abc import Callable
from dataclasses import dataclass
from openpilot.common.params import Params
from openpilot.common.hardware import HARDWARE
from openpilot.system.ui.lib.application import gui_app, FontWeight, FONT_SCALE
from openpilot.system.ui.lib.multilang import tr
from openpilot.system.ui.lib.scroll_panel import GuiScrollPanel
from openpilot.system.ui.lib.text_measure import measure_text_cached
from openpilot.system.ui.lib.wrap_text import wrap_text
from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.widgets.html_render import HtmlRenderer
from openpilot.selfdrive.selfdrived.alertmanager import OFFROAD_ALERTS
class AlertColors:
HIGH_SEVERITY = rl.Color(226, 44, 44, 255)
LOW_SEVERITY = rl.Color(41, 41, 41, 255)
BACKGROUND = rl.Color(57, 57, 57, 255)
BUTTON = rl.WHITE
BUTTON_PRESSED = rl.Color(200, 200, 200, 255)
BUTTON_TEXT = rl.BLACK
SNOOZE_BG = rl.Color(79, 79, 79, 255)
SNOOZE_BG_PRESSED = rl.Color(100, 100, 100, 255)
TEXT = rl.WHITE
class AlertConstants:
MIN_BUTTON_WIDTH = 400
BUTTON_HEIGHT = 125
MARGIN = 50
SPACING = 30
FONT_SIZE = 48
BORDER_RADIUS = 30 * 2 # matches Qt's 30px
ALERT_HEIGHT = 120
ALERT_SPACING = 10
ALERT_INSET = 60
@dataclass
class AlertData:
key: str
text: str
severity: int
visible: bool = False
class ButtonStyle(IntEnum):
LIGHT = 0
DARK = 1
class ActionButton(Widget):
def __init__(self, text: str | Callable[[], str], style: ButtonStyle = ButtonStyle.LIGHT,
min_width: int = AlertConstants.MIN_BUTTON_WIDTH):
super().__init__()
self._text = text
self._style = style
self._min_width = min_width
self._font = gui_app.font(FontWeight.MEDIUM)
@property
def text(self) -> str:
return self._text if isinstance(self._text, str) else self._text()
def _render(self, _):
text_size = measure_text_cached(gui_app.font(FontWeight.MEDIUM), self.text, AlertConstants.FONT_SIZE)
self._rect.width = max(text_size.x + 60 * 2, self._min_width)
self._rect.height = AlertConstants.BUTTON_HEIGHT
roundness = AlertConstants.BORDER_RADIUS / self._rect.height
bg_color = AlertColors.BUTTON if self._style == ButtonStyle.LIGHT else AlertColors.SNOOZE_BG
if self.is_pressed:
bg_color = AlertColors.BUTTON_PRESSED if self._style == ButtonStyle.LIGHT else AlertColors.SNOOZE_BG_PRESSED
rl.draw_rectangle_rounded(self._rect, roundness, 10, bg_color)
# center text
color = rl.WHITE if self._style == ButtonStyle.DARK else rl.BLACK
text_x = int(self._rect.x + (self._rect.width - text_size.x) // 2)
text_y = int(self._rect.y + (self._rect.height - text_size.y) // 2)
rl.draw_text_ex(self._font, self.text, rl.Vector2(text_x, text_y), AlertConstants.FONT_SIZE, 0, color)
class AbstractAlert(Widget, ABC):
def __init__(self, has_reboot_btn: bool = False):
super().__init__()
self.params = Params()
self.has_reboot_btn = has_reboot_btn
self.dismiss_callback: Callable | None = None
def snooze_callback():
self.params.put_bool("SnoozeUpdate", True)
if self.dismiss_callback:
self.dismiss_callback()
def excessive_actuation_callback():
self.params.remove("Offroad_ExcessiveActuation")
if self.dismiss_callback:
self.dismiss_callback()
self.dismiss_btn = ActionButton(lambda: tr("Close"))
self.snooze_btn = ActionButton(lambda: tr("Snooze Update"), style=ButtonStyle.DARK)
self.snooze_btn.set_click_callback(snooze_callback)
self.excessive_actuation_btn = ActionButton(lambda: tr("Acknowledge Excessive Actuation"), style=ButtonStyle.DARK, min_width=800)
self.excessive_actuation_btn.set_click_callback(excessive_actuation_callback)
self.reboot_btn = ActionButton(lambda: tr("Reboot and Update"), min_width=600)
self.reboot_btn.set_click_callback(lambda: HARDWARE.reboot())
# TODO: just use a Scroller?
self.content_rect = rl.Rectangle(0, 0, 0, 0)
self.scroll_panel_rect = rl.Rectangle(0, 0, 0, 0)
self.scroll_panel = GuiScrollPanel()
def show_event(self):
super().show_event()
self.scroll_panel.set_offset(0)
def set_dismiss_callback(self, callback: Callable):
self.dismiss_callback = callback
self.dismiss_btn.set_click_callback(self.dismiss_callback)
@abstractmethod
def refresh(self) -> bool:
pass
@abstractmethod
def get_content_height(self) -> float:
pass
def _render(self, rect: rl.Rectangle):
rl.draw_rectangle_rounded(rect, AlertConstants.BORDER_RADIUS / rect.height, 10, AlertColors.BACKGROUND)
footer_height = AlertConstants.BUTTON_HEIGHT + AlertConstants.SPACING
content_height = rect.height - 2 * AlertConstants.MARGIN - footer_height
self.content_rect = rl.Rectangle(
rect.x + AlertConstants.MARGIN,
rect.y + AlertConstants.MARGIN,
rect.width - 2 * AlertConstants.MARGIN,
content_height,
)
self.scroll_panel_rect = rl.Rectangle(
self.content_rect.x, self.content_rect.y, self.content_rect.width, self.content_rect.height
)
self._render_scrollable_content()
self._render_footer(rect)
def _render_scrollable_content(self):
content_total_height = self.get_content_height()
content_bounds = rl.Rectangle(0, 0, self.scroll_panel_rect.width, content_total_height)
scroll_offset = self.scroll_panel.update(self.scroll_panel_rect, content_bounds)
rl.begin_scissor_mode(
int(self.scroll_panel_rect.x),
int(self.scroll_panel_rect.y),
int(self.scroll_panel_rect.width),
int(self.scroll_panel_rect.height),
)
content_rect_with_scroll = rl.Rectangle(
self.scroll_panel_rect.x,
self.scroll_panel_rect.y + scroll_offset,
self.scroll_panel_rect.width,
content_total_height,
)
self._render_content(content_rect_with_scroll)
rl.end_scissor_mode()
@abstractmethod
def _render_content(self, content_rect: rl.Rectangle):
pass
def _render_footer(self, rect: rl.Rectangle):
footer_y = rect.y + rect.height - AlertConstants.MARGIN - AlertConstants.BUTTON_HEIGHT
dismiss_x = rect.x + AlertConstants.MARGIN
self.dismiss_btn.set_position(dismiss_x, footer_y)
self.dismiss_btn.render()
if self.has_reboot_btn:
reboot_x = rect.x + rect.width - AlertConstants.MARGIN - self.reboot_btn.rect.width
self.reboot_btn.set_position(reboot_x, footer_y)
self.reboot_btn.render()
elif self.excessive_actuation_btn.is_visible:
actuation_x = rect.x + rect.width - AlertConstants.MARGIN - self.excessive_actuation_btn.rect.width
self.excessive_actuation_btn.set_position(actuation_x, footer_y)
self.excessive_actuation_btn.render()
elif self.snooze_btn.is_visible:
snooze_x = rect.x + rect.width - AlertConstants.MARGIN - self.snooze_btn.rect.width
self.snooze_btn.set_position(snooze_x, footer_y)
self.snooze_btn.render()
class OffroadAlert(AbstractAlert):
def __init__(self):
super().__init__(has_reboot_btn=False)
self.sorted_alerts: list[AlertData] = []
def refresh(self):
if not self.sorted_alerts:
self._build_alerts()
active_count = 0
connectivity_needed = False
excessive_actuation = False
for alert_data in self.sorted_alerts:
text = ""
alert_json = self.params.get(alert_data.key)
if alert_json:
text = alert_json.get("text", "").replace("%1", alert_json.get("extra", ""))
alert_data.text = text
alert_data.visible = bool(text)
if alert_data.visible:
active_count += 1
if alert_data.key == "Offroad_ConnectivityNeeded" and alert_data.visible:
connectivity_needed = True
if alert_data.key == "Offroad_ExcessiveActuation" and alert_data.visible:
excessive_actuation = True
self.excessive_actuation_btn.set_visible(excessive_actuation)
self.snooze_btn.set_visible(connectivity_needed and not excessive_actuation)
return active_count
def get_content_height(self) -> float:
if not self.sorted_alerts:
return 0
total_height = 20
font = gui_app.font(FontWeight.NORMAL)
for alert_data in self.sorted_alerts:
if not alert_data.visible:
continue
text_width = int(self.content_rect.width - (AlertConstants.ALERT_INSET * 2))
wrapped_lines = wrap_text(font, alert_data.text, AlertConstants.FONT_SIZE, text_width)
line_count = len(wrapped_lines)
text_height = line_count * (AlertConstants.FONT_SIZE * FONT_SCALE)
alert_item_height = max(text_height + (AlertConstants.ALERT_INSET * 2), AlertConstants.ALERT_HEIGHT)
total_height += round(alert_item_height + AlertConstants.ALERT_SPACING)
if total_height > 20:
total_height = total_height - AlertConstants.ALERT_SPACING + 20
return total_height
def _build_alerts(self):
self.sorted_alerts = []
for key, config in sorted(OFFROAD_ALERTS.items(), key=lambda x: x[1].get("severity", 0), reverse=True):
severity = config.get("severity", 0)
alert_data = AlertData(key=key, text="", severity=severity)
self.sorted_alerts.append(alert_data)
def _render_content(self, content_rect: rl.Rectangle):
y_offset = AlertConstants.ALERT_SPACING
font = gui_app.font(FontWeight.NORMAL)
for alert_data in self.sorted_alerts:
if not alert_data.visible:
continue
bg_color = AlertColors.HIGH_SEVERITY if alert_data.severity > 0 else AlertColors.LOW_SEVERITY
text_width = int(content_rect.width - (AlertConstants.ALERT_INSET * 2))
wrapped_lines = wrap_text(font, alert_data.text, AlertConstants.FONT_SIZE, text_width)
line_count = len(wrapped_lines)
text_height = line_count * (AlertConstants.FONT_SIZE * FONT_SCALE)
alert_item_height = max(text_height + (AlertConstants.ALERT_INSET * 2), AlertConstants.ALERT_HEIGHT)
alert_rect = rl.Rectangle(
content_rect.x + 10,
content_rect.y + y_offset,
content_rect.width - 30,
alert_item_height,
)
roundness = AlertConstants.BORDER_RADIUS / min(alert_rect.height, alert_rect.width)
rl.draw_rectangle_rounded(alert_rect, roundness, 10, bg_color)
text_x = alert_rect.x + AlertConstants.ALERT_INSET
text_y = alert_rect.y + AlertConstants.ALERT_INSET
for i, line in enumerate(wrapped_lines):
rl.draw_text_ex(
font,
line,
rl.Vector2(text_x, text_y + i * AlertConstants.FONT_SIZE * FONT_SCALE),
AlertConstants.FONT_SIZE,
0,
AlertColors.TEXT,
)
y_offset += round(alert_item_height + AlertConstants.ALERT_SPACING)
class UpdateAlert(AbstractAlert):
def __init__(self):
super().__init__(has_reboot_btn=True)
self.release_notes = ""
self._wrapped_release_notes = ""
self._cached_content_height: float = 0.0
self._html_renderer = HtmlRenderer(text="")
def refresh(self) -> bool:
update_available: bool = self.params.get_bool("UpdateAvailable")
no_release_notes = "<h2>" + tr("No release notes available.") + "</h2>"
if update_available:
self.release_notes = (self.params.get("UpdaterNewReleaseNotes") or b"").decode("utf8").strip()
self._html_renderer.parse_html_content(self.release_notes or no_release_notes)
self._cached_content_height = 0
else:
self._html_renderer.parse_html_content(no_release_notes)
return update_available
def get_content_height(self) -> float:
if not self.release_notes:
return 100
if self._cached_content_height == 0:
self._wrapped_release_notes = self.release_notes
size = measure_text_cached(gui_app.font(FontWeight.NORMAL), self._wrapped_release_notes, AlertConstants.FONT_SIZE)
self._cached_content_height = max(size.y + 60, 100)
return self._cached_content_height
def _render_content(self, content_rect: rl.Rectangle):
notes_rect = rl.Rectangle(content_rect.x + 30, content_rect.y + 30, content_rect.width - 60, content_rect.height - 60)
self._html_renderer.render(notes_rect)
@@ -0,0 +1,170 @@
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.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:
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:
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
+63
View File
@@ -0,0 +1,63 @@
import pyray as rl
from openpilot.selfdrive.ui.ui_state import ui_state
from openpilot.system.ui.lib.application import gui_app, FontWeight
from openpilot.system.ui.lib.multilang import tr
from openpilot.system.ui.lib.text_measure import measure_text_cached
from openpilot.system.ui.lib.wrap_text import wrap_text
from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.widgets.label import gui_label
class PrimeWidget(Widget):
"""Widget for displaying comma prime subscription status"""
PRIME_BG_COLOR = rl.Color(51, 51, 51, 255)
def _render(self, rect):
if ui_state.prime_state.is_prime():
self._render_for_prime_user(rect)
else:
self._render_for_non_prime_users(rect)
def _render_for_non_prime_users(self, rect: rl.Rectangle):
"""Renders the advertisement for non-Prime users."""
rl.draw_rectangle_rounded(rect, 0.025, 10, self.PRIME_BG_COLOR)
# Layout
x, y = rect.x + 80, rect.y + 90
w = rect.width - 160
# Title
gui_label(rl.Rectangle(x, y, w, 90), tr("Upgrade Now"), 75, font_weight=FontWeight.BOLD)
# Description with wrapping
desc_y = y + 140
font = gui_app.font(FontWeight.NORMAL)
wrapped_text = "\n".join(wrap_text(font, tr("Become a comma prime member at connect.comma.ai"), 56, int(w)))
text_size = measure_text_cached(font, wrapped_text, 56)
rl.draw_text_ex(font, wrapped_text, rl.Vector2(x, desc_y), 56, 0, rl.WHITE)
# Features section
features_y = desc_y + text_size.y + 50
gui_label(rl.Rectangle(x, features_y, w, 50), tr("PRIME FEATURES:"), 41, font_weight=FontWeight.BOLD)
# Feature list
features = [tr("Remote access"), tr("24/7 LTE connectivity"), tr("1 year of drive storage"), tr("Remote snapshots")]
for i, feature in enumerate(features):
item_y = features_y + 80 + i * 65
gui_label(rl.Rectangle(x, item_y, 100, 60), "✓", 50, color=rl.Color(70, 91, 234, 255))
gui_label(rl.Rectangle(x + 60, item_y, w - 60, 60), feature, 50)
def _render_for_prime_user(self, rect: rl.Rectangle):
"""Renders the prime user widget with subscription status."""
rl.draw_rectangle_rounded(rl.Rectangle(rect.x, rect.y, rect.width, 230), 0.1, 10, self.PRIME_BG_COLOR)
x = rect.x + 56
y = rect.y + 40
font = gui_app.font(FontWeight.BOLD)
rl.draw_text_ex(font, tr("✓ SUBSCRIBED"), rl.Vector2(x, y), 41, 0, rl.Color(134, 255, 78, 255))
rl.draw_text_ex(font, tr("comma prime"), rl.Vector2(x, y + 61), 75, 0, rl.WHITE)
+95
View File
@@ -0,0 +1,95 @@
import pyray as rl
from openpilot.common.time_helpers import system_time_valid
from openpilot.selfdrive.ui.ui_state import ui_state
from openpilot.selfdrive.ui.widgets.pairing_dialog import PairingDialog
from openpilot.system.ui.lib.application import gui_app, FontWeight, FONT_SCALE
from openpilot.system.ui.lib.multilang import tr
from openpilot.system.ui.lib.wrap_text import wrap_text
from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.widgets.confirm_dialog import alert_dialog
from openpilot.system.ui.widgets.button import Button, ButtonStyle
from openpilot.system.ui.widgets.label import Label
class SetupWidget(Widget):
def __init__(self):
super().__init__()
self._open_settings_callback = None
self._pair_device_btn = Button(lambda: tr("Pair device"), self._show_pairing, button_style=ButtonStyle.PRIMARY)
self._open_settings_btn = Button(lambda: tr("Open"), lambda: self._open_settings_callback() if self._open_settings_callback else None,
button_style=ButtonStyle.PRIMARY)
self._firehose_label = Label(lambda: tr("🔥 Firehose Mode 🔥"), font_weight=FontWeight.MEDIUM, font_size=64)
def set_open_settings_callback(self, callback):
self._open_settings_callback = callback
def _render(self, rect: rl.Rectangle):
if not ui_state.prime_state.is_paired():
self._render_registration(rect)
else:
self._render_firehose_prompt(rect)
def _render_registration(self, rect: rl.Rectangle):
"""Render registration prompt."""
rl.draw_rectangle_rounded(rl.Rectangle(rect.x, rect.y, rect.width, rect.height), 0.03, 20, rl.Color(51, 51, 51, 255))
x = rect.x + 64
y = rect.y + 48
w = rect.width - 128
# Title
font = gui_app.font(FontWeight.BOLD)
rl.draw_text_ex(font, tr("Finish Setup"), rl.Vector2(x, y), 75, 0, rl.WHITE)
y += 113 # 75 + 38 spacing
# Description
desc = tr("Pair your device with comma connect (connect.comma.ai) and claim your comma prime offer.")
light_font = gui_app.font(FontWeight.NORMAL)
wrapped = wrap_text(light_font, desc, 50, int(w))
for line in wrapped:
rl.draw_text_ex(light_font, line, rl.Vector2(x, y), 50, 0, rl.WHITE)
y += 50 * FONT_SCALE
button_rect = rl.Rectangle(x, y + 30, w, 200)
self._pair_device_btn.render(button_rect)
def _render_firehose_prompt(self, rect: rl.Rectangle):
"""Render firehose prompt widget."""
rl.draw_rectangle_rounded(rl.Rectangle(rect.x, rect.y, rect.width, 500), 0.04, 20, rl.Color(51, 51, 51, 255))
# Content margins (56, 40, 56, 40)
x = rect.x + 56
y = rect.y + 40
w = rect.width - 112
spacing = 42
# Title with fire emojis
self._firehose_label.render(rl.Rectangle(rect.x, y, rect.width, 64))
y += 64 + spacing
# Description
desc_font = gui_app.font(FontWeight.NORMAL)
desc_text = tr("Maximize your training data uploads to improve openpilot's driving models.")
wrapped_desc = wrap_text(desc_font, desc_text, 40, int(w))
for line in wrapped_desc:
rl.draw_text_ex(desc_font, line, rl.Vector2(x, y), 40, 0, rl.WHITE)
y += 40 * FONT_SCALE
y += spacing
# Open button
button_height = 48 + 64 # font size + padding
button_rect = rl.Rectangle(x, y, w, button_height)
self._open_settings_btn.render(button_rect)
@staticmethod
def _show_pairing():
if not system_time_valid():
dlg = alert_dialog(tr("Please connect to Wi-Fi to complete initial pairing"))
gui_app.push_widget(dlg)
return
gui_app.push_widget(PairingDialog())
+158
View File
@@ -0,0 +1,158 @@
import pyray as rl
import requests
import threading
from collections.abc import Callable
from enum import Enum
from openpilot.common.params import Params
from openpilot.system.ui.lib.application import gui_app, FontWeight
from openpilot.system.ui.lib.multilang import tr, tr_noop
from openpilot.system.ui.lib.text_measure import measure_text_cached
from openpilot.system.ui.widgets import DialogResult
from openpilot.system.ui.widgets.button import Button, ButtonStyle
from openpilot.system.ui.widgets.confirm_dialog import alert_dialog
from openpilot.system.ui.widgets.keyboard import Keyboard
from openpilot.system.ui.widgets.list_view import (
ItemAction,
ListItem,
BUTTON_HEIGHT,
BUTTON_BORDER_RADIUS,
BUTTON_FONT_SIZE,
BUTTON_WIDTH,
)
VALUE_FONT_SIZE = 48
class SshKeyFetcher:
HTTP_TIMEOUT = 15 # seconds
def __init__(self, params: Params):
self._params = params
self._on_response: Callable[[str | None], None] | None = None
self._done: bool = False
self._error: str | None = None
def fetch(self, username: str, on_response: Callable[[str | None], None]):
self._error = None
self._on_response = on_response
threading.Thread(target=self._fetch_thread, args=(username,), daemon=True).start()
def update(self):
if not self._done:
return
self._done = False
if self._error is not None:
self.clear()
if self._on_response:
self._on_response(self._error)
def clear(self):
self._params.remove("GithubUsername")
self._params.remove("GithubSshKeys")
def _fetch_thread(self, username: str):
try:
response = requests.get(f"https://github.com/{username}.keys", timeout=self.HTTP_TIMEOUT)
response.raise_for_status()
keys = response.text.strip()
if not keys:
raise requests.exceptions.HTTPError("No SSH keys found")
self._params.put("GithubUsername", username, block=True)
self._params.put("GithubSshKeys", keys, block=True)
except requests.exceptions.Timeout:
self._error = tr("Request timed out")
except Exception:
self._error = tr("No SSH keys found for user '{}'").format(username)
finally:
self._done = True
class SshKeyActionState(Enum):
LOADING = tr_noop("LOADING")
ADD = tr_noop("ADD")
REMOVE = tr_noop("REMOVE")
class SshKeyAction(ItemAction):
MAX_WIDTH = 500
def __init__(self):
super().__init__(self.MAX_WIDTH, True)
self._keyboard = Keyboard(min_text_size=1)
self._params = Params()
self._fetcher = SshKeyFetcher(self._params)
self._text_font = gui_app.font(FontWeight.NORMAL)
self._button = Button("", click_callback=self._handle_button_click, button_style=ButtonStyle.LIST_ACTION,
border_radius=BUTTON_BORDER_RADIUS, font_size=BUTTON_FONT_SIZE)
self._refresh_state()
def set_touch_valid_callback(self, touch_callback: Callable[[], bool]) -> None:
super().set_touch_valid_callback(touch_callback)
self._button.set_touch_valid_callback(touch_callback)
def _refresh_state(self):
self._username = self._params.get("GithubUsername")
self._state = SshKeyActionState.REMOVE if self._params.get("GithubSshKeys") else SshKeyActionState.ADD
def _update_state(self):
super()._update_state()
self._fetcher.update()
def _render(self, rect: rl.Rectangle) -> bool:
# Draw username if exists
if self._username:
text_size = measure_text_cached(self._text_font, self._username, VALUE_FONT_SIZE)
rl.draw_text_ex(
self._text_font,
self._username,
(rect.x + rect.width - BUTTON_WIDTH - text_size.x - 30, rect.y + (rect.height - text_size.y) / 2),
VALUE_FONT_SIZE,
1.0,
rl.Color(170, 170, 170, 255),
)
# Draw button
button_rect = rl.Rectangle(rect.x + rect.width - BUTTON_WIDTH, rect.y + (rect.height - BUTTON_HEIGHT) / 2, BUTTON_WIDTH, BUTTON_HEIGHT)
self._button.set_rect(button_rect)
self._button.set_text(tr(self._state.value))
self._button.set_enabled(self._state != SshKeyActionState.LOADING)
self._button.render(button_rect)
return False
def _handle_button_click(self):
if self._state == SshKeyActionState.ADD:
self._keyboard.reset()
self._keyboard.set_title(tr("Enter your GitHub username"))
self._keyboard.set_callback(self._on_username_submit)
gui_app.push_widget(self._keyboard)
elif self._state == SshKeyActionState.REMOVE:
self._fetcher.clear()
self._refresh_state()
def _on_username_submit(self, result: DialogResult):
if result != DialogResult.CONFIRM:
return
username = self._keyboard.text.strip()
if not username:
return
self._state = SshKeyActionState.LOADING
self._fetcher.fetch(username, self._on_fetch_response)
def _on_fetch_response(self, error: str | None):
if error is None:
self._state = SshKeyActionState.REMOVE
self._username = self._params.get("GithubUsername")
else:
self._state = SshKeyActionState.ADD
self._username = ""
gui_app.push_widget(alert_dialog(error))
def ssh_key_item(title: str | Callable[[], str], description: str | Callable[[], str]) -> ListItem:
return ListItem(title=title, description=description, action_item=SshKeyAction())