1
0
forked from IQ.Lvbs/IQ.Pilot

IQ.Pilot Prebuilt Release @ ab07000

This commit is contained in:
IQ.Lvbs history cleanup
2026-08-22 23:42:42 -05:00
commit 9f9c9a70cc
3729 changed files with 778697 additions and 0 deletions

View File

View File

@@ -0,0 +1,92 @@
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.iq_dynamic_mode = self.params.get_bool("IQDynamicMode")
self.alpha_longitudinal_enabled = self.params.get_bool("AlphaLongitudinalEnabled")
self.chill_pixmap = gui_app.texture("icons/couch.png", self.img_width, self.img_width)
self.experimental_pixmap = gui_app.texture("icons_mici/experimental_mode_tizi.png", self.img_width, self.img_width)
self.iqstandard_pixmap = gui_app.texture("icons_mici/iqstandard_mode_tizi.png", self.img_width, self.img_width)
self.iqdynamic_pixmap = gui_app.texture("icons_mici/iqdynamic_mode_tizi.png", self.img_width, self.img_width)
def show_event(self):
self.experimental_mode = self.params.get_bool("ExperimentalMode")
self.iq_dynamic_mode = self.params.get_bool("IQDynamicMode")
self.alpha_longitudinal_enabled = self.params.get_bool("AlphaLongitudinalEnabled")
def _get_gradient_colors(self):
alpha = 0xCC if self.is_pressed else 0xFF
if not self.alpha_longitudinal_enabled:
return rl.Color(112, 112, 112, alpha), rl.Color(78, 78, 78, alpha)
if self.experimental_mode:
# IQ.Pilot / IQ.Dynamic active gradient
return rl.Color(0x13, 0xC3, 0xE2, alpha), rl.Color(0xE2, 0x13, 0xAD, alpha)
else:
return rl.Color(0xCC, 0x00, 0xCC, alpha), rl.Color(0xFF, 0xDD, 0x00, 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)
if not self.alpha_longitudinal_enabled:
text = tr("STOCK ACC")
font_size = 45
else:
if self.experimental_mode and self.iq_dynamic_mode:
text = tr("IQ.DYNAMIC")
elif self.experimental_mode:
text = tr("IQ.PILOT")
else:
text = tr("IQ.STANDARD")
font_size = 45
text_x = rect.x + self.horizontal_padding
text_y = rect.y + rect.height / 2 - font_size * FONT_SCALE // 2 # Center vertically
rl.draw_text_ex(gui_app.font(FontWeight.NORMAL), text, rl.Vector2(int(text_x), int(text_y)), font_size, 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
if not self.alpha_longitudinal_enabled:
current_icon = self.chill_pixmap
elif self.experimental_mode and self.iq_dynamic_mode:
current_icon = self.iqdynamic_pixmap
elif self.experimental_mode:
current_icon = self.experimental_pixmap
else:
current_icon = self.iqstandard_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)

View File

@@ -0,0 +1,79 @@
import random
import pyray as rl
from openpilot.system.ui.lib.application import gui_app, FontWeight
from openpilot.selfdrive.ui.lib.motd import load_motds
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
PANEL_BG = rl.Color(34, 36, 42, 255)
PANEL_BORDER = rl.Color(255, 255, 255, 26)
TEAL_DIM = rl.Color(16, 185, 169, 22)
TEXT_COLOR = rl.Color(235, 235, 235, 255)
LABEL_COLOR = rl.Color(16, 185, 169, 255)
def _load_messages() -> list[str]:
msgs = load_motds()
random.shuffle(msgs)
return msgs
class InspireWidget(Widget):
def __init__(self):
super().__init__()
self._messages = _load_messages()
self._idx = 0
self._current: str = self._messages[0] if self._messages else "Drive safely. Stay focused."
def _pick(self):
if not self._messages:
self._messages = _load_messages()
self._idx = 0
if self._messages:
self._current = self._messages[self._idx % len(self._messages)]
self._idx += 1
def show_event(self):
self._pick()
def _render(self, rect: rl.Rectangle):
rl.draw_rectangle_rounded(rect, 0.06, 24, PANEL_BG)
rl.draw_rectangle_rounded_lines_ex(rect, 0.06, 24, 2, PANEL_BORDER)
cx = rect.x + rect.width / 2
cy = rect.y + rect.height / 2
font = gui_app.font(FontWeight.MEDIUM)
font_label = gui_app.font(FontWeight.BOLD)
TEXT_FS = 52
LABEL_FS = 24
LABEL_H = 48
max_w = int(rect.width - 80)
wrapped = wrap_text(font, self._current, TEXT_FS, max_w)
line_h = int(TEXT_FS * 1.3)
total_text_h = len(wrapped) * line_h
text_block_cy = cy - LABEL_H / 2
text_y = text_block_cy - total_text_h / 2
for i, line in enumerate(wrapped):
lw = measure_text_cached(font, line, TEXT_FS).x
rl.draw_text_ex(font, line,
rl.Vector2(int(cx - lw / 2), int(text_y + i * line_h)),
TEXT_FS, 0, TEXT_COLOR)
label = "DAILY INSPIRATION"
LABEL_SPACING = 3
lw = measure_text_cached(font_label, label, LABEL_FS).x + len(label) * LABEL_SPACING
label_x = int(cx - lw / 2)
label_y = int(rect.y + rect.height - LABEL_H - 8)
pill = rl.Rectangle(label_x - 20, label_y - 8, lw + 40, LABEL_FS + 16)
rl.draw_rectangle_rounded(pill, 0.5, 12, TEAL_DIM)
rl.draw_text_ex(font_label, label,
rl.Vector2(label_x, label_y),
LABEL_FS, LABEL_SPACING, LABEL_COLOR)

