sunnypilot v2026.002.000
version: sunnypilot v2026.002.000 (staging) date: 2026-07-21T07:44:02 master commit: 2c334ede443d7391d27575af8c854a095ba702a8
This commit is contained in:
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
|
||||
@@ -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)
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
Reference in new issue
Block a user