IQ.Pilot Release Commit @ b6534c0
This commit is contained in:
0
iqpilot/selfdrive/ui/widgets/__init__.py
Normal file
0
iqpilot/selfdrive/ui/widgets/__init__.py
Normal file
79
iqpilot/selfdrive/ui/widgets/inspire_widget.py
Normal file
79
iqpilot/selfdrive/ui/widgets/inspire_widget.py
Normal file
@@ -0,0 +1,79 @@
|
||||
import random
|
||||
import pyray as rl
|
||||
|
||||
from iqpilot.system.ui.lib.application import gui_app, FontWeight
|
||||
from iqpilot.selfdrive.ui.lib.motd import load_motds
|
||||
from iqpilot.system.ui.lib.text_measure import measure_text_cached
|
||||
from iqpilot.system.ui.lib.wrap_text import wrap_text
|
||||
from iqpilot.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)
|
||||
230
iqpilot/selfdrive/ui/widgets/interactive_map.py
Normal file
230
iqpilot/selfdrive/ui/widgets/interactive_map.py
Normal file
@@ -0,0 +1,230 @@
|
||||
"""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 iqpilot.common.params import Params
|
||||
from iqpilot.selfdrive.ui.lib.nav_helpers import current_or_last_gps_position
|
||||
from iqpilot.ui.onroad.nav_map_panel import MapboxTileProvider
|
||||
from iqpilot.ui.onroad.nav_map_utils import TILE_SIZE
|
||||
from iqpilot.system.ui.lib.application import gui_app, FontWeight, MouseEvent, MousePos
|
||||
from iqpilot.system.ui.lib.multilang import tr
|
||||
from iqpilot.system.ui.lib.text_measure import measure_text_cached
|
||||
from iqpilot.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, tr("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()
|
||||
82
iqpilot/selfdrive/ui/widgets/map_panel_widget.py
Normal file
82
iqpilot/selfdrive/ui/widgets/map_panel_widget.py
Normal file
@@ -0,0 +1,82 @@
|
||||
import pyray as rl
|
||||
|
||||
from iqpilot.selfdrive.ui.layouts.nav import _MapPreview
|
||||
from iqpilot.selfdrive.ui.lib.nav_helpers import current_or_last_gps_position
|
||||
from iqpilot.system.ui.lib.application import gui_app, FontWeight
|
||||
from iqpilot.system.ui.lib.multilang import tr
|
||||
from iqpilot.system.ui.lib.text_measure import measure_text_cached
|
||||
from iqpilot.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 = tr("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),
|
||||
)
|
||||
344
iqpilot/selfdrive/ui/widgets/offroad_alerts.py
Normal file
344
iqpilot/selfdrive/ui/widgets/offroad_alerts.py
Normal 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 iqpilot.common.params import Params
|
||||
from iqpilot.system.hardware import HARDWARE
|
||||
from iqpilot.system.ui.lib.application import gui_app, FontWeight, FONT_SCALE
|
||||
from iqpilot.system.ui.lib.multilang import tr
|
||||
from iqpilot.system.ui.lib.scroll_panel import GuiScrollPanel
|
||||
from iqpilot.system.ui.lib.text_measure import measure_text_cached
|
||||
from iqpilot.system.ui.lib.wrap_text import wrap_text
|
||||
from iqpilot.system.ui.widgets import Widget
|
||||
from iqpilot.system.ui.widgets.html_render import HtmlRenderer
|
||||
from iqpilot.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)
|
||||
255
iqpilot/selfdrive/ui/widgets/pairing_dialog.py
Normal file
255
iqpilot/selfdrive/ui/widgets/pairing_dialog.py
Normal 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 iqpilot.common.api.base import BaseApi
|
||||
from iqpilot.common.swaglog import cloudlog
|
||||
from iqpilot.common.params import Params
|
||||
from iqpilot.konn3kt.registration import get_or_create_dongle_id, ensure_dev_pairing_identity
|
||||
from iqpilot.system.hardware import HARDWARE, PC
|
||||
from iqpilot.system.ui.widgets import Widget
|
||||
from iqpilot.system.ui.lib.application import FontWeight, gui_app
|
||||
from iqpilot.system.ui.lib.multilang import tr
|
||||
from iqpilot.system.ui.lib.wrap_text import wrap_text
|
||||
from iqpilot.system.ui.lib.text_measure import measure_text_cached
|
||||
from iqpilot.system.ui.widgets.button import IconButton
|
||||
from iqpilot.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
|
||||
58
iqpilot/selfdrive/ui/widgets/screen_header.py
Normal file
58
iqpilot/selfdrive/ui/widgets/screen_header.py
Normal file
@@ -0,0 +1,58 @@
|
||||
import pyray as rl
|
||||
from collections.abc import Callable
|
||||
|
||||
from iqpilot.system.ui.lib.application import gui_app, FontWeight, MousePos
|
||||
from iqpilot.system.ui.lib.text_measure import measure_text_cached
|
||||
from iqpilot.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 | Callable[[], 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 | Callable[[], 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 = self._title() if callable(self._title) else self._title
|
||||
title_size = measure_text_cached(font, 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, 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()
|
||||
85
iqpilot/selfdrive/ui/widgets/setup.py
Normal file
85
iqpilot/selfdrive/ui/widgets/setup.py
Normal file
@@ -0,0 +1,85 @@
|
||||
import math
|
||||
import pyray as rl
|
||||
from iqpilot.common.time_helpers import system_time_valid
|
||||
from iqpilot.selfdrive.ui.ui_state import ui_state
|
||||
from iqpilot.selfdrive.ui.widgets.pairing_dialog import PairingDialog
|
||||
from iqpilot.system.ui.lib.application import gui_app, FontWeight, FONT_SCALE
|
||||
from iqpilot.system.ui.lib.multilang import tr
|
||||
from iqpilot.system.ui.lib.text_measure import measure_text_cached
|
||||
from iqpilot.system.ui.lib.wrap_text import wrap_text
|
||||
from iqpilot.system.ui.widgets import Widget
|
||||
from iqpilot.system.ui.widgets.confirm_dialog import alert_dialog
|
||||
from iqpilot.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
|
||||
217
iqpilot/selfdrive/ui/widgets/ssh_key.py
Normal file
217
iqpilot/selfdrive/ui/widgets/ssh_key.py
Normal 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 iqpilot.common.params import Params
|
||||
from iqpilot.common.swaglog import cloudlog
|
||||
from iqpilot.system.ui.lib.application import gui_app, FontWeight
|
||||
from iqpilot.system.ui.lib.multilang import tr, tr_noop
|
||||
from iqpilot.system.ui.lib.text_measure import measure_text_cached
|
||||
from iqpilot.system.ui.widgets import DialogResult
|
||||
from iqpilot.system.ui.widgets.button import Button, ButtonStyle
|
||||
from iqpilot.system.ui.widgets.confirm_dialog import alert_dialog
|
||||
from iqpilot.system.ui.widgets.keyboard import Keyboard
|
||||
from iqpilot.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())
|
||||
Reference in New Issue
Block a user