View File

@@ -0,0 +1,229 @@
"""Touch-interactive tile map for the offroad Navigate screen.
Drag to pan, +/- to zoom, recenter snaps back to GPS. Tiles come from the onroad
MapboxTileProvider (async fetch, disk+GPU cache). Tiles are drawn directly every frame (~20
texture blits); the fetch/prune pipeline is throttled so panning doesn't churn the cache.
"""
from __future__ import annotations
import math
import time
import pyray as rl
from openpilot.common.params import Params
from openpilot.selfdrive.ui.lib.nav_helpers import current_or_last_gps_position
from openpilot.iqpilot.ui.onroad.nav_map_panel import MapboxTileProvider
from openpilot.iqpilot.ui.onroad.nav_map_utils import TILE_SIZE
from openpilot.system.ui.lib.application import gui_app, FontWeight, MouseEvent, MousePos
from openpilot.system.ui.lib.text_measure import measure_text_cached
from openpilot.system.ui.widgets import Widget
DEFAULT_ZOOM = 15.0
MIN_ZOOM = 4.0
MAX_ZOOM = 18.0
ZOOM_STEP = 1.0
TILE_UPDATE_S = 0.20 # fetch/prune cadence; drawing runs every frame
GPS_POLL_S = 0.5
DEST_POLL_S = 1.0
BTN = 92
PUCK_BLUE = rl.Color(23, 134, 246, 255)
MAP_BG = rl.Color(18, 18, 20, 255)
BTN_BG = rl.Color(28, 30, 36, 235)
BTN_BORDER = rl.Color(255, 255, 255, 45)
def _norm(lat: float, lon: float) -> tuple[float, float]:
lat = max(-85.05, min(85.05, lat))
x = (lon + 180.0) / 360.0
s = math.sin(math.radians(lat))
y = 0.5 - math.log((1.0 + s) / (1.0 - s)) / (4.0 * math.pi)
return x, y
def _denorm(x: float, y: float) -> tuple[float, float]:
lon = x * 360.0 - 180.0
lat = math.degrees(math.atan(math.sinh(math.pi * (1.0 - 2.0 * y))))
return lat, lon
class InteractiveNavMap(Widget):
def __init__(self):
super().__init__()
self._params = Params()
self._tiles = MapboxTileProvider()
self._lat = 0.0
self._lon = 0.0
self._zoom = DEFAULT_ZOOM
self._follow = True
self._have_center = False
self._drag_start: MousePos | None = None
self._drag_center: tuple[float, float] | None = None
self._dragging = False
self._tiles_time = 0.0
self._gps_cache: tuple[float, float, bool] = (0.0, 0.0, False)
self._gps_time = 0.0
self._dest_cache = None
self._dest_time = 0.0
self._pin_icon = gui_app.texture("icons/iq/pin.png", 64, 64, keep_aspect_ratio=True)
def _world_size(self) -> float:
return TILE_SIZE * (2.0 ** self._zoom)
def _poll_gps(self):
now = time.monotonic()
if now - self._gps_time > GPS_POLL_S:
lat, lon, _, fix = current_or_last_gps_position(self._params)
self._gps_cache = (lat, lon, fix)
self._gps_time = now
lat, lon, fix = self._gps_cache
if fix and self._follow and not self._dragging:
self._lat, self._lon = lat, lon
self._have_center = True
elif fix and not self._have_center:
self._lat, self._lon = lat, lon
self._have_center = True
return lat, lon, fix
def recenter(self):
self._follow = True
self._gps_time = 0.0
# --- gestures ---------------------------------------------------------------
def _handle_mouse_event(self, mouse_event: MouseEvent) -> None:
super()._handle_mouse_event(mouse_event)
if mouse_event.slot != 0 or not self._have_center:
return
if mouse_event.left_pressed:
if rl.check_collision_point_rec(mouse_event.pos, self._rect) and self._control_hit(mouse_event.pos) is None:
self._drag_start = mouse_event.pos
self._drag_center = _norm(self._lat, self._lon)
self._dragging = False
else:
self._drag_start = None
elif mouse_event.left_down and self._drag_start is not None:
dx = mouse_event.pos.x - self._drag_start.x
dy = mouse_event.pos.y - self._drag_start.y
if not self._dragging and (abs(dx) > 10 or abs(dy) > 10):
self._dragging = True
self._follow = False
if self._dragging:
ws = self._world_size()
nx = (self._drag_center[0] - dx / ws) % 1.0
ny = max(0.001, min(0.999, self._drag_center[1] - dy / ws))
self._lat, self._lon = _denorm(nx, ny)
elif mouse_event.left_released:
self._drag_start = None
self._drag_center = None
self._dragging = False
def _controls(self) -> list[tuple[str, rl.Rectangle]]:
r = self._rect
x = r.x + r.width - BTN - 20
ctrls = [("+", rl.Rectangle(x, r.y + 20, BTN, BTN)),
("-", rl.Rectangle(x, r.y + 20 + BTN + 14, BTN, BTN))]
if not self._follow:
ctrls.append(("recenter", rl.Rectangle(x, r.y + r.height - BTN - 20, BTN, BTN)))
return ctrls
def _control_hit(self, pos: MousePos) -> str | None:
for name, rect in self._controls():
if rl.check_collision_point_rec(pos, rect):
return name
return None
def _handle_mouse_release(self, mouse_pos: MousePos) -> None:
hit = self._control_hit(mouse_pos)
if hit == "+":
self._zoom = min(MAX_ZOOM, self._zoom + ZOOM_STEP)
self._tiles_time = 0.0
elif hit == "-":
self._zoom = max(MIN_ZOOM, self._zoom - ZOOM_STEP)
self._tiles_time = 0.0
elif hit == "recenter":
self.recenter()
# --- rendering --------------------------------------------------------------
def _render(self, rect: rl.Rectangle):
gps_lat, gps_lon, fix = self._poll_gps()
if not self._have_center:
rl.draw_rectangle_rounded(rect, 0.03, 20, MAP_BG)
self._center_note(rect, "Waiting for GPS fix...")
return
now = time.monotonic()
if now - self._tiles_time > TILE_UPDATE_S:
self._tiles.update(self._lat, self._lon, self._zoom, rect.width, rect.height)
self._tiles_time = now
rl.draw_rectangle_rec(rect, MAP_BG)
rl.begin_scissor_mode(int(rect.x), int(rect.y), int(rect.width), int(rect.height))
self._tiles.draw(rect, self._lat, self._lon, self._zoom)
if fix:
self._draw_puck(rect, gps_lat, gps_lon)
self._draw_destination(rect)
rl.end_scissor_mode()
rl.draw_rectangle_rounded_lines_ex(rect, 0.03, 20, 2, rl.Color(255, 255, 255, 38))
self._draw_controls()
def _center_note(self, rect: rl.Rectangle, text: str):
font = gui_app.font(FontWeight.MEDIUM)
ns = measure_text_cached(font, text, 40)
rl.draw_text_ex(font, text, rl.Vector2(int(rect.x + (rect.width - ns.x) / 2),
int(rect.y + rect.height / 2 - ns.y / 2)), 40, 0, rl.Color(165, 165, 170, 255))
def _project(self, rect: rl.Rectangle, lat: float, lon: float) -> tuple[float, float]:
ws = self._world_size()
cx, cy = _norm(self._lat, self._lon)
px, py = _norm(lat, lon)
return (rect.x + rect.width / 2 + (px - cx) * ws,
rect.y + rect.height / 2 + (py - cy) * ws)
def _draw_puck(self, rect: rl.Rectangle, lat: float, lon: float):
x, y = self._project(rect, lat, lon)
if rect.x <= x <= rect.x + rect.width and rect.y <= y <= rect.y + rect.height:
rl.draw_circle(int(x), int(y), 24, rl.Color(255, 255, 255, 235))
rl.draw_circle(int(x), int(y), 16, PUCK_BLUE)
def _draw_destination(self, rect: rl.Rectangle):
now = time.monotonic()
if now - self._dest_time > DEST_POLL_S:
try:
self._dest_cache = self._params.get("NavigationDestination")
except Exception:
self._dest_cache = None
self._dest_time = now
dest = self._dest_cache
if not dest:
return
try:
lat, lon = float(dest["latitude"]), float(dest["longitude"])
except Exception:
return
x, y = self._project(rect, lat, lon)
if rect.x <= x <= rect.x + rect.width and rect.y <= y <= rect.y + rect.height:
rl.draw_texture(self._pin_icon, int(x - self._pin_icon.width / 2), int(y - self._pin_icon.height), rl.WHITE)
def _draw_controls(self):
font = gui_app.font(FontWeight.MEDIUM)
for name, r in self._controls():
rl.draw_rectangle_rounded(r, 0.35, 16, BTN_BG)
rl.draw_rectangle_rounded_lines_ex(r, 0.35, 16, 2, BTN_BORDER)
cx, cy = r.x + r.width / 2, r.y + r.height / 2
if name == "recenter":
rl.draw_circle_lines(int(cx), int(cy), 20, rl.WHITE)
rl.draw_circle(int(cx), int(cy), 6, PUCK_BLUE)
for ang in (0, 90, 180, 270):
a = math.radians(ang)
rl.draw_line_ex(rl.Vector2(cx + 20 * math.cos(a), cy + 20 * math.sin(a)),
rl.Vector2(cx + 30 * math.cos(a), cy + 30 * math.sin(a)), 3, rl.WHITE)
else:
ts = measure_text_cached(font, name, 56)
rl.draw_text_ex(font, name, rl.Vector2(int(cx - ts.x / 2), int(cy - ts.y / 2)), 56, 0, rl.WHITE)
def release(self):
self._tiles.release()

View File

@@ -0,0 +1,81 @@
import pyray as rl
from openpilot.selfdrive.ui.layouts.nav import _MapPreview
from openpilot.selfdrive.ui.lib.nav_helpers import current_or_last_gps_position
from openpilot.system.ui.lib.application import gui_app, FontWeight
from openpilot.system.ui.lib.text_measure import measure_text_cached
from openpilot.system.ui.widgets import Widget
PANEL_BG = rl.Color(34, 36, 42, 255)
PANEL_BORDER = rl.Color(255, 255, 255, 26)
TEAL = rl.Color(16, 185, 169, 255)
MUTED = rl.Color(160, 160, 165, 255)
PANEL_ROUNDNESS = 0.16
PANEL_SEGMENTS = 24
class MapPanelWidget(Widget):
def __init__(self):
super().__init__()
self._map = _MapPreview()
self._last_lat = 0.0
self._last_lon = 0.0
def _render(self, rect: rl.Rectangle):
rl.draw_rectangle_rounded(rect, PANEL_ROUNDNESS, PANEL_SEGMENTS, PANEL_BG)
lat, lon, bearing, have_fix = current_or_last_gps_position()
if have_fix:
self._map.request(lat, lon, bearing, rect.width, rect.height)
drew_map = self._map.draw(rect, roundness=PANEL_ROUNDNESS)
rl.draw_rectangle_rounded_lines_ex(rect, PANEL_ROUNDNESS, PANEL_SEGMENTS, 2, PANEL_BORDER)
if drew_map:
font = gui_app.font(FontWeight.BOLD)
label = "LAST KNOWN LOCATION"
fs = 26
spacing = 3
text_size = measure_text_cached(font, label, fs, spacing)
pill_w = text_size.x + 56
pill_h = text_size.y + 24
pill = rl.Rectangle(
rect.x + (rect.width - pill_w) / 2,
rect.y + rect.height - pill_h - 24,
pill_w,
pill_h,
)
text_x = pill.x + (pill.width - text_size.x) / 2
text_y = pill.y + (pill.height - text_size.y) / 2
rl.draw_rectangle_rounded(pill, 0.5, 18, rl.Color(0, 0, 0, 170))
rl.draw_text_ex(font, label, rl.Vector2(int(text_x), int(text_y)), fs, spacing, TEAL)
else:
# No token or no fix — placeholder
font = gui_app.font(FontWeight.MEDIUM)
cx = rect.x + rect.width / 2
cy = rect.y + rect.height / 2
if not self._map.has_token():
line1 = "Map unavailable"
line2 = "Set MapboxToken to enable"
elif not have_fix:
line1 = "Waiting for GPS fix..."
line2 = "No live or saved location"
elif self._map.status() == "error":
line1 = "Map unavailable"
line2 = "Mapbox request failed"
else:
line1 = "Loading map..."
line2 = "Fetching Mapbox preview"
ts1 = measure_text_cached(font, line1, 46)
rl.draw_text_ex(font, line1, rl.Vector2(int(cx - ts1.x / 2), int(cy - 40)), 46, 0, MUTED)
if line2:
ts2 = measure_text_cached(font, line2, 32)
rl.draw_text_ex(
font,
line2,
rl.Vector2(int(cx - ts2.x / 2), int(cy + 20)),
32,
0,
rl.Color(120, 120, 125, 255),
)

View File

@@ -0,0 +1,344 @@
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.system.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 callable(self._text) 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):
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)

View File

@@ -0,0 +1,255 @@
import pyray as rl
import qrcode
import numpy as np
import time
import jwt
import os
from datetime import datetime, timedelta, UTC
from openpilot.common.api.base import BaseApi
from openpilot.common.swaglog import cloudlog
from openpilot.common.params import Params
from openpilot.iqpilot.konn3kt.registration import get_or_create_dongle_id, ensure_dev_pairing_identity
from openpilot.system.hardware import HARDWARE, PC
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/iq/close.png", 80, 80))
self._close_btn.set_click_callback(lambda: gui_app.set_modal_overlay(None))
def _get_pairing_url(self) -> str:
dev_pairing = PC and os.getenv("KONN3KT_DEV_PAIRING") == "1"
if dev_pairing:
try:
ensure_dev_pairing_identity(self.params, force_reset=os.getenv("KONN3KT_DEV_PAIRING_RESET") == "1")
except Exception:
return "error://dev_identity_setup_failed"
try:
imei1 = HARDWARE.get_imei(0) or ""
except Exception as e:
cloudlog.warning(f"Failed to get imei1: {e}")
imei1 = ""
try:
imei2 = HARDWARE.get_imei(1) or ""
except Exception as e:
cloudlog.warning(f"Failed to get imei2: {e}")
imei2 = ""
try:
algorithm, private_key, public_key = BaseApi.get_key_pair()
if not private_key or not algorithm:
cloudlog.error("No device keys found")
return "error://keys_not_found"
dongle_id = get_or_create_dongle_id(self.params, prefer_readonly=True)
try:
serial = HARDWARE.get_serial() or ""
except Exception as e:
cloudlog.warning(f"Failed to get serial: {e}")
serial = ""
if not serial:
serial = (self.params.get("HardwareSerial") or "") if dev_pairing else ""
if not serial:
cloudlog.error("No hardware serial found, cannot generate pairing token")
return "error://serial_not_found"
now = datetime.now(UTC).replace(tzinfo=None)
payload = {
'identity': dongle_id,
'nbf': now,
'iat': now,
'imei': imei1,
'imei2': imei2,
'serial': serial,
'public_key': public_key,
'register': True,
'exp': now + timedelta(hours=1),
}
try:
token = jwt.encode(payload, private_key, algorithm=algorithm)
except Exception as e:
cloudlog.warning(f"jwt.encode failed ({e}), retrying with normalized key")
try:
from cryptography.hazmat.primitives import serialization
key_bytes = private_key.encode("utf-8") if isinstance(private_key, str) else private_key
try:
key_obj = serialization.load_pem_private_key(key_bytes, password=None)
except Exception:
key_obj = serialization.load_ssh_private_key(key_bytes, password=None)
token = jwt.encode(payload, key_obj, algorithm=algorithm)
except Exception as e2:
cloudlog.error(f"Failed to generate pairing token: {e2}")
return "error://token_generation_failed"
if isinstance(token, bytes):
token = token.decode('utf8')
return f"https://konn3kt.com/?pair={token}"
except FileNotFoundError as e:
cloudlog.error(f"Key files not found: {e}")
return "error://keys_not_found"
except Exception as e:
cloudlog.error(f"Failed to generate pairing token: {e}")
return "error://token_generation_failed"
def _generate_qr_code(self) -> None:
try:
url = self._get_pairing_url()
if url.startswith("error://"):
cloudlog.warning(f"Cannot generate QR code: {url}")
self.qr_texture = None
return
qr = qrcode.QRCode(version=1, error_correction=qrcode.constants.ERROR_CORRECT_L, box_size=10, border=4)
qr.add_data(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.set_modal_overlay(None)
def _render(self, rect: rl.Rectangle) -> int:
rl.clear_background(rl.Color(20, 21, 24, 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 Konn3kt 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.WHITE)
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("Open the Konn3kt app on your phone"),
tr("Tap \"Add Device\" and scan the QR code on the right"),
tr("Follow the prompts in the app to finish pairing"),
]
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(16, 185, 169, 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.WHITE)
y += text_height + 50
def _render_qr_code(self, rect: rl.Rectangle) -> None:
# White card: QR codes must stay light to scan, and it reads as an intentional panel on the dark theme.
rl.draw_rectangle_rounded(rect, 0.06, 20, rl.Color(245, 245, 245, 255))
if not self.qr_texture:
error_font = gui_app.font(FontWeight.BOLD)
msg = tr("QR Code Error")
ms = measure_text_cached(error_font, msg, 34)
pos = rl.Vector2(rect.x + (rect.width - ms.x) / 2, rect.y + (rect.height - ms.y) / 2)
rl.draw_text_ex(error_font, msg, pos, 34, 0.0, rl.Color(200, 60, 52, 255))
return
pad = 28
inner = rl.Rectangle(rect.x + pad, rect.y + pad, rect.width - 2 * pad, rect.height - 2 * pad)
source = rl.Rectangle(0, 0, self.qr_texture.width, self.qr_texture.height)
rl.draw_texture_pro(self.qr_texture, source, inner, 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

View File

@@ -0,0 +1,183 @@
import pyray as rl
import time
import json
from openpilot.common.constants import CV
from openpilot.common.params import Params
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 Konn3kt pairing status"""
PRIME_BG_COLOR = rl.Color(51, 51, 51, 255)
KONN3KT_ONLINE_NS = 80_000_000_000 # 80 seconds in nanoseconds
TRIPS_PARAM_KEY = "ApiCache_DriveStats"
def __init__(self):
super().__init__()
self._params = Params()
self._icon_distance = gui_app.texture("icons/road.png", 58, 58, keep_aspect_ratio=True)
self._icon_drives = gui_app.texture("icons_mici/wheel.png", 52, 52, keep_aspect_ratio=True)
self._icon_hours = gui_app.texture("../../iqpilot/selfdrive/assets/icons/clock.png", 52, 52, keep_aspect_ratio=True)
def _render(self, rect):
if ui_state.prime_state.is_paired():
self._render_for_paired_user(rect)
else:
self._render_for_unpaired_users(rect)
def _is_konn3kt_online(self) -> bool:
last_ping = ui_state.sm['deviceState'].lastAthenaPingTime
return last_ping != 0 and (time.monotonic_ns() - last_ping) < self.KONN3KT_ONLINE_NS
def _render_for_unpaired_users(self, rect: rl.Rectangle):
"""Renders the pairing prompt for unpaired 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("Pair Your Device"), 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("Pair your device in the Konn3kt app"), 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("Konn3kt Features:"), 41, font_weight=FontWeight.BOLD)
# Feature list
features = [tr("Remote access"), tr("Live streaming"), tr("Unlimited route storage"), tr("And so much more")]
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_paired_user(self, rect: rl.Rectangle):
"""Renders the paired status widget."""
status_card_height = 188
trips_spacing = 12
trips_y = rect.y + status_card_height + trips_spacing
trips_height = max(0, rect.height - status_card_height - trips_spacing)
rl.draw_rectangle_rounded(rl.Rectangle(rect.x, rect.y, rect.width, status_card_height), 0.1, 10, self.PRIME_BG_COLOR)
x = rect.x + 56
y = rect.y + 26
font = gui_app.font(FontWeight.BOLD)
rl.draw_text_ex(font, tr("Konn3kt"), rl.Vector2(x, y), 72, 0, rl.WHITE)
status_label = tr("Konn3kt Status:")
status_font = gui_app.font(FontWeight.NORMAL)
status_font_size = 46
status_pos = rl.Vector2(x, y + 84)
rl.draw_text_ex(status_font, status_label, status_pos, status_font_size, 0, rl.WHITE)
status_size = measure_text_cached(status_font, status_label, status_font_size)
is_online = self._is_konn3kt_online()
status_color = rl.Color(134, 255, 78, 255) if is_online else rl.Color(201, 34, 49, 255)
dot_x = int(status_pos.x + status_size.x + 26)
dot_y = int(status_pos.y + status_size.y / 2)
rl.draw_circle(dot_x, dot_y, 14, status_color)
if trips_height > 0:
self._render_all_time_stats(rl.Rectangle(rect.x, trips_y, rect.width, trips_height))
def _get_all_time_stats(self) -> dict:
raw_stats = self._params.get(self.TRIPS_PARAM_KEY)
if not raw_stats:
return {}
if isinstance(raw_stats, dict):
stats = raw_stats
elif isinstance(raw_stats, (bytes, bytearray)):
try:
stats = json.loads(raw_stats.decode("utf-8"))
except Exception:
return {}
elif isinstance(raw_stats, str):
try:
stats = json.loads(raw_stats)
except Exception:
return {}
else:
return {}
return stats.get("all", {}) if isinstance(stats.get("all", {}), dict) else {}
def _render_all_time_stats(self, rect: rl.Rectangle):
rl.draw_rectangle_rounded(rect, 0.05, 10, rl.Color(30, 30, 30, 255))
stats = self._get_all_time_stats()
is_metric = self._params.get_bool("IsMetric")
routes = int(stats.get("routes", 0))
distance = float(stats.get("distance", 0))
distance_val = int(distance * CV.MPH_TO_KPH) if is_metric else int(distance)
hours = int(float(stats.get("minutes", 0)) / 60.0)
title_font = gui_app.font(FontWeight.BOLD)
title_size = 52
title_y = rect.y + 20
rl.draw_text_ex(title_font, tr("ALL TIME"), rl.Vector2(rect.x + 36, title_y), title_size, 0, rl.Color(228, 228, 228, 255))
header_line_y = rect.y + 82
rl.draw_line_ex(
rl.Vector2(rect.x + 28, header_line_y),
rl.Vector2(rect.x + rect.width - 28, header_line_y),
2,
rl.Color(95, 95, 95, 150),
)
inner_rect = rl.Rectangle(rect.x + 22, rect.y + 94, rect.width - 44, rect.height - 116)
rl.draw_rectangle_rounded(inner_rect, 0.04, 10, rl.Color(26, 26, 31, 255))
# Center a compact stats block within the inner area.
group_height = min(220, max(180, inner_rect.height - 16))
base_y = inner_rect.y + max(0.0, (inner_rect.height - group_height) / 2.0)
col_width = inner_rect.width / 3
number_font = gui_app.font(FontWeight.BOLD)
number_size = 74
unit_font = gui_app.font(FontWeight.LIGHT)
unit_size = 48
unit_color = rl.Color(170, 170, 170, 255)
for i in (1, 2):
line_x = inner_rect.x + (col_width * i)
rl.draw_line_ex(rl.Vector2(line_x, base_y + 8), rl.Vector2(line_x, base_y + group_height - 10), 2, rl.Color(82, 82, 86, 160))
def draw_col(col_idx: int, icon, value: str, unit: str):
col_x = inner_rect.x + (col_width * col_idx)
center_x = col_x + (col_width / 2)
icon_x = int(center_x - (icon.width / 2))
rl.draw_texture(icon, icon_x, int(base_y + 12), rl.WHITE)
val_size = measure_text_cached(number_font, value, number_size)
val_x = center_x - (val_size.x / 2)
rl.draw_text_ex(number_font, value, rl.Vector2(val_x, base_y + 86), number_size, 0, rl.WHITE)
unit_size_vec = measure_text_cached(unit_font, unit, unit_size)
unit_x = center_x - (unit_size_vec.x / 2)
rl.draw_text_ex(unit_font, unit, rl.Vector2(unit_x, base_y + 168), unit_size, 0, unit_color)
draw_col(0, self._icon_drives, str(routes), tr("Drives"))
draw_col(1, self._icon_distance, str(distance_val), tr("KM") if is_metric else tr("Miles"))
draw_col(2, self._icon_hours, str(hours), tr("Hours"))

View File

@@ -0,0 +1,57 @@
import pyray as rl
from collections.abc import Callable
from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos
from openpilot.system.ui.lib.text_measure import measure_text_cached
from openpilot.system.ui.widgets import Widget
HEADER_HEIGHT = 130
BACK_BTN_SIZE = 110
class ScreenHeader(Widget):
"""Reusable offroad sub-screen header: a circular back button on the left and a title.
Used by the Stats and NAV screens reached from the offroad launcher tiles.
"""
BTN_COLOR = rl.Color(38, 40, 46, 255)
BTN_PRESSED = rl.Color(54, 57, 65, 255)
def __init__(self, title: str, on_back: Callable[[], None] | None = None):
super().__init__()
self._title = title
self._on_back = on_back
self._title_offset = 0 # extra space between back button and title (for inline buttons)
self._back_icon = gui_app.texture("icons/iq/back.png", 56, 56, keep_aspect_ratio=True)
self._back_rect = rl.Rectangle(0, 0, BACK_BTN_SIZE, BACK_BTN_SIZE)
def set_on_back(self, cb: Callable[[], None]) -> None:
self._on_back = cb
def set_title(self, title: str) -> None:
self._title = title
def set_title_offset(self, offset: int) -> None:
self._title_offset = offset
def _render(self, rect: rl.Rectangle):
self._back_rect = rl.Rectangle(rect.x, rect.y + (rect.height - BACK_BTN_SIZE) / 2, BACK_BTN_SIZE, BACK_BTN_SIZE)
mouse_pos = rl.get_mouse_position()
pressed = self.is_pressed and rl.check_collision_point_rec(mouse_pos, self._back_rect)
rl.draw_rectangle_rounded(self._back_rect, 1.0, 20, self.BTN_PRESSED if pressed else self.BTN_COLOR)
icon_x = int(self._back_rect.x + (BACK_BTN_SIZE - self._back_icon.width) / 2)
icon_y = int(self._back_rect.y + (BACK_BTN_SIZE - self._back_icon.height) / 2)
rl.draw_texture(self._back_icon, icon_x, icon_y, rl.WHITE)
# Title, vertically centered, to the right of the back button
font = gui_app.font(FontWeight.BOLD)
title_size = measure_text_cached(font, self._title, 64)
title_x = self._back_rect.x + BACK_BTN_SIZE + 36 + self._title_offset
title_y = rect.y + (rect.height - title_size.y) / 2
rl.draw_text_ex(font, self._title, rl.Vector2(int(title_x), int(title_y)), 64, 0, rl.WHITE)
def _handle_mouse_release(self, mouse_pos: MousePos):
if rl.check_collision_point_rec(mouse_pos, self._back_rect) and self._on_back:
self._on_back()

View File

@@ -0,0 +1,85 @@
import math
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.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.confirm_dialog import alert_dialog
from openpilot.system.ui.widgets.button import Button, ButtonStyle
SETUP_CARD_BG = rl.Color(34, 36, 42, 255)
SETUP_CARD_BORDER = rl.Color(255, 255, 255, 26)
SETUP_ACCENT = rl.Color(16, 185, 169, 255)
class SetupWidget(Widget):
def __init__(self):
super().__init__()
self._pairing_dialog: PairingDialog | None = None
self._pair_device_btn = Button(lambda: tr("Pair device"), self._show_pairing, button_style=ButtonStyle.PRIMARY)
def _render(self, rect: rl.Rectangle):
if not ui_state.prime_state.is_paired():
self._render_registration(rect)
def _render_registration(self, rect: rl.Rectangle):
"""Render registration prompt."""
t = rl.get_time()
pulse = 0.5 + 0.5 * math.sin(t * 2.3)
glow_alpha = int(24 + pulse * 34)
border_alpha = int(28 + pulse * 38)
glow_rect = rl.Rectangle(rect.x - 4, rect.y - 4, rect.width + 8, rect.height + 8)
rl.draw_rectangle_rounded_lines_ex(glow_rect, 0.06, 24, 5, rl.Color(SETUP_ACCENT.r, SETUP_ACCENT.g, SETUP_ACCENT.b, glow_alpha))
rl.draw_rectangle_rounded(rl.Rectangle(rect.x, rect.y, rect.width, rect.height), 0.06, 24, SETUP_CARD_BG)
rl.draw_rectangle_rounded_lines_ex(rl.Rectangle(rect.x, rect.y, rect.width, rect.height), 0.06, 24, 2, SETUP_CARD_BORDER)
rl.draw_rectangle_rounded_lines_ex(rl.Rectangle(rect.x, rect.y, rect.width, rect.height), 0.06, 24, 2,
rl.Color(SETUP_ACCENT.r, SETUP_ACCENT.g, SETUP_ACCENT.b, border_alpha))
x = rect.x + 64
w = rect.width - 128
font = gui_app.font(FontWeight.BOLD)
title = tr("Finish Setup")
title_size = measure_text_cached(font, title, 75)
desc = tr("Pair your device in the Konn3kt app.")
light_font = gui_app.font(FontWeight.NORMAL)
wrapped = wrap_text(light_font, desc, 50, int(w))
desc_line_h = 50 * FONT_SCALE
content_h = title_size.y + 38 + len(wrapped) * desc_line_h + 30 + 200
y = rect.y + (rect.height - content_h) / 2 - 44
title_x = x + (w - title_size.x) / 2
rl.draw_text_ex(font, title, rl.Vector2(int(title_x), int(y)), 75, 0, rl.WHITE)
y += title_size.y + 38
for line in wrapped:
line_size = measure_text_cached(light_font, line, 50)
line_x = x + (w - line_size.x) / 2
rl.draw_text_ex(light_font, line, rl.Vector2(int(line_x), int(y)), 50, 0, rl.WHITE)
y += desc_line_h
button_rect = rl.Rectangle(x, y + 30, w, 200)
cta_glow = rl.Rectangle(button_rect.x - 8, button_rect.y - 8, button_rect.width + 16, button_rect.height + 16)
rl.draw_rectangle_rounded(cta_glow, 0.5, 24, rl.Color(SETUP_ACCENT.r, SETUP_ACCENT.g, SETUP_ACCENT.b, int(16 + pulse * 20)))
self._pair_device_btn.render(button_rect)
def _show_pairing(self):
if not system_time_valid():
dlg = alert_dialog(tr("Please connect to Wi-Fi to complete initial pairing"))
gui_app.set_modal_overlay(dlg)
return
if not self._pairing_dialog:
self._pairing_dialog = PairingDialog()
gui_app.set_modal_overlay(self._pairing_dialog, lambda result: setattr(self, '_pairing_dialog', None))
def __del__(self):
if self._pairing_dialog:
del self._pairing_dialog

View File

@@ -0,0 +1,217 @@
"""
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
"""
import pyray as rl
import requests
import threading
import copy
from collections.abc import Callable
from enum import Enum
from openpilot.common.params import Params
from openpilot.common.swaglog import cloudlog
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
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:
# Genuinely no public SSH keys on this GitHub account
self._error = tr("No SSH keys found for user '{}'").format(username)
else:
self._params.put("GithubUsername", username)
self._params.put("GithubSshKeys", keys)
except requests.exceptions.Timeout:
self._error = tr("Request timed out")
except requests.exceptions.HTTPError as e:
status = e.response.status_code if e.response is not None else None
if status == 404:
self._error = tr("No SSH keys found for user '{}'").format(username)
else:
cloudlog.exception("SSH key fetch HTTP error")
self._error = tr("GitHub error ({}) fetching keys").format(status or "?")
except requests.exceptions.SSLError:
# Almost always a wrong device clock (cert validity check) or stale CA bundle
cloudlog.exception("SSH key fetch SSL error")
self._error = tr("Couldn't verify GitHub - check device time")
except requests.exceptions.ConnectionError:
cloudlog.exception("SSH key fetch connection error")
self._error = tr("Couldn't reach GitHub - check connection")
except Exception:
cloudlog.exception("SSH key fetch failed")
self._error = tr("Couldn't fetch SSH keys - see logs")
finally:
self._done = True
class SshKeyActionState(Enum):
LOADING = tr_noop("LOADING")
ADD = tr_noop("ADD")
REMOVE = tr_noop("REMOVE")
class SshKeyAction(ItemAction):
HTTP_TIMEOUT = 15
MAX_WIDTH = 500
def __init__(self):
super().__init__(self.MAX_WIDTH, True)
self._keyboard = Keyboard(min_text_size=1)
self._params = Params()
self._error_message: str = ""
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 _render(self, rect: rl.Rectangle) -> bool:
# Show error dialog if there's an error
if self._error_message:
message = copy.copy(self._error_message)
gui_app.set_modal_overlay(alert_dialog(message))
self._username = ""
self._error_message = ""
# 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"))
gui_app.set_modal_overlay(self._keyboard, callback=self._on_username_submit)
elif self._state == SshKeyActionState.REMOVE:
self._params.remove("GithubUsername")
self._params.remove("GithubSshKeys")
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
threading.Thread(target=lambda: self._fetch_ssh_key(username), daemon=True).start()
def _fetch_ssh_key(self, username: str):
try:
url = f"https://github.com/{username}.keys"
response = requests.get(url, timeout=self.HTTP_TIMEOUT)
response.raise_for_status()
keys = response.text.strip()
if not keys:
# Genuinely no public SSH keys on this GitHub account
self._error_message = tr("No SSH keys found for user '{}'").format(username)
self._state = SshKeyActionState.ADD
return
# Success - save keys
self._params.put("GithubUsername", username)
self._params.put("GithubSshKeys", keys)
self._state = SshKeyActionState.REMOVE
self._username = username
except requests.exceptions.Timeout:
self._error_message = tr("Request timed out")
self._state = SshKeyActionState.ADD
except requests.exceptions.HTTPError as e:
status = e.response.status_code if e.response is not None else None
if status == 404:
self._error_message = tr("No SSH keys found for user '{}'").format(username)
else:
cloudlog.exception("SSH key fetch HTTP error")
self._error_message = tr("GitHub error ({}) fetching keys").format(status or "?")
self._state = SshKeyActionState.ADD
except requests.exceptions.SSLError:
cloudlog.exception("SSH key fetch SSL error")
self._error_message = tr("Couldn't verify GitHub - check device time")
self._state = SshKeyActionState.ADD
except requests.exceptions.ConnectionError:
cloudlog.exception("SSH key fetch connection error")
self._error_message = tr("Couldn't reach GitHub - check connection")
self._state = SshKeyActionState.ADD
except Exception:
cloudlog.exception("SSH key fetch failed")
self._error_message = tr("Couldn't fetch SSH keys - see logs")
self._state = SshKeyActionState.ADD
def ssh_key_item(title: str | Callable[[], str], description: str | Callable[[], str]) -> ListItem:
return ListItem(title=title, description=description, action_item=SshKeyAction())