IQ.Pilot Release Commit @ bec7652

This commit is contained in:
IQ.Lvbs history cleanup
2026-08-22 23:42:41 -05:00
commit 58039e647c
4603 changed files with 1236178 additions and 0 deletions

View File

@@ -0,0 +1,353 @@
"""
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
"""
import time
from iqpilot.cereal import log
import pyray as rl
from collections.abc import Callable
from iqpilot.system.ui.widgets.label import gui_label, MiciLabel, UnifiedLabel
from iqpilot.system.ui.widgets import Widget
from iqpilot.system.ui.lib.application import gui_app, FontWeight, DEFAULT_TEXT_COLOR, MousePos
from iqpilot.system.ui.lib.text_measure import measure_text_cached
from iqpilot.selfdrive.ui.ui_state import ui_state
from iqpilot.system.ui.text import wrap_text
from iqpilot.system.version import training_version, RELEASE_IQ_BRANCHES
HEAD_BUTTON_FONT_SIZE = 40
HOME_PADDING = 8
HOME_TITLE_MAX_FONT_SIZE = 72
HOME_TITLE_MIN_FONT_SIZE = 36
HOME_TITLE_TEXT = "IQ.Pilot"
NetworkType = log.DeviceState.NetworkType
NETWORK_TYPES = {
NetworkType.none: "Offline",
NetworkType.wifi: "WiFi",
NetworkType.cell2G: "2G",
NetworkType.cell3G: "3G",
NetworkType.cell4G: "LTE",
NetworkType.cell5G: "5G",
NetworkType.ethernet: "Ethernet",
}
class DeviceStatus(Widget):
def __init__(self):
super().__init__()
self.set_rect(rl.Rectangle(0, 0, 300, 175))
self._update_state()
self._version_text = self._get_version_text()
self._do_welcome()
def _do_welcome(self):
ui_state.params.put("CompletedTrainingVersion", training_version)
def refresh(self):
self._update_state()
self._version_text = self._get_version_text()
def _get_version_text(self) -> str:
brand = "IQ.Pilot"
description = ui_state.params.get("UpdaterCurrentDescription")
return f"{brand} {description}" if description else brand
def _update_state(self):
# TODO: refresh function that can be called periodically, not at 60 fps, so we can update version
# update system status
self._system_status = "SYSTEM READY ✓" if ui_state.panda_type != log.PandaState.PandaType.unknown else "BOOTING UP..."
# update network status
strength = ui_state.sm['deviceState'].networkStrength.raw
strength_text = "" * strength + "" * (4 - strength) # ◌ also works
network_type = NETWORK_TYPES[ui_state.sm['deviceState'].networkType.raw]
self._network_status = f"{network_type} {strength_text}"
def _render(self, _):
# draw status
status_rect = rl.Rectangle(self._rect.x, self._rect.y, self._rect.width, 40)
gui_label(status_rect, self._system_status, font_size=HEAD_BUTTON_FONT_SIZE, color=DEFAULT_TEXT_COLOR,
font_weight=FontWeight.BOLD, alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER)
# draw network status
network_rect = rl.Rectangle(self._rect.x, self._rect.y + 60, self._rect.width, 40)
gui_label(network_rect, self._network_status, font_size=40, color=DEFAULT_TEXT_COLOR,
font_weight=FontWeight.MEDIUM, alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER)
# draw version
version_font_size = 30
version_rect = rl.Rectangle(self._rect.x, self._rect.y + 140, self._rect.width + 20, 40)
wrapped_text = '\n'.join(wrap_text(self._version_text, version_font_size, version_rect.width))
gui_label(version_rect, wrapped_text, font_size=version_font_size, color=DEFAULT_TEXT_COLOR,
font_weight=FontWeight.MEDIUM, alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT)
class MiciHomeLayout(Widget):
def __init__(self):
super().__init__()
self._on_settings_click: Callable | None = None
self._last_refresh = 0
self._mouse_down_t: None | float = None
self._did_long_press = False
self._is_pressed_prev = False
self._version_text = None
self._experimental_mode = False
self._settings_txt = gui_app.texture("icons_mici/settings.png", 48, 48)
self._experimental_txt = gui_app.texture("icons_mici/experimental_mode_mici.png", 48, 48)
self._iqdynamic_txt = gui_app.texture("icons_mici/iqdynamic_mode_mici.png", 48, 48)
self._iqstandard_txt = gui_app.texture("icons_mici/iqstandard_mode_mici.png", 48, 48)
self._mode_txt = None
self._mic_txt = gui_app.texture("icons_mici/microphone.png", 32, 46)
self._net_type = NETWORK_TYPES.get(NetworkType.none)
self._net_strength = 0
self._wifi_slash_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_slash.png", 50, 44)
self._wifi_none_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_none.png", 50, 37)
self._wifi_low_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_low.png", 50, 37)
self._wifi_medium_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_medium.png", 50, 37)
self._wifi_full_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_full.png", 50, 37)
self._cell_none_txt = gui_app.texture("icons_mici/settings/network/cell_strength_none.png", 54, 36)
self._cell_low_txt = gui_app.texture("icons_mici/settings/network/cell_strength_low.png", 54, 36)
self._cell_medium_txt = gui_app.texture("icons_mici/settings/network/cell_strength_medium.png", 54, 36)
self._cell_high_txt = gui_app.texture("icons_mici/settings/network/cell_strength_high.png", 54, 36)
self._cell_full_txt = gui_app.texture("icons_mici/settings/network/cell_strength_full.png", 54, 36)
self._lte_label = UnifiedLabel("LTE", font_size=22, text_color=rl.Color(255, 255, 255, int(255 * 0.82)),
font_weight=FontWeight.BOLD)
self._openpilot_label = MiciLabel(HOME_TITLE_TEXT, font_size=HOME_TITLE_MAX_FONT_SIZE, color=rl.Color(255, 255, 255, int(255 * 0.9)),
font_weight=FontWeight.SYNCOPATE)
self._version_label = MiciLabel("", font_size=36, font_weight=FontWeight.ROMAN)
self._large_version_label = MiciLabel("", font_size=64, color=rl.GRAY, font_weight=FontWeight.ROMAN)
self._date_label = MiciLabel("", font_size=36, color=rl.GRAY, font_weight=FontWeight.ROMAN)
self._branch_label = UnifiedLabel("", font_size=36, text_color=rl.GRAY, font_weight=FontWeight.ROMAN, scroll=True)
self._version_commit_label = UnifiedLabel("", font_size=36, text_color=rl.GRAY,
font_weight=FontWeight.ROMAN, wrap_text=False, scroll=True)
def show_event(self):
self._version_text = self._get_version_text()
self._update_network_status(ui_state.sm['deviceState'])
self._update_params()
def _update_params(self):
p = ui_state.params
self._experimental_mode = p.get_bool("ExperimentalMode")
if not p.get_bool("AlphaLongitudinalEnabled"):
self._mode_txt = None
elif not self._experimental_mode:
self._mode_txt = self._iqstandard_txt
elif p.get_bool("IQDynamicMode"):
self._mode_txt = self._iqdynamic_txt
else:
self._mode_txt = self._experimental_txt
def _update_state(self):
if self.is_pressed and not self._is_pressed_prev:
self._mouse_down_t = time.monotonic()
elif not self.is_pressed and self._is_pressed_prev:
self._mouse_down_t = None
self._did_long_press = False
self._is_pressed_prev = self.is_pressed
if self._mouse_down_t is not None:
if time.monotonic() - self._mouse_down_t > 0.5:
# long gating for experimental mode - only allow toggle if longitudinal control is available
if ui_state.has_longitudinal_control:
self._experimental_mode = not self._experimental_mode
ui_state.params.put("ExperimentalMode", self._experimental_mode)
if not self._experimental_mode:
ui_state.params.put_bool("IQDynamicMode", False)
self._update_params()
self._mouse_down_t = None
self._did_long_press = True
if rl.get_time() - self._last_refresh > 5.0:
device_state = ui_state.sm['deviceState']
self._update_network_status(device_state)
# Update version text
self._version_text = self._get_version_text()
self._last_refresh = rl.get_time()
self._update_params()
def _update_network_status(self, device_state):
self._net_type = device_state.networkType
strength = device_state.networkStrength
self._net_strength = max(0, min(5, strength.raw + 1)) if strength.raw > 0 else 0
def set_callbacks(self, on_settings: Callable | None = None):
self._on_settings_click = on_settings
def _handle_mouse_release(self, mouse_pos: MousePos):
if not self._did_long_press:
if self._on_settings_click:
self._on_settings_click()
self._did_long_press = False
def _get_version_text(self) -> tuple[str, str, str, str] | None:
description = ui_state.params.get("UpdaterCurrentDescription")
if description is not None and len(description) > 0:
# Expect "version / branch / commit / date"; be tolerant of other formats
try:
version, branch, commit, date = description.split(" / ")
return version, branch, commit, date
except Exception:
return None
return None
def _fit_title_font(self, title_x: float) -> None:
font = gui_app.font(FontWeight.SYNCOPATE)
viewport_width = min(w for w in (self.rect.width, gui_app.width, rl.get_screen_width()) if w > 0)
max_width = max(100, int(viewport_width - title_x - HOME_PADDING - 16))
fit_width = max_width * 0.9
text_width = measure_text_cached(font, HOME_TITLE_TEXT, HOME_TITLE_MAX_FONT_SIZE).x
if text_width <= fit_width:
target_size = HOME_TITLE_MAX_FONT_SIZE
else:
target_size = int(HOME_TITLE_MAX_FONT_SIZE * (fit_width / text_width))
target_size = max(HOME_TITLE_MIN_FONT_SIZE, min(HOME_TITLE_MAX_FONT_SIZE, target_size))
if target_size != self._openpilot_label.font_size:
self._openpilot_label.set_font_size(target_size)
def _render_title_gradient(self, x: float, y: float) -> None:
"""Render HOME_TITLE_TEXT with a left-to-right gradient (#7400b8 → #80ffdb).
Technique: render white text to an offscreen RenderTexture, then draw it
tinted by sampling the gradient per-column using draw_texture_pro with a
tint. Since draw_texture_pro only supports a single tint color, we use the
BLEND_MULTIPLIED trick:
1. Draw white text normally onto the framebuffer.
2. Draw gradient rect with BLEND_MULTIPLIED on top — this multiplies each
existing pixel by the gradient color, turning white text into the gradient
while the dark background (near-zero RGB) stays dark.
"""
font = gui_app.font(FontWeight.SYNCOPATE)
font_size = self._openpilot_label.font_size
text = HOME_TITLE_TEXT
text_size = measure_text_cached(font, text, font_size)
tw = int(text_size.x) + 4
th = int(text_size.y) + 4
# Step 1: draw white text at full opacity
rl.draw_text_ex(font, text, rl.Vector2(x + 2, y + 2), font_size, 0,
rl.Color(255, 255, 255, 230))
# Step 2: multiply gradient over the text region — white → gradient color,
# black background → stays black (0 × anything = 0)
rl.begin_blend_mode(rl.BlendMode.BLEND_MULTIPLIED)
rl.draw_rectangle_gradient_h(
int(x), int(y), tw, th,
rl.Color(0x80, 0xff, 0xdb, 255), # #80ffdb — left
rl.Color(0x74, 0x00, 0xb8, 255), # #7400b8 — right
)
rl.end_blend_mode()
def _render(self, _):
text_pos = rl.Vector2(self.rect.x - 2 + HOME_PADDING, self.rect.y + HOME_PADDING)
self._fit_title_font(text_pos.x)
self._render_title_gradient(text_pos.x, text_pos.y)
if self._version_text is not None:
# release branch
release_branch = self._version_text[1] in RELEASE_IQ_BRANCHES
version_pos = rl.Rectangle(text_pos.x, text_pos.y + self._openpilot_label.font_size + 16, 100, 44)
self._version_label.set_text(self._version_text[0])
self._version_label.set_position(version_pos.x, version_pos.y)
self._version_label.render()
self._date_label.set_text(" " + self._version_text[3])
self._date_label.set_position(version_pos.x + self._version_label.rect.width + 10, version_pos.y)
self._date_label.render()
viewport_right = min(w for w in (self.rect.x + self.rect.width, gui_app.width, rl.get_screen_width()) if w > 0)
branch_x = version_pos.x + self._version_label.rect.width + self._date_label.rect.width + 20
self._branch_label.set_max_width(max(80, int(viewport_right - branch_x - HOME_PADDING)))
self._branch_label.set_text(" " + ("release" if release_branch else self._version_text[1]))
self._branch_label.set_position(branch_x, version_pos.y)
self._branch_label.render()
if not release_branch:
# 2nd line
self._version_commit_label.set_text(self._version_text[2])
commit_y = version_pos.y + self._date_label.font_size + 7
commit_rect = rl.Rectangle(version_pos.x, commit_y, max(100, viewport_right - version_pos.x - HOME_PADDING), 44)
self._version_commit_label.render(commit_rect)
self._render_bottom_status_bar()
def _render_bottom_status_bar(self):
# ***** Center-aligned bottom section icons *****
# TODO: refactor repeated icon drawing into a small loop
ITEM_SPACING = 18
Y_CENTER = 24
last_x = self.rect.x + HOME_PADDING
# Draw settings icon in bottom left corner
rl.draw_texture(self._settings_txt, int(last_x), int(self._rect.y + self.rect.height - self._settings_txt.height / 2 - Y_CENTER),
rl.Color(255, 255, 255, int(255 * 0.9)))
last_x = last_x + self._settings_txt.width + ITEM_SPACING
# draw network
if self._net_type == NetworkType.wifi:
# There is no 1
draw_net_txt = {0: self._wifi_none_txt,
2: self._wifi_low_txt,
3: self._wifi_medium_txt,
4: self._wifi_full_txt,
5: self._wifi_full_txt}.get(self._net_strength, self._wifi_low_txt)
rl.draw_texture(draw_net_txt, int(last_x),
int(self._rect.y + self.rect.height - draw_net_txt.height / 2 - Y_CENTER), rl.Color(255, 255, 255, int(255 * 0.9)))
last_x += draw_net_txt.width + ITEM_SPACING
elif self._net_type in (NetworkType.cell2G, NetworkType.cell3G, NetworkType.cell4G, NetworkType.cell5G):
last_x = self._draw_cellular_cluster(last_x, ITEM_SPACING, Y_CENTER, connected=True)
else:
last_x = self._draw_cellular_cluster(last_x, ITEM_SPACING, Y_CENTER, connected=False)
if self._mode_txt is not None:
rl.draw_texture(self._mode_txt, int(last_x),
int(self._rect.y + self.rect.height - self._mode_txt.height / 2 - Y_CENTER), rl.Color(255, 255, 255, 255))
last_x += self._mode_txt.width + ITEM_SPACING
# draw microphone icon when recording audio is enabled
if ui_state.recording_audio:
rl.draw_texture(self._mic_txt, int(last_x),
int(self._rect.y + self.rect.height - self._mic_txt.height / 2 - Y_CENTER), rl.Color(255, 255, 255, 255))
last_x += self._mic_txt.width + ITEM_SPACING
def _draw_cellular_cluster(self, start_x: float, spacing: int, y_center: int, connected: bool) -> float:
draw_net_txt = {0: self._cell_none_txt,
2: self._cell_low_txt,
3: self._cell_medium_txt,
4: self._cell_high_txt,
5: self._cell_full_txt}.get(self._net_strength, self._cell_none_txt)
icon_y = int(self._rect.y + self.rect.height - draw_net_txt.height / 2 - y_center)
rl.draw_texture(draw_net_txt, int(start_x), icon_y, rl.Color(255, 255, 255, int(255 * 0.9)))
label_x = start_x + draw_net_txt.width + 12
label_y = self._rect.y + self.rect.height - 37
label_rect = rl.Rectangle(label_x, label_y, 44, 24)
self._lte_label.render(label_rect)
if not connected:
slash_start = rl.Vector2(start_x + 8, icon_y + draw_net_txt.height - 3)
slash_end = rl.Vector2(start_x + draw_net_txt.width - 2, icon_y + 3)
rl.draw_line_ex(slash_start, slash_end, 8, rl.Color(255, 255, 255, int(255 * 0.22)))
rl.draw_line_ex(slash_start, slash_end, 5, rl.Color(255, 255, 255, int(255 * 0.82)))
return label_x + label_rect.width + spacing

View File

@@ -0,0 +1,134 @@
"""
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
"""
import pyray as rl
import iqpilot.cereal.messaging as messaging
from iqpilot.selfdrive.ui.mici.layouts.home import MiciHomeLayout
from iqpilot.selfdrive.ui.mici.layouts.settings.settings import SettingsLayout
from iqpilot.selfdrive.ui.mici.layouts.offroad_alerts import MiciOffroadAlerts
from iqpilot.selfdrive.ui.mici.onroad.augmented_road_view import AugmentedRoadView
from iqpilot.selfdrive.ui.ui_state import device, ui_state
from iqpilot.selfdrive.ui.mici.layouts.onboarding import OnboardingWindow
from iqpilot.system.ui.widgets import Widget
from iqpilot.system.ui.widgets.scroller import Scroller
from iqpilot.system.ui.lib.application import gui_app
from iqpilot.system.ui.lib.multilang import multilang
from iqpilot.system.version import training_version
ONROAD_DELAY = 2.5 # seconds
class MiciMainLayout(Widget):
"""Root mici layout. Lives at the bottom of the nav stack; settings push on top.
Keeps the IQ.Pilot custom home + onroad as horizontally-scrolled pages, while
the (stock) settings open as a swipe-to-dismiss NavWidget on the nav stack.
"""
def __init__(self):
super().__init__()
self._pm = messaging.PubMaster(['bookmarkButton'])
self._prev_onroad = False
self._prev_standstill = False
self._onroad_time_delay: float | None = None
self._setup = False
self._rebuild_settings = False
self._home_layout = MiciHomeLayout()
self._alerts_layout = MiciOffroadAlerts()
self._settings_layout = SettingsLayout()
self._onroad_layout = AugmentedRoadView(bookmark_callback=self._on_bookmark_clicked)
for widget in (self._home_layout, self._settings_layout, self._alerts_layout, self._onroad_layout):
widget.set_rect(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
self._scroller = Scroller([
self._alerts_layout,
self._home_layout,
self._onroad_layout,
], spacing=0, pad_start=0, pad_end=0)
self._scroller.set_reset_scroll_at_show(False)
# Disable scrolling when onroad is interacting with bookmark
self._scroller.set_scrolling_enabled(lambda: not self._onroad_layout.is_swiping_left())
self._setup_callbacks()
if ui_state.params.get("CompletedTrainingVersion") != training_version:
ui_state.params.put("CompletedTrainingVersion", training_version)
gui_app.add_nav_stack_tick(self._handle_transitions)
gui_app.push_widget(self)
self._onboarding_window = OnboardingWindow()
if not self._onboarding_window.completed:
gui_app.set_modal_overlay(self._onboarding_window)
def _setup_callbacks(self):
self._home_layout.set_callbacks(on_settings=self._on_settings_clicked)
self._onroad_layout.set_click_callback(lambda: self._scroll_to(self._home_layout))
device.add_interactive_timeout_callback(self._on_interactive_timeout)
multilang.add_change_callback(self._on_language_changed)
def _on_language_changed(self):
self._rebuild_settings = True
def _scroll_to(self, layout: Widget):
self._scroller.scroll_to(int(layout.rect.x), smooth=True)
def show_event(self):
super().show_event()
self._scroller.show_event()
def _render(self, _):
if not self._setup:
if self._alerts_layout.active_alerts() > 0:
self._scroller.scroll_to(self._alerts_layout.rect.x)
else:
self._scroller.scroll_to(self._rect.width)
self._setup = True
self._scroller.render(self._rect)
def _handle_transitions(self):
if ui_state.started != self._prev_onroad:
self._prev_onroad = ui_state.started
if ui_state.started:
self._onroad_time_delay = rl.get_time()
else:
self._scroll_to(self._home_layout)
if self._onroad_time_delay is not None and rl.get_time() - self._onroad_time_delay >= ONROAD_DELAY:
gui_app.pop_widgets_to(self, lambda: self._scroll_to(self._onroad_layout))
self._onroad_time_delay = None
CS = ui_state.sm["carState"]
if not CS.standstill and self._prev_standstill:
gui_app.pop_widgets_to(self, lambda: self._scroll_to(self._onroad_layout))
self._prev_standstill = CS.standstill
if self._rebuild_settings:
self._rebuild_settings = False
gui_app.pop_widgets_to(self, instant=True)
self._settings_layout = SettingsLayout()
self._settings_layout.set_rect(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
def _on_interactive_timeout(self):
if ui_state.started:
if not ui_state.sm["carState"].standstill:
gui_app.pop_widgets_to(self, lambda: self._scroll_to(self._onroad_layout))
else:
gui_app.pop_widgets_to(self, instant=True)
self._scroll_to(self._home_layout)
def _on_settings_clicked(self):
gui_app.push_widget(self._settings_layout)
def _on_bookmark_clicked(self):
user_bookmark = messaging.new_message('bookmarkButton')
user_bookmark.valid = True
self._pm.send('bookmarkButton', user_bookmark)

View File

@@ -0,0 +1,309 @@
import pyray as rl
import re
import time
from dataclasses import dataclass
from enum import IntEnum
from iqpilot.common.params import Params
from iqpilot.selfdrive.selfdrived.alertmanager import OFFROAD_ALERTS
from iqpilot.system.hardware import HARDWARE
from iqpilot.system.ui.widgets import Widget
from iqpilot.system.ui.widgets.label import UnifiedLabel
from iqpilot.system.ui.widgets.scroller import Scroller
from iqpilot.system.ui.lib.application import gui_app, FontWeight
from iqpilot.system.ui.lib.multilang import tr
REFRESH_INTERVAL = 5.0 # seconds
class AlertSize(IntEnum):
SMALL = 0
MEDIUM = 1
BIG = 2
@dataclass
class AlertData:
key: str
text: str
severity: int
visible: bool = False
class AlertItem(Widget):
# TODO: click should always go somewhere: home or specific settings pane
"""Individual alert item widget with background image and text."""
ALERT_WIDTH = 520
ALERT_HEIGHT_SMALL = 212
ALERT_HEIGHT_MED = 240
ALERT_HEIGHT_BIG = 324
ALERT_PADDING = 28
ICON_SIZE = 64
ICON_MARGIN = 12
TEXT_COLOR = rl.Color(255, 255, 255, int(255 * 0.9))
TITLE_BODY_SPACING = 24
def __init__(self, alert_data: AlertData):
super().__init__()
self.alert_data = alert_data
# Load background textures
self._bg_small = gui_app.texture("icons_mici/offroad_alerts/small_alert.png", self.ALERT_WIDTH, self.ALERT_HEIGHT_SMALL)
self._bg_small_pressed = gui_app.texture("icons_mici/offroad_alerts/small_alert_pressed.png", self.ALERT_WIDTH, self.ALERT_HEIGHT_SMALL)
self._bg_medium = gui_app.texture("icons_mici/offroad_alerts/medium_alert.png", self.ALERT_WIDTH, self.ALERT_HEIGHT_MED)
self._bg_medium_pressed = gui_app.texture("icons_mici/offroad_alerts/medium_alert_pressed.png", self.ALERT_WIDTH, self.ALERT_HEIGHT_MED)
self._bg_big = gui_app.texture("icons_mici/offroad_alerts/big_alert.png", self.ALERT_WIDTH, self.ALERT_HEIGHT_BIG)
self._bg_big_pressed = gui_app.texture("icons_mici/offroad_alerts/big_alert_pressed.png", self.ALERT_WIDTH, self.ALERT_HEIGHT_BIG)
# Load warning icons
self._icon_orange = gui_app.texture("icons_mici/offroad_alerts/orange_warning.png", self.ICON_SIZE, self.ICON_SIZE)
self._icon_red = gui_app.texture("icons_mici/offroad_alerts/red_warning.png", self.ICON_SIZE, self.ICON_SIZE)
self._icon_green = gui_app.texture("icons_mici/offroad_alerts/green_wheel.png", self.ICON_SIZE, self.ICON_SIZE)
self._title_label = UnifiedLabel(text="", font_size=32, font_weight=FontWeight.SEMI_BOLD, text_color=self.TEXT_COLOR,
alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT,
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP, line_height=0.95)
self._body_label = UnifiedLabel(text="", font_size=28, font_weight=FontWeight.ROMAN, text_color=self.TEXT_COLOR,
alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT,
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM, line_height=0.95)
self._title_text = ""
self._body_text = ""
self._alert_size = AlertSize.SMALL
self._update_content()
def _split_text(self, text: str) -> tuple[str, str]:
"""Split text into title (first sentence) and body (remaining text)."""
# Find the end of the first sentence (period, exclamation, or question mark followed by space or end)
match = re.search(r'[.!?](?:\s+|$)', text)
if match:
# Found a sentence boundary - split at the end of the sentence
title = text[:match.start()].strip()
body = text[match.end():].strip()
return title, body
else:
# No sentence boundary found, return full text as title
return "", text
def _update_content(self):
"""Update text and calculate height."""
if not self.alert_data.visible or not self.alert_data.text:
self.set_visible(False)
return
self.set_visible(True)
# Split text into title and body
self._title_text, self._body_text = self._split_text(self.alert_data.text)
# Calculate text width (alert width minus padding and icon space on right)
title_width = self.ALERT_WIDTH - (self.ALERT_PADDING * 2) - self.ICON_SIZE - self.ICON_MARGIN
body_width = self.ALERT_WIDTH - (self.ALERT_PADDING * 2)
# Update labels
self._title_label.set_text(self._title_text)
self._body_label.set_text(self._body_text)
# Calculate content height
title_height = self._title_label.get_content_height(title_width) if self._title_text else 0
body_height = self._body_label.get_content_height(body_width) if self._body_text else 0
spacing = self.TITLE_BODY_SPACING if (self._title_text and self._body_text) else 0
total_text_height = title_height + spacing + body_height
# Determine which background size to use based on content height
min_height_with_padding = total_text_height + (self.ALERT_PADDING * 2)
if min_height_with_padding > self.ALERT_HEIGHT_MED:
self._alert_size = AlertSize.BIG
height = self.ALERT_HEIGHT_BIG
elif min_height_with_padding > self.ALERT_HEIGHT_SMALL:
self._alert_size = AlertSize.MEDIUM
height = self.ALERT_HEIGHT_MED
else:
self._alert_size = AlertSize.SMALL
height = self.ALERT_HEIGHT_SMALL
# Set rect size
self.set_rect(rl.Rectangle(0, 0, self.ALERT_WIDTH, height))
def update_alert_data(self, alert_data: AlertData):
"""Update alert data and refresh display."""
self.alert_data = alert_data
self._update_content()
def _render(self, _):
if not self.alert_data.visible or not self.alert_data.text:
return
# Choose background based on size
if self._alert_size == AlertSize.BIG:
bg_texture = self._bg_big_pressed if self.is_pressed else self._bg_big
elif self._alert_size == AlertSize.MEDIUM:
bg_texture = self._bg_medium_pressed if self.is_pressed else self._bg_medium
else: # AlertSize.SMALL
bg_texture = self._bg_small_pressed if self.is_pressed else self._bg_small
# Draw background
rl.draw_texture(bg_texture, int(self._rect.x), int(self._rect.y), rl.WHITE)
# Calculate text area (left side, avoiding icon on right)
title_width = self.ALERT_WIDTH - (self.ALERT_PADDING * 2) - self.ICON_SIZE - self.ICON_MARGIN
body_width = self.ALERT_WIDTH - (self.ALERT_PADDING * 2)
text_x = self._rect.x + self.ALERT_PADDING
text_y = self._rect.y + self.ALERT_PADDING
# Draw title label
if self._title_text:
title_rect = rl.Rectangle(
text_x,
text_y,
title_width,
self._title_label.get_content_height(title_width),
)
self._title_label.render(title_rect)
text_y += title_rect.height + self.TITLE_BODY_SPACING
# Draw body label
if self._body_text:
body_rect = rl.Rectangle(
text_x,
text_y,
body_width,
self._rect.height - text_y + self._rect.y - self.ALERT_PADDING,
)
self._body_label.render(body_rect)
# Draw warning icon on the right side
# Use green icon for update alerts (severity = -1), red for high severity, orange for low severity
if self.alert_data.severity == -1:
icon_texture = self._icon_green
elif self.alert_data.severity > 0:
icon_texture = self._icon_red
else:
icon_texture = self._icon_orange
icon_x = self._rect.x + self.ALERT_WIDTH - self.ALERT_PADDING - self.ICON_SIZE
icon_y = self._rect.y + self.ALERT_PADDING
rl.draw_texture(icon_texture, int(icon_x), int(icon_y), rl.WHITE)
class MiciOffroadAlerts(Widget):
"""Offroad alerts layout with vertical scrolling."""
def __init__(self):
super().__init__()
self.params = Params()
self.sorted_alerts: list[AlertData] = []
self.alert_items: list[AlertItem] = []
self._last_refresh = 0.0
# Create vertical scroller
self._scroller = Scroller([], horizontal=False, spacing=12, pad_start=0, pad_end=0, snap_items=False)
# Create empty state label
self._empty_label = UnifiedLabel(tr("no alerts"), 65, FontWeight.DISPLAY, rl.WHITE,
alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER,
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE)
# Build initial alert list
self._build_alerts()
def active_alerts(self) -> int:
return sum(alert.visible for alert in self.sorted_alerts)
def scrolling(self):
return self._scroller.scroll_panel.is_touch_valid()
def _build_alerts(self):
"""Build sorted list of alerts from OFFROAD_ALERTS."""
self.sorted_alerts = []
# Add UpdateAvailable alert at the top (severity = -1 to indicate special handling)
update_alert_data = AlertData(key="UpdateAvailable", text="", severity=-1)
self.sorted_alerts.append(update_alert_data)
update_alert_item = AlertItem(update_alert_data)
update_alert_item.set_click_callback(lambda: HARDWARE.reboot())
self.alert_items.append(update_alert_item)
self._scroller.add_widget(update_alert_item)
# Add regular alerts sorted by severity
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)
# Create alert item widget
alert_item = AlertItem(alert_data)
self.alert_items.append(alert_item)
self._scroller.add_widget(alert_item)
def refresh(self) -> int:
"""Refresh alerts from params and return active count."""
active_count = 0
# Handle UpdateAvailable alert specially
update_available = self.params.get_bool("UpdateAvailable")
update_alert_data = next((alert_data for alert_data in self.sorted_alerts if alert_data.key == "UpdateAvailable"), None)
if update_alert_data:
if update_available:
version_string = ""
# Get new version description and parse version and date
new_desc = self.params.get("UpdaterNewDescription") or ""
if new_desc:
# format: "version / branch / commit / date"
parts = new_desc.split(" / ")
if len(parts) > 3:
version, date = parts[0], parts[3]
version_string = f"\nIQ.Pilot {version}, {date}\n"
update_alert_data.text = f"Update available {version_string}. Click to update."
update_alert_data.visible = True
active_count += 1
else:
update_alert_data.text = ""
update_alert_data.visible = False
# Handle regular alerts
for alert_data in self.sorted_alerts:
if alert_data.key == "UpdateAvailable":
continue # Skip, already handled above
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
# Update alert items (they reference the same alert_data objects)
for alert_item in self.alert_items:
alert_item.update_alert_data(alert_item.alert_data)
return active_count
def show_event(self):
"""Reset scroll position when shown and refresh alerts."""
self._scroller.show_event()
self._last_refresh = time.monotonic()
self.refresh()
def _update_state(self):
"""Periodically refresh alerts."""
# Refresh alerts periodically, not every frame
current_time = time.monotonic()
if current_time - self._last_refresh >= REFRESH_INTERVAL:
self.refresh()
self._last_refresh = current_time
def _render(self, rect: rl.Rectangle):
"""Render the alerts scroller or empty state."""
if self.active_alerts() == 0:
self._empty_label.render(rect)
else:
self._scroller.render(rect)

View File

@@ -0,0 +1,533 @@
from enum import IntEnum
import weakref
import math
import os
import numpy as np
import pyray as rl
from iqpilot.common.filter_simple import FirstOrderFilter
from iqpilot.system.hardware import HARDWARE, PC
from iqpilot.system.ui.lib.application import FontWeight, gui_app
from iqpilot.system.ui.widgets import Widget
from iqpilot.system.ui.widgets.button import SmallButton, SmallCircleIconButton
from iqpilot.system.ui.widgets.label import UnifiedLabel
from iqpilot.system.ui.widgets.slider import SmallSlider
from iqpilot.system.ui.mici_setup import TermsHeader, TermsPage as SetupTermsPage
from iqpilot.selfdrive.ui.ui_state import ui_state, device
from iqpilot.selfdrive.ui.mici.onroad.driver_state import DriverStateRenderer
from iqpilot.selfdrive.ui.mici.onroad.driver_camera_dialog import DriverCameraDialog
from iqpilot.system.ui.widgets.label import gui_label
from iqpilot.system.ui.lib.multilang import tr
from iqpilot.system.version import terms_version, training_version
class OnboardingState(IntEnum):
TERMS = 0
ONBOARDING = 1
DECLINE = 2
class DriverCameraSetupDialog(DriverCameraDialog):
def __init__(self):
super().__init__(no_escape=True)
self.driver_state_renderer = DriverStateRenderer(inset=True)
self.driver_state_renderer.set_rect(rl.Rectangle(0, 0, 120, 120))
self.driver_state_renderer.load_icons()
self.driver_state_renderer.set_force_active(True)
def _render(self, rect):
rl.begin_scissor_mode(int(rect.x), int(rect.y), int(rect.width), int(rect.height))
self._camera_view._render(rect)
if not self._camera_view.frame:
gui_label(rect, tr("camera starting"), font_size=64, font_weight=FontWeight.BOLD,
alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER)
rl.end_scissor_mode()
return -1
# Position dmoji on opposite side from driver
is_rhd = self.driver_state_renderer.is_rhd
self.driver_state_renderer.set_position(
rect.x + 8 if is_rhd else rect.x + rect.width - self.driver_state_renderer.rect.width - 8,
rect.y + 8,
)
self.driver_state_renderer.render()
self._draw_face_detection(rect)
rl.end_scissor_mode()
return -1
class TrainingGuidePreDMTutorial(SetupTermsPage):
def __init__(self, continue_callback):
super().__init__(continue_callback, continue_text=tr("continue"))
self._title_header = TermsHeader("driver monitoring setup", gui_app.texture("icons_mici/setup/green_dm.png", 60, 60))
self._dm_label = UnifiedLabel("Next, we'll ensure comma four is mounted properly.\n\nIf it does not have a clear view of the driver, " +
"unplug and remount before continuing.", 42,
FontWeight.ROMAN)
def show_event(self):
super().show_event()
# Get driver monitoring model ready for next step
ui_state.params.put_bool("IsDriverViewEnabled", True)
@property
def _content_height(self):
return self._dm_label.rect.y + self._dm_label.rect.height - self._scroll_panel.get_offset()
def _render_content(self, scroll_offset):
self._title_header.render(rl.Rectangle(
self._rect.x + 16,
self._rect.y + 16 + scroll_offset,
self._title_header.rect.width,
self._title_header.rect.height,
))
self._dm_label.render(rl.Rectangle(
self._rect.x + 16,
self._title_header.rect.y + self._title_header.rect.height + 16,
self._rect.width - 32,
self._dm_label.get_content_height(int(self._rect.width - 32)),
))
class DMBadFaceDetected(SetupTermsPage):
def __init__(self, continue_callback, back_callback):
super().__init__(continue_callback, back_callback, continue_text=tr("power off"))
self._title_header = TermsHeader("make sure comma four can see your face", gui_app.texture("icons_mici/setup/orange_dm.png", 60, 60))
self._dm_label = UnifiedLabel(tr("Re-mount if your face is occluded or driver monitoring has difficulty tracking your face."), 42, FontWeight.ROMAN)
@property
def _content_height(self):
return self._dm_label.rect.y + self._dm_label.rect.height - self._scroll_panel.get_offset()
def _render_content(self, scroll_offset):
self._title_header.render(rl.Rectangle(
self._rect.x + 16,
self._rect.y + 16 + scroll_offset,
self._title_header.rect.width,
self._title_header.rect.height,
))
self._dm_label.render(rl.Rectangle(
self._rect.x + 16,
self._title_header.rect.y + self._title_header.rect.height + 16,
self._rect.width - 32,
self._dm_label.get_content_height(int(self._rect.width - 32)),
))
class TrainingGuideDMTutorial(Widget):
PROGRESS_DURATION = 4
LOOKING_THRESHOLD_DEG = 30.0
NO_CAMERA_BYPASS_DELAY_SEC = 2.0
def __init__(self, continue_callback):
super().__init__()
self._back_button = SmallCircleIconButton(gui_app.texture("icons_mici/setup/driver_monitoring/dm_question.png", 28, 48))
self._back_button.set_click_callback(self._show_bad_face_page)
self._good_button = SmallCircleIconButton(gui_app.texture("icons_mici/setup/driver_monitoring/dm_check.png", 42, 42))
# Wrap the continue callback to restore settings
def wrapped_continue_callback():
device.set_offroad_brightness(None)
continue_callback()
self._good_button.set_click_callback(wrapped_continue_callback)
self._good_button.set_enabled(False)
self._progress = FirstOrderFilter(0.0, 0.5, 1 / gui_app.target_fps)
self._dialog = DriverCameraSetupDialog()
self._bad_face_page = DMBadFaceDetected(HARDWARE.shutdown, self._hide_bad_face_page)
self._should_show_bad_face_page = False
self._no_camera_elapsed_sec = 0.0
self._allow_no_camera_bypass = PC or os.getenv("IQPILOT_ALLOW_DM_NO_CAMERA", "0") == "1"
# Disable driver monitoring model when device times out for inactivity
def inactivity_callback():
ui_state.params.put_bool("IsDriverViewEnabled", False)
device.add_interactive_timeout_callback(inactivity_callback)
def _show_bad_face_page(self):
self._bad_face_page.show_event()
self.hide_event()
self._should_show_bad_face_page = True
def _hide_bad_face_page(self):
self._bad_face_page.hide_event()
self.show_event()
self._should_show_bad_face_page = False
def show_event(self):
super().show_event()
self._dialog.show_event()
self._progress.x = 0.0
self._no_camera_elapsed_sec = 0.0
device.set_offroad_brightness(100)
def _update_state(self):
super()._update_state()
if device.awake and not ui_state.params.get_bool("IsDriverViewEnabled"):
ui_state.params.put_bool_nonblocking("IsDriverViewEnabled", True)
has_camera_frame = self._dialog._camera_view.frame is not None
if has_camera_frame:
self._no_camera_elapsed_sec = 0.0
else:
self._no_camera_elapsed_sec += 1.0 / gui_app.target_fps
# Dev-mode fallback: allow progressing onboarding even when no camera frames are available.
if self._allow_no_camera_bypass and not has_camera_frame:
self._good_button.set_enabled(self._no_camera_elapsed_sec >= self.NO_CAMERA_BYPASS_DELAY_SEC)
return
sm = ui_state.sm
if sm.recv_frame.get("driverMonitoringState", 0) == 0:
self._good_button.set_enabled(False)
return
dm_state = sm["driverMonitoringState"]
driver_data = self._dialog.driver_state_renderer.get_driver_data()
if len(driver_data.faceOrientation) == 3:
pitch, yaw, _ = driver_data.faceOrientation
looking_center = abs(math.degrees(pitch)) < self.LOOKING_THRESHOLD_DEG and abs(math.degrees(yaw)) < self.LOOKING_THRESHOLD_DEG
else:
looking_center = False
# stay at 100% once reached
if (dm_state.faceDetected and looking_center) or self._progress.x > 0.99:
slow = self._progress.x < 0.25
duration = self.PROGRESS_DURATION * 2 if slow else self.PROGRESS_DURATION
self._progress.x += 1.0 / (duration * gui_app.target_fps)
self._progress.x = min(1.0, self._progress.x)
else:
self._progress.update(0.0)
self._good_button.set_enabled(self._progress.x >= 0.999)
def _render(self, _):
if self._should_show_bad_face_page:
return self._bad_face_page.render(self._rect)
self._dialog.render(self._rect)
rl.draw_rectangle_gradient_v(int(self._rect.x), int(self._rect.y + self._rect.height - 80),
int(self._rect.width), 80, rl.BLANK, rl.BLACK)
# draw white ring around dm icon to indicate progress
ring_thickness = 8
# DM icon is 120x120, positioned on opposite side from driver
dm_size = 120
is_rhd = self._dialog.driver_state_renderer._is_rhd
dm_center_x = (self._rect.x + dm_size / 2 + 8) if is_rhd else (self._rect.x + self._rect.width - dm_size / 2 - 8)
dm_center_y = self._rect.y + dm_size / 2 + 8
icon_edge_radius = dm_size / 2
outer_radius = icon_edge_radius + 1 # 2px outward from icon edge
inner_radius = outer_radius - ring_thickness # Inset by ring_thickness
start_angle = 90.0 # Start from bottom
end_angle = start_angle + self._progress.x * 360.0 # Clockwise
# Fade in alpha
current_angle = end_angle - start_angle
alpha = int(np.interp(current_angle, [0.0, 45.0], [0, 255]))
# White to green
color_t = np.clip(np.interp(current_angle, [45.0, 360.0], [0.0, 1.0]), 0.0, 1.0)
r = int(np.interp(color_t, [0.0, 1.0], [255, 0]))
g = int(np.interp(color_t, [0.0, 1.0], [255, 255]))
b = int(np.interp(color_t, [0.0, 1.0], [255, 64]))
ring_color = rl.Color(r, g, b, alpha)
rl.draw_ring(
rl.Vector2(dm_center_x, dm_center_y),
inner_radius,
outer_radius,
start_angle,
end_angle,
36,
ring_color,
)
has_camera_frame = self._dialog._camera_view.frame is not None
show_no_camera_bypass = self._allow_no_camera_bypass and not has_camera_frame
if has_camera_frame or show_no_camera_bypass:
self._back_button.render(rl.Rectangle(
self._rect.x + 8,
self._rect.y + self._rect.height - self._back_button.rect.height,
self._back_button.rect.width,
self._back_button.rect.height,
))
self._good_button.render(rl.Rectangle(
self._rect.x + self._rect.width - self._good_button.rect.width - 8,
self._rect.y + self._rect.height - self._good_button.rect.height,
self._good_button.rect.width,
self._good_button.rect.height,
))
if show_no_camera_bypass:
gui_label(
rl.Rectangle(self._rect.x + 20, self._rect.y + self._rect.height - 140, self._rect.width - 40, 60),
tr("No camera detected in dev mode. Tap check to continue."),
font_size=34,
font_weight=FontWeight.MEDIUM,
alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER,
)
# rounded border
rl.draw_rectangle_rounded_lines_ex(self._rect, 0.2 * 1.02, 10, 50, rl.BLACK)
class TrainingGuideRecordFront(SetupTermsPage):
def __init__(self, continue_callback):
def on_back():
ui_state.params.put_bool("RecordFront", False)
continue_callback()
def on_continue():
ui_state.params.put_bool("RecordFront", True)
continue_callback()
super().__init__(on_continue, back_callback=on_back, back_text=tr("no"), continue_text=tr("yes"))
self._title_header = TermsHeader("improve driver monitoring", gui_app.texture("icons_mici/setup/green_dm.png", 60, 60))
self._dm_label = UnifiedLabel(tr("Do you want to upload driver camera data?"), 42,
FontWeight.ROMAN)
def show_event(self):
super().show_event()
# Disable driver monitoring model after last step
ui_state.params.put_bool("IsDriverViewEnabled", False)
@property
def _content_height(self):
return self._dm_label.rect.y + self._dm_label.rect.height - self._scroll_panel.get_offset()
def _render_content(self, scroll_offset):
self._title_header.render(rl.Rectangle(
self._rect.x + 16,
self._rect.y + 16 + scroll_offset,
self._title_header.rect.width,
self._title_header.rect.height,
))
self._dm_label.render(rl.Rectangle(
self._rect.x + 16,
self._title_header.rect.y + self._title_header.rect.height + 16,
self._rect.width - 32,
self._dm_label.get_content_height(int(self._rect.width - 32)),
))
class TrainingGuideAttentionNotice(SetupTermsPage):
def __init__(self, continue_callback):
super().__init__(continue_callback, continue_text=tr("continue"))
self._title_header = TermsHeader("driver assistance", gui_app.texture("icons_mici/setup/warning.png", 60, 60))
self._warning_label = UnifiedLabel("1. IQ.Pilot is a driver assistance system.\n\n" +
"2. You must pay attention at all times.\n\n" +
"3. You must be ready to take over at any time.\n\n" +
"4. You are fully responsible for driving the car.", 42,
FontWeight.ROMAN)
@property
def _content_height(self):
return self._warning_label.rect.y + self._warning_label.rect.height - self._scroll_panel.get_offset()
def _render_content(self, scroll_offset):
self._title_header.render(rl.Rectangle(
self._rect.x + 16,
self._rect.y + 16 + scroll_offset,
self._title_header.rect.width,
self._title_header.rect.height,
))
self._warning_label.render(rl.Rectangle(
self._rect.x + 16,
self._title_header.rect.y + self._title_header.rect.height + 16,
self._rect.width - 32,
self._warning_label.get_content_height(int(self._rect.width - 32)),
))
class TrainingGuide(Widget):
def __init__(self, completed_callback=None):
super().__init__()
self._completed_callback = completed_callback
self._step = 0
self_ref = weakref.ref(self)
def on_continue():
if obj := self_ref():
obj._advance_step()
self._steps = [
TrainingGuideAttentionNotice(continue_callback=on_continue),
TrainingGuidePreDMTutorial(continue_callback=on_continue),
TrainingGuideDMTutorial(continue_callback=on_continue),
TrainingGuideRecordFront(continue_callback=on_continue),
]
def show_event(self):
super().show_event()
device.set_override_interactive_timeout(300)
def hide_event(self):
super().hide_event()
device.set_override_interactive_timeout(None)
def _advance_step(self):
if self._step < len(self._steps) - 1:
self._step += 1
self._steps[self._step].show_event()
else:
self._step = 0
if self._completed_callback:
self._completed_callback()
def _render(self, _):
if self._step < len(self._steps):
self._steps[self._step].render(self._rect)
return -1
class DeclinePage(Widget):
def __init__(self, back_callback=None):
super().__init__()
self._uninstall_slider = SmallSlider(tr("uninstall IQ.Pilot"), self._on_uninstall)
self._back_button = SmallButton(tr("back"))
self._back_button.set_click_callback(back_callback)
self._warning_header = TermsHeader("you must accept the\nterms to use IQ.Pilot",
gui_app.texture("icons_mici/setup/red_warning.png", 66, 60))
def _on_uninstall(self):
ui_state.params.put_bool("DoUninstall", True)
gui_app.request_close()
def _render(self, _):
self._warning_header.render(rl.Rectangle(
self._rect.x + 16,
self._rect.y + 16,
self._warning_header.rect.width,
self._warning_header.rect.height,
))
self._back_button.set_opacity(1 - self._uninstall_slider.slider_percentage)
self._back_button.render(rl.Rectangle(
self._rect.x + 8,
self._rect.y + self._rect.height - self._back_button.rect.height,
self._back_button.rect.width,
self._back_button.rect.height,
))
self._uninstall_slider.render(rl.Rectangle(
self._rect.x + self._rect.width - self._uninstall_slider.rect.width,
self._rect.y + self._rect.height - self._uninstall_slider.rect.height,
self._uninstall_slider.rect.width,
self._uninstall_slider.rect.height,
))
class TermsPage(SetupTermsPage):
def __init__(self, on_accept=None, on_decline=None):
super().__init__(on_accept, on_decline, tr("decline"))
info_txt = gui_app.texture("icons_mici/setup/green_info.png", 60, 60)
self._title_header = TermsHeader("terms of service", info_txt)
self._terms_label = UnifiedLabel("You must accept the Terms of Service to use IQ.Pilot. " +
"Read the latest terms before continuing at https://iqlvbs.com/tos", 36,
FontWeight.ROMAN)
@property
def _content_height(self):
return self._terms_label.rect.y + self._terms_label.rect.height - self._scroll_panel.get_offset()
def _render_content(self, scroll_offset):
self._title_header.set_position(self._rect.x + 16, self._rect.y + 12 + scroll_offset)
self._title_header.render()
self._terms_label.render(rl.Rectangle(
self._rect.x + 16,
self._title_header.rect.y + self._title_header.rect.height + self.ITEM_SPACING,
self._rect.width - 100,
self._terms_label.get_content_height(int(self._rect.width - 100)),
))
class OnboardingWindow(Widget):
def __init__(self):
super().__init__()
self._accepted_terms: bool = ui_state.params.get("HasAcceptedTerms") == terms_version
self._training_done: bool = ui_state.params.get("CompletedTrainingVersion") == training_version
self._state = OnboardingState.TERMS if not self._accepted_terms else OnboardingState.ONBOARDING
self.set_rect(rl.Rectangle(0, 0, 458, gui_app.height))
# Windows
self._terms = TermsPage(on_accept=self._on_terms_accepted, on_decline=self._on_terms_declined)
self._training_guide = TrainingGuide(completed_callback=self._on_completed_training)
self._decline_page = DeclinePage(back_callback=self._on_decline_back)
if not self._accepted_terms:
self._state = OnboardingState.TERMS
elif not self._training_done:
self._state = OnboardingState.ONBOARDING
else:
self._state = OnboardingState.ONBOARDING
def show_event(self):
super().show_event()
device.set_override_interactive_timeout(300)
def hide_event(self):
super().hide_event()
device.set_override_interactive_timeout(None)
@property
def completed(self) -> bool:
return self._accepted_terms and self._training_done
def _on_terms_declined(self):
self._state = OnboardingState.DECLINE
def _on_decline_back(self):
self._state = OnboardingState.TERMS
def close(self):
ui_state.params.put_bool("IsDriverViewEnabled", False)
gui_app.set_modal_overlay(None)
def _on_terms_accepted(self):
ui_state.params.put("HasAcceptedTerms", terms_version)
if not self._training_done:
self._state = OnboardingState.ONBOARDING
else:
self.close()
def _on_completed_training(self):
ui_state.params.put("CompletedTrainingVersion", training_version)
self.close()
def _render(self, _):
# opaque full-screen background so the home/onroad underneath doesn't show through
rl.draw_rectangle_rec(rl.Rectangle(0, 0, gui_app.width, gui_app.height), rl.BLACK)
if self._state == OnboardingState.TERMS:
self._terms.render(self._rect)
elif self._state == OnboardingState.ONBOARDING:
if not self._training_done:
self._training_guide.render(self._rect)
else:
self.close()
elif self._state == OnboardingState.DECLINE:
self._decline_page.render(self._rect)
return -1

View File

@@ -0,0 +1,104 @@
"""
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
"""
from iqpilot.selfdrive.ui.mici.widgets.stock_button import BigButton, BigParamControl
from iqpilot.selfdrive.ui.mici.layouts.settings.iq_widgets import MappedParamToggle, IQModeSelector, SafeParamControl
from iqpilot.system.ui.lib.application import gui_app
from iqpilot.system.ui.widgets.scroller import NavScroller
from iqpilot.system.ui.lib.multilang import tr
FOLLOW_DISTANCE_VALUES = [0, 1, 2, 3]
MS_TO_MPH = 2.23694
_SPEED_MPH = [10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60, 65, 70, 75, 80]
_SPEED_OPTIONS = [f"{s} mph" for s in _SPEED_MPH]
_SPEED_VALUES = [round(s / MS_TO_MPH, 2) for s in _SPEED_MPH]
_LEAD_SPEED_MPH = [10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60, 65, 70, 75, 80, 85]
_LEAD_SPEED_OPTIONS = [f"{s} mph" for s in _LEAD_SPEED_MPH]
_LEAD_SPEED_VALUES = [round(s / MS_TO_MPH, 2) for s in _LEAD_SPEED_MPH]
_STOP_TIME_OPTIONS = ["1.0s", "1.5s", "2.0s", "2.5s", "3.0s", "3.5s", "4.0s", "4.5s", "5.0s", "5.5s", "6.0s"]
_STOP_TIME_VALUES = [1.0, 1.5, 2.0, 2.5, 3.0, 3.5, 4.0, 4.5, 5.0, 5.5, 6.0]
_LOOKAHEAD_OPTIONS = ["1.0s", "2.0s", "3.0s", "4.0s", "5.0s", "6.0s", "7.0s", "8.0s", "9.0s", "10.0s"]
_LOOKAHEAD_VALUES = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0]
class DynamicSettingsPanel(NavScroller):
def __init__(self):
super().__init__()
self._items = [
BigParamControl(tr("IQ.Dynamic Curves"), "IQDynamicConditionalCurves"),
BigParamControl(tr("IQ.Dynamic Slower Lead"), "IQDynamicConditionalSlowerLead"),
BigParamControl(tr("IQ.Dynamic Stopped Lead"), "IQDynamicConditionalStoppedLead"),
BigParamControl(tr("IQ.Dynamic Model Stops"), "IQDynamicConditionalModelStops"),
BigParamControl(tr("IQ.Dynamic SLC Fallback"), "IQDynamicConditionalSLCFallback"),
MappedParamToggle(tr("IQ.Dynamic Low Speed"), "IQDynamicConditionalSpeed", _SPEED_OPTIONS, _SPEED_VALUES),
MappedParamToggle(tr("IQ.Dynamic Lead Speed"), "IQDynamicConditionalLeadSpeed", _LEAD_SPEED_OPTIONS, _LEAD_SPEED_VALUES),
MappedParamToggle(tr("Model Stop Time"), "IQDynamicModelStopTime", _STOP_TIME_OPTIONS, _STOP_TIME_VALUES),
BigParamControl(tr("IQ Force Stops"), "IQForceStops"),
]
self._scroller.add_widgets(self._items)
def show_event(self):
super().show_event()
for w in self._items:
w.refresh()
class SlcSettingsPanel(NavScroller):
def __init__(self):
super().__init__()
self._items = [
MappedParamToggle(tr("SLC Policy"), "SLCPolicy", [tr("map only"), tr("map priority"), tr("combined")], [0, 1, 2]),
MappedParamToggle(tr("SLC Override"), "SLCOverrideMethod", [tr("manual"), tr("set speed")], [0, 1]),
BigParamControl(tr("SLC Confirm Higher"), "SpeedLimitConfirmationHigher"),
BigParamControl(tr("SLC Confirm Lower"), "SpeedLimitConfirmationLower"),
BigParamControl(tr("SLC Auto Confirm"), "SLCAutoConfirm"),
BigParamControl(tr("SLC Fallback IQ.Pilot"), "SLCFallbackExperimentalMode"),
BigParamControl(tr("SLC Online Filler"), "SLCOnlineFiller"),
MappedParamToggle(tr("Lookahead Higher"), "MapSpeedLookaheadHigher", _LOOKAHEAD_OPTIONS, _LOOKAHEAD_VALUES),
MappedParamToggle(tr("Lookahead Lower"), "MapSpeedLookaheadLower", _LOOKAHEAD_OPTIONS, _LOOKAHEAD_VALUES),
]
self._scroller.add_widgets(self._items)
def show_event(self):
super().show_event()
for w in self._items:
w.refresh()
class CruiseLayoutMici(NavScroller):
def __init__(self):
super().__init__()
self._dynamic_panel = DynamicSettingsPanel()
self._slc_panel = SlcSettingsPanel()
self._mode = IQModeSelector()
self._dynamic_settings = BigButton(tr("iq.dynamic settings"))
self._dynamic_settings.set_click_callback(lambda: gui_app.push_widget(self._dynamic_panel))
self._dynamic_settings.set_visible(self._mode.is_dynamic)
self._follow_dist = MappedParamToggle(tr("Follow Distance"), "LongitudinalPersonality",
[tr("aggressive"), tr("standard"), tr("relaxed"), tr("stock")], FOLLOW_DISTANCE_VALUES)
self._speed_limit = MappedParamToggle(tr("Speed Limit"), "IQSpeedAssistMode",
[tr("off"), tr("info"), tr("warning"), tr("control")])
self._slc_settings = BigButton(tr("speed limit settings"))
self._slc_settings.set_click_callback(lambda: gui_app.push_widget(self._slc_panel))
self._new_lead_mpc = SafeParamControl(tr("Experimental Lead MPC"), "newLeadMpc", default_on=True)
self._main = [self._mode, self._dynamic_settings, self._follow_dist, self._speed_limit,
self._new_lead_mpc, self._slc_settings]
self._scroller.add_widgets(self._main)
def _refresh(self):
self._mode.refresh()
self._follow_dist.refresh()
self._speed_limit.refresh()
self._new_lead_mpc.refresh()
def show_event(self):
super().show_event()
self._refresh()

View File

@@ -0,0 +1,38 @@
"""
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
"""
from iqpilot.system.ui.widgets.scroller import NavScroller
from iqpilot.selfdrive.ui.mici.widgets.stock_button import BigParamControl
from iqpilot.selfdrive.ui.layouts.settings.common import restart_needed_callback
from iqpilot.selfdrive.ui.ui_state import ui_state
from iqpilot.system.ui.lib.multilang import tr
class DashcamLayoutMici(NavScroller):
def __init__(self):
super().__init__()
self._dashcam = BigParamControl(tr("enable dashcam"), "DashcamEnabled", toggle_callback=restart_needed_callback)
self._record_front = BigParamControl(tr("record driver camera"), "RecordFront", toggle_callback=restart_needed_callback)
self._record_audio = BigParamControl(tr("record microphone audio"), "RecordAudio", toggle_callback=restart_needed_callback)
self._scroller.add_widgets([self._dashcam, self._record_front, self._record_audio])
self._refresh_toggles = (
("DashcamEnabled", self._dashcam),
("RecordFront", self._record_front),
("RecordAudio", self._record_audio),
)
self._record_front.set_enabled(False if ui_state.params.get_bool("RecordFrontLock") else (lambda: not ui_state.engaged))
self._record_audio.set_enabled(lambda: not ui_state.engaged)
ui_state.add_engaged_transition_callback(self._update_toggles)
def show_event(self):
super().show_event()
self._update_toggles()
def _update_toggles(self):
ui_state.update_params()
for key, item in self._refresh_toggles:
item.set_checked(ui_state.params.get_bool(key))

View File

@@ -0,0 +1,129 @@
"""
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
"""
from iqpilot.common.time_helpers import system_time_valid
from iqpilot.system.hardware.tici.usb_storage import apply_usb_storage_state
from iqpilot.system.ui.widgets.scroller import NavScroller
from iqpilot.selfdrive.ui.mici.widgets.stock_button import BigButton, BigToggle, BigCircleParamControl
from iqpilot.selfdrive.ui.mici.widgets.stock_dialog import BigDialog, BigInputDialog
from iqpilot.system.ui.lib.application import gui_app
from iqpilot.selfdrive.ui.layouts.settings.common import restart_needed_callback
from iqpilot.selfdrive.ui.ui_state import ui_state
from iqpilot.selfdrive.ui.widgets.ssh_key import SshKeyFetcher
from iqpilot.system.ui.lib.multilang import tr
class DeveloperLayoutMici(NavScroller):
def __init__(self):
super().__init__()
self._ssh_fetcher = SshKeyFetcher(ui_state.params)
def github_username_callback(username: str):
if username:
self._ssh_keys_btn.set_value(tr("Loading..."))
self._ssh_keys_btn.set_enabled(False)
def on_response(error):
self._ssh_keys_btn.set_enabled(True)
if error is None:
self._ssh_keys_btn.set_value(username)
else:
self._ssh_keys_btn.set_value(tr("Not set"))
gui_app.push_widget(BigDialog("", error))
self._ssh_fetcher.fetch(username, on_response)
else:
self._ssh_fetcher.clear()
self._ssh_keys_btn.set_value(tr("Not set"))
def ssh_keys_callback():
github_username = ui_state.params.get("GithubUsername") or ""
dlg = BigInputDialog(tr("enter GitHub username..."), github_username, minimum_length=0, confirm_callback=github_username_callback)
if not system_time_valid():
dlg = BigDialog("", tr("Please connect to Wi-Fi to fetch your key."))
gui_app.push_widget(dlg)
return
gui_app.push_widget(dlg)
txt_ssh = gui_app.texture("icons_mici/settings/developer/ssh.png", 56, 64)
github_username = ui_state.params.get("GithubUsername") or ""
self._ssh_keys_btn = BigButton(tr("SSH keys"), tr("Not set") if not github_username else github_username, icon=txt_ssh)
self._ssh_keys_btn.set_click_callback(ssh_keys_callback)
self._adb_toggle = BigCircleParamControl(gui_app.texture("icons_mici/adb_short.png", 82, 82), "AdbEnabled", icon_offset=(0, 12))
self._usb_storage_toggle = BigCircleParamControl(gui_app.texture("icons_mici/adb_short.png", 82, 82), "UsbStorageEnabled",
toggle_callback=apply_usb_storage_state, icon_offset=(0, 12))
self._ssh_toggle = BigCircleParamControl(gui_app.texture("icons_mici/ssh_short.png", 82, 82), "SshEnabled", icon_offset=(0, 12))
self._long_maneuver_toggle = BigToggle(tr("longitudinal maneuver mode"),
initial_state=ui_state.params.get_bool("LongitudinalManeuverMode"),
toggle_callback=self._on_long_maneuver_mode)
self._lat_maneuver_toggle = BigToggle(tr("lateral maneuver mode"),
initial_state=ui_state.params.get_bool("LateralManeuverMode"),
toggle_callback=self._on_lat_maneuver_mode)
self._scroller.add_widgets([
self._adb_toggle,
self._usb_storage_toggle,
self._ssh_toggle,
self._ssh_keys_btn,
self._long_maneuver_toggle,
self._lat_maneuver_toggle,
])
self._refresh_toggles = (
("AdbEnabled", self._adb_toggle),
("UsbStorageEnabled", self._usb_storage_toggle),
("SshEnabled", self._ssh_toggle),
("LongitudinalManeuverMode", self._long_maneuver_toggle),
("LateralManeuverMode", self._lat_maneuver_toggle),
)
onroad_blocked_toggles = (self._adb_toggle, self._usb_storage_toggle)
release_blocked_toggles = (self._long_maneuver_toggle, self._lat_maneuver_toggle)
engaged_blocked_toggles = (self._long_maneuver_toggle, self._lat_maneuver_toggle)
for item in release_blocked_toggles:
item.set_visible(not ui_state.is_release)
for item in onroad_blocked_toggles:
item.set_enabled(lambda: ui_state.is_offroad())
for item in engaged_blocked_toggles:
item.set_enabled(lambda: not ui_state.engaged)
ui_state.add_offroad_transition_callback(self._update_toggles)
def _update_state(self):
super()._update_state()
self._ssh_fetcher.update()
def show_event(self):
super().show_event()
self._update_toggles()
def _update_toggles(self):
ui_state.update_params()
if ui_state.CP is not None:
long_man_enabled = ui_state.has_longitudinal_control and ui_state.is_offroad()
self._long_maneuver_toggle.set_enabled(long_man_enabled)
self._lat_maneuver_toggle.set_enabled(ui_state.is_offroad())
else:
self._long_maneuver_toggle.set_enabled(False)
self._lat_maneuver_toggle.set_enabled(False)
for key, item in self._refresh_toggles:
item.set_checked(ui_state.params.get_bool(key))
def _on_long_maneuver_mode(self, state: bool):
ui_state.params.put_bool("LongitudinalManeuverMode", state)
ui_state.params.put_bool("LateralManeuverMode", False)
self._lat_maneuver_toggle.set_checked(False)
restart_needed_callback()
def _on_lat_maneuver_mode(self, state: bool):
ui_state.params.put_bool("LateralManeuverMode", state)
ui_state.params.put_bool("ExperimentalMode", False)
ui_state.params.put_bool("LongitudinalManeuverMode", False)
self._long_maneuver_toggle.set_checked(False)
restart_needed_callback()

View File

@@ -0,0 +1,250 @@
"""
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
"""
import os
import time
import pyray as rl
from collections.abc import Callable
from iqpilot.common.basedir import BASEDIR
from iqpilot.common.params import Params
from iqpilot.common.time_helpers import system_time_valid
from iqpilot.system.ui.widgets.scroller import NavRawScrollPanel, NavScroller
from iqpilot.selfdrive.ui.mici.widgets.stock_button import BigButton, BigCircleButton
from iqpilot.selfdrive.ui.mici.widgets.stock_dialog import BigDialog, BigConfirmationDialog
from iqpilot.selfdrive.ui.mici.widgets.dialog import BigMultiOptionDialog
from iqpilot.selfdrive.ui.mici.widgets.stock_pairing_dialog import PairingDialog
from iqpilot.selfdrive.ui.mici.onroad.driver_camera_dialog import DriverCameraDialog
from iqpilot.selfdrive.ui.mici.layouts.onboarding import TrainingGuide, TermsPage
from iqpilot.system.ui.lib.application import gui_app, FontWeight, MousePos
from iqpilot.system.ui.lib.multilang import multilang, tr
from iqpilot.system.ui.widgets import Widget
from iqpilot.selfdrive.ui.ui_state import device, ui_state
from iqpilot.system.ui.widgets.label import UnifiedLabel
from iqpilot.system.ui.widgets.html_render import HtmlModal, HtmlRenderer
from iqpilot.konn3kt.registration import UNREGISTERED_DONGLE_ID
class ReviewTermsPage(TermsPage, NavScroller):
"""TermsPage with NavWidget swipe-to-dismiss for reviewing in device settings."""
def __init__(self):
super().__init__(on_accept=self.dismiss, on_decline=self.dismiss)
self._continue_button.set_visible(False)
self._back_button.set_visible(False)
class ReviewTrainingGuide(TrainingGuide):
def show_event(self):
super().show_event()
device.set_override_interactive_timeout(300)
def hide_event(self):
super().hide_event()
device.set_override_interactive_timeout(None)
ui_state.params.put_bool("IsDriverViewEnabled", False)
class MiciFccModal(NavRawScrollPanel):
def __init__(self, file_path: str | None = None, text: str | None = None):
super().__init__()
self._content = HtmlRenderer(file_path=file_path, text=text)
self._fcc_logo = gui_app.texture("icons_mici/settings/device/fcc_logo.png", 76, 64)
def _render(self, rect: rl.Rectangle):
content_height = self._content.get_total_height(int(rect.width))
content_height += self._fcc_logo.height + 20
scroll_content_rect = rl.Rectangle(rect.x, rect.y, rect.width, content_height)
scroll_offset = round(self._scroll_panel.update(rect, scroll_content_rect.height))
fcc_pos = rl.Vector2(rect.x + 20, rect.y + 20 + scroll_offset)
scroll_content_rect.y += scroll_offset + self._fcc_logo.height + 20
self._content.render(scroll_content_rect)
rl.draw_texture_ex(self._fcc_logo, fcc_pos, 0.0, 1.0, rl.WHITE)
def _engaged_confirmation_click(callback: Callable, action_text: str, icon: rl.Texture, exit_on_confirm: bool = True, red: bool = False):
if not ui_state.engaged:
def confirm_callback():
# Check engaged again in case it changed while the dialog was open
# TODO: if true, we stay on the dialog if not exit_on_confirm until normal onroad timeout
if not ui_state.engaged:
callback()
gui_app.push_widget(BigConfirmationDialog(tr("slide to\n{}").format(action_text.lower()), icon, confirm_callback, exit_on_confirm=exit_on_confirm, red=red))
else:
gui_app.push_widget(BigDialog("", tr("Disengage to {}").format(action_text)))
class EngagedConfirmationCircleButton(BigCircleButton):
def __init__(self, title: str, icon: rl.Texture, callback: Callable[[], None], exit_on_confirm: bool = True,
red: bool = False, icon_offset: tuple[int, int] = (0, 0)):
super().__init__(icon, red, icon_offset)
self.set_click_callback(lambda: _engaged_confirmation_click(callback, title, icon, exit_on_confirm=exit_on_confirm, red=red))
class EngagedConfirmationButton(BigButton):
def __init__(self, text: str, action_text: str, icon: rl.Texture, callback: Callable[[], None],
exit_on_confirm: bool = True, red: bool = False):
super().__init__(text, "", icon)
self.set_click_callback(lambda: _engaged_confirmation_click(callback, action_text, icon, exit_on_confirm=exit_on_confirm, red=red))
class DeviceInfoLayoutMici(Widget):
def __init__(self):
super().__init__()
self.set_rect(rl.Rectangle(0, 0, 360, 180))
params = Params()
subheader_color = rl.Color(255, 255, 255, int(255 * 0.9 * 0.65))
max_width = int(self._rect.width - 20)
self._dongle_id_label = UnifiedLabel(tr("device ID"), 48, max_width=max_width, font_weight=FontWeight.DISPLAY, wrap_text=False)
self._dongle_id_text_label = UnifiedLabel(params.get("DongleId") or 'N/A', 32, max_width=max_width, text_color=subheader_color,
font_weight=FontWeight.ROMAN, wrap_text=False)
self._serial_number_label = UnifiedLabel(tr("serial"), 48, max_width=max_width, font_weight=FontWeight.DISPLAY, wrap_text=False)
self._serial_number_text_label = UnifiedLabel(params.get("HardwareSerial") or 'N/A', 32, max_width=max_width, text_color=subheader_color,
font_weight=FontWeight.ROMAN, wrap_text=False)
def _render(self, _):
self._dongle_id_label.set_position(self._rect.x + 20, self._rect.y - 10)
self._dongle_id_label.render()
self._dongle_id_text_label.set_position(self._rect.x + 20, self._rect.y + 68 - 25)
self._dongle_id_text_label.render()
self._serial_number_label.set_position(self._rect.x + 20, self._rect.y + 114 - 30)
self._serial_number_label.render()
self._serial_number_text_label.set_position(self._rect.x + 20, self._rect.y + 161 - 25)
self._serial_number_text_label.render()
class PairBigButton(BigButton):
"""Konn3kt connect button: logo + live connection-status dot. Uses the new accent box style."""
KONN3KT_ONLINE_NS = 80_000_000_000 # 80 seconds in nanoseconds
STATUS_ONLINE = rl.Color(0x86, 0xFF, 0x4E, 255)
STATUS_OFFLINE = rl.Color(0xC9, 0x22, 0x31, 255)
def __init__(self):
super().__init__("konn3kt", tr("pair in app"), gui_app.texture("icons_mici/settings/konn3kt_icon.png", 56, 56))
def _get_label_font_size(self):
return 64
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 _update_state(self):
super()._update_state()
if ui_state.prime_state.is_paired():
self.set_value(tr("online") if self._is_konn3kt_online() else tr("offline"))
else:
self.set_value(tr("pair in app"))
def _render(self, _):
super()._render(_)
if ui_state.prime_state.is_paired():
color = self.STATUS_ONLINE if self._is_konn3kt_online() else self.STATUS_OFFLINE
rl.draw_circle(int(self._rect.x + 30), int(self._rect.y + 30), 9, color)
def _handle_mouse_release(self, mouse_pos: MousePos):
super()._handle_mouse_release(mouse_pos)
if ui_state.prime_state.is_paired():
return
dlg: BigDialog | PairingDialog
if not system_time_valid():
dlg = BigDialog("", tr("Please connect to Wi-Fi to complete initial pairing."))
elif UNREGISTERED_DONGLE_ID == (ui_state.params.get("DongleId") or UNREGISTERED_DONGLE_ID):
dlg = BigDialog("", tr("Device must be registered with Konn3kt to pair."))
else:
dlg = PairingDialog()
gui_app.push_widget(dlg)
class DeviceLayoutMici(NavScroller):
def __init__(self):
super().__init__()
self._fcc_dialog: HtmlModal | None = None
def power_off_callback():
ui_state.params.put_bool("DoShutdown", True)
def reboot_callback():
ui_state.params.put_bool("DoReboot", True)
def reset_calibration_callback():
params = ui_state.params
params.remove("CalibrationParams")
params.remove("LiveTorqueParameters")
params.remove("LiveParameters")
params.remove("LiveParametersV2")
params.remove("LiveDelay")
params.put_bool("OnroadCycleRequested", True)
reset_calibration_btn = EngagedConfirmationButton(tr("reset calibration"), tr("reset"), gui_app.texture("icons_mici/settings/device/lkas.png", 122, 64),
reset_calibration_callback)
reboot_btn = EngagedConfirmationCircleButton(tr("reboot"), gui_app.texture("icons_mici/settings/device/reboot.png", 64, 70),
reboot_callback, exit_on_confirm=False)
self._power_off_btn = EngagedConfirmationCircleButton(tr("power off"), gui_app.texture("icons_mici/settings/device/power.png", 64, 66),
power_off_callback, exit_on_confirm=False, red=True)
self._power_off_btn.set_visible(lambda: not ui_state.ignition)
regulatory_btn = BigButton(tr("regulatory info"), "", gui_app.texture("icons_mici/settings/device/info.png", 64, 64))
regulatory_btn.set_click_callback(self._on_regulatory)
driver_cam_btn = BigButton(tr("driver\ncamera preview"), "", gui_app.texture("icons_mici/settings/device/cameras.png", 64, 64))
driver_cam_btn.set_click_callback(lambda: gui_app.push_widget(DriverCameraDialog()))
driver_cam_btn.set_enabled(lambda: ui_state.is_offroad())
review_training_guide_btn = BigButton(tr("review\ntraining guide"), "", gui_app.texture("icons_mici/settings/device/info.png", 64, 64))
review_training_guide_btn.set_click_callback(lambda: gui_app.push_widget(ReviewTrainingGuide(completed_callback=lambda: gui_app.pop_widgets_to(self))))
review_training_guide_btn.set_enabled(lambda: ui_state.is_offroad())
terms_btn = BigButton(tr("terms &\nconditions"), "", gui_app.texture("icons_mici/settings/device/info.png", 64, 64))
terms_btn.set_click_callback(lambda: gui_app.push_widget(ReviewTermsPage()))
language_btn = BigButton(tr("change\nlanguage"), multilang.codes.get(multilang.language, ""),
gui_app.texture("icons_mici/settings/device/language.png", 64, 64))
language_btn.set_click_callback(self._on_change_language)
self._scroller.add_widgets([
DeviceInfoLayoutMici(),
PairBigButton(),
review_training_guide_btn,
driver_cam_btn,
language_btn,
terms_btn,
regulatory_btn,
reset_calibration_btn,
reboot_btn,
self._power_off_btn,
])
def _on_change_language(self):
names = list(multilang.languages.keys())
if not names:
return
current = multilang.codes.get(multilang.language)
dlg = BigMultiOptionDialog(names, current if current in names else names[0], right_btn="check",
right_btn_callback=lambda: self._apply_language(dlg.get_selected_option()))
gui_app.push_widget(dlg)
def _apply_language(self, name: str):
code = multilang.languages.get(name)
if code and code != multilang.language:
multilang.change_language(code)
def _on_regulatory(self):
if not self._fcc_dialog:
self._fcc_dialog = MiciFccModal(os.path.join(BASEDIR, "iqpilot/selfdrive/assets/offroad/mici_fcc.html"))
gui_app.push_widget(self._fcc_dialog)

View File

@@ -0,0 +1,56 @@
"""
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
"""
from iqpilot.selfdrive.ui.mici.widgets.stock_button import BigParamControl
from iqpilot.selfdrive.ui.mici.layouts.settings.iq_widgets import MappedParamToggle
from iqpilot.selfdrive.ui.ui_state import ui_state
from iqpilot.system.hardware import HARDWARE
from iqpilot.system.ui.widgets.scroller import NavScroller
from iqpilot.system.ui.lib.multilang import tr
_BRIGHT_PERCENTS = [f"{p}%" for p in range(5, 101, 5)]
_DISPLAY_BRIGHT_VALUES = [0] + list(range(5, 101, 5))
_ONROAD_BRIGHT_OPTIONS = ["auto", "auto dark"] + _BRIGHT_PERCENTS
_ONROAD_BRIGHT_VALUES = list(range(len(_ONROAD_BRIGHT_OPTIONS)))
_DELAY_OPTIONS = ["15s", "30s", "1m", "2m", "3m", "4m", "5m", "6m", "7m", "8m", "9m", "10m"]
_DELAY_VALUES = [15, 30, 60, 120, 180, 240, 300, 360, 420, 480, 540, 600]
_INTERACT_SUFFIX = ["10s", "20s", "30s", "40s", "50s", "1m", "70s", "80s", "90s", "100s", "110s", "2m"]
_INTERACT_VALUES = [0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 120]
class DisplayLayoutMici(NavScroller):
def __init__(self):
super().__init__()
self._force_mici = BigParamControl(tr("force mici UI"), "ForceSmallUI")
self._display_bright = MappedParamToggle(tr("display brightness"), "Brightness",
[tr("default")] + _BRIGHT_PERCENTS, _DISPLAY_BRIGHT_VALUES)
self._onroad_bright = MappedParamToggle(tr("driving brightness"), "OnroadScreenOffBrightness",
[tr("auto"), tr("auto dark")] + _BRIGHT_PERCENTS, _ONROAD_BRIGHT_VALUES)
self._delay = MappedParamToggle(tr("brightness delay"), "OnroadScreenOffTimer",
_DELAY_OPTIONS, _DELAY_VALUES)
self._interact = MappedParamToggle(tr("interactivity"), "InteractivityTimeout",
[tr("default")] + _INTERACT_SUFFIX, _INTERACT_VALUES)
self._items = [self._display_bright, self._onroad_bright, self._delay, self._interact]
if HARDWARE.get_device_type() != "mici":
self._items.insert(0, self._force_mici)
self._scroller.add_widgets(self._items)
def _refresh(self):
for w in self._items:
w.refresh()
bval = int(float(ui_state.params.get("OnroadScreenOffBrightness", return_default=True) or 0))
self._delay.set_enabled(bval not in (0, 1))
def _update_state(self):
super()._update_state()
bval = int(float(ui_state.params.get("OnroadScreenOffBrightness", return_default=True) or 0))
self._delay.set_enabled(bval not in (0, 1))
def show_event(self):
super().show_event()
self._refresh()

View File

@@ -0,0 +1,89 @@
"""
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
"""
import threading
import time
import pyray as rl
from iqpilot.common.api import api_get
from iqpilot.common.constants import CV
from iqpilot.common.params import Params
from iqpilot.common.swaglog import cloudlog
from iqpilot.selfdrive.ui.lib.api_helpers import get_token
from iqpilot.selfdrive.ui.ui_state import ui_state, device
from iqpilot.konn3kt.registration import UNREGISTERED_DONGLE_ID
from iqpilot.system.ui.lib.application import gui_app, FontWeight
from iqpilot.system.ui.lib.text_measure import measure_text_cached
from iqpilot.system.ui.widgets.nav_widget import NavWidget
_ACCENT = rl.Color(0x3A, 0xDD, 0xC6, 255)
_CARD_BG = rl.Color(26, 27, 30, 255)
_LABEL = rl.Color(150, 150, 150, 255)
class TripsLayoutMici(NavWidget):
PARAM_KEY = "ApiCache_DriveStats"
UPDATE_INTERVAL = 30
def __init__(self):
super().__init__()
self._params = Params()
self._stats = self._params.get(self.PARAM_KEY) or {}
self._bold = gui_app.font(FontWeight.BOLD)
self._medium = gui_app.font(FontWeight.MEDIUM)
self._running = True
self._thread = threading.Thread(target=self._update_loop, daemon=True)
self._thread.start()
def __del__(self):
self._running = False
def _fetch(self):
try:
dongle_id = self._params.get("DongleId")
if not dongle_id or dongle_id == UNREGISTERED_DONGLE_ID:
return
resp = api_get(f"v1.1/devices/{dongle_id}/stats", access_token=get_token(dongle_id))
if resp.status_code == 200:
data = resp.json()
self._stats = data
self._params.put(self.PARAM_KEY, data)
except Exception as e:
cloudlog.error(f"trips: failed to fetch drive stats: {e}")
def _update_loop(self):
while self._running:
if not ui_state.started and device._awake:
self._fetch()
time.sleep(self.UPDATE_INTERVAL)
def _render_group(self, x, y, w, h, title, data, is_metric):
rl.draw_rectangle_rounded(rl.Rectangle(x, y, w, h), 0.16, 8, _CARD_BG)
rl.draw_text_ex(self._bold, title, rl.Vector2(x + 22, y + 12), 24, 0, _ACCENT)
routes = int(data.get("routes", 0) or 0)
distance = data.get("distance", 0) or 0
dist = int(distance * CV.MPH_TO_KPH) if is_metric else int(distance)
hours = int((data.get("minutes", 0) or 0) / 60)
cols = [(str(routes), "drives"), (str(dist), "km" if is_metric else "mi"), (str(hours), "hours")]
col_w = w / 3
for i, (val, lbl) in enumerate(cols):
cx = x + col_w * i + col_w / 2
vs = measure_text_cached(self._bold, val, 46)
rl.draw_text_ex(self._bold, val, rl.Vector2(cx - vs.x / 2, y + h / 2 - 28), 46, 0, rl.WHITE)
ls = measure_text_cached(self._medium, lbl, 22)
rl.draw_text_ex(self._medium, lbl, rl.Vector2(cx - ls.x / 2, y + h / 2 + 24), 22, 0, _LABEL)
def _render(self, _):
rect = self._rect
is_metric = self._params.get_bool("IsMetric")
stats = self._stats if isinstance(self._stats, dict) else {}
pad = 12
h = (rect.height - 3 * pad) / 2
w = rect.width - 2 * pad
x = rect.x + pad
self._render_group(x, rect.y + pad, w, h, "ALL TIME", stats.get("all", {}), is_metric)
self._render_group(x, rect.y + 2 * pad + h, w, h, "PAST WEEK", stats.get("week", {}), is_metric)

View File

@@ -0,0 +1,145 @@
"""
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
"""
from iqpilot.common.params import Params, UnknownKeyName
from iqpilot.selfdrive.ui.mici.widgets.stock_button import BigButton, BigMultiToggle, BigToggle, BigParamControl
from iqpilot.system.ui.lib.multilang import tr
class SafeParamControl(BigParamControl):
"""BigParamControl that tolerates a param missing from the COMPILED params registry.
A key added to params_keys.h only exists at runtime once params_pyx.so is rebuilt; a
.py-only / stale prebuilt deploy leaves get_bool/put_bool raising UnknownKeyName, which
would crash the UI on construction. Default to `default_on` for display and no-op the
write instead of crashing — mirrors the plannerd defensive read in long_mpc.py.
"""
def __init__(self, text: str, param: str, default_on: bool = True, toggle_callback=None):
self._default_on = default_on
BigToggle.__init__(self, text, "", toggle_callback=toggle_callback)
self.param = param
self.params = Params()
self.set_checked(self._safe_get())
def _safe_get(self) -> bool:
try:
return self.params.get_bool(self.param)
except UnknownKeyName:
return self._default_on
def refresh(self):
self.set_checked(self._safe_get())
def _handle_mouse_release(self, mouse_pos):
BigToggle._handle_mouse_release(self, mouse_pos)
try:
self.params.put_bool(self.param, self._checked)
except UnknownKeyName:
pass
class MappedParamToggle(BigMultiToggle):
"""Multi-option toggle whose options map to arbitrary param values (int or float, drum-style).
Up to PILL_LIMIT options render as the stock vertical pill column; more options would
overflow the box, so they instead show the current value as a sub-label and cycle on tap.
"""
PILL_LIMIT = 4
def __init__(self, text: str, param: str, options: list[str], values: list | None = None):
super().__init__(text, options)
self._param = param
self._values = values if values is not None else list(range(len(options)))
self._params = Params()
self.refresh()
def _value_only(self) -> bool:
return len(self._options) > self.PILL_LIMIT
def _width_hint(self) -> int:
if self._value_only():
return BigButton._width_hint(self)
return super()._width_hint()
def _draw_content(self, btn_x: float, btn_y: float, btn_width: float, btn_height: float):
if self._value_only():
BigButton._draw_content(self, btn_x, btn_y, btn_width, btn_height)
else:
super()._draw_content(btn_x, btn_y, btn_width, btn_height)
def refresh(self):
try:
raw = self._params.get(self._param, return_default=True)
except UnknownKeyName:
raw = self._values[0]
try:
cur = float(raw)
except (TypeError, ValueError):
cur = float(self._values[0])
idx = min(range(len(self._values)), key=lambda i: abs(float(self._values[i]) - cur))
self.set_value(self._options[idx])
def _handle_mouse_release(self, mouse_pos):
super()._handle_mouse_release(mouse_pos)
idx = self._options.index(self.value)
try:
self._params.put(self._param, self._values[idx])
except UnknownKeyName:
pass
class IQModeSelector(BigMultiToggle):
"""Longitudinal mode selector: Stock ACC / IQ.Chill / IQ.Dynamic / IQ.Pilot.
A single tap cycles to the next mode and applies the matching param combo immediately.
"""
OPTIONS = ["Stock ACC", "IQ.Chill", "IQ.Dynamic", "IQ.Pilot"]
PERSONALITY_RELAXED = 2
def __init__(self):
self._display_options = [tr(option) for option in self.OPTIONS]
super().__init__(tr("IQ Mode"), self._display_options)
self._params = Params()
self.refresh()
def _index(self) -> int:
p = self._params
if not p.get_bool("AlphaLongitudinalEnabled"):
return 0
if not p.get_bool("ExperimentalMode"):
return 1
return 2 if p.get_bool("IQDynamicMode") else 3
def is_dynamic(self) -> bool:
return self._index() == 2
def refresh(self):
self.set_value(self._display_options[self._index()])
def _apply(self, idx: int):
p = self._params
if idx == 0:
p.put_bool("AlphaLongitudinalEnabled", False)
p.put_bool("ExperimentalMode", False)
p.put_bool("IQDynamicMode", False)
elif idx == 1:
p.put_bool("AlphaLongitudinalEnabled", True)
p.put_bool("ExperimentalMode", False)
p.put_bool("IQDynamicMode", False)
p.put("LongitudinalPersonality", self.PERSONALITY_RELAXED)
elif idx == 2:
p.put_bool("AlphaLongitudinalEnabled", True)
p.put_bool("ExperimentalMode", True)
p.put_bool("IQDynamicMode", True)
else:
p.put_bool("AlphaLongitudinalEnabled", True)
p.put_bool("ExperimentalMode", True)
p.put_bool("IQDynamicMode", False)
p.put_bool("OnroadCycleRequested", True)
def _handle_mouse_release(self, mouse_pos):
nxt = (self._index() + 1) % len(self.OPTIONS)
self._apply(nxt)
self.set_value(self._display_options[nxt])

View File

@@ -0,0 +1,499 @@
"""
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
"""
import os
import re
import time
import pyray as rl
from iqpilot.cereal import custom
from iqpilot.system.ui.iqwidgets.widgets.helpers.glyphs import draw_star
from iqpilot.selfdrive.ui.ui_state import ui_state
from iqpilot.system.ui.lib.application import gui_app
from iqpilot.system.ui.widgets.scroller import NavScroller
from iqpilot.selfdrive.ui.mici.widgets.stock_button import BigButton, BigParamControl, GreyBigButton
from iqpilot.selfdrive.ui.mici.widgets.stock_dialog import BigConfirmationDialog
from iqpilot.selfdrive.ui.mici.layouts.settings.iq_widgets import MappedParamToggle
from iqpilot.selfdrive.iqmodeld.models.helpers import select_default_model, is_default_bundle
from iqpilot.selfdrive.iqmodeld.models.runners.model_runner import CUSTOM_MODEL_PATH
from iqpilot.system.ui.lib.multilang import tr
_DELAY_OPTIONS = ["0.05s", "0.10s", "0.15s", "0.20s", "0.25s", "0.30s", "0.35s", "0.40s", "0.45s", "0.50s"]
_DELAY_VALUES = [0.05, 0.10, 0.15, 0.20, 0.25, 0.30, 0.35, 0.40, 0.45, 0.50]
_LANE_TURN_VALUES = [15.0, 19.0, 20.0]
_DL = custom.IQModelManager.DownloadStatus
_ACTIVE_BUNDLE_KEY = "ModelManager_ActiveBundle"
_DOWNLOAD_INDEX_KEY = "ModelManager_DownloadIndex"
_RUNNER_CACHE_KEY = "ModelRunnerTypeCache"
def _display_model_name(bundle) -> str:
return bundle.internalName if getattr(bundle, "internalName", "") else bundle.displayName
class _ModelSelectPanel(NavScroller):
"""A throwaway scroller panel (folder list or bundle list) pushed onto the nav stack."""
def __init__(self, items):
super().__init__()
self._scroller.add_widgets(items)
class _ModelButton(BigButton):
"""A bundle in the model list: single tap selects (download), double tap toggles favorite.
A golden star is drawn in the corner when the model is favorited.
"""
THRESHOLD = 0.4
_STAR_GOLD = rl.Color(0xFF, 0xC1, 0x07, 255)
def __init__(self, bundle, on_select, on_favorite, is_favorite):
super().__init__(bundle.displayName)
self._bundle = bundle
self._on_select = on_select
self._on_favorite = on_favorite
self._is_favorite = is_favorite
self._pending_t = 0.0
self._pending_pos = None
def _handle_mouse_release(self, mouse_pos):
now = time.monotonic()
if self._pending_pos is not None and now - self._pending_t < self.THRESHOLD:
self._pending_pos = None
self._pending_t = 0.0
self._is_favorite = self._on_favorite(self._bundle)
return
self._pending_t = now
self._pending_pos = mouse_pos
def _update_state(self):
super()._update_state()
if self._pending_pos is not None and time.monotonic() - self._pending_t >= self.THRESHOLD:
self._pending_pos = None
self._on_select(self._bundle)
def _render(self, _):
super()._render(_)
if self._is_favorite:
cx = self._rect.x + self._rect.width - 46
cy = self._rect.y + 46
draw_star(cx, cy, 24, True, self._STAR_GOLD)
class ModelsLayoutMici(NavScroller):
def __init__(self):
super().__init__()
self._last_cache_t = 0.0
self._download_status = None
self._prev_download_status = None
self._clear_icon = gui_app.texture("icons_mici/settings/developer_icon.png", 56, 56)
self._redownload_icon = gui_app.texture("icons_mici/settings/device/update.png", 56, 56, keep_aspect_ratio=True)
self._reset_icon = gui_app.texture("icons_mici/wheel.png", 56, 56)
self._current = BigButton(tr("active model"))
self._current.set_click_callback(self._show_folders)
self._cancel = BigButton(tr("stop download"))
self._cancel.set_click_callback(self._cancel_model_request)
self._cancel.set_visible(self._is_downloading)
self._redownload = BigButton(tr("redownload model"))
self._redownload.set_click_callback(self._confirm_redownload_model)
self._redownload.set_enabled(self._can_redownload)
self._refresh = BigButton(tr("reload model list"))
self._refresh.set_click_callback(lambda: ui_state.params.put("ModelManager_LastSyncTime", 0))
self._supercombo = GreyBigButton(tr("combined model"))
self._supercombo.set_visible(False)
self._vision = GreyBigButton(tr("vision weights"))
self._vision.set_visible(False)
self._policy = GreyBigButton(tr("policy weights"))
self._policy.set_visible(False)
self._clear = BigButton(tr("purge model cache"))
self._clear.set_click_callback(self._confirm_clear_cache)
self._clear.set_enabled(lambda: ui_state.is_offroad())
self._steer_delay = BigParamControl(tr("self-tuning steer delay"), "IQLiveSteerDelay")
self._sw_delay = MappedParamToggle(tr("manual delay offset"), "IQSoftwareSteerDelay", _DELAY_OPTIONS, _DELAY_VALUES)
self._sw_delay.set_visible(lambda: not self._steer_delay._checked)
self._lane_turn = BigParamControl(tr("low-speed turn planning"), "IQLaneTurnDesire")
self._lane_speed = MappedParamToggle(tr("lane turn speed"), "IQLaneTurnValue", [tr("slow"), tr("normal"), tr("fast")], _LANE_TURN_VALUES)
self._lane_speed.set_visible(lambda: self._lane_turn._checked)
self._main_items = [self._current, self._cancel, self._supercombo, self._vision, self._policy, self._redownload, self._refresh, self._clear,
self._steer_delay, self._sw_delay, self._lane_turn, self._lane_speed]
self._scroller.add_widgets(self._main_items)
@property
def model_manager(self):
return ui_state.sm["iqModelManager"]
@staticmethod
def _has_download_request() -> bool:
try:
return int(ui_state.params.get(_DOWNLOAD_INDEX_KEY)) >= 0
except (TypeError, ValueError):
return False
@staticmethod
def _has_active_bundle_param() -> bool:
return bool(ui_state.params.get(_ACTIVE_BUNDLE_KEY))
def _has_model_request(self) -> bool:
return self._has_download_request()
def _is_downloading(self) -> bool:
try:
return bool(self.model_manager.selectedBundle and self.model_manager.selectedBundle.status == _DL.downloading)
except Exception:
return False
@staticmethod
def _calculate_cache_size() -> float:
if os.path.exists(CUSTOM_MODEL_PATH):
return sum(os.path.getsize(os.path.join(CUSTOM_MODEL_PATH, f)) for f in os.listdir(CUSTOM_MODEL_PATH)) / (1024 ** 2)
return 0.0
@staticmethod
def _bundle_index(bundle) -> int | None:
try:
return int(getattr(bundle, "index", -1))
except (TypeError, ValueError):
return None
@classmethod
def _bundle_matches(cls, left, right) -> bool:
if left is None or right is None:
return False
left_index = cls._bundle_index(left)
right_index = cls._bundle_index(right)
if left_index is not None and right_index is not None and left_index == right_index:
return True
for attr in ("ref", "internalName", "displayName"):
left_value = getattr(left, attr, None)
if left_value and left_value == getattr(right, attr, None):
return True
return False
@staticmethod
def _safe_model_path(filename: str) -> str | None:
if not filename or os.path.basename(filename) != filename:
return None
root = os.path.realpath(CUSTOM_MODEL_PATH)
path = os.path.realpath(os.path.join(root, filename))
try:
if os.path.commonpath([root, path]) != root:
return None
except ValueError:
return None
return path
def _remove_bundle_files(self, bundle) -> None:
for model in getattr(bundle, "models", []) or []:
for artifact in (getattr(model, "metadata", None), getattr(model, "artifact", None)):
filename = getattr(artifact, "fileName", "") if artifact is not None else ""
path = self._safe_model_path(filename)
if path is None:
continue
for candidate in (path, f"{path}.download"):
try:
if os.path.isfile(candidate):
os.remove(candidate)
except OSError:
pass
def _group_folders(self, bundles):
folders: dict = {}
for bundle in bundles:
folder = next((ov.value for ov in bundle.overrides if ov.key == "folder"), "")
folders.setdefault(folder, []).append(bundle)
return folders
@staticmethod
def _read_favorites() -> set:
favs = ui_state.params.get("IQModelFavorites")
return set(favs.split(';')) if favs else set()
def _toggle_favorite(self, bundle) -> bool:
favs = self._read_favorites()
if bundle.ref in favs:
favs.discard(bundle.ref)
else:
favs.add(bundle.ref)
ui_state.params.put("IQModelFavorites", ';'.join(sorted(favs)))
return bundle.ref in favs
def _confirm_clear_cache(self):
gui_app.push_widget(BigConfirmationDialog(tr("slide to\nclear cache"), self._clear_icon,
lambda: ui_state.params.put_bool("ModelManager_ClearCache", True),
red=True))
def _redownload_target_bundle(self):
try:
selected = self.model_manager.selectedBundle
if selected and selected.status == _DL.failed:
return selected
active = self.model_manager.activeBundle
if self._has_active_bundle_param() and active and active.ref:
return active
except Exception:
pass
return None
def _redownload_target_index(self) -> int | None:
target = self._redownload_target_bundle()
if not target:
return None
try:
return int(target.index)
except (TypeError, ValueError):
pass
try:
for bundle in self.model_manager.availableBundles:
if bundle.ref and bundle.ref == target.ref:
return int(bundle.index)
if bundle.internalName and bundle.internalName == target.internalName:
return int(bundle.index)
except Exception:
pass
return None
def _can_redownload(self) -> bool:
return bool(ui_state.is_offroad() and not self._is_downloading() and not self._has_model_request() and self._redownload_target_index() is not None)
def _cancel_model_request(self):
ui_state.params.remove(_DOWNLOAD_INDEX_KEY)
def _confirm_redownload_model(self):
index = self._redownload_target_index()
if index is None:
return
def _redownload():
target = self._redownload_target_bundle()
if target is not None:
self._remove_bundle_files(target)
if self._bundle_matches(getattr(self.model_manager, "activeBundle", None), target):
ui_state.params.remove(_ACTIVE_BUNDLE_KEY)
ui_state.params.remove(_RUNNER_CACHE_KEY)
ui_state.params.put(_DOWNLOAD_INDEX_KEY, index)
self._redownload.set_value(tr("queued"))
gui_app.push_widget(BigConfirmationDialog(tr("slide to\nredownload"), self._redownload_icon, _redownload, red=True))
def _show_folders(self):
bundles = list(self.model_manager.availableBundles)
favorites = self._read_favorites()
btns = []
default_btn = BigButton(tr("Default (CD210)"))
default_btn.set_click_callback(self._select_default)
btns.append(default_btn)
if favorites and (fav_bundles := [b for b in bundles if b.ref in favorites]):
fav_btn = BigButton(tr("Favorites"), str(len(fav_bundles)))
fav_btn.set_click_callback(lambda fb=fav_bundles: self._show_bundles(fb))
btns.append(fav_btn)
folders = self._group_folders(bundles)
for folder in sorted(folders, key=lambda f: max((b.index for b in folders[f]), default=-1), reverse=True):
name = folder if folder else "Other"
folder_bundles = sorted(folders[folder], key=lambda b: b.index, reverse=True)
if folder_bundles and (m := re.search(r'\(([^)]*)\)[^(]*$', folder_bundles[0].displayName)):
name += f" ({m.group(1)})"
btn = BigButton(name)
btn.set_click_callback(lambda fb=folder_bundles: self._show_bundles(fb))
btns.append(btn)
gui_app.push_widget(_ModelSelectPanel(btns))
def _show_bundles(self, bundles):
favorites = self._read_favorites()
btns = [_ModelButton(b, self._select_model, self._toggle_favorite, b.ref in favorites) for b in bundles]
gui_app.push_widget(_ModelSelectPanel(btns))
def _generation_changed(self, bundle) -> bool:
try:
active = self.model_manager.activeBundle
return bool(active and active.ref and bundle.generation != active.generation)
except Exception:
return False
def _select_model(self, bundle):
ui_state.params.put(_DOWNLOAD_INDEX_KEY, bundle.index)
cb = self._show_reset_calibration_prompt if self._generation_changed(bundle) else lambda: None
gui_app.pop_widgets_to(self, callback=cb)
def _select_default(self):
try:
active = self.model_manager.activeBundle
had_custom_model = bool(active and active.ref and not is_default_bundle(active))
except Exception:
had_custom_model = False
select_default_model(ui_state.params)
gui_app.pop_widgets_to(self, callback=self._show_reset_calibration_prompt if had_custom_model else (lambda: None))
def _show_reset_calibration_prompt(self):
def _reset():
ui_state.params.remove("CalibrationParams")
ui_state.params.remove("LiveTorqueParameters")
gui_app.push_widget(BigConfirmationDialog(tr("slide to\nreset calibration"), self._reset_icon, _reset))
def _update_state(self):
super()._update_state()
self._handle_bundle_download_progress()
self._current.set_value(self._current_model_value())
self._current.set_enabled(ui_state.is_offroad())
target = self._redownload_target_bundle()
self._redownload.set_value(_display_model_name(target) if target else "")
now = time.monotonic()
if now - self._last_cache_t > 1.0:
self._last_cache_t = now
self._clear.set_value(f"{self._calculate_cache_size():.1f} MB")
self._update_steer_delay_subtext()
def _progress_target_bundle(self):
try:
selected = self.model_manager.selectedBundle
active = self.model_manager.activeBundle
except Exception:
return None
if selected and (selected.status == _DL.downloading or selected.status == _DL.failed):
return selected
return active if self._has_active_bundle_param() else None
def _handle_bundle_download_progress(self):
labels = {
custom.IQModelManager.Model.Type.supercombo: self._supercombo,
custom.IQModelManager.Model.Type.vision: self._vision,
custom.IQModelManager.Model.Type.policy: self._policy,
}
for label in labels.values():
label.set_visible(False)
label.set_value("")
self._cancel.set_visible(False)
bundle = self._progress_target_bundle()
if not bundle:
self._download_status = None
self._prev_download_status = None
return
self._download_status = bundle.status
status_changed = self._download_status != self._prev_download_status
self._prev_download_status = self._download_status
self._cancel.set_visible(bool(getattr(self.model_manager, "selectedBundle", None)) and self._has_download_request())
if self._download_status not in (_DL.downloading, _DL.failed):
return
if self._download_status == _DL.downloading:
try:
from iqpilot.selfdrive.ui.ui_state import device
device._reset_interactive_timeout()
except Exception:
pass
for model in bundle.models:
label = labels.get(getattr(model.type, "raw", model.type))
if label is None:
continue
label.set_visible(True)
label.set_value(self._download_label_text(bundle, model, status_changed))
def _download_label_text(self, bundle, model, status_changed: bool) -> str:
p = model.artifact.downloadProgress
if p.status == _DL.downloading:
return f"{int(p.progress)}% downloading {_display_model_name(bundle)}"
if p.status in (_DL.downloaded, _DL.cached):
if self._download_status == _DL.downloading:
return f"{_display_model_name(bundle)} ready"
return f"{_display_model_name(bundle)} {'downloaded' if status_changed else 'ready'}"
if p.status == _DL.failed:
return f"download failed {_display_model_name(bundle)}"
return f"pending {_display_model_name(bundle)}"
def _current_model_value(self) -> str:
bundle = self._progress_target_bundle()
if not bundle:
return self._active_model_name()
if self._download_status == _DL.downloading:
return self._download_progress_text(bundle)
if self._download_status == _DL.failed:
return f"failed: {_display_model_name(bundle)}"
return self._active_model_name()
def _update_steer_delay_subtext(self):
if self._steer_delay._checked:
try:
self._steer_delay.set_value(f"measured {ui_state.sm['lateralDelay'].lateralDelay:.3f} s")
except Exception:
self._steer_delay.set_value("")
return
try:
sw = float(ui_state.params.get("IQSoftwareSteerDelay", return_default=True))
except (TypeError, ValueError):
sw = 0.2
if ui_state.CP is not None:
self._steer_delay.set_value(f"total {ui_state.CP.steerActuatorDelay + sw:.2f} s")
else:
self._steer_delay.set_value(f"+{sw:.2f} s offset")
def _active_model_name(self) -> str:
if not self._has_active_bundle_param():
return "Default (CD210)"
try:
active = self.model_manager.activeBundle
if is_default_bundle(active):
return active.displayName or "Default (CD210)"
if active and active.ref:
return _display_model_name(active)
except Exception:
pass
return "Default (CD210)"
def _download_progress_text(self, bundle=None) -> str:
bundle = bundle or getattr(self.model_manager, "selectedBundle", None)
if not bundle:
return "downloading..."
try:
parts = []
for model in bundle.models:
p = model.artifact.downloadProgress
if p.status == _DL.downloading:
parts.append(f"{int(p.progress)}%")
elif p.status in (_DL.downloaded, _DL.cached):
parts.append("ready")
elif p.status == _DL.failed:
parts.append("failed")
return f"{_display_model_name(bundle)} {' '.join(parts)}".strip() or "downloading..."
except Exception:
return "downloading..."
def show_event(self):
super().show_event()
for w in (self._steer_delay, self._sw_delay, self._lane_turn, self._lane_speed):
w.refresh()

View File

@@ -0,0 +1,3 @@
"""
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
"""

View File

@@ -0,0 +1,189 @@
from __future__ import annotations
from collections.abc import Callable
import pyray as rl
from iqpilot.system.hardware.base import Profile
from iqpilot.system.hardware.tici.esim_manager import EsimManager, EsimUiState, get_esim_manager
from iqpilot.system.ui.lib.application import gui_app
from iqpilot.system.ui.widgets import DialogResult, NavWidget
from iqpilot.system.ui.widgets.esim_scanner import EsimQrScannerDialog
from iqpilot.selfdrive.ui.mici.widgets.button import NeonBigButton
from iqpilot.selfdrive.ui.mici.widgets.dialog import BigDialog, BigInputDialog, BigMultiOptionDialog, BigConfirmationDialogV2
from iqpilot.system.ui.widgets.scroller import Scroller
from iqpilot.system.ui.lib.multilang import tr
class EsimUIMici(NavWidget):
def __init__(self, back_callback: Callable):
super().__init__()
self._manager: EsimManager = get_esim_manager()
self._state = EsimUiState()
self._callback_registered = False
self._scroller = Scroller([], snap_items=False)
self._rebuild_scroller()
self.set_back_callback(back_callback)
def show_event(self):
super().show_event()
self._scroller.show_event()
if not self._callback_registered:
self._manager.add_callback(self._on_state_update)
self._callback_registered = True
self._manager.refresh_profiles()
def hide_event(self):
super().hide_event()
if self._callback_registered:
self._manager.remove_callback(self._on_state_update)
self._callback_registered = False
def _is_busy(self) -> bool:
return self._state.busy
def _status_text(self) -> str:
if self._state.message:
return self._state.message
return self._state.state.value
def _on_state_update(self, state: EsimUiState):
self._state = state
self._rebuild_scroller()
def _show_choice_dialog(self, title: str, options: list[str], callback: Callable[[str], None]) -> None:
if not options:
return
dlg = BigMultiOptionDialog(
options,
options[0],
right_btn="check",
right_btn_callback=lambda: callback(dlg.get_selected_option()),
)
gui_app.set_modal_overlay(dlg)
def _rebuild_scroller(self):
widgets = []
status_btn = NeonBigButton(tr("status"), chips=[self._status_text()])
status_btn.set_enabled(False)
widgets.append(status_btn)
refresh_btn = NeonBigButton(tr("refresh profiles"))
refresh_btn.set_enabled(lambda: not self._is_busy())
refresh_btn.set_click_callback(lambda: self._manager.refresh_profiles())
widgets.append(refresh_btn)
add_btn = NeonBigButton(tr("add profile"), chips=[tr("scan qr / enter code")])
add_btn.set_enabled(lambda: not self._is_busy())
add_btn.set_click_callback(self._on_add_profile)
widgets.append(add_btn)
profiles = self._state.profiles or []
for p in profiles:
widgets.append(self._make_profile_button(p))
self._scroller = Scroller(widgets, snap_items=False)
def _make_profile_button(self, profile: Profile) -> NeonBigButton:
title = profile.nickname if profile.nickname else profile.iccid
provider = profile.provider or tr("provider unknown")
value = f"{provider}{'' + tr('active') if profile.enabled else ''}"
btn = NeonBigButton(title, chips=[value])
btn.set_enabled(lambda: not self._is_busy())
btn.set_click_callback(lambda profile=profile: self._on_profile_selected(profile))
return btn
def _on_add_profile(self):
options = [tr("scan qr"), tr("enter code")]
def _selected(option: str):
if option == options[0]:
self._scan_qr()
elif option == options[1]:
self._manual_entry()
self._show_choice_dialog(tr("add esim profile"), options, _selected)
def _scan_qr(self):
scanner = EsimQrScannerDialog()
self._manager.set_scanning_state(True)
def _done(result: int):
self._manager.set_scanning_state(False)
if result != DialogResult.CONFIRM or not scanner.code:
return
self._prompt_nickname_and_add(scanner.code)
gui_app.set_modal_overlay(scanner, _done)
def _manual_entry(self):
dlg = BigInputDialog(tr("enter LPA activation code..."), "", minimum_length=1,
confirm_callback=lambda code: self._prompt_nickname_and_add(code.strip()) if code.strip() else None)
gui_app.set_modal_overlay(dlg)
def _prompt_nickname_and_add(self, code: str):
dlg = BigInputDialog(tr("optional nickname..."), "", minimum_length=0,
confirm_callback=lambda nickname: self._manager.add_profile(code, nickname.strip() or None))
gui_app.set_modal_overlay(dlg)
def _on_profile_selected(self, profile: Profile):
options = []
if not profile.enabled:
options.append(tr("activate"))
options.append(tr("rename"))
if self._manager.is_comma_profile(profile.iccid):
options.append(tr("remove comma psim"))
elif not profile.enabled:
options.append(tr("delete"))
def _selected(option: str):
if option == tr("activate"):
self._manager.switch_profile(profile.iccid)
elif option == tr("rename"):
self._rename_profile(profile)
elif option == tr("remove comma psim"):
self._remove_comma_profile()
elif option == tr("delete"):
self._manager.delete_profile(profile.iccid)
self._show_choice_dialog(tr("profile actions"), options, _selected)
def _rename_profile(self, profile: Profile):
dlg = BigInputDialog(tr("rename profile..."), profile.nickname or "", minimum_length=1,
confirm_callback=lambda nickname: self._manager.rename_profile(profile.iccid, nickname.strip()) if nickname.strip() else None)
gui_app.set_modal_overlay(dlg)
def _remove_comma_profile(self):
dlg = BigDialog(
tr("Warning"),
tr("This will permanently wipe the Comma pSIM profile from the SIM."),
right_btn="check",
right_btn_callback=self._remove_comma_profile_final_warning,
)
gui_app.set_modal_overlay(dlg)
def _remove_comma_profile_final_warning(self):
dlg = BigDialog(
tr("Final Warning"),
tr("You must use your own eSIM profile after this. You cannot use Comma Prime again unless you buy a new SIM from comma."),
right_btn="check",
right_btn_callback=self._confirm_remove_comma_profile,
)
gui_app.set_modal_overlay(dlg)
def _confirm_remove_comma_profile(self):
dlg = BigConfirmationDialogV2(
tr("slide to remove\ncomma psim"),
"icons_mici/settings/network/new/trash.png",
red=True,
confirm_callback=self._manager.bootstrap,
)
gui_app.set_modal_overlay(dlg)
def _render(self, rect: rl.Rectangle):
if not self._manager.is_supported():
from iqpilot.system.ui.widgets.label import gui_label
gui_label(rect, tr("Insert the original comma SIM card that came with the device to use eSIM"), 48, alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER)
return
self._scroller.render(rect)

View File

@@ -0,0 +1,233 @@
"""
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
"""
import pyray as rl
from enum import IntEnum
from collections.abc import Callable
from iqpilot.system.ui.widgets.scroller import Scroller, draw_scroller_edge_fades, draw_scroller_page_slider
from iqpilot.selfdrive.ui.mici.layouts.settings.network.wifi_ui import WifiUIMici
from iqpilot.selfdrive.ui.mici.layouts.settings.network.esim_ui import EsimUIMici
from iqpilot.selfdrive.ui.mici.widgets.stock_button import BigButton, BigParamControl, BigMultiToggle
from iqpilot.selfdrive.ui.mici.widgets.stock_dialog import BigInputDialog
from iqpilot.selfdrive.ui.ui_state import ui_state
from iqpilot.system.ui.lib.application import gui_app
from iqpilot.system.ui.widgets.nav_widget import NavWidget
from iqpilot.system.ui.lib.wifi_manager import WifiManager, Network, MeteredType
from iqpilot.system.hardware.tici.esim_manager import get_esim_manager
from iqpilot.system.ui.lib.multilang import tr
class NetworkPanelType(IntEnum):
NONE = 0
WIFI = 1
ESIM = 2
class NetworkLayoutMici(NavWidget):
CALLBACK_INTERVAL_FRAMES = 3
def __init__(self, back_callback: Callable):
super().__init__()
self._current_panel = NetworkPanelType.WIFI
self._callback_frame = 0
self._esim_profile_count: str | None = None
self._esim_profile_frame = 0
self._wifi_manager = WifiManager()
self._wifi_manager.set_active(False)
self._wifi_ui = WifiUIMici(self._wifi_manager)
self._esim_ui = EsimUIMici(back_callback=lambda: self._switch_to_panel(NetworkPanelType.NONE))
self._esim_manager = get_esim_manager()
self._wifi_manager.add_callbacks(
networks_updated=self._on_network_updated,
)
# ******** Tethering ********
def tethering_toggle_callback(checked: bool):
self._tethering_toggle_btn.set_enabled(False)
self._network_metered_btn.set_enabled(False)
self._wifi_manager.set_tethering_active(checked)
self._tethering_checked = False
self._tethering_toggle_btn = BigButton(tr("tethering"), tr("disabled"))
self._tethering_toggle_btn.set_click_callback(lambda: self._on_tethering_clicked(tethering_toggle_callback))
def tethering_password_callback(password: str):
if password:
self._wifi_manager.set_tethering_password(password)
def tethering_password_clicked():
tethering_password = self._wifi_manager.tethering_password
dlg = BigInputDialog(tr("enter password..."), tethering_password, minimum_length=8,
confirm_callback=tethering_password_callback)
gui_app.push_widget(dlg)
self._tethering_password_btn = BigButton(tr("tethering password"))
self._tethering_password_btn.set_click_callback(tethering_password_clicked)
# ******** IP Address ********
self._ip_address_btn = BigButton(tr("IP Address"), tr("Not connected"))
# ******** Network Metered ********
self._metered_options = [tr("default"), tr("metered"), tr("unmetered")]
def network_metered_callback(value: str):
self._network_metered_btn.set_enabled(False)
metered = {
self._metered_options[0]: MeteredType.UNKNOWN,
self._metered_options[1]: MeteredType.YES,
self._metered_options[2]: MeteredType.NO
}.get(value, MeteredType.UNKNOWN)
self._wifi_manager.set_current_network_metered(metered)
# TODO: signal for current network metered type when changing networks, this is wrong until you press it once
# TODO: disable when not connected
self._network_metered_btn = BigMultiToggle(tr("network usage"), self._metered_options, select_callback=network_metered_callback)
self._network_metered_btn.set_enabled(False)
wifi_button = BigButton(tr("wi-fi"))
wifi_button.set_click_callback(lambda: gui_app.push_widget(self._wifi_ui))
self._esim_button = BigButton(tr("eSIM"), tr("manage profiles"))
self._esim_button.set_click_callback(lambda: self._switch_to_panel(NetworkPanelType.ESIM))
self._esim_button.set_visible(lambda: self._esim_manager.is_supported())
# ******** Advanced settings ********
# ******** Roaming toggle ********
self._roaming_btn = BigParamControl(tr("enable roaming"), "GsmRoaming", toggle_callback=self._toggle_roaming)
# ******** APN settings ********
self._apn_btn = BigButton(tr("apn settings"))
self._apn_btn.set_click_callback(self._edit_apn)
# ******** Cellular metered toggle ********
self._cellular_metered_btn = BigParamControl(tr("cellular metered"), "GsmMetered", toggle_callback=self._toggle_cellular_metered)
# Main scroller ----------------------------------
self._scroller = Scroller([
wifi_button,
self._esim_button,
self._network_metered_btn,
self._tethering_toggle_btn,
self._tethering_password_btn,
# /* Advanced settings
self._roaming_btn,
self._apn_btn,
self._cellular_metered_btn,
# */
self._ip_address_btn,
], snap_items=False)
# Set initial config
roaming_enabled = ui_state.params.get_bool("GsmRoaming")
metered = ui_state.params.get_bool("GsmMetered")
self._wifi_manager.update_gsm_settings(roaming_enabled, ui_state.params.get("GsmApn") or "", metered)
# Set up back navigation
self.set_back_callback(back_callback)
def _back_enabled(self) -> bool:
# Only allow swipe-to-dismiss back to settings when no sub-panel (eSIM) is open.
return self._current_panel == NetworkPanelType.NONE
def _update_state(self):
super()._update_state()
# konn3kt has no managed cellular SIM, so always expose the GSM/APN settings.
show_cell_settings = True
self._wifi_manager.set_ipv4_forward(show_cell_settings)
self._roaming_btn.set_visible(show_cell_settings)
self._apn_btn.set_visible(show_cell_settings)
self._cellular_metered_btn.set_visible(show_cell_settings)
self._esim_profile_frame += 1
if self._esim_profile_frame % 30 == 0:
esim_profiles = (self._esim_manager.get_state().profiles or []) if self._esim_manager.is_supported() else []
count = f"{len(esim_profiles)} profiles"
if count != self._esim_profile_count:
self._esim_profile_count = count
self._esim_button.set_value(count)
def show_event(self):
super().show_event()
self._current_panel = NetworkPanelType.NONE
self._esim_profile_frame = 0
self._esim_profile_count = None
self._roaming_btn.refresh()
self._cellular_metered_btn.refresh()
self._scroller.show_event()
def hide_event(self):
super().hide_event()
if self._current_panel == NetworkPanelType.ESIM:
self._esim_ui.hide_event()
def _toggle_roaming(self, checked: bool):
self._wifi_manager.update_gsm_settings(checked, ui_state.params.get("GsmApn") or "", ui_state.params.get_bool("GsmMetered"))
def _edit_apn(self):
def update_apn(apn: str):
apn = apn.strip()
if apn == "":
ui_state.params.remove("GsmApn")
else:
ui_state.params.put("GsmApn", apn)
self._wifi_manager.update_gsm_settings(ui_state.params.get_bool("GsmRoaming"), apn, ui_state.params.get_bool("GsmMetered"))
current_apn = ui_state.params.get("GsmApn") or ""
dlg = BigInputDialog(tr("enter APN"), current_apn, minimum_length=0, confirm_callback=update_apn)
gui_app.push_widget(dlg)
def _toggle_cellular_metered(self, checked: bool):
self._wifi_manager.update_gsm_settings(ui_state.params.get_bool("GsmRoaming"), ui_state.params.get("GsmApn") or "", checked)
def _on_tethering_clicked(self, toggle_callback):
self._tethering_checked = not self._tethering_checked
self._tethering_toggle_btn.set_value(tr("enabled") if self._tethering_checked else tr("disabled"))
toggle_callback(self._tethering_checked)
def _on_network_updated(self, networks: list[Network]):
# Update tethering state
tethering_active = self._wifi_manager.is_tethering_active()
self._tethering_toggle_btn.set_enabled(True)
self._network_metered_btn.set_enabled(lambda: not tethering_active and bool(self._wifi_manager.ipv4_address))
self._tethering_checked = tethering_active
self._tethering_toggle_btn.set_value(tr("enabled") if tethering_active else tr("disabled"))
# Update IP address
self._ip_address_btn.set_value(self._wifi_manager.ipv4_address or tr("Not connected"))
# Update network metered
self._network_metered_btn.set_value(
{
MeteredType.UNKNOWN: self._metered_options[0],
MeteredType.YES: self._metered_options[1],
MeteredType.NO: self._metered_options[2]
}.get(self._wifi_manager.current_network_metered, self._metered_options[0]))
def _switch_to_panel(self, panel_type: NetworkPanelType):
if panel_type == NetworkPanelType.ESIM:
if not self._esim_manager.is_supported():
return
self._esim_ui.show_event()
elif self._current_panel == NetworkPanelType.ESIM:
self._esim_ui.hide_event()
self._current_panel = panel_type
def _render(self, rect: rl.Rectangle):
rl.draw_rectangle_rec(rect, rl.BLACK)
if self._callback_frame % self.CALLBACK_INTERVAL_FRAMES == 0:
self._wifi_manager.process_callbacks()
self._callback_frame += 1
if self._current_panel == NetworkPanelType.ESIM:
self._esim_ui.render(rect)
else:
self._scroller.render(rect)
draw_scroller_edge_fades(rect)
draw_scroller_page_slider(self._scroller, rect)

View File

@@ -0,0 +1,36 @@
#!/usr/bin/env python3
"""
Small standalone check for the mici WiFi menu sort order.
Run with:
uv run python selfdrive/ui/mici/layouts/settings/network/test_wifi_sort.py
"""
from iqpilot.system.ui.lib.wifi_manager import Network, SecurityType, wifi_network_sort_key
def _network(ssid: str, strength: int, connected: bool = False) -> Network:
return Network(ssid, strength, connected, SecurityType.WPA2, True)
def main() -> None:
cafe = _network("cafe", 35)
home = _network("home", 75)
connected = _network("connected", 20, connected=True)
missing_saved = _network("missing-saved", 90)
zero_strength = _network("zero-strength", 0)
entries = [
(cafe, False),
(missing_saved, True),
(zero_strength, False),
(connected, False),
(home, False),
]
ordered = [network.ssid for network, missing in sorted(entries, key=lambda entry: wifi_network_sort_key(*entry))]
assert ordered == ["connected", "home", "cafe", "missing-saved", "zero-strength"], ordered
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,443 @@
"""
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
"""
import math
import numpy as np
import pyray as rl
from collections.abc import Callable
from iqpilot.common.swaglog import cloudlog
from iqpilot.selfdrive.ui.mici.widgets.stock_dialog import BigInputDialog, BigConfirmationDialog
from iqpilot.selfdrive.ui.mici.widgets.stock_button import BigButton, LABEL_COLOR
from iqpilot.system.ui.lib.application import gui_app, MousePos, FontWeight
from iqpilot.system.ui.widgets import Widget
from iqpilot.system.ui.widgets.scroller import NavScroller
from iqpilot.system.ui.lib.wifi_manager import WifiManager, Network, SecurityType, normalize_ssid, wifi_network_sort_key
from iqpilot.system.ui.lib.multilang import tr
def _wifi_network_signature(networks: list[Network]) -> tuple[tuple[str, int, bool, int, bool], ...]:
return tuple(sorted((n.ssid, n.strength, n.is_connected, int(n.security_type), n.is_saved) for n in networks))
class LoadingAnimation(Widget):
RADIUS = 8
SPACING = 24
Y_MAG = 11.2
def __init__(self):
super().__init__()
self.set_rect(rl.Rectangle(0, 0, self.SPACING * 2 + self.RADIUS * 2, self.RADIUS * 2 + int(self.Y_MAG)))
def _render(self, _):
base_x = int(self._rect.x + self._rect.width / 2)
base_y = int(self._rect.y + self._rect.height - self.RADIUS)
for i in range(3):
x = base_x + (i - 1) * self.SPACING
y = int(base_y + min(math.sin((rl.get_time() - i * 0.2) * 4) * self.Y_MAG, 0))
alpha = int(np.interp(base_y - y, [0, self.Y_MAG], [255 * 0.45, 255 * 0.9]))
rl.draw_circle(x, y, self.RADIUS, rl.Color(255, 255, 255, alpha))
class WifiIcon(Widget):
def __init__(self, network: Network):
super().__init__()
self.set_rect(rl.Rectangle(0, 0, 48 + 5, 36 + 5))
self._wifi_slash_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_slash.png", 48, 42)
self._wifi_low_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_low.png", 48, 36)
self._wifi_medium_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_medium.png", 48, 36)
self._wifi_full_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_full.png", 48, 36)
self._lock_txt = gui_app.texture("icons_mici/settings/network/new/lock.png", 21, 27)
self._network = network
self._network_missing = False
def update_network(self, network: Network):
self._network = network
def set_network_missing(self, missing: bool):
self._network_missing = missing
def _render(self, _):
strength = round(self._network.strength / 100 * 2)
if self._network_missing:
strength_icon = self._wifi_slash_txt
elif strength == 2:
strength_icon = self._wifi_full_txt
elif strength == 1:
strength_icon = self._wifi_medium_txt
else:
strength_icon = self._wifi_low_txt
rl.draw_texture_ex(strength_icon, (self._rect.x, self._rect.y + self._rect.height - strength_icon.height), 0.0, 1.0, rl.WHITE)
if self._network.security_type not in (SecurityType.OPEN, SecurityType.UNSUPPORTED):
lock_x = self._rect.x + self._rect.width - self._lock_txt.width
lock_y = self._rect.y + self._rect.height - self._lock_txt.height + 6
rl.draw_texture_ex(self._lock_txt, (lock_x, lock_y), 0.0, 1.0, rl.WHITE)
class ForgetButton(Widget):
MARGIN = 12
def __init__(self, forget_network: Callable):
super().__init__()
self._forget_network = forget_network
self._bg_txt = gui_app.texture("icons_mici/settings/network/new/forget_button.png", 84, 84)
self._bg_pressed_txt = gui_app.texture("icons_mici/settings/network/new/forget_button_pressed.png", 84, 84)
self._trash_txt = gui_app.texture("icons_mici/settings/network/new/trash.png", 29, 35)
self.set_rect(rl.Rectangle(0, 0, 84 + self.MARGIN * 2, 84 + self.MARGIN * 2))
def _handle_mouse_release(self, mouse_pos: MousePos):
super()._handle_mouse_release(mouse_pos)
dlg = BigConfirmationDialog(tr("slide to\nforget"), gui_app.texture("icons_mici/settings/network/new/trash.png", 54, 64),
self._forget_network, red=True)
gui_app.push_widget(dlg)
def _render(self, _):
bg_txt = self._bg_pressed_txt if self.is_pressed else self._bg_txt
rl.draw_texture_ex(bg_txt, (self._rect.x + (self._rect.width - self._bg_txt.width) / 2,
self._rect.y + (self._rect.height - self._bg_txt.height) / 2), 0, 1.0, rl.WHITE)
trash_x = self._rect.x + (self._rect.width - self._trash_txt.width) / 2
trash_y = self._rect.y + (self._rect.height - self._trash_txt.height) / 2
rl.draw_texture_ex(self._trash_txt, (trash_x, trash_y), 0, 1.0, rl.WHITE)
class DisconnectButton(Widget):
MARGIN = 12
RADIUS = 42
# the only round button art is the destructive red one, and dropping a connection is not destructive
BG = rl.Color(56, 56, 61, 255)
BG_PRESSED = rl.Color(84, 84, 90, 255)
def __init__(self, disconnect_network: Callable):
super().__init__()
self._disconnect_network = disconnect_network
self._slash_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_slash.png", 38, 38)
self.set_rect(rl.Rectangle(0, 0, 84 + self.MARGIN * 2, 84 + self.MARGIN * 2))
def _handle_mouse_release(self, mouse_pos: MousePos):
super()._handle_mouse_release(mouse_pos)
dlg = BigConfirmationDialog(tr("slide to\ndisconnect"), gui_app.texture("icons_mici/settings/network/wifi_strength_slash.png", 54, 54),
self._disconnect_network)
gui_app.push_widget(dlg)
def _render(self, _):
center = rl.Vector2(self._rect.x + self._rect.width / 2, self._rect.y + self._rect.height / 2)
rl.draw_circle_v(center, self.RADIUS, self.BG_PRESSED if self.is_pressed else self.BG)
rl.draw_texture_ex(self._slash_txt, (center.x - self._slash_txt.width / 2, center.y - self._slash_txt.height / 2),
0, 1.0, rl.WHITE)
class WifiButton(BigButton):
LABEL_PADDING = 98
LABEL_WIDTH = 402 - 98 - 28
SUB_LABEL_WIDTH = 402 - BigButton.LABEL_HORIZONTAL_PADDING * 2
def __init__(self, network: Network, wifi_manager: WifiManager, connecting_ssid: Callable[[], str | None]):
super().__init__(normalize_ssid(network.ssid), scroll=True)
self._network = network
self._wifi_manager = wifi_manager
self._connecting_ssid = connecting_ssid
self._wifi_icon = WifiIcon(network)
self._forget_btn = ForgetButton(self._forget_network)
self._disconnect_btn = DisconnectButton(self._disconnect_network)
self._check_txt = gui_app.texture("icons_mici/setup/driver_monitoring/dm_check.png", 32, 32)
self._network_missing = False
self._network_forgetting = False
self._network_disconnecting = False
self._wrong_password = False
@property
def network(self) -> Network:
return self._network
def update_network(self, network: Network):
self._network = network
self._wifi_icon.update_network(network)
self._network_missing = False
self._wifi_icon.set_network_missing(False)
if self._is_connected or self._is_connecting:
self._wrong_password = False
@property
def network_forgetting(self) -> bool:
return self._network_forgetting
@property
def network_missing(self) -> bool:
return self._network_missing
def _forget_network(self):
if self._network_forgetting:
return
self._network_forgetting = True
self._wifi_manager.forget_connection(self._network.ssid)
def _disconnect_network(self):
if self._network_disconnecting:
return
self._network_disconnecting = True
self._wifi_manager.disconnect_connection(self._network.ssid)
def on_forgotten(self):
self._network_forgetting = False
def on_disconnected(self):
self._network_disconnecting = False
def set_network_missing(self, missing: bool):
self._network_missing = missing
self._wifi_icon.set_network_missing(missing)
def set_wrong_password(self):
self._wrong_password = True
self.trigger_shake()
@property
def _is_saved(self) -> bool:
return self._network.is_saved
@property
def _is_connecting(self) -> bool:
return self._connecting_ssid() == self._network.ssid
@property
def _is_connected(self) -> bool:
return self._network.is_connected
@property
def _is_tethering(self) -> bool:
return getattr(self._network, "is_tethering", False)
@property
def _show_forget_btn(self) -> bool:
if self._is_tethering or self._network_forgetting or self._show_disconnect_btn:
return False
return (self._is_saved and not self._wrong_password) or self._is_connecting
@property
def _show_disconnect_btn(self) -> bool:
# 402 units of row cannot hold both buttons plus the status word, so the slot is contextual:
# disconnect while connected, forget once it is only saved
if self._is_tethering or self._network_forgetting or self._network_disconnecting:
return False
return self._is_connected
def _handle_mouse_release(self, mouse_pos: MousePos):
if self._show_forget_btn and rl.check_collision_point_rec(mouse_pos, self._forget_btn.rect):
return
if self._show_disconnect_btn and rl.check_collision_point_rec(mouse_pos, self._disconnect_btn.rect):
return
super()._handle_mouse_release(mouse_pos)
def _get_label_font_size(self):
return 48
def set_touch_valid_callback(self, touch_callback: Callable[[], bool]) -> None:
super().set_touch_valid_callback(lambda: touch_callback() and not self._forget_btn.is_pressed and not self._disconnect_btn.is_pressed)
self._forget_btn.set_touch_valid_callback(touch_callback)
self._disconnect_btn.set_touch_valid_callback(touch_callback)
def _update_state(self):
super()._update_state()
if any((self._network_missing, self._is_connecting, self._is_connected, self._network_forgetting,
self._network_disconnecting, self._network.security_type == SecurityType.UNSUPPORTED)):
self.set_enabled(False)
self._sub_label.set_color(rl.Color(255, 255, 255, int(255 * 0.585)))
self._sub_label.set_font_weight(FontWeight.ROMAN)
if self._network_forgetting:
self.set_value(tr("forgetting..."))
elif self._network_disconnecting:
self.set_value(tr("disconnecting..."))
elif self._is_connecting:
self.set_value(tr("starting...") if self._is_tethering else tr("connecting..."))
elif self._is_connected:
self.set_value(tr("tethering") if self._is_tethering else tr("connected"))
elif self._network_missing:
self.set_value(tr("not in range"))
else:
self.set_value(tr("unsupported"))
else:
self.set_value(tr("wrong password") if self._wrong_password else tr("connect"))
self.set_enabled(True)
self._sub_label.set_color(rl.Color(255, 255, 255, int(255 * 0.9)))
self._sub_label.set_font_weight(FontWeight.SEMI_BOLD)
def _draw_content(self, btn_x: float, btn_y: float, btn_width: float, btn_height: float):
self._label.set_color(LABEL_COLOR)
label_rect = rl.Rectangle(btn_x + self.LABEL_PADDING, btn_y + self.LABEL_VERTICAL_PADDING,
self.LABEL_WIDTH, btn_height - self.LABEL_VERTICAL_PADDING * 2)
self._label.render(label_rect)
if self.value:
sub_label_x = self._rect.x + self.LABEL_HORIZONTAL_PADDING
label_y = btn_y + self._rect.height - self.LABEL_VERTICAL_PADDING
sub_label_w = self.SUB_LABEL_WIDTH - (self._forget_btn.rect.width if self._show_forget_btn else 0) \
- (self._disconnect_btn.rect.width if self._show_disconnect_btn else 0)
sub_label_height = self._sub_label.get_content_height(sub_label_w)
if self._is_connected and not self._network_forgetting and not self._network_disconnecting:
check_y = int(label_y - sub_label_height + (sub_label_height - self._check_txt.height) / 2)
rl.draw_texture_ex(self._check_txt, rl.Vector2(sub_label_x, check_y), 0.0, 1.0, rl.Color(255, 255, 255, int(255 * 0.9 * 0.65)))
sub_label_x += self._check_txt.width + 14
sub_label_rect = rl.Rectangle(sub_label_x, label_y - sub_label_height, sub_label_w, sub_label_height)
self._sub_label.render(sub_label_rect)
self._wifi_icon.render(rl.Rectangle(self._rect.x + 30, btn_y + 30, self._wifi_icon.rect.width, self._wifi_icon.rect.height))
btn_right = self._rect.x + self._rect.width
if self._show_forget_btn:
self._forget_btn.render(rl.Rectangle(
btn_right - self._forget_btn.rect.width,
btn_y + self._rect.height - self._forget_btn.rect.height,
self._forget_btn.rect.width, self._forget_btn.rect.height))
btn_right -= self._forget_btn.rect.width
if self._show_disconnect_btn:
self._disconnect_btn.render(rl.Rectangle(
btn_right - self._disconnect_btn.rect.width,
btn_y + self._rect.height - self._disconnect_btn.rect.height,
self._disconnect_btn.rect.width, self._disconnect_btn.rect.height))
class ScanningButton(BigButton):
def __init__(self, is_scanning: Callable[[], bool]):
super().__init__("", tr("searching for networks"))
self.set_enabled(False)
self._loading_animation = LoadingAnimation()
self._is_scanning = is_scanning
def _draw_content(self, btn_x: float, btn_y: float, btn_width: float, btn_height: float):
super()._draw_content(btn_x, btn_y, btn_width, btn_height)
if not self._is_scanning():
return
anim = self._loading_animation
anim.set_position(btn_x + btn_width - anim.rect.width - 40, btn_y + btn_height - anim.rect.height - 30)
anim.render()
class WifiUIMici(NavScroller):
CALLBACK_INTERVAL_FRAMES = 3
def __init__(self, wifi_manager: WifiManager):
super().__init__()
self._wifi_manager = wifi_manager
self._scanning_btn = ScanningButton(lambda: self._wifi_manager.is_scanning)
self._networks: dict[str, Network] = {}
self._network_signature: tuple[tuple[str, int, bool, int, bool], ...] = ()
self._connecting: str | None = None
self._callback_frame = 0
self._wifi_manager.add_callbacks(
need_auth=self._on_need_auth,
activated=self._on_activated,
forgotten=self._on_forgotten,
networks_updated=self._on_network_updated,
disconnected=self._on_disconnected,
)
def _connecting_ssid(self) -> str | None:
return self._connecting
def show_event(self):
super().show_event()
self._wifi_manager.set_active(True)
self._callback_frame = 0
self._update_buttons(re_sort=True)
def hide_event(self):
super().hide_event()
self._wifi_manager.set_active(False)
def _on_network_updated(self, networks: list[Network]):
signature = _wifi_network_signature(networks)
if signature == self._network_signature:
return
self._network_signature = signature
self._networks = {n.ssid: n for n in networks}
self._update_buttons()
def _on_activated(self):
self._connecting = None
def _on_disconnected(self):
self._connecting = None
for btn in self._scroller.items:
if isinstance(btn, WifiButton):
btn.on_disconnected()
def _on_forgotten(self, ssid=None):
self._connecting = None
for btn in self._scroller.items:
if isinstance(btn, WifiButton) and (ssid is None or btn.network.ssid == ssid):
btn.on_forgotten()
def _update_buttons(self, re_sort: bool = False):
sorted_networks = sorted(self._networks.values(), key=wifi_network_sort_key)
existing = {btn.network.ssid: btn for btn in self._scroller.items if isinstance(btn, WifiButton)}
for network in sorted_networks:
if network.ssid in existing:
existing[network.ssid].update_network(network)
else:
btn = WifiButton(network, self._wifi_manager, self._connecting_ssid)
btn.set_click_callback(lambda ssid=network.ssid: self._connect_to_network(ssid))
self._scroller.add_widget(btn)
current_ssids = set(self._networks)
for btn in self._scroller.items:
if isinstance(btn, WifiButton):
btn.set_network_missing(btn.network.ssid not in current_ssids)
if re_sort or sorted_networks:
order = {btn.network.ssid: idx for idx, btn in enumerate(self._scroller.items) if isinstance(btn, WifiButton)}
wifi_buttons = [btn for btn in self._scroller.items if isinstance(btn, WifiButton)]
other_items = [btn for btn in self._scroller.items if not isinstance(btn, WifiButton) and btn is not self._scanning_btn]
wifi_buttons.sort(key=lambda btn: (*wifi_network_sort_key(btn.network, btn.network_missing), order[btn.network.ssid]))
self._scroller.items[:] = [*wifi_buttons, *other_items]
items = self._scroller.items
if self._scanning_btn in items:
items.append(items.pop(items.index(self._scanning_btn)))
else:
self._scroller.add_widget(self._scanning_btn)
def _connect_with_password(self, ssid: str, password: str):
self._connecting = ssid
self._wifi_manager.connect_to_network(ssid, password)
self._move_network_to_front(ssid)
def _connect_to_network(self, ssid: str):
network = self._networks.get(ssid)
if network is None:
cloudlog.warning(f"Trying to connect to unknown network: {ssid}")
return
if network.is_saved:
self._connecting = ssid
self._wifi_manager.activate_connection(ssid)
elif network.security_type == SecurityType.OPEN:
self._connecting = ssid
self._wifi_manager.connect_to_network(ssid, "")
else:
self._on_need_auth(ssid, False)
return
self._move_network_to_front(ssid)
def _on_need_auth(self, ssid, incorrect_password=True):
if incorrect_password:
self._connecting = None
for btn in self._scroller.items:
if isinstance(btn, WifiButton) and btn.network.ssid == ssid:
btn.set_wrong_password()
break
return
dlg = BigInputDialog(tr("enter password..."), "", minimum_length=8,
confirm_callback=lambda _password: self._connect_with_password(ssid, _password))
gui_app.push_widget(dlg)
def _move_network_to_front(self, ssid: str | None):
idx = next((i for i, btn in enumerate(self._scroller.items)
if isinstance(btn, WifiButton) and btn.network.ssid == ssid), None) if ssid else None
if idx is not None and idx > 0:
self._scroller.move_item(idx, 0)
def _update_state(self):
super()._update_state()
if self._callback_frame % self.CALLBACK_INTERVAL_FRAMES == 0:
self._wifi_manager.process_callbacks()
self._callback_frame += 1

View File

@@ -0,0 +1,131 @@
"""
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
"""
from iqpilot.common.params import Params
from iqpilot.system.ui.widgets.scroller import NavScroller
from iqpilot.selfdrive.ui.mici.widgets.stock_button import BigButton
from iqpilot.selfdrive.ui.mici.layouts.settings.toggles import TogglesLayoutMici
from iqpilot.selfdrive.ui.mici.layouts.settings.steering import SteeringLayoutMici
from iqpilot.selfdrive.ui.mici.layouts.settings.cruise import CruiseLayoutMici
from iqpilot.selfdrive.ui.mici.layouts.settings.visuals import VisualsLayoutMici
from iqpilot.selfdrive.ui.mici.layouts.settings.models import ModelsLayoutMici
from iqpilot.selfdrive.ui.mici.layouts.settings.display import DisplayLayoutMici
from iqpilot.selfdrive.ui.mici.layouts.settings.drive_history import TripsLayoutMici
from iqpilot.selfdrive.ui.mici.layouts.settings.vehicle import VehicleLayoutMici
from iqpilot.selfdrive.ui.mici.layouts.settings.dashcam import DashcamLayoutMici
from iqpilot.selfdrive.ui.mici.layouts.settings.network.network_layout import NetworkLayoutMici
from iqpilot.selfdrive.ui.mici.layouts.settings.device import DeviceLayoutMici, PairBigButton
from iqpilot.selfdrive.ui.mici.layouts.settings.developer import DeveloperLayoutMici
from iqpilot.selfdrive.ui.mici.layouts.settings.software import SoftwareLayoutMici
from iqpilot.system.ui.lib.application import gui_app, FontWeight
from iqpilot.system.ui.lib.multilang import tr
class SettingsBigButton(BigButton):
def _get_label_font_size(self):
return 64
class CruiseModeButton(SettingsBigButton):
"""Cruise menu button whose icon reflects the active longitudinal mode."""
_ICON_SIZE = 60
def __init__(self):
super().__init__(tr("cruise"), "", gui_app.texture("icons_mici/speedometer.png", self._ICON_SIZE, self._ICON_SIZE))
self._p = Params()
self._icons = [
gui_app.texture("icons_mici/speedometer.png", self._ICON_SIZE, self._ICON_SIZE),
gui_app.texture("icons_mici/iqstandard_mode_mici.png", self._ICON_SIZE, self._ICON_SIZE),
gui_app.texture("icons_mici/iqdynamic_mode_mici.png", self._ICON_SIZE, self._ICON_SIZE),
gui_app.texture("icons_mici/experimental_mode_mici.png", self._ICON_SIZE, self._ICON_SIZE),
]
def _mode_index(self) -> int:
if not self._p.get_bool("AlphaLongitudinalEnabled"):
return 0
if not self._p.get_bool("ExperimentalMode"):
return 1
return 2 if self._p.get_bool("IQDynamicMode") else 3
def _update_state(self):
super()._update_state()
self.set_icon(self._icons[self._mode_index()])
class SettingsLayout(NavScroller):
def __init__(self):
super().__init__()
self._params = Params()
toggles_panel = TogglesLayoutMici()
toggles_btn = SettingsBigButton(tr("toggles"), "", gui_app.texture("icons_mici/settings.png", 64, 64))
toggles_btn.set_click_callback(lambda: gui_app.push_widget(toggles_panel))
steering_panel = SteeringLayoutMici()
steering_btn = SettingsBigButton(tr("steering"), "", gui_app.texture("icons_mici/wheel.png", 64, 64))
steering_btn.set_click_callback(lambda: gui_app.push_widget(steering_panel))
cruise_panel = CruiseLayoutMici()
cruise_btn = CruiseModeButton()
cruise_btn.set_click_callback(lambda: gui_app.push_widget(cruise_panel))
visuals_panel = VisualsLayoutMici()
visuals_btn = SettingsBigButton(tr("visuals"), "", gui_app.texture("icons_mici/onroad/eye_fill.png", 64, 46))
visuals_btn.set_click_callback(lambda: gui_app.push_widget(visuals_panel))
models_panel = ModelsLayoutMici()
models_btn = SettingsBigButton(tr("models"), "", gui_app.texture("icons_mici/models.png", 60, 60))
models_btn.set_click_callback(lambda: gui_app.push_widget(models_panel))
display_panel = DisplayLayoutMici()
display_btn = SettingsBigButton(tr("display"), "", gui_app.texture("icons_mici/settings/brightness.png", 62, 62))
display_btn.set_click_callback(lambda: gui_app.push_widget(display_panel))
trips_panel = TripsLayoutMici()
trips_btn = SettingsBigButton(tr("trips"), "", gui_app.texture("icons_mici/settings/trips.png", 62, 56))
trips_btn.set_click_callback(lambda: gui_app.push_widget(trips_panel))
vehicle_panel = VehicleLayoutMici()
vehicle_btn = SettingsBigButton(tr("vehicle"), "", gui_app.texture("icons_mici/settings/vehicle.png", 70, 56))
vehicle_btn.set_click_callback(lambda: gui_app.push_widget(vehicle_panel))
dashcam_panel = DashcamLayoutMici()
dashcam_btn = SettingsBigButton(tr("dashcam"), "", gui_app.texture("icons_mici/settings/camera.png", 64, 56))
dashcam_btn.set_click_callback(lambda: gui_app.push_widget(dashcam_panel))
network_panel = NetworkLayoutMici(back_callback=lambda: gui_app.pop_widget())
network_btn = SettingsBigButton(tr("network"), "", gui_app.texture("icons_mici/settings/network/wifi_strength_full.png", 76, 56))
network_btn.set_click_callback(lambda: gui_app.push_widget(network_panel))
device_panel = DeviceLayoutMici()
device_btn = SettingsBigButton(tr("device"), "", gui_app.texture("icons_mici/settings/device_icon.png", 72, 58))
device_btn.set_click_callback(lambda: gui_app.push_widget(device_panel))
software_panel = SoftwareLayoutMici()
software_btn = SettingsBigButton(tr("software"), "", gui_app.texture("icons_mici/settings/sd_card.png", 60, 72))
software_btn.set_click_callback(lambda: gui_app.push_widget(software_panel))
developer_panel = DeveloperLayoutMici()
developer_btn = SettingsBigButton(tr("developer"), "", gui_app.texture("icons_mici/settings/developer_icon.png", 64, 60))
developer_btn.set_click_callback(lambda: gui_app.push_widget(developer_panel))
self._scroller.add_widgets([
device_btn,
network_btn,
PairBigButton(),
models_btn,
software_btn,
steering_btn,
cruise_btn,
visuals_btn,
display_btn,
dashcam_btn,
vehicle_btn,
toggles_btn,
trips_btn,
developer_btn,
])
self._font_medium = gui_app.font(FontWeight.MEDIUM)

View File

@@ -0,0 +1,366 @@
import os
import threading
import pyray as rl
from enum import IntEnum
from collections.abc import Callable
from iqpilot.common.time_helpers import system_time_valid
from iqpilot.selfdrive.ui.mici.layouts.settings.device import EngagedConfirmationButton
from iqpilot.selfdrive.ui.mici.widgets.stock_button import BigButton
from iqpilot.selfdrive.ui.mici.widgets.stock_dialog import BigDialog
from iqpilot.selfdrive.ui.ui_state import ui_state
from iqpilot.system.ui.lib.application import gui_app, FontWeight, MousePos
from iqpilot.system.ui.lib.multilang import tr
from iqpilot.system.ui.widgets import Widget
from iqpilot.system.ui.widgets.label import UnifiedLabel
from iqpilot.system.ui.widgets.scroller import NavScroller
UPDATER_TIMEOUT = 10.0 # seconds to wait for updater to respond
def _split_description(desc: str) -> tuple[str, str, str, str] | None:
# UpdaterCurrentDescription/UpdaterNewDescription format: "version / branch / commit / date"
parts = [p.strip() for p in desc.split(" / ")]
if len(parts) != 4:
return None
version, branch, commit, date = parts
return version, branch, commit, date
class UpdaterState(IntEnum):
IDLE = 0
WAITING_FOR_UPDATER = 1
UPDATER_RESPONDING = 2
class SoftwareInfoLayoutMici(Widget):
def __init__(self):
super().__init__()
self.set_rect(rl.Rectangle(0, 0, 360, 180))
subheader_color = rl.Color(255, 255, 255, int(255 * 0.9 * 0.65))
max_width = int(self._rect.width - 20)
self._version_label = UnifiedLabel(tr("version"), 48, max_width=max_width, font_weight=FontWeight.DISPLAY, wrap_text=False)
self._version_text_label = UnifiedLabel("", 32, max_width=max_width, text_color=subheader_color,
font_weight=FontWeight.ROMAN, wrap_text=False, scroll=True)
self._branch_label = UnifiedLabel(tr("branch"), 48, max_width=max_width, font_weight=FontWeight.DISPLAY, wrap_text=False)
self._branch_text_label = UnifiedLabel("", 32, max_width=max_width, text_color=subheader_color,
font_weight=FontWeight.ROMAN, wrap_text=False, scroll=True)
def _update_state(self):
desc = _split_description(ui_state.params.get("UpdaterCurrentDescription") or "")
if desc is not None:
version, branch, commit, date = desc
self._version_text_label.set_text(f"{version} ({date})")
self._branch_text_label.set_text(f"{branch} ({commit})")
else:
self._version_text_label.set_text(ui_state.params.get("Version") or "N/A")
self._branch_text_label.set_text(ui_state.params.get("GitBranch") or "N/A")
def _render(self, _):
self._version_label.set_position(self._rect.x + 20, self._rect.y - 10)
self._version_label.render()
self._version_text_label.set_position(self._rect.x + 20, self._rect.y + 68 - 25)
self._version_text_label.render()
self._branch_label.set_position(self._rect.x + 20, self._rect.y + 114 - 30)
self._branch_label.render()
self._branch_text_label.set_position(self._rect.x + 20, self._rect.y + 161 - 25)
self._branch_text_label.render()
class CheckUpdateButton(BigButton):
def __init__(self):
self._txt_update_icon = gui_app.texture("icons_mici/settings/device/update.png", 64, 75)
self._txt_up_to_date_icon = gui_app.texture("icons_mici/settings/device/up_to_date.png", 64, 64)
super().__init__(tr("check for update"), "", self._txt_update_icon)
self.set_press_effect_enabled(False)
self._waiting_for_updater_t: float | None = None
self._hide_value_t: float | None = None
self._state: UpdaterState = UpdaterState.IDLE
ui_state.add_offroad_transition_callback(self.offroad_transition)
def offroad_transition(self):
if ui_state.is_offroad():
self.set_enabled(True)
def _handle_mouse_release(self, mouse_pos: MousePos):
super()._handle_mouse_release(mouse_pos)
if not system_time_valid():
dlg = BigDialog("", tr("Please connect to Wi-Fi to update."))
gui_app.push_widget(dlg)
return
self.set_enabled(False)
self._state = UpdaterState.WAITING_FOR_UPDATER
self.set_icon(self._txt_update_icon)
def run():
if self.get_value() == "download update":
os.system("pkill -SIGHUP -f system.updated.updated")
else:
os.system("pkill -SIGUSR1 -f system.updated.updated")
threading.Thread(target=run, daemon=True).start()
def set_value(self, value: str):
super().set_value(value)
if value:
self.set_text("")
else:
self.set_text(tr("check for update"))
def _update_state(self):
super()._update_state()
if ui_state.started:
self.set_enabled(False)
return
updater_state = ui_state.params.get("UpdaterState") or ""
failed_count = ui_state.params.get("UpdateFailedCount") or 0
failed = int(failed_count) > 0
update_available = ui_state.params.get_bool("UpdateAvailable")
fetch_available = ui_state.params.get_bool("UpdaterFetchAvailable")
cur_desc = ui_state.params.get("UpdaterCurrentDescription") or ""
new_desc = ui_state.params.get("UpdaterNewDescription") or ""
# Ignore a stale failure marker once the updater is idle and already agrees there is
# nothing new to install. Otherwise mici can get stuck on "failed to update" forever
# even after a later successful fetch.
stale_failure = failed and updater_state == "idle" and not fetch_available and not update_available and cur_desc == new_desc
if self._state == UpdaterState.WAITING_FOR_UPDATER:
self.set_rotate_icon(True)
if updater_state != "idle":
self._state = UpdaterState.UPDATER_RESPONDING
# Recover from updater not responding (time invalid shortly after boot)
if self._waiting_for_updater_t is None:
self._waiting_for_updater_t = rl.get_time()
if self._waiting_for_updater_t is not None and rl.get_time() - self._waiting_for_updater_t > UPDATER_TIMEOUT:
self.set_rotate_icon(False)
self.set_value(tr("updater failed\nto respond"))
self._state = UpdaterState.IDLE
self._hide_value_t = rl.get_time()
elif self._state == UpdaterState.UPDATER_RESPONDING:
if updater_state == "idle":
self.set_rotate_icon(False)
self._state = UpdaterState.IDLE
self._hide_value_t = rl.get_time()
else:
if self.get_value() != tr(updater_state):
self.set_value(tr(updater_state))
elif self._state == UpdaterState.IDLE:
self.set_rotate_icon(False)
if failed and not stale_failure:
self.set_enabled(True) # allow retry when failure came from updater param
if self.get_value() != tr("failed to update"):
self.set_value(tr("failed to update"))
elif fetch_available:
self.set_enabled(True)
if self.get_value() != tr("download update"):
self.set_value(tr("download update"))
elif self._hide_value_t is not None:
self.set_enabled(True)
if self.get_value() == tr("checking..."):
self.set_value(tr("up to date"))
self.set_icon(self._txt_up_to_date_icon)
# Hide previous text after short amount of time (up to date or failed)
if rl.get_time() - self._hide_value_t > 3.0:
self._hide_value_t = None
self.set_value("")
self.set_icon(self._txt_update_icon)
else:
self.set_enabled(True)
if self.get_value() != "":
self.set_value("")
if self._state != UpdaterState.WAITING_FOR_UPDATER:
self._waiting_for_updater_t = None
class InstallUpdateButton(BigButton):
def __init__(self):
super().__init__(tr("install update"), "", gui_app.texture("icons_mici/settings/device/reboot.png", 64, 70))
self.set_press_effect_enabled(False)
self.set_visible(lambda: ui_state.is_offroad() and ui_state.params.get_bool("UpdateAvailable"))
def _update_state(self):
super()._update_state()
desc = _split_description(ui_state.params.get("UpdaterNewDescription") or "")
value = f"{desc[0]} ({desc[1]})" if desc is not None else ""
if self.get_value() != value:
self.set_value(value)
def _handle_mouse_release(self, mouse_pos: MousePos):
super()._handle_mouse_release(mouse_pos)
self.set_enabled(False)
def run():
ui_state.params.put_bool("DoReboot", True)
threading.Thread(target=run, daemon=True).start()
class InstallModePage(NavScroller):
MODES = [
("download_only", "predownload only"),
("download_and_install", "predownload + preinstall"),
]
def __init__(self, on_select: Callable[[str], None]):
super().__init__()
current_mode = ui_state.params.get("UpdaterInstallMode") or "download_and_install"
check_icon = gui_app.texture("icons_mici/settings/device/up_to_date.png", 64, 64)
buttons = []
for mode, label in self.MODES:
btn = BigButton(tr(label), "", check_icon if mode == current_mode else None, scroll=True)
btn.set_click_callback(lambda m=mode: self.dismiss(lambda: on_select(m)))
buttons.append(btn)
self._scroller.add_widgets(buttons)
class InstallModeButton(BigButton):
MODE_LABELS = {
"download_only": "predownload only",
"download_and_install": "predownload + preinstall",
}
def __init__(self):
super().__init__(tr("update\ninstall mode"), "")
self.set_press_effect_enabled(False)
self._label.set_font_size(40)
self._label.set_line_height(0.95)
self.set_click_callback(self._on_click)
self.set_enabled(lambda: ui_state.is_offroad())
def _current_mode(self) -> str:
mode = ui_state.params.get("UpdaterInstallMode") or "download_and_install"
if mode not in self.MODE_LABELS:
return "download_and_install"
return mode
def _update_state(self):
super()._update_state()
self.set_value(tr(self.MODE_LABELS[self._current_mode()]))
def _on_click(self):
gui_app.push_widget(InstallModePage(self._on_select))
def _on_select(self, mode: str):
ui_state.params.put("UpdaterInstallMode", mode)
self.set_value(tr(self.MODE_LABELS[self._current_mode()]))
class BranchSelectPage(NavScroller):
def __init__(self, on_select: Callable[[str], None]):
super().__init__()
params = ui_state.params
current_git_branch = params.get("GitBranch") or ""
branches_str = params.get("UpdaterAvailableBranches") or ""
branches = [b for b in branches_str.split(",") if b]
for b in [current_git_branch, "devel-staging", "devel", "nightly", "nightly-dev", "master"]:
if b in branches:
branches.remove(b)
branches.insert(0, b)
current_target = params.get("UpdaterTargetBranch") or ""
check_icon = gui_app.texture("icons_mici/settings/device/up_to_date.png", 64, 64)
buttons = []
for branch in branches:
btn = BigButton(branch, "", check_icon if branch == current_target else None, scroll=True)
btn.set_click_callback(lambda b=branch: self.dismiss(lambda: on_select(b)))
buttons.append(btn)
self._scroller.add_widgets(buttons)
class TargetBranchButton(BigButton):
def __init__(self):
super().__init__(tr("target branch"), ui_state.params.get("UpdaterTargetBranch") or "")
self.set_press_effect_enabled(False)
self.set_click_callback(self._on_click)
self.set_visible(not ui_state.params.get_bool("IsTestedBranch"))
self.set_enabled(lambda: ui_state.is_offroad())
def _update_state(self):
super()._update_state()
target = ui_state.params.get("UpdaterTargetBranch") or ""
if self.get_value() != target:
self.set_value(target)
def _on_click(self):
gui_app.push_widget(BranchSelectPage(self._on_select))
def _on_select(self, branch: str):
ui_state.params.put("UpdaterTargetBranch", branch)
self.set_value(branch)
os.system("pkill -SIGUSR1 -f system.updated.updated")
class DisableUpdatesButton(BigButton):
def __init__(self):
super().__init__(tr("disable\nupdates"), tr("currently on"))
self.set_enabled(lambda: ui_state.is_offroad())
self.set_press_effect_enabled(False)
self._label.set_font_size(40)
self._label.set_line_height(0.95)
self.set_click_callback(self._on_pressed)
def _on_pressed(self):
disabled = ui_state.params.get_bool("DisableUpdates")
ui_state.params.put_bool("DisableUpdates", not disabled)
self._sync_from_params()
def _sync_from_params(self):
disabled = ui_state.params.get_bool("DisableUpdates")
self.set_text(tr("enable\nupdates") if disabled else tr("disable\nupdates"))
self.set_value(tr("currently off") if disabled else tr("currently on"))
def _update_state(self):
super()._update_state()
self._sync_from_params()
class SoftwareLayoutMici(NavScroller):
def __init__(self):
super().__init__()
def uninstall_openpilot_callback():
ui_state.params.put_bool("DoUninstall", True)
uninstall_openpilot_btn = EngagedConfirmationButton(tr("uninstall IQ.Pilot"), tr("uninstall"),
gui_app.texture("icons_mici/settings/device/uninstall.png", 64, 64),
uninstall_openpilot_callback, exit_on_confirm=False)
uninstall_openpilot_btn.set_press_effect_enabled(False)
self._scroller.add_widgets([
SoftwareInfoLayoutMici(),
CheckUpdateButton(),
InstallUpdateButton(),
InstallModeButton(),
DisableUpdatesButton(),
TargetBranchButton(),
uninstall_openpilot_btn,
])

View File

@@ -0,0 +1,151 @@
"""
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
"""
from iqpilot.cereal import car
from iqpilot.common.params import Params
from iqpilot.selfdrive.controls.lib.helpers.lane_change import AutoLaneChangeMode
from iqpilot.selfdrive.ui.mici.widgets.stock_button import BigButton, BigParamControl
from iqpilot.selfdrive.ui.mici.layouts.settings.iq_widgets import MappedParamToggle
from iqpilot.selfdrive.ui.ui_state import ui_state
from iqpilot.system.ui.lib.application import gui_app
from iqpilot.system.ui.widgets.scroller import NavScroller
from iqpilot.system.ui.lib.multilang import tr
def _aol_modes() -> list[str]:
return [tr("stay engaged"), tr("standby"), tr("disengage")]
class SabBrakeToggle(BigParamControl):
"""Driver-intervention toggle backed by AolSteeringMode == 2."""
def __init__(self):
super().__init__(tr("Driver Intervention Handling"), "AolEnabled")
def refresh(self):
self.set_checked(int(self.params.get("AolSteeringMode", return_default=True)) == 2)
def _handle_mouse_release(self, mouse_pos):
super(BigParamControl, self)._handle_mouse_release(mouse_pos)
enabled = self._checked
current_mode = int(self.params.get("AolSteeringMode", return_default=True))
if enabled:
self.params.put("AolSteeringMode", 2)
elif current_mode == 2:
self.params.put("AolSteeringMode", 1)
def _has_limited_sab_options() -> bool:
brand = ""
if ui_state.is_offroad():
bundle = ui_state.params.get("CarPlatformBundle")
if bundle:
brand = bundle.get("brand", "")
if not brand:
brand = ui_state.CP.brand if ui_state.CP else ""
return brand == "rivian"
class SabSettingsPanel(NavScroller):
def __init__(self):
super().__init__()
self._main_cruise = BigParamControl(tr("Availability While Cruise Changes"), "AolMainCruiseAllowed")
self._brake = SabBrakeToggle()
self._mode = MappedParamToggle(tr("Brake Response Mode"), "AolSteeringMode",
_aol_modes(), [0, 1, 2])
self._steer_override = BigParamControl(tr("Pause While You Steer"), "AolPauseOnSteeringOverride")
self._scroller.add_widgets([self._main_cruise, self._brake, self._mode, self._steer_override])
def show_event(self):
super().show_event()
limited = _has_limited_sab_options()
if limited:
ui_state.params.remove("AolMainCruiseAllowed")
ui_state.params.put_bool("AolUnifiedEngagementMode", True)
ui_state.params.put("AolSteeringMode", 2)
offroad = ui_state.is_offroad()
for w in (self._main_cruise, self._brake, self._mode):
w.refresh()
w.set_enabled(offroad and not limited)
self._steer_override.refresh()
self._steer_override.set_enabled(offroad)
class LaneChangePanel(NavScroller):
def __init__(self):
super().__init__()
self._timer = MappedParamToggle(tr("Auto Lane Change"), "IQLaneChangeTimer",
[tr("off"), tr("nudge"), tr("no nudge"), "0.5 s", "1 s", "2 s", "3 s"],
[-1, 0, 1, 2, 3, 4, 5])
self._bsm_delay = BigParamControl(tr("Delay with Blind Spot"), "IQLaneChangeBsmDelay")
self._continuous = BigParamControl(tr("Continuous Changes"), "LaneChangeContinuous")
self._scroller.add_widgets([self._timer, self._bsm_delay, self._continuous])
def show_event(self):
super().show_event()
self._timer.refresh()
enable_bsm = bool(ui_state.CP and ui_state.CP.enableBsm)
if not enable_bsm and ui_state.params.get_bool("IQLaneChangeBsmDelay"):
ui_state.params.remove("IQLaneChangeBsmDelay")
self._bsm_delay.refresh()
self._bsm_delay.set_enabled(
enable_bsm and int(ui_state.params.get("IQLaneChangeTimer", return_default=True)) > AutoLaneChangeMode.NUDGE
)
self._continuous.refresh()
class SteeringLayoutMici(NavScroller):
def __init__(self):
super().__init__()
self._sab_panel = SabSettingsPanel()
self._lc_panel = LaneChangePanel()
self._aol = BigParamControl(tr("AOL"), "AolEnabled", toggle_callback=self._on_aol_toggled)
self._sab_settings_button = BigButton(tr("steering assistance behavior"))
self._sab_settings_button.set_click_callback(lambda: gui_app.push_widget(self._sab_panel))
self._lane_change = BigButton(tr("lane change"))
self._lane_change.set_click_callback(lambda: gui_app.push_widget(self._lc_panel))
self._nnff = BigParamControl(tr("Neural Net FF"), "NeuralNetworkFeedForward", toggle_callback=self._on_nnff_toggled)
self._scroller.add_widgets([
self._aol, self._sab_settings_button, self._lane_change,
self._nnff,
])
def _aol_mode_str(self) -> str:
try:
return _aol_modes()[int(ui_state.params.get("AolSteeringMode", return_default=True))]
except (TypeError, ValueError, IndexError):
return _aol_modes()[0]
def _on_aol_toggled(self, checked: bool):
if checked:
ui_state.params.put_bool("AolUnifiedEngagementMode", True)
def _on_nnff_toggled(self, checked: bool):
return None
def _refresh(self):
offroad = ui_state.is_offroad()
self._aol.refresh()
self._aol.set_value(self._aol_mode_str())
self._nnff.refresh()
steering_supported = (ui_state.CP is not None and
ui_state.CP.steerControlType != car.CarParams.SteerControlType.angle)
if not steering_supported:
ui_state.params.remove("NeuralNetworkFeedForward")
self._nnff.refresh()
self._aol.set_enabled(offroad)
self._sab_settings_button.set_enabled(offroad and self._aol._checked)
self._nnff.set_enabled(offroad and steering_supported)
def _update_state(self):
super()._update_state()
self._refresh()
def show_event(self):
super().show_event()
self._refresh()

View File

@@ -0,0 +1,48 @@
"""
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
"""
from iqpilot.system.ui.widgets.scroller import NavScroller
from iqpilot.selfdrive.ui.mici.widgets.stock_button import BigParamControl
from iqpilot.system.ui.lib.application import gui_app
from iqpilot.selfdrive.ui.ui_state import ui_state
from iqpilot.system.ui.lib.multilang import tr
class TogglesLayoutMici(NavScroller):
"""Equivalent to the BIG UI toggles page, minus cruise items (personality / speed limit /
longitudinal control live in Cruise) and dashcam items (dashcam / driver-cam / mic live in Dashcam)."""
def __init__(self):
super().__init__()
ui_state.params.put_bool("OpenpilotEnabledToggle", True)
disengage = BigParamControl(tr("disengage on accelerator"), "DisengageOnAccelerator")
ldw = BigParamControl(tr("lane departure warnings"), "IsLdwEnabled")
is_metric = BigParamControl(tr("use metric units"), "IsMetric")
auto_units = BigParamControl(tr("set units from location"), "IQAutoUnits", toggle_callback=self._auto_units_callback)
self._scroller.add_widgets([disengage, ldw, is_metric, auto_units])
self._refresh_toggles = (
("DisengageOnAccelerator", disengage),
("IsLdwEnabled", ldw),
("IsMetric", is_metric),
("IQAutoUnits", auto_units),
)
if ui_state.params.get_bool("ShowDebugInfo"):
gui_app.set_show_touches(True)
gui_app.set_show_fps(True)
def show_event(self):
super().show_event()
self._update_toggles()
def _auto_units_callback(self, state: bool):
if state:
ui_state.params.remove("IQAutoUnitsRegion")
def _update_toggles(self):
ui_state.update_params()
for key, item in self._refresh_toggles:
item.set_checked(ui_state.params.get_bool(key))

View File

@@ -0,0 +1,217 @@
"""
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
"""
import unicodedata
import pyray as rl
from iqpilot.selfdrive.car.vehicle_catalog import load_catalog
from iqpilot.selfdrive.ui.mici.widgets.stock_button import BigButton, BigParamControl
from iqpilot.selfdrive.ui.mici.layouts.settings.iq_widgets import MappedParamToggle
from iqpilot.selfdrive.ui.ui_state import ui_state
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
from iqpilot.system.ui.widgets.scroller import NavScroller
from iqpilot.system.ui.lib.multilang import tr
def _ascii_safe(text: str) -> str:
return unicodedata.normalize("NFD", text).encode("ascii", "ignore").decode("ascii")
def _load_platforms() -> dict:
try:
return load_catalog()
except Exception:
return {}
class _PickerRow(Widget):
HEIGHT = 92
def __init__(self, label: str, on_tap):
super().__init__()
self._label = label
self._on_tap = on_tap
self.set_rect(rl.Rectangle(0, 0, gui_app.width, self.HEIGHT))
self._font = gui_app.font(FontWeight.MEDIUM)
def _handle_mouse_release(self, mouse_pos: MousePos):
super()._handle_mouse_release(mouse_pos)
self._on_tap(self._label)
def _render(self, _):
color = rl.Color(255, 255, 255, 255) if self.is_pressed else rl.Color(255, 255, 255, 200)
ts = measure_text_cached(self._font, self._label, 46)
rl.draw_text_ex(self._font, self._label, rl.Vector2(self._rect.x + 44, self._rect.y + (self.HEIGHT - ts.y) / 2), 46, 0, color)
class _VerticalPicker(NavScroller):
def __init__(self, options: list[str], on_pick):
super().__init__(horizontal=False, snap_items=False, pad_start=20, pad_end=20)
self._on_pick = on_pick
rows = [_PickerRow(o, self._pick) for o in options]
for row in rows:
row.set_touch_valid_callback(lambda: self._scroller.scroll_panel.is_touch_valid())
self._scroller.add_widgets(rows)
def _pick(self, option: str):
gui_app.pop_widget()
self._on_pick(option)
class VehicleLayoutMici(NavScroller):
def __init__(self):
super().__init__()
self._platforms = _load_platforms()
self._vehicle_btn = BigButton(tr("vehicle"))
self._vehicle_btn.set_click_callback(self._on_vehicle_clicked)
self._toyota_long = BigParamControl(tr("enforce factory long."), "IQToyotaFactoryLong",
toggle_callback=self._on_toyota_long)
self._hyundai_tuning = MappedParamToggle(tr("hyundai long. tuning"), "IQHyundaiLongTune",
[tr("off"), tr("dynamic"), tr("predictive")], [0, 1, 2])
self._subaru_snag = BigParamControl(tr("creep from standstill (beta)"), "IQSubaruCreepAssist")
self._subaru_manual = BigParamControl(tr("stop and go manual brake"), "IQSubaruCreepAssistManualBrake")
self._vw_pq_hca = BigParamControl(tr("PQ HCA status 7 mode"), "pqhca5or7Toggle")
self._vw_lateral = BigParamControl(tr("lateral when cruise faulted"), "AllowLateralWhenLongUnavailable")
self._vw_mqb_acc_resume = BigParamControl(tr("MQB ACC resume"), "iqMqbAccResume")
self._vw_mqb_steering_lockout = BigParamControl(tr("MQB steering lockout"), "iqMqbSteeringLockout")
self._tesla_vtb = BigParamControl(tr("virtual torque blending"), "IQTeslaTorqueBlend")
self._tesla_fsd_visualization = BigParamControl(tr("FSD visuals"), "IQTeslaFsdVisualization")
self._brand_widgets = {
"toyota": [self._toyota_long],
"hyundai": [self._hyundai_tuning],
"subaru": [self._subaru_snag, self._subaru_manual],
"volkswagen": [self._vw_pq_hca, self._vw_lateral, self._vw_mqb_acc_resume, self._vw_mqb_steering_lockout],
"tesla": [self._tesla_vtb, self._tesla_fsd_visualization],
}
self._all_brand_widgets = [w for ws in self._brand_widgets.values() for w in ws]
self._scroller.add_widgets([self._vehicle_btn] + self._all_brand_widgets)
def _get_current_brand(self) -> str:
bundle = ui_state.params.get("CarPlatformBundle")
if bundle:
return bundle.get("brand", "")
if ui_state.CP:
return getattr(ui_state.CP, "brand", "")
return ""
def _vw_flags(self):
try:
from iqdbc.car.volkswagen.values import CAR
bundle = ui_state.params.get("CarPlatformBundle")
if bundle and (platform := bundle.get("platform")):
return CAR[platform].config.flags
if ui_state.CP:
return ui_state.CP.flags
except Exception:
pass
return 0
def _uses_vw_hca_status_toggle(self) -> bool:
from iqdbc.car.volkswagen.values import VolkswagenFlags
return bool(self._vw_flags() & (VolkswagenFlags.PQ | VolkswagenFlags.MLB))
def _is_vw_mqb(self) -> bool:
from iqdbc.car.volkswagen.values import VolkswagenFlags
flags = self._vw_flags()
return not bool(flags & (VolkswagenFlags.PQ | VolkswagenFlags.MLB | VolkswagenFlags.MEB | VolkswagenFlags.MEB_GEN2 | VolkswagenFlags.MQB_EVO))
def _supports_vw_lateral_when_faulted(self) -> bool:
from iqdbc.car.volkswagen.values import VolkswagenFlags
# PQ, MEB, MQB_EVO and base MQB all implement cruiseFaultLateralMode in carstate.py.
# MLB does not.
return not bool(self._vw_flags() & VolkswagenFlags.MLB)
def _pretty_name(self, platform: str) -> str:
for name, v in self._platforms.items():
if v.get("platform") == platform:
make = v.get("make", "")
if make and name.lower().startswith(make.lower() + " "):
return name[len(make) + 1:]
return name
return _ascii_safe(platform).replace("_", " ").title()
def _vehicle_status(self) -> str:
bundle = ui_state.params.get("CarPlatformBundle")
if bundle:
name = _ascii_safe(bundle.get("name", "?"))
make = bundle.get("make", "")
if make and name.lower().startswith(make.lower() + " "):
name = name[len(make) + 1:]
return name[:1].upper() + name[1:]
if ui_state.CP and ui_state.CP.carFingerprint not in ("", "MOCK"):
return self._pretty_name(ui_state.CP.carFingerprint)
return "tap to select"
def _on_vehicle_clicked(self):
if ui_state.params.get("CarPlatformBundle"):
ui_state.params.remove("CarPlatformBundle")
self._refresh()
else:
self._open_make_picker()
def _open_make_picker(self):
makes = sorted({v.get("make", "") for v in self._platforms.values() if v.get("make")})
label_to_make = {_ascii_safe(m): m for m in makes}
gui_app.push_widget(_VerticalPicker(list(label_to_make.keys()),
lambda lbl: self._open_model_picker(label_to_make.get(lbl, ""))))
def _open_model_picker(self, make: str):
if not make:
return
prefix = make + " "
label_to_key: dict = {}
for key in sorted(p for p, v in self._platforms.items() if v.get("make") == make):
label = key[len(prefix):] if key.lower().startswith(prefix.lower()) else key
label_to_key[_ascii_safe(label)] = key
gui_app.push_widget(_VerticalPicker(list(label_to_key.keys()),
lambda lbl: self._select_platform(label_to_key.get(lbl, ""))))
def _select_platform(self, key: str):
if key and (data := self._platforms.get(key)):
ui_state.params.put("CarPlatformBundle", {**data, "name": key})
gui_app.pop_widgets_to(self)
self._refresh()
def _on_toyota_long(self, checked: bool):
if checked and ui_state.params.get_bool("AlphaLongitudinalEnabled"):
ui_state.params.put_bool("AlphaLongitudinalEnabled", False)
ui_state.params.put_bool("OnroadCycleRequested", True)
def _refresh(self):
self._vehicle_btn.set_value(self._vehicle_status())
brand = self._get_current_brand()
offroad = ui_state.is_offroad()
uses_hca_status_toggle = self._uses_vw_hca_status_toggle()
is_mqb = self._is_vw_mqb()
supports_lateral_when_faulted = self._supports_vw_lateral_when_faulted()
visible = set(self._brand_widgets.get(brand, []))
for w in self._all_brand_widgets:
show = w in visible
if w is self._vw_pq_hca:
show = show and uses_hca_status_toggle
elif w in (self._vw_mqb_acc_resume, self._vw_mqb_steering_lockout):
show = show and is_mqb
elif w is self._vw_lateral:
show = show and supports_lateral_when_faulted
w.set_visible(show)
if show:
w.refresh()
for w in (self._toyota_long, self._subaru_snag, self._subaru_manual, self._tesla_vtb, self._tesla_fsd_visualization):
w.set_enabled(offroad)
def _update_state(self):
super()._update_state()
self._vehicle_btn.set_value(self._vehicle_status())
def show_event(self):
super().show_event()
self._refresh()

View File

@@ -0,0 +1,26 @@
"""
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
"""
from iqpilot.selfdrive.ui.mici.widgets.stock_button import BigParamControl
from iqpilot.system.ui.widgets.scroller import NavScroller
from iqpilot.system.ui.lib.multilang import tr
class VisualsLayoutMici(NavScroller):
def __init__(self):
super().__init__()
self._blind_spot = BigParamControl(tr("Blind Spot Warnings"), "IQBlindSpotAlerts")
self._steering_arc = BigParamControl(tr("Steering Effort Arc"), "IQSteerEffortArc")
self._road_name = BigParamControl(tr("Road Name"), "IQRoadNameOverlay")
self._turn_signals = BigParamControl(tr("Turn Signals"), "IQBlinkerIndicators")
self._accel_bar = BigParamControl(tr("Acceleration Bar"), "IQAccelMeter")
self._toggles = [self._blind_spot, self._steering_arc, self._road_name,
self._turn_signals, self._accel_bar]
self._scroller.add_widgets(self._toggles)
def show_event(self):
super().show_event()
for w in self._toggles:
w.refresh()

View File

@@ -0,0 +1,12 @@
import pyray as rl
SIDE_PANEL_WIDTH = 60
def blend_colors(a: rl.Color, b: rl.Color, f: float) -> rl.Color:
h0, s0, v0 = (hsv0 := rl.color_to_hsv(a)).x, hsv0.y, hsv0.z
h1, s1, v1 = (hsv1 := rl.color_to_hsv(b)).x, hsv1.y, hsv1.z
dh = ((h1 - h0 + 180) % 360) - 180 # shortest hue delta
return rl.color_from_hsv((h0 + f * dh) % 360,
s0 + f * (s1 - s0),
v0 + f * (v1 - v0))

View File

@@ -0,0 +1,369 @@
import time
from enum import StrEnum
from typing import NamedTuple
import pyray as rl
import random
import string
from dataclasses import dataclass
from iqpilot.cereal import messaging, log, car
from iqpilot.selfdrive.ui.ui_state import ui_state
from iqpilot.common.filter_simple import BounceFilter, FirstOrderFilter
from iqpilot.system.hardware import TICI
from iqpilot.system.ui.lib.application import gui_app, FontWeight
from iqpilot.system.ui.widgets import Widget
from iqpilot.system.ui.widgets.label import UnifiedLabel
AlertSize = log.SelfdriveState.AlertSize
AlertStatus = log.SelfdriveState.AlertStatus
ALERT_MARGIN = 18
ALERT_FONT_SMALL = 66 - 50
ALERT_FONT_BIG = 88 - 40
SELFDRIVE_STATE_TIMEOUT = 5 # Seconds
SELFDRIVE_UNRESPONSIVE_TIMEOUT = 10 # Seconds
# Constants
ALERT_COLORS = {
AlertStatus.normal: rl.Color(0, 0, 0, 255),
AlertStatus.userPrompt: rl.Color(255, 115, 0, 255),
AlertStatus.critical: rl.Color(255, 0, 21, 255),
}
TURN_SIGNAL_BLINK_PERIOD = 1 / (80 / 60) # Mazda heartbeat turn signal BPM
DEBUG = False
class IconSide(StrEnum):
left = 'left'
right = 'right'
class IconLayout(NamedTuple):
texture: rl.Texture
side: IconSide
margin_x: int
margin_y: int
class AlertLayout(NamedTuple):
text_rect: rl.Rectangle
icon: IconLayout | None
@dataclass
class Alert:
text1: str = ""
text2: str = ""
size: int = 0
status: int = 0
visual_alert: int = car.CarControl.HUDControl.VisualAlert.none
alert_type: str = ""
# Pre-defined alert instances
ALERT_STARTUP_PENDING = Alert(
text1="IQ.Pilot Unavailable",
text2="Waiting to start",
size=AlertSize.mid,
status=AlertStatus.normal,
)
ALERT_CRITICAL_TIMEOUT = Alert(
text1="TAKE CONTROL IMMEDIATELY",
text2="System Unresponsive",
size=AlertSize.full,
status=AlertStatus.critical,
)
ALERT_CRITICAL_REBOOT = Alert(
text1="System Unresponsive",
text2="Reboot Device",
size=AlertSize.full,
status=AlertStatus.critical,
)
class AlertRenderer(Widget):
def __init__(self):
super().__init__()
self._alert_text1_label = UnifiedLabel(text="", font_size=ALERT_FONT_BIG, font_weight=FontWeight.DISPLAY, line_height=0.86,
letter_spacing=-0.02)
self._alert_text2_label = UnifiedLabel(text="", font_size=ALERT_FONT_SMALL, font_weight=FontWeight.ROMAN, line_height=0.86,
letter_spacing=0.025)
self._prev_alert: Alert | None = None
self._text_gen_time = 0
self._alert_text2_gen = ''
# animation filters
# TODO: use 0.1 but with proper alert height calculation
self._alert_y_filter = BounceFilter(0, 0.1, 1 / gui_app.target_fps)
self._alpha_filter = FirstOrderFilter(0, 0.05, 1 / gui_app.target_fps)
self._turn_signal_timer = 0.0
self._turn_signal_alpha_filter = FirstOrderFilter(0.0, 0.3, 1 / gui_app.target_fps)
self._last_icon_side: IconSide | None = None
self._load_icons()
def _load_icons(self):
self._txt_turn_signal_left = gui_app.texture('icons_mici/onroad/turn_signal_left.png', 104, 96)
self._txt_turn_signal_right = gui_app.texture('icons_mici/onroad/turn_signal_right.png', 104, 96)
self._txt_blind_spot_left = gui_app.texture('icons_mici/onroad/blind_spot_left.png', 134, 150)
self._txt_blind_spot_right = gui_app.texture('icons_mici/onroad/blind_spot_right.png', 134, 150)
def get_alert(self, sm: messaging.SubMaster) -> Alert | None:
"""Generate the current alert based on selfdrive state."""
ss = sm['selfdriveState']
# Check if selfdriveState messages have stopped arriving
if not sm.updated['selfdriveState']:
recv_frame = sm.recv_frame['selfdriveState']
time_since_onroad = time.monotonic() - ui_state.started_time
# 1. Never received selfdriveState since going onroad
waiting_for_startup = recv_frame < ui_state.started_frame
if waiting_for_startup and time_since_onroad > 5:
return ALERT_STARTUP_PENDING
# 2. Lost communication with selfdriveState after receiving it
if TICI and not waiting_for_startup:
ss_missing = time.monotonic() - sm.recv_time['selfdriveState']
if ss_missing > SELFDRIVE_STATE_TIMEOUT:
if ss.enabled and (ss_missing - SELFDRIVE_STATE_TIMEOUT) < SELFDRIVE_UNRESPONSIVE_TIMEOUT:
return ALERT_CRITICAL_TIMEOUT
return ALERT_CRITICAL_REBOOT
# No alert if size is none
if ss.alertSize == 0:
return None
event_name = ss.alertType.split('/')[0] if ss.alertType else ''
if event_name in {'selfdrivedLagging', 'commIssue', 'commIssueAvgFreq'}:
return None
# Return current alert
ret = Alert(text1=ss.alertText1, text2=ss.alertText2, size=ss.alertSize.raw, status=ss.alertStatus.raw,
visual_alert=ss.alertHudVisual, alert_type=ss.alertType)
self._prev_alert = ret
return ret
def will_render(self) -> tuple[Alert | None, bool]:
alert = self.get_alert(ui_state.sm)
return alert or self._prev_alert, alert is None
def _icon_helper(self, alert: Alert) -> AlertLayout:
icon_side = None
txt_icon = None
icon_margin_x = 20
icon_margin_y = 18
# alert_type format is "EventName/eventType" (e.g., "preLaneChangeLeft/warning")
event_name = alert.alert_type.split('/')[0] if alert.alert_type else ''
if event_name == 'preLaneChangeLeft':
icon_side = IconSide.left
txt_icon = self._txt_turn_signal_left
icon_margin_x = 2
icon_margin_y = 5
elif event_name == 'preLaneChangeRight':
icon_side = IconSide.right
txt_icon = self._txt_turn_signal_right
icon_margin_x = 2
icon_margin_y = 5
elif event_name == 'laneChange':
icon_side = self._last_icon_side
txt_icon = self._txt_turn_signal_left if self._last_icon_side == 'left' else self._txt_turn_signal_right
icon_margin_x = 2
icon_margin_y = 5
elif event_name == 'laneChangeBlocked':
CS = ui_state.sm['carState']
if CS.leftBlinker:
icon_side = IconSide.left
elif CS.rightBlinker:
icon_side = IconSide.right
else:
icon_side = self._last_icon_side
txt_icon = self._txt_blind_spot_left if icon_side == 'left' else self._txt_blind_spot_right
icon_margin_x = 8
icon_margin_y = 0
else:
self._turn_signal_timer = 0.0
self._last_icon_side = icon_side
# create text rect based on icon presence
text_x = self._rect.x + ALERT_MARGIN
text_width = self._rect.width - ALERT_MARGIN
if icon_side == 'left':
text_x = self._rect.x + self._txt_turn_signal_right.width
text_width = self._rect.width - ALERT_MARGIN - self._txt_turn_signal_right.width
elif icon_side == 'right':
text_x = self._rect.x + ALERT_MARGIN
text_width = self._rect.width - ALERT_MARGIN - self._txt_turn_signal_right.width
text_rect = rl.Rectangle(
text_x,
self._alert_y_filter.x,
text_width,
self._rect.height,
)
icon_layout = IconLayout(txt_icon, icon_side, icon_margin_x, icon_margin_y) if txt_icon is not None and icon_side is not None else None
return AlertLayout(text_rect, icon_layout)
def _render(self, rect: rl.Rectangle) -> bool:
alert = self.get_alert(ui_state.sm)
# Animate fade and slide in/out
self._alert_y_filter.update(self._rect.y - 50 if alert is None else self._rect.y)
self._alpha_filter.update(0 if alert is None else 1)
if gui_app.iqpilot_ui():
ui_state.onroad_brightness_handle_alerts(ui_state.started, alert)
if alert is None:
# If still animating out, keep the previous alert
if self._alpha_filter.x > 0.01 and self._prev_alert is not None:
alert = self._prev_alert
else:
self._prev_alert = None
return False
self._draw_background(alert)
alert_layout = self._icon_helper(alert)
self._draw_text(alert, alert_layout)
self._draw_icons(alert_layout)
return True
def _draw_icons(self, alert_layout: AlertLayout) -> None:
if alert_layout.icon is None:
return
# re-derive dt every frame: this filter is constructed at offroad startup (60fps) but onroad
# runs at a lower target_fps, and a dt frozen from construction decays far too slowly against
# the real frame cadence, so the icon never dims and reads as static-on instead of blinking.
self._turn_signal_alpha_filter.dt = 1 / gui_app.target_fps
self._turn_signal_alpha_filter.update_alpha(0.3)
if time.monotonic() - self._turn_signal_timer > TURN_SIGNAL_BLINK_PERIOD:
self._turn_signal_timer = time.monotonic()
self._turn_signal_alpha_filter.x = 255 * 2
else:
self._turn_signal_alpha_filter.update(255 * 0.2)
if alert_layout.icon.side == 'left':
pos_x = int(self._rect.x + alert_layout.icon.margin_x)
else:
pos_x = int(self._rect.x + self._rect.width - alert_layout.icon.margin_x - alert_layout.icon.texture.width)
if alert_layout.icon.texture not in (self._txt_turn_signal_left, self._txt_turn_signal_right):
icon_alpha = 255
else:
icon_alpha = int(min(self._turn_signal_alpha_filter.x, 255))
rl.draw_texture(alert_layout.icon.texture, pos_x, int(self._rect.y + alert_layout.icon.margin_y),
rl.Color(255, 255, 255, int(icon_alpha * self._alpha_filter.x)))
def _draw_background(self, alert: Alert) -> None:
# draw top gradient for alert text at top
color = ALERT_COLORS.get(alert.status, ALERT_COLORS[AlertStatus.normal])
color = rl.Color(color.r, color.g, color.b, int(255 * 0.90 * self._alpha_filter.x))
translucent_color = rl.Color(color.r, color.g, color.b, int(0 * self._alpha_filter.x))
small_alert_height = round(self._rect.height * 0.583) # 140px at mici height
medium_alert_height = round(self._rect.height * 0.833) # 200px at mici height
# alert_type format is "EventName/eventType" (e.g., "preLaneChangeLeft/warning")
event_name = alert.alert_type.split('/')[0] if alert.alert_type else ''
if event_name == 'preLaneChangeLeft':
bg_height = small_alert_height
elif event_name == 'preLaneChangeRight':
bg_height = small_alert_height
elif event_name == 'laneChange':
bg_height = small_alert_height
elif event_name == 'laneChangeBlocked':
bg_height = medium_alert_height
else:
bg_height = int(self._rect.height)
solid_height = round(bg_height * 0.2)
rl.draw_rectangle(int(self._rect.x), int(self._rect.y), int(self._rect.width), solid_height, color)
rl.draw_rectangle_gradient_v(int(self._rect.x), int(self._rect.y + solid_height), int(self._rect.width),
int(bg_height - solid_height),
color, translucent_color)
def _draw_text(self, alert: Alert, alert_layout: AlertLayout) -> None:
icon_side = alert_layout.icon.side if alert_layout.icon is not None else None
# TODO: hack
alert_text1 = alert.text1.lower().replace('calibrating: ', 'calibrating:\n')
can_draw_second_line = False
# TODO: there should be a common way to determine font size based on text length to maximize rect
if len(alert_text1) <= 12:
can_draw_second_line = True
font_size = 92 - 10
elif len(alert_text1) <= 16:
can_draw_second_line = True
font_size = 70
else:
font_size = 64 - 10
if icon_side is not None:
font_size -= 10
color = rl.Color(255, 255, 255, int(255 * 0.9 * self._alpha_filter.x))
text1_y_offset = 11 if font_size >= 70 else 4
text_rect1 = rl.Rectangle(
alert_layout.text_rect.x,
alert_layout.text_rect.y - text1_y_offset,
alert_layout.text_rect.width,
alert_layout.text_rect.height,
)
self._alert_text1_label.set_text(alert_text1)
self._alert_text1_label.set_text_color(color)
self._alert_text1_label.set_font_size(font_size)
self._alert_text1_label.set_alignment(rl.GuiTextAlignment.TEXT_ALIGN_LEFT if icon_side != 'left' else rl.GuiTextAlignment.TEXT_ALIGN_RIGHT)
self._alert_text1_label.render(text_rect1)
alert_text2 = alert.text2.lower()
# randomize chars and length for testing
if DEBUG:
if time.monotonic() - self._text_gen_time > 0.5:
self._alert_text2_gen = ''.join(random.choices(string.ascii_lowercase + ' ', k=random.randint(0, 40)))
self._text_gen_time = time.monotonic()
alert_text2 = self._alert_text2_gen or alert_text2
if can_draw_second_line and alert_text2:
last_line_h = self._alert_text1_label.rect.y + self._alert_text1_label.get_content_height(int(alert_layout.text_rect.width))
last_line_h -= 4
if len(alert_text2) > 18:
small_font_size = 36
elif len(alert_text2) > 24:
small_font_size = 32
else:
small_font_size = 40
text_rect2 = rl.Rectangle(
alert_layout.text_rect.x,
last_line_h,
alert_layout.text_rect.width,
alert_layout.text_rect.height - last_line_h
)
color = rl.Color(255, 255, 255, int(255 * 0.65 * self._alpha_filter.x))
self._alert_text2_label.set_text(alert_text2)
self._alert_text2_label.set_text_color(color)
self._alert_text2_label.set_font_size(small_font_size)
self._alert_text2_label.set_alignment(rl.GuiTextAlignment.TEXT_ALIGN_LEFT if icon_side != 'left' else rl.GuiTextAlignment.TEXT_ALIGN_RIGHT)
self._alert_text2_label.render(text_rect2)

View File

@@ -0,0 +1,493 @@
"""
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
"""
import time
import numpy as np
import pyray as rl
from iqpilot.cereal import messaging, car, log
from iqpilot.common.params import Params
from iqpilot.cereal.visionipc import VisionStreamType
from iqpilot.selfdrive.ui.ui_state import ui_state, UIStatus
from iqpilot.selfdrive.ui.mici.onroad import SIDE_PANEL_WIDTH
from iqpilot.selfdrive.ui.mici.onroad.alert_renderer import AlertRenderer
from iqpilot.selfdrive.ui.mici.onroad.driver_state import DriverStateRenderer
from iqpilot.selfdrive.ui.mici.onroad.hud_renderer import HudRenderer
from iqpilot.selfdrive.ui.mici.onroad.model_renderer import ModelRenderer
from iqpilot.selfdrive.ui.mici.onroad.confidence_ball import ConfidenceBall
from iqpilot.selfdrive.ui.mici.onroad.cameraview import CameraView
from iqpilot.system.ui.lib.application import FontWeight, gui_app, MousePos, MouseEvent
from iqpilot.system.ui.widgets.label import UnifiedLabel
from iqpilot.system.ui.widgets import Widget
from iqpilot.common.issue_debug import log_issue_limited
from iqpilot.common.filter_simple import BounceFilter
from iqpilot.common.transformations.camera import DEVICE_CAMERAS, DeviceCameraConfig, view_frame_from_device_frame
from iqpilot.common.transformations.orientation import rot_from_euler
from iqpilot.selfdrive.locationd.calibration_helpers import get_calibrated_rpy
from enum import IntEnum
from iqpilot.ui.onroad.augmented_road_view import BORDER_COLORS_IQ
from iqpilot.system.ui.lib.multilang import tr
if gui_app.iqpilot_ui():
from iqpilot.ui.mici.onroad.hud_renderer import IQMiciHudRenderer as HudRenderer
from iqpilot.ui.mici.onroad.road_label import RoadNameRendererMici
from iqpilot.selfdrive.ui.ui_state import OnroadTimerStatus
OpState = log.SelfdriveState.OpenpilotState
CALIBRATED = log.ExtrinsicsCalibration.Status.calibrated
ROAD_CAM = VisionStreamType.VISION_STREAM_ROAD
WIDE_CAM = VisionStreamType.VISION_STREAM_WIDE_ROAD
DEFAULT_DEVICE_CAMERA = DEVICE_CAMERAS["tici", "ar0231"]
class BookmarkState(IntEnum):
HIDDEN = 0
DRAGGING = 1
TRIGGERED = 2
WIDE_CAM_MAX_SPEED = 5.0 # m/s (10 mph)
ROAD_CAM_MIN_SPEED = 10 # m/s (25 mph)
CAM_Y_OFFSET = 20
MICI_BORDER_COLOR = rl.Color(0x0C, 0x94, 0x96, 0xFF)
MICI_BORDER_THICKNESS = 50
MICI_BORDER_ROUNDNESS = 0.2 * 1.02
MICI_BORDER_BOTTOM_ONLY_HEIGHT = 95
MICI_EXPERIMENTAL_ICON_SIZE = 28
MICI_EXPERIMENTAL_ICON_SPACING = 8
class BookmarkIcon(Widget):
PEEK_THRESHOLD = 50 # If icon peeks out this much, snap it fully visible
FULL_VISIBLE_OFFSET = 200 # How far onscreen when fully visible
HIDDEN_OFFSET = -50 # How far offscreen when hidden
def __init__(self, bookmark_callback):
super().__init__()
self._bookmark_callback = bookmark_callback
self._icon = gui_app.texture("icons_mici/onroad/bookmark.png", 180, 180)
self._icon_fill = gui_app.texture("icons_mici/onroad/bookmark_fill.png", 180, 180)
self._active_icon = self._icon
self._offset_filter = BounceFilter(0.0, 0.1, 1 / gui_app.target_fps)
# State
self._interacting = False
self._state = BookmarkState.HIDDEN
self._swipe_start_x = 0.0
self._swipe_current_x = 0.0
self._is_swiping = False
self._is_swiping_left: bool = False
self._triggered_time: float = 0.0
def is_swiping_left(self) -> bool:
"""Check if currently swiping left (for scroller to disable)."""
return self._is_swiping_left
def interacting(self):
interacting, self._interacting = self._interacting, False
return interacting
def _update_state(self):
if self._state == BookmarkState.DRAGGING:
# Allow pulling past activated position with rubber band effect
swipe_offset = self._swipe_start_x - self._swipe_current_x
swipe_offset = min(swipe_offset, self.FULL_VISIBLE_OFFSET + 50)
self._offset_filter.update(swipe_offset)
elif self._state == BookmarkState.TRIGGERED:
# Continue animating to fully visible
self._offset_filter.update(self.FULL_VISIBLE_OFFSET)
# Stay in TRIGGERED state for 1 second
if rl.get_time() - self._triggered_time >= 1.5:
self._state = BookmarkState.HIDDEN
elif self._state == BookmarkState.HIDDEN:
self._offset_filter.update(self.HIDDEN_OFFSET)
if self._offset_filter.x < 1e-3:
self._interacting = False
self._active_icon = self._icon
def _handle_mouse_event(self, mouse_event: MouseEvent):
if not ui_state.started:
return
if mouse_event.left_pressed:
# Store relative position within widget
self._swipe_start_x = mouse_event.pos.x
self._swipe_current_x = mouse_event.pos.x
self._is_swiping = True
self._is_swiping_left = False
self._state = BookmarkState.DRAGGING
self._active_icon = self._icon
elif mouse_event.left_down and self._is_swiping:
self._swipe_current_x = mouse_event.pos.x
swipe_offset = self._swipe_start_x - self._swipe_current_x
self._is_swiping_left = swipe_offset > 0
if self._is_swiping_left:
self._interacting = True
elif mouse_event.left_released:
if self._is_swiping:
swipe_distance = self._swipe_start_x - self._swipe_current_x
# If peeking past threshold, transition to animating to fully visible and bookmark
if swipe_distance > self.PEEK_THRESHOLD:
self._state = BookmarkState.TRIGGERED
self._triggered_time = rl.get_time()
self._active_icon = self._icon_fill
self._bookmark_callback()
else:
# Otherwise, transition back to hidden
self._state = BookmarkState.HIDDEN
# Reset swipe state
self._is_swiping = False
self._is_swiping_left = False
def _render(self, _):
"""Render the bookmark icon."""
if self._offset_filter.x > 0:
icon_x = self.rect.x + self.rect.width - round(self._offset_filter.x)
icon_y = self.rect.y + (self.rect.height - self._active_icon.height) / 2 # Vertically centered
rl.draw_texture(self._active_icon, int(icon_x), int(icon_y), rl.WHITE)
class AugmentedRoadView(CameraView):
def __init__(self, bookmark_callback=None, stream_type: VisionStreamType = VisionStreamType.VISION_STREAM_ROAD):
super().__init__("camerad", stream_type)
self._bookmark_callback = bookmark_callback
self._set_placeholder_color(rl.BLACK)
self.device_camera: DeviceCameraConfig | None = None
self.view_from_calib = view_frame_from_device_frame.copy()
self.view_from_wide_calib = view_frame_from_device_frame.copy()
self._matrix_cache_key: tuple | None = None
self._cached_matrix: np.ndarray | None = None
self._content_rect = rl.Rectangle()
self._last_click_time = 0.0
# Bookmark icon with swipe gesture
self._bookmark_icon = BookmarkIcon(bookmark_callback)
self._params = Params()
self._iq_dynamic_mode: bool = False
self._iq_dynamic_refresh: int = 0
self._model_renderer = ModelRenderer()
self._hud_renderer = HudRenderer()
self._alert_renderer = AlertRenderer()
self._driver_state_renderer = DriverStateRenderer()
self._confidence_ball = ConfidenceBall()
self._road_name = RoadNameRendererMici() if gui_app.iqpilot_ui() else None
self._experimental_txt = gui_app.texture("icons_mici/experimental_mode_mici.png",
MICI_EXPERIMENTAL_ICON_SIZE,
MICI_EXPERIMENTAL_ICON_SIZE)
self._iqdynamic_txt = gui_app.texture("icons_mici/iqdynamic_mode_mici.png",
MICI_EXPERIMENTAL_ICON_SIZE,
MICI_EXPERIMENTAL_ICON_SIZE)
self._iqstandard_txt = gui_app.texture("icons_mici/iqstandard_mode_mici.png",
MICI_EXPERIMENTAL_ICON_SIZE,
MICI_EXPERIMENTAL_ICON_SIZE)
self._offroad_label = UnifiedLabel(tr("start the car to\nuse IQ.Pilot"), 54, FontWeight.DISPLAY,
text_color=rl.Color(255, 255, 255, int(255 * 0.9)),
alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER,
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE)
# debug
self._pm = messaging.PubMaster(['uiDebug'])
def is_swiping_left(self) -> bool:
"""Check if currently swiping left (for scroller to disable)."""
return self._bookmark_icon.is_swiping_left()
def _update_state(self):
super()._update_state()
# IQDynamicMode only changes from the settings UI; don't pay a Params syscall every frame on
# the onroad hot path. Refresh ~1s (60 frames), matching model_renderer's throttled reads.
self._iq_dynamic_refresh -= 1
if self._iq_dynamic_refresh <= 0:
self._iq_dynamic_refresh = 60
self._iq_dynamic_mode = self._params.get_bool("IQDynamicMode")
# update offroad label
if ui_state.panda_type == log.PandaState.PandaType.unknown:
self._offroad_label.set_text(tr("system booting"))
else:
self._offroad_label.set_text(tr("start the car to\nuse IQ.Pilot"))
def _handle_mouse_release(self, mouse_pos: MousePos):
# Don't trigger click callback if bookmark was triggered
if not self._bookmark_icon.interacting():
super()._handle_mouse_release(mouse_pos)
def _render(self, _):
start_draw = time.monotonic()
self._switch_stream_if_needed(ui_state.sm)
# Update calibration before rendering
self._update_calibration()
# Create inner content area with border padding
self._content_rect = rl.Rectangle(
self.rect.x,
self.rect.y,
self.rect.width - SIDE_PANEL_WIDTH,
self.rect.height,
)
# Enable scissor mode to clip all rendering within content rectangle boundaries
# This creates a rendering viewport that prevents graphics from drawing outside the border
rl.begin_scissor_mode(
int(self._content_rect.x),
int(self._content_rect.y),
int(self._content_rect.width),
int(self._content_rect.height)
)
# Render the base camera view
super()._render(self._content_rect)
# Draw all UI overlays
self._model_renderer.render(self._content_rect)
alert_to_render, not_animating_out = self._alert_renderer.will_render()
# Hide DMoji when disengaged unless AlwaysOnDM is enabled
should_draw_dmoji = (not self._hud_renderer.drawing_top_icons() and ui_state.is_onroad() and
(ui_state.status != UIStatus.DISENGAGED or ui_state.always_on_dm))
self._driver_state_renderer.set_should_draw(should_draw_dmoji)
self._driver_state_renderer.set_position(self._rect.x + 16, self._rect.y + 10)
self._driver_state_renderer.render()
self._hud_renderer.set_can_draw_top_icons(alert_to_render is None)
self._hud_renderer.set_wheel_critical_icon(alert_to_render is not None and not not_animating_out and
alert_to_render.visual_alert == car.CarControl.HUDControl.VisualAlert.steerRequired)
# TODO: have alert renderer draw offroad mici label below
if ui_state.started:
self._alert_renderer.render(self._content_rect)
self._hud_renderer.render(self._content_rect)
if self._road_name is not None and alert_to_render is None:
self._road_name.update()
self._road_name.render(self._content_rect)
# don't draw the experimental/IQ.Dynamic icon over alert text (it falls back to the
# top-left alert anchor when the DMoji is hidden while disengaged)
if alert_to_render is None:
self._draw_experimental_icon(should_draw_dmoji)
# End clipping region
rl.end_scissor_mode()
self._draw_border()
# Custom UI extension point - add custom overlays here
# Use self._content_rect for positioning within camera bounds
self._confidence_ball.render(self.rect)
self._bookmark_icon.render(self.rect)
draw_time_ms = (time.monotonic() - start_draw) * 1000
if draw_time_ms > 40.0:
log_issue_limited(
"ui_draw_slow_mici",
"ui",
f"mici onroad draw slow drawTimeMillis={draw_time_ms:.2f} navActive={getattr(ui_state.sm['iqNavState'], 'active', False)}",
interval_sec=1.0,
)
msg = messaging.new_message('uiDebug')
msg.uiDebug.drawTimeMillis = draw_time_ms
self._pm.send('uiDebug', msg)
# Draw darkened background and text if not onroad
if not ui_state.started:
rl.draw_rectangle(int(self.rect.x), int(self.rect.y), int(self.rect.width), int(self.rect.height), rl.Color(0, 0, 0, 175))
self._offroad_label.render(self._content_rect)
def _draw_experimental_icon(self, draw_below_driver_state: bool) -> None:
if not ui_state.started:
return
if not ui_state.sm['carParams'].openpilotLongitudinalControl:
return
if ui_state.sm['selfdriveState'].experimentalMode:
icon = self._iqdynamic_txt if self._iq_dynamic_mode else self._experimental_txt
else:
icon = self._iqstandard_txt
if draw_below_driver_state:
pos_x = self._rect.x + 16 + (self._driver_state_renderer.rect.width - icon.width) / 2
pos_y = self._rect.y + 10 + self._driver_state_renderer.rect.height + MICI_EXPERIMENTAL_ICON_SPACING
else:
pos_x = self._rect.x + 18
pos_y = self._rect.y + 18
rl.draw_texture(icon, int(pos_x), int(pos_y), rl.WHITE)
def _draw_border(self):
rl.draw_rectangle_rounded_lines_ex(self._content_rect, MICI_BORDER_ROUNDNESS, 10, MICI_BORDER_THICKNESS, rl.BLACK)
aol = ui_state.sm["iqState"].aol
ss_enabled = ui_state.sm["selfdriveState"].enabled
if aol.active and ss_enabled:
rl.draw_rectangle_rounded_lines_ex(self._content_rect, MICI_BORDER_ROUNDNESS, 10, MICI_BORDER_THICKNESS, MICI_BORDER_COLOR)
self._reblacken_border_edges()
elif aol.active and not ss_enabled:
clip_y = int(self._content_rect.y + self._content_rect.height - MICI_BORDER_BOTTOM_ONLY_HEIGHT)
rl.begin_scissor_mode(int(self._content_rect.x), clip_y,
int(self._content_rect.width), MICI_BORDER_BOTTOM_ONLY_HEIGHT)
border_color = BORDER_COLORS_IQ[UIStatus.LAT_ONLY] if ui_state.status != UIStatus.OVERRIDE else rl.Color(0x89, 0x92, 0x8D, 0xFF)
rl.draw_rectangle_rounded_lines_ex(self._content_rect, MICI_BORDER_ROUNDNESS, 10, MICI_BORDER_THICKNESS, border_color)
rl.end_scissor_mode()
self._reblacken_border_edges()
def _reblacken_border_edges(self):
cr = self._content_rect
r = int(MICI_BORDER_ROUNDNESS * min(cr.width, cr.height) / 2) + MICI_BORDER_THICKNESS + 6
regions = (
(cr.x, cr.y, r, r), # top-left corner
(cr.x, cr.y + cr.height - r, r, r), # bottom-left corner
(cr.x + cr.width - r, cr.y, r + SIDE_PANEL_WIDTH, cr.height), # right edge + both right corners
)
for rx, ry, rw, rh in regions:
rl.begin_scissor_mode(int(rx), int(ry), int(rw), int(rh))
rl.draw_rectangle_rounded_lines_ex(cr, MICI_BORDER_ROUNDNESS, 10, MICI_BORDER_THICKNESS, rl.BLACK)
rl.end_scissor_mode()
def _switch_stream_if_needed(self, sm):
if sm['selfdriveState'].experimentalMode and WIDE_CAM in self.available_streams:
v_ego = sm['carState'].vEgo
if v_ego < WIDE_CAM_MAX_SPEED:
target = WIDE_CAM
elif v_ego > ROAD_CAM_MIN_SPEED:
target = ROAD_CAM
else:
# Hysteresis zone - keep current stream
target = self.stream_type
else:
target = ROAD_CAM
if self.stream_type != target:
self.switch_stream(target)
def _update_calibration(self):
# Update device camera if not already set
sm = ui_state.sm
if not self.device_camera and sm.seen['roadCameraState'] and sm.seen['deviceState']:
self.device_camera = DEVICE_CAMERAS[(str(sm['deviceState'].deviceType), str(sm['roadCameraState'].sensor))]
if not sm.seen["extrinsicsCalibration"]:
return
calib = sm['extrinsicsCalibration']
calib_rpy = get_calibrated_rpy(calib)
if calib_rpy is None:
return
# Update view_from_calib matrix
prev_view_from_calib = self.view_from_calib.copy()
prev_view_from_wide_calib = self.view_from_wide_calib.copy()
device_from_calib = rot_from_euler(calib_rpy)
self.view_from_calib = view_frame_from_device_frame @ device_from_calib
# Update wide calibration if available
if hasattr(calib, 'wideFromDeviceEuler') and len(calib.wideFromDeviceEuler) == 3:
wide_from_device = rot_from_euler(calib.wideFromDeviceEuler)
self.view_from_wide_calib = view_frame_from_device_frame @ wide_from_device @ device_from_calib
if (not np.allclose(self.view_from_calib, prev_view_from_calib) or
not np.allclose(self.view_from_wide_calib, prev_view_from_wide_calib)):
self._matrix_cache_key = (0, 0, 0, self.stream_type, 0.0)
self._cached_matrix = None
def _calc_frame_matrix(self, rect: rl.Rectangle) -> np.ndarray:
# Early-return the cached matrix when nothing that affects it changed. The key deliberately
# excludes rect.x/y — those are applied as a draw-time offset in ModelRenderer (below), so the
# cache stays hot while the onroad view translates during a scroll/transition (stock PR #37948).
cache_key = (
ui_state.sm.recv_frame['extrinsicsCalibration'],
int(self._content_rect.width),
int(self._content_rect.height),
self.stream_type,
round(ui_state.sm['carState'].vEgo, 1),
)
if cache_key == self._matrix_cache_key and self._cached_matrix is not None:
return self._cached_matrix
# Get camera configuration
device_camera = self.device_camera or DEFAULT_DEVICE_CAMERA
is_wide_camera = self.stream_type == WIDE_CAM
intrinsic = device_camera.ecam.intrinsics if is_wide_camera else device_camera.fcam.intrinsics
calibration = self.view_from_wide_calib if is_wide_camera else self.view_from_calib
if is_wide_camera:
zoom = 0.7 * 1.5
else:
zoom = np.interp(ui_state.sm['carState'].vEgo, [10, 30], [0.8, 1.0])
# Calculate transforms for vanishing point
inf_point = np.array([1000.0, 0.0, 0.0])
calib_transform = intrinsic @ calibration
kep = calib_transform @ inf_point
# Calculate center points and dimensions (rect.x/y are NOT used here — applied at draw time)
w, h = self._content_rect.width, self._content_rect.height
cx, cy = intrinsic[0, 2], intrinsic[1, 2]
# Calculate max allowed offsets with margins
margin = 5
max_x_offset = cx * zoom - w / 2 - margin
max_y_offset = cy * zoom - h / 2 - margin
# Calculate and clamp offsets to prevent out-of-bounds issues
try:
if abs(kep[2]) > 1e-6:
x_offset = np.clip((kep[0] / kep[2] - cx) * zoom, -max_x_offset, max_x_offset)
y_offset = np.clip((kep[1] / kep[2] - cy) * zoom + CAM_Y_OFFSET, -max_y_offset, max_y_offset)
else:
x_offset, y_offset = 0, 0
except (ZeroDivisionError, OverflowError):
x_offset, y_offset = 0, 0
# Cache the computed transformation matrix to avoid recalculations
self._matrix_cache_key = cache_key
self._cached_matrix = np.array([
[zoom * 2 * cx / w, 0, -x_offset / w * 2],
[0, zoom * 2 * cy / h, -y_offset / h * 2],
[0, 0, 1.0]
])
# Built WITHOUT rect.x/y so the matrix (and the model_renderer projection it drives) stays
# cache-stable while the view slides; ModelRenderer adds (rect.x, rect.y) as a draw-time offset.
video_transform = np.array([
[zoom, 0.0, (w / 2 - x_offset) - (cx * zoom)],
[0.0, zoom, (h / 2 - y_offset) - (cy * zoom)],
[0.0, 0.0, 1.0]
])
self._model_renderer.set_transform(video_transform @ calib_transform)
return self._cached_matrix
def show_event(self):
if gui_app.iqpilot_ui():
ui_state.reset_onroad_sleep_timer(OnroadTimerStatus.RESUME)
def hide_event(self):
if gui_app.iqpilot_ui():
ui_state.reset_onroad_sleep_timer(OnroadTimerStatus.PAUSE)
if __name__ == "__main__":
gui_app.init_window("OnRoad Camera View")
road_camera_view = AugmentedRoadView(ROAD_CAM)
print("***press space to switch camera view***")
try:
for _ in gui_app.render():
ui_state.update()
if rl.is_key_released(rl.KeyboardKey.KEY_SPACE):
if WIDE_CAM in road_camera_view.available_streams:
stream = ROAD_CAM if road_camera_view.stream_type == WIDE_CAM else WIDE_CAM
road_camera_view.switch_stream(stream)
road_camera_view.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
finally:
road_camera_view.close()

View File

@@ -0,0 +1,418 @@
import platform
import numpy as np
import pyray as rl
from iqpilot.cereal.visionipc import VisionStreamType
from msgq.visionipc import VisionIpcClient, VisionBuf
from iqpilot.common.swaglog import cloudlog
from iqpilot.system.hardware import EGL_DMA_BUF_SUPPORTED
from iqpilot.system.ui.lib.application import gui_app
from iqpilot.system.ui.lib.egl import init_egl, create_egl_image, destroy_egl_image, bind_egl_image_to_texture, EGLImage
from iqpilot.system.ui.widgets import Widget
from iqpilot.selfdrive.ui.ui_state import ui_state, UIStatus
CONNECTION_RETRY_INTERVAL = 0.2 # seconds between connection attempts
VERSION = """
#version 300 es
precision mediump float;
"""
if platform.system() == "Darwin":
VERSION = """
#version 330 core
"""
VERTEX_SHADER = VERSION + """
in vec3 vertexPosition;
in vec2 vertexTexCoord;
in vec3 vertexNormal;
in vec4 vertexColor;
uniform mat4 mvp;
out vec2 fragTexCoord;
out vec4 fragColor;
void main() {
fragTexCoord = vertexTexCoord;
fragColor = vertexColor;
gl_Position = mvp * vec4(vertexPosition, 1.0);
}
"""
# Choose fragment shader based on platform capabilities
if EGL_DMA_BUF_SUPPORTED:
FRAME_FRAGMENT_SHADER = """
#version 300 es
#extension GL_OES_EGL_image_external_essl3 : enable
precision mediump float;
in vec2 fragTexCoord;
uniform samplerExternalOES texture0;
out vec4 fragColor;
uniform int engaged;
uniform int enhance_driver;
void main() {
vec4 color = texture(texture0, fragTexCoord);
if (engaged == 1) {
float gray = dot(color.rgb, vec3(0.299, 0.587, 0.114)); // Luma
color.rgb = mix(vec3(gray), color.rgb, 0.2); // 20% saturation
color.rgb = clamp((color.rgb - 0.5) * 1.2 + 0.5, 0.0, 1.0); // +20% contrast
color.rgb = pow(color.rgb, vec3(1.0/1.28));
fragColor = vec4(color.rgb, color.a);
} else {
color.rgb *= 0.85; // 85% opacity
}
if (enhance_driver == 1) {
float brightness = 1.1;
color.rgb = color.rgb + 0.15;
color.rgb = clamp((color.rgb - 0.5) * (brightness * 0.8) + 0.5, 0.0, 1.0);
color.rgb = color.rgb * color.rgb * (3.0 - 2.0 * color.rgb);
color.rgb = pow(color.rgb, vec3(0.8));
}
fragColor = vec4(color.rgb, color.a);
}
"""
else:
FRAME_FRAGMENT_SHADER = VERSION + """
in vec2 fragTexCoord;
uniform sampler2D texture0;
uniform sampler2D texture1;
out vec4 fragColor;
uniform int engaged;
uniform int enhance_driver;
void main() {
float y = texture(texture0, fragTexCoord).r;
vec2 uv = texture(texture1, fragTexCoord).ra - 0.5;
vec3 rgb = vec3(y + 1.402*uv.y, y - 0.344*uv.x - 0.714*uv.y, y + 1.772*uv.x);
if (engaged == 1) {
float gray = dot(rgb, vec3(0.299, 0.587, 0.114));
rgb = mix(vec3(gray), rgb, 0.2); // 20% saturation
rgb = clamp((rgb - 0.5) * 1.2 + 0.5, 0.0, 1.0); // +20% contrast
} else {
rgb *= 0.85; // 85% opacity
}
// TODO: the images out of camerad need some more correction and
// the ui should apply a gamma curve for the device display
if (enhance_driver == 1) {
float brightness = 1.1;
rgb = rgb + 0.15;
rgb = clamp((rgb - 0.5) * (brightness * 0.8) + 0.5, 0.0, 1.0);
rgb = rgb * rgb * (3.0 - 2.0 * rgb);
rgb = pow(rgb, vec3(0.8));
}
fragColor = vec4(rgb, 1.0);
}
"""
class CameraView(Widget):
def __init__(self, name: str, stream_type: VisionStreamType):
super().__init__()
self._name = name
# Primary stream
self.client = VisionIpcClient(name, stream_type, conflate=True)
self._stream_type = stream_type
self.available_streams: list[VisionStreamType] = []
# Target stream for switching
self._target_client: VisionIpcClient | None = None
self._target_stream_type: VisionStreamType | None = None
self._switching: bool = False
self._texture_needs_update = True
self.last_connection_attempt: float = 0.0
self.shader = rl.load_shader_from_memory(VERTEX_SHADER, FRAME_FRAGMENT_SHADER)
self._texture1_loc: int = rl.get_shader_location(self.shader, "texture1") if not EGL_DMA_BUF_SUPPORTED else -1
self._engaged_loc = rl.get_shader_location(self.shader, "engaged")
self._engaged_val = rl.ffi.new("int[1]", [1])
self._enhance_driver_loc = rl.get_shader_location(self.shader, "enhance_driver")
self._enhance_driver_val = rl.ffi.new("int[1]", [1 if stream_type == VisionStreamType.VISION_STREAM_DRIVER else 0])
self.frame: VisionBuf | None = None
self.texture_y: rl.Texture | None = None
self.texture_uv: rl.Texture | None = None
# EGL resources
self.egl_images: dict[int, EGLImage] = {}
self.egl_texture: rl.Texture | None = None
self._placeholder_color: rl.Color | None = None
# Initialize EGL for zero-copy rendering on comma 3/3X.
if EGL_DMA_BUF_SUPPORTED:
if not init_egl():
raise RuntimeError("Failed to initialize EGL")
# Create a 1x1 pixel placeholder texture for EGL image binding
temp_image = rl.gen_image_color(1, 1, rl.BLACK)
self.egl_texture = rl.load_texture_from_image(temp_image)
rl.unload_image(temp_image)
ui_state.add_offroad_transition_callback(self._offroad_transition)
def _offroad_transition(self):
# Reconnect if not first time going onroad
if ui_state.is_onroad() and self.frame is not None:
# Prevent old frames from showing when going onroad. Qt has a separate thread
# which drains the VisionIpcClient SubSocket for us. Re-connecting is not enough
# and only clears internal buffers, not the message queue.
self.frame = None
self.available_streams.clear()
if self.client:
del self.client
self.client = VisionIpcClient(self._name, self._stream_type, conflate=True)
def _set_placeholder_color(self, color: rl.Color):
"""Set a placeholder color to be drawn when no frame is available."""
self._placeholder_color = color
def switch_stream(self, stream_type: VisionStreamType) -> None:
if self._stream_type == stream_type:
return
if self._switching and self._target_stream_type == stream_type:
return
cloudlog.debug(f'Preparing switch from {self._stream_type} to {stream_type}')
if self._target_client:
del self._target_client
self._target_stream_type = stream_type
self._target_client = VisionIpcClient(self._name, stream_type, conflate=True)
self._switching = True
@property
def stream_type(self) -> VisionStreamType:
return self._stream_type
def close(self) -> None:
self._clear_textures()
# Clean up EGL texture
if EGL_DMA_BUF_SUPPORTED and self.egl_texture:
rl.unload_texture(self.egl_texture)
self.egl_texture = None
# Clean up shader
if self.shader and self.shader.id:
rl.unload_shader(self.shader)
self.shader.id = 0
self.frame = None
self.available_streams.clear()
self.client = None
def __del__(self):
self.close()
def _calc_frame_matrix(self, rect: rl.Rectangle) -> np.ndarray:
if not self.frame:
return np.eye(3)
# Calculate aspect ratios
widget_aspect_ratio = rect.width / rect.height
frame_aspect_ratio = self.frame.width / self.frame.height
# Calculate scaling factors to maintain aspect ratio
zx = min(frame_aspect_ratio / widget_aspect_ratio, 1.0)
zy = min(widget_aspect_ratio / frame_aspect_ratio, 1.0)
return np.array([
[zx, 0.0, 0.0],
[0.0, zy, 0.0],
[0.0, 0.0, 1.0]
])
def _render(self, rect: rl.Rectangle):
if self._switching:
self._handle_switch()
if not self._ensure_connection():
self._draw_placeholder(rect)
return
# Try to get a new buffer without blocking
buffer = self.client.recv(timeout_ms=0)
if buffer:
self._texture_needs_update = True
self.frame = buffer
elif not self.client.is_connected():
# ensure we clear the displayed frame when the connection is lost
self.frame = None
if not self.frame:
self._draw_placeholder(rect)
return
transform = self._calc_frame_matrix(rect)
src_rect = rl.Rectangle(0, 0, float(self.frame.width), float(self.frame.height))
# Flip driver camera horizontally
if self._stream_type == VisionStreamType.VISION_STREAM_DRIVER:
src_rect.width = -src_rect.width
# Calculate scale
scale_x = rect.width * transform[0, 0] # zx
scale_y = rect.height * transform[1, 1] # zy
# Calculate base position (centered)
x_offset = rect.x + (rect.width - scale_x) / 2
y_offset = rect.y + (rect.height - scale_y) / 2
x_offset += transform[0, 2] * rect.width / 2
y_offset += transform[1, 2] * rect.height / 2
dst_rect = rl.Rectangle(x_offset, y_offset, scale_x, scale_y)
# Render with appropriate method
if EGL_DMA_BUF_SUPPORTED:
self._render_egl(src_rect, dst_rect)
else:
self._render_textures(src_rect, dst_rect)
def _draw_placeholder(self, rect: rl.Rectangle):
if self._placeholder_color:
rl.draw_rectangle_rec(rect, self._placeholder_color)
def _render_egl(self, src_rect: rl.Rectangle, dst_rect: rl.Rectangle) -> None:
"""Render using EGL for direct buffer access"""
if self.frame is None or self.egl_texture is None:
return
idx = self.frame.idx
egl_image = self.egl_images.get(idx)
# Create EGL image if needed
if egl_image is None:
egl_image = create_egl_image(self.frame.width, self.frame.height, self.frame.stride, self.frame.fd, self.frame.uv_offset)
if egl_image:
self.egl_images[idx] = egl_image
else:
return
# Update texture dimensions to match current frame
self.egl_texture.width = self.frame.width
self.egl_texture.height = self.frame.height
# Bind the EGL image to our texture
bind_egl_image_to_texture(self.egl_texture.id, egl_image)
# Render with shader
rl.begin_shader_mode(self.shader)
self._update_texture_color_filtering()
rl.draw_texture_pro(self.egl_texture, src_rect, dst_rect, rl.Vector2(0, 0), 0.0, rl.WHITE)
rl.end_shader_mode()
def _render_textures(self, src_rect: rl.Rectangle, dst_rect: rl.Rectangle) -> None:
"""Render using texture copies"""
if not self.texture_y or not self.texture_uv or self.frame is None:
return
# Update textures with new frame data
if self._texture_needs_update:
y_data = self.frame.data[: self.frame.uv_offset]
uv_data = self.frame.data[self.frame.uv_offset:]
rl.update_texture(self.texture_y, rl.ffi.cast("void *", y_data.ctypes.data))
rl.update_texture(self.texture_uv, rl.ffi.cast("void *", uv_data.ctypes.data))
self._texture_needs_update = False
# Render with shader
rl.begin_shader_mode(self.shader)
self._update_texture_color_filtering()
rl.set_shader_value_texture(self.shader, self._texture1_loc, self.texture_uv)
rl.draw_texture_pro(self.texture_y, src_rect, dst_rect, rl.Vector2(0, 0), 0.0, rl.WHITE)
rl.end_shader_mode()
def _update_texture_color_filtering(self):
self._engaged_val[0] = 1 if ui_state.status != UIStatus.DISENGAGED else 0
rl.set_shader_value(self.shader, self._engaged_loc, self._engaged_val, rl.ShaderUniformDataType.SHADER_UNIFORM_INT)
rl.set_shader_value(self.shader, self._enhance_driver_loc, self._enhance_driver_val, rl.ShaderUniformDataType.SHADER_UNIFORM_INT)
def _ensure_connection(self) -> bool:
if not self.client.is_connected():
self.frame = None
self.available_streams.clear()
# Throttle connection attempts
current_time = rl.get_time()
if current_time - self.last_connection_attempt < CONNECTION_RETRY_INTERVAL:
return False
self.last_connection_attempt = current_time
if not self.client.connect(False) or not self.client.num_buffers:
return False
cloudlog.debug(f"Connected to {self._name} stream: {self._stream_type}, buffers: {self.client.num_buffers}")
self._initialize_textures()
self.available_streams = self.client.available_streams(self._name, block=False)
return True
def _handle_switch(self) -> None:
"""Check if target stream is ready and switch immediately."""
if not self._target_client or not self._switching:
return
# Try to connect target if needed
if not self._target_client.is_connected():
if not self._target_client.connect(False) or not self._target_client.num_buffers:
return
cloudlog.debug(f"Target stream connected: {self._target_stream_type}")
# Check if target has frames ready
target_frame = self._target_client.recv(timeout_ms=0)
if target_frame:
self.frame = target_frame # Update current frame to target frame
self._complete_switch()
def _complete_switch(self) -> None:
"""Instantly switch to target stream."""
cloudlog.debug(f"Switching to {self._target_stream_type}")
# Clean up current resources
if self.client:
del self.client
# Switch to target
self.client = self._target_client
self._stream_type = self._target_stream_type
self._texture_needs_update = True
# Reset state
self._target_client = None
self._target_stream_type = None
self._switching = False
# Initialize textures for new stream
self._initialize_textures()
def _initialize_textures(self):
self._clear_textures()
if not EGL_DMA_BUF_SUPPORTED:
self.texture_y = rl.load_texture_from_image(rl.Image(None, int(self.client.stride),
int(self.client.height), 1, rl.PixelFormat.PIXELFORMAT_UNCOMPRESSED_GRAYSCALE))
self.texture_uv = rl.load_texture_from_image(rl.Image(None, int(self.client.stride // 2),
int(self.client.height // 2), 1, rl.PixelFormat.PIXELFORMAT_UNCOMPRESSED_GRAY_ALPHA))
def _clear_textures(self):
if self.texture_y and self.texture_y.id:
rl.unload_texture(self.texture_y)
self.texture_y = None
if self.texture_uv and self.texture_uv.id:
rl.unload_texture(self.texture_uv)
self.texture_uv = None
# Clean up EGL resources
if EGL_DMA_BUF_SUPPORTED:
for data in self.egl_images.values():
destroy_egl_image(data)
self.egl_images = {}
if __name__ == "__main__":
gui_app.init_window("camera view")
road = CameraView("camerad", VisionStreamType.VISION_STREAM_ROAD)
for _ in gui_app.render():
road.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height))

View File

@@ -0,0 +1,86 @@
import math
import pyray as rl
from iqpilot.selfdrive.ui.mici.onroad import SIDE_PANEL_WIDTH
from iqpilot.selfdrive.ui.ui_state import ui_state, UIStatus
from iqpilot.system.ui.widgets import Widget
from iqpilot.system.ui.lib.application import gui_app
from iqpilot.common.filter_simple import FirstOrderFilter
from iqpilot.ui.mici.onroad.confidence_ball import IQConfidenceBall
def draw_circle_gradient(center_x: float, center_y: float, radius: int,
top: rl.Color, bottom: rl.Color) -> None:
# Draw a square with the gradient
rl.draw_rectangle_gradient_v(int(center_x - radius), int(center_y - radius),
radius * 2, radius * 2,
top, bottom)
# Paint over square with a ring
outer_radius = math.ceil(radius * math.sqrt(2)) + 1
rl.draw_ring(rl.Vector2(int(center_x), int(center_y)), radius, outer_radius,
0.0, 360.0,
20, rl.BLACK)
class ConfidenceBall(Widget, IQConfidenceBall):
def __init__(self, demo: bool = False):
Widget.__init__(self)
IQConfidenceBall.__init__(self)
self._demo = demo
self._confidence_filter = FirstOrderFilter(-0.5, 0.5, 1 / gui_app.target_fps)
def update_filter(self, value: float):
self._confidence_filter.update(value)
def _update_state(self):
if self._demo:
return
# animate status dot in from bottom
if ui_state.status == UIStatus.DISENGAGED:
self._confidence_filter.update(-0.5)
elif ui_state.status in (UIStatus.LAT_ONLY, UIStatus.LONG_ONLY):
self._confidence_filter.update(1 - max(self.get_animate_status_probs() or [1]))
else:
self._confidence_filter.update((1 - max(ui_state.sm['modelV2'].meta.disengagePredictions.brakeDisengageProbs or [1])) *
(1 - max(ui_state.sm['modelV2'].meta.disengagePredictions.steerOverrideProbs or [1])))
def _render(self, _):
content_rect = rl.Rectangle(
self.rect.x + self.rect.width - SIDE_PANEL_WIDTH,
self.rect.y,
SIDE_PANEL_WIDTH,
self.rect.height,
)
status_dot_radius = 24
dot_height = (1 - self._confidence_filter.x) * (content_rect.height - 2 * status_dot_radius) + status_dot_radius
dot_height = self._rect.y + dot_height
# confidence zones
if ui_state.status == UIStatus.ENGAGED or self._demo:
if self._confidence_filter.x > 0.5:
top_dot_color = rl.Color(0, 255, 204, 255)
bottom_dot_color = rl.Color(0, 255, 38, 255)
elif self._confidence_filter.x > 0.2:
top_dot_color = rl.Color(255, 200, 0, 255)
bottom_dot_color = rl.Color(255, 115, 0, 255)
else:
top_dot_color = rl.Color(255, 0, 21, 255)
bottom_dot_color = rl.Color(255, 0, 89, 255)
elif ui_state.status in (UIStatus.LAT_ONLY, UIStatus.LONG_ONLY):
top_dot_color, bottom_dot_color = self.get_lat_long_dot_colors(self._confidence_filter.x)
elif ui_state.status == UIStatus.OVERRIDE:
top_dot_color = rl.Color(255, 255, 255, 255)
bottom_dot_color = rl.Color(82, 82, 82, 255)
else:
top_dot_color = rl.Color(50, 50, 50, 255)
bottom_dot_color = rl.Color(13, 13, 13, 255)
draw_circle_gradient(content_rect.x + content_rect.width - status_dot_radius,
dot_height, status_dot_radius,
top_dot_color, bottom_dot_color)

View File

@@ -0,0 +1,246 @@
import pyray as rl
from iqpilot.cereal import log, messaging
from iqpilot.cereal.visionipc import VisionStreamType
from iqpilot.selfdrive.ui.mici.onroad.cameraview import CameraView
from iqpilot.selfdrive.ui.mici.onroad.driver_state import DriverStateRenderer
from iqpilot.selfdrive.ui.ui_state import ui_state, device
from iqpilot.selfdrive.selfdrived.events import EVENTS, ET
from iqpilot.system.ui.lib.application import gui_app, FontWeight
from iqpilot.system.ui.lib.multilang import tr
from iqpilot.system.ui.widgets.nav_widget import NavWidget
from iqpilot.system.ui.widgets.label import gui_label
EventName = log.OnroadEvent.EventName
EVENT_TO_INT = EventName.schema.enumerants
class DriverCameraView(CameraView):
def _calc_frame_matrix(self, rect: rl.Rectangle):
base = super()._calc_frame_matrix(rect)
driver_view_ratio = 1.5
base[0, 0] *= driver_view_ratio
base[1, 1] *= driver_view_ratio
return base
class DriverCameraDialog(NavWidget):
def __init__(self, no_escape=False):
super().__init__()
self._no_escape = no_escape
self._camera_view = DriverCameraView("camerad", VisionStreamType.VISION_STREAM_DRIVER)
self.driver_state_renderer = DriverStateRenderer(lines=True)
self.driver_state_renderer.set_rect(rl.Rectangle(0, 0, 200, 200))
self.driver_state_renderer.load_icons()
self._pm: messaging.PubMaster | None = None
if not no_escape:
# TODO: this can grow unbounded, should be given some thought
device.add_interactive_timeout_callback(lambda: gui_app.set_modal_overlay(None))
self.set_back_callback(lambda: gui_app.set_modal_overlay(None))
# Load eye icons
self._eye_fill_texture = None
self._eye_orange_texture = None
self._eye_size = 74
self._glasses_texture = None
self._glasses_size = 171
self._load_eye_textures()
def _back_enabled(self) -> bool:
return not self._no_escape
def show_event(self):
super().show_event()
ui_state.params.put_bool("IsDriverViewEnabled", True)
self._publish_alert_sound(None)
device.set_override_interactive_timeout(300)
ui_state.params.remove("DriverTooDistracted")
self._pm = messaging.PubMaster(['selfdriveState'])
def hide_event(self):
super().hide_event()
ui_state.params.put_bool("IsDriverViewEnabled", False)
device.set_override_interactive_timeout(None)
def _handle_mouse_release(self, _):
ui_state.params.remove("DriverTooDistracted")
def __del__(self):
self.close()
def close(self):
if self._camera_view:
self._camera_view.close()
def _update_state(self):
if self._camera_view:
self._camera_view._update_state()
# Enable driver state renderer to show Dmoji in preview
self.driver_state_renderer.set_should_draw(True)
self.driver_state_renderer.set_force_active(True)
super()._update_state()
def _render(self, rect):
rl.begin_scissor_mode(int(rect.x), int(rect.y), int(rect.width), int(rect.height))
self._camera_view._render(rect)
if not self._camera_view.frame:
gui_label(rect, tr("camera starting"), font_size=54, font_weight=FontWeight.BOLD,
alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER)
rl.end_scissor_mode()
self._publish_alert_sound(None)
return -1
driver_data = self._draw_face_detection(rect)
if driver_data is not None:
self._draw_eyes(rect, driver_data)
# Position dmoji on opposite side from driver
driver_state_rect = (
rect.x if self.driver_state_renderer.is_rhd else rect.x + rect.width - self.driver_state_renderer.rect.width,
rect.y + (rect.height - self.driver_state_renderer.rect.height) / 2,
)
self.driver_state_renderer.set_position(*driver_state_rect)
self.driver_state_renderer.render()
# Render driver monitoring alerts
self._render_dm_alerts(rect)
rl.end_scissor_mode()
return -1
def _publish_alert_sound(self, dm_state):
"""Publish selfdriveState with only alertSound field set"""
if self._pm is None:
return
msg = messaging.new_message('selfdriveState')
if dm_state is not None and len(dm_state.events):
event_name = EVENT_TO_INT[dm_state.events[0].name]
if event_name is not None and event_name in EVENTS and ET.PERMANENT in EVENTS[event_name]:
msg.selfdriveState.alertSound = EVENTS[event_name][ET.PERMANENT].audible_alert
self._pm.send('selfdriveState', msg)
def _render_dm_alerts(self, rect: rl.Rectangle):
"""Render driver monitoring event names"""
dm_state = ui_state.sm["driverMonitoringState"]
self._publish_alert_sound(dm_state)
gui_label(rl.Rectangle(rect.x + 2, rect.y + 2, rect.width, rect.height),
f"Awareness: {dm_state.awarenessStatus * 100:.0f}%", font_size=44, font_weight=FontWeight.MEDIUM,
alignment=rl.GuiTextAlignment.TEXT_ALIGN_RIGHT,
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP,
color=rl.Color(0, 0, 0, 180))
gui_label(rect, f"Awareness: {dm_state.awarenessStatus * 100:.0f}%", font_size=44, font_weight=FontWeight.MEDIUM,
alignment=rl.GuiTextAlignment.TEXT_ALIGN_RIGHT,
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP,
color=rl.Color(255, 255, 255, int(255 * 0.9)))
if not dm_state.events:
return
# Show first event (only one should be active at a time)
event_name_str = str(dm_state.events[0].name).split('.')[-1]
alignment = rl.GuiTextAlignment.TEXT_ALIGN_RIGHT if self.driver_state_renderer.is_rhd else rl.GuiTextAlignment.TEXT_ALIGN_LEFT
shadow_rect = rl.Rectangle(rect.x + 2, rect.y + 2, rect.width, rect.height)
gui_label(shadow_rect, event_name_str, font_size=40, font_weight=FontWeight.BOLD,
alignment=alignment,
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM,
color=rl.Color(0, 0, 0, 180))
gui_label(rect, event_name_str, font_size=40, font_weight=FontWeight.BOLD,
alignment=alignment,
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM,
color=rl.Color(255, 255, 255, int(255 * 0.9)))
def _load_eye_textures(self):
"""Lazy load eye textures"""
if self._eye_fill_texture is None:
self._eye_fill_texture = gui_app.texture("icons_mici/onroad/eye_fill.png", self._eye_size, self._eye_size)
if self._eye_orange_texture is None:
self._eye_orange_texture = gui_app.texture("icons_mici/onroad/eye_orange.png", self._eye_size, self._eye_size)
if self._glasses_texture is None:
self._glasses_texture = gui_app.texture("icons_mici/onroad/glasses.png", self._glasses_size, self._glasses_size)
def _draw_face_detection(self, rect: rl.Rectangle):
dm_state = ui_state.sm["driverMonitoringState"]
driver_data = self.driver_state_renderer.get_driver_data()
if not dm_state.faceDetected:
return
# Get face position and orientation
face_x, face_y = driver_data.facePosition
face_std = max(driver_data.faceOrientationStd[0], driver_data.faceOrientationStd[1])
alpha = 0.7
if face_std > 0.15:
alpha = max(0.7 - (face_std - 0.15) * 3.5, 0.0)
# use approx instead of distort_points
# TODO: replace with distort_points
tici_x = 1080.0 - 1714.0 * face_x
tici_y = -135.0 + (504.0 + abs(face_x) * 112.0) + (1205.0 - abs(face_x) * 724.0) * face_y
# Tici coords are relative to center, scale offset
offset_x = (tici_x - 1080.0) * 1.25
offset_y = (tici_y - 540.0) * 1.25
# Map to mici screen (scale from 2160x1080 to rect dimensions)
scale_x = rect.width / 2160.0
scale_y = rect.height / 1080.0
fbox_x = rect.x + rect.width / 2 + offset_x * scale_x
fbox_y = rect.y + rect.height / 2 + offset_y * scale_y
box_size = 75
line_thickness = 3
line_color = rl.Color(255, 255, 255, int(alpha * 255))
rl.draw_rectangle_rounded_lines_ex(
rl.Rectangle(fbox_x - box_size / 2, fbox_y - box_size / 2, box_size, box_size),
35.0 / box_size / 2,
line_thickness,
line_thickness,
line_color,
)
return driver_data
def _draw_eyes(self, rect: rl.Rectangle, driver_data):
# Draw eye indicators based on eye probabilities
eye_offset_x = 10
eye_offset_y = 10
eye_spacing = self._eye_size + 15
left_eye_x = rect.x + eye_offset_x
left_eye_y = rect.y + eye_offset_y
left_eye_prob = driver_data.leftEyeProb
right_eye_x = rect.x + eye_offset_x + eye_spacing
right_eye_y = rect.y + eye_offset_y
right_eye_prob = driver_data.rightEyeProb
# Draw eyes with opacity based on probability
for eye_x, eye_y, eye_prob in [(left_eye_x, left_eye_y, left_eye_prob), (right_eye_x, right_eye_y, right_eye_prob)]:
fill_opacity = eye_prob
orange_opacity = 1.0 - eye_prob
rl.draw_texture_v(self._eye_orange_texture, (eye_x, eye_y), rl.Color(255, 255, 255, int(255 * orange_opacity)))
rl.draw_texture_v(self._eye_fill_texture, (eye_x, eye_y), rl.Color(255, 255, 255, int(255 * fill_opacity)))
# Draw sunglasses indicator based on sunglasses probability
# Position glasses centered between the two eyes at top left
glasses_x = rect.x + eye_offset_x - 4
glasses_y = rect.y
glasses_pos = rl.Vector2(glasses_x, glasses_y)
glasses_prob = driver_data.sunglassesProb
rl.draw_texture_v(self._glasses_texture, glasses_pos, rl.Color(70, 80, 161, int(255 * glasses_prob)))
if __name__ == "__main__":
gui_app.init_window("Driver Camera View (mici)")
driver_camera_view = DriverCameraDialog()
try:
for _ in gui_app.render():
ui_state.update()
driver_camera_view.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
finally:
driver_camera_view.close()

View File

@@ -0,0 +1,213 @@
import pyray as rl
import numpy as np
import math
from iqpilot.cereal import log
from iqpilot.common.filter_simple import FirstOrderFilter
from iqpilot.system.ui.lib.application import gui_app
from iqpilot.system.ui.widgets import Widget
from iqpilot.selfdrive.ui.ui_state import ui_state
from iqpilot.selfdrive.monitoring.helpers import face_orientation_from_net
AlertSize = log.SelfdriveState.AlertSize
DEBUG = False
ACTIVE_ACCENT = rl.Color(0x0C, 0x94, 0x96, 0xFF)
LOOKING_CENTER_THRESHOLD_UPPER = math.radians(6)
LOOKING_CENTER_THRESHOLD_LOWER = math.radians(3)
class DriverStateRenderer(Widget):
BASE_SIZE = 60
LINES_ANGLE_INCREMENT = 5
LINES_STALE_ANGLES = 3.0 # seconds
def __init__(self, lines: bool = False, inset: bool = False):
super().__init__()
self.set_rect(rl.Rectangle(0, 0, self.BASE_SIZE, self.BASE_SIZE))
self._lines = lines
self._inset = inset
# In line mode, track smoothed angles
assert 360 % self.LINES_ANGLE_INCREMENT == 0
self._head_angles = {i * self.LINES_ANGLE_INCREMENT: FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps) for i in range(360 // self.LINES_ANGLE_INCREMENT)}
self._is_active = False
self._is_rhd = False
self._face_detected = False
self._should_draw = False
self._force_active = False
self._looking_center = False
self._fade_filter = FirstOrderFilter(0.0, 0.05, 1 / gui_app.target_fps)
self._pitch_filter = FirstOrderFilter(0.0, 0.05, 1 / gui_app.target_fps, initialized=False)
self._yaw_filter = FirstOrderFilter(0.0, 0.05, 1 / gui_app.target_fps, initialized=False)
self._rotation_filter = FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps, initialized=False)
self._looking_center_filter = FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps)
# Load the driver face icons
self.load_icons()
def load_icons(self):
cone_and_person_size = round(56 / self.BASE_SIZE * self._rect.width)
if self._inset:
current_inset = (self._rect.width - cone_and_person_size) / 2
cone_and_person_size = round(cone_and_person_size - current_inset * 2)
self._dm_person = gui_app.texture("icons_mici/onroad/driver_monitoring/dm_person.png", cone_and_person_size, cone_and_person_size)
self._dm_cone = gui_app.texture("icons_mici/onroad/driver_monitoring/dm_cone.png", cone_and_person_size, cone_and_person_size)
self._dm_background = gui_app.texture("icons_mici/onroad/driver_monitoring/dm_background.png", self._rect.width, self._rect.height)
def set_should_draw(self, should_draw: bool):
self._should_draw = should_draw
@property
def should_draw(self):
return (self._should_draw and ui_state.sm["selfdriveState"].alertSize == AlertSize.none and
ui_state.sm.recv_frame["driverStateV2"] > ui_state.started_frame)
def set_force_active(self, force_active: bool):
"""Force the dmoji to always appear active (green) regardless of actual state"""
self._force_active = force_active
@property
def effective_active(self) -> bool:
"""Returns True if dmoji should appear active (either actually active or forced)"""
return bool(self._force_active or self._is_active)
@property
def is_rhd(self) -> bool:
return self._is_rhd
def _render(self, _):
if DEBUG:
rl.draw_rectangle_lines_ex(self._rect, 1, rl.RED)
rl.draw_texture(self._dm_background,
int(self._rect.x),
int(self._rect.y),
rl.Color(255, 255, 255, int(255 * self._fade_filter.x)))
rl.draw_texture(self._dm_person,
int(self._rect.x + (self._rect.width - self._dm_person.width) / 2),
int(self._rect.y + (self._rect.height - self._dm_person.height) / 2),
rl.Color(255, 255, 255, int(255 * 0.9 * self._fade_filter.x)))
if self.effective_active:
source_rect = rl.Rectangle(0, 0, self._dm_cone.width, self._dm_cone.height)
dest_rect = rl.Rectangle(
self._rect.x + self._rect.width / 2,
self._rect.y + self._rect.height / 2,
self._dm_cone.width,
self._dm_cone.height,
)
if not self._lines:
rl.draw_texture_pro(
self._dm_cone,
source_rect,
dest_rect,
rl.Vector2(dest_rect.width / 2, dest_rect.height / 2),
self._rotation_filter.x - 90,
rl.Color(ACTIVE_ACCENT.r, ACTIVE_ACCENT.g, ACTIVE_ACCENT.b, int(255 * self._fade_filter.x)),
)
else:
# remove old angles
for angle, f in self._head_angles.items():
dst_from_current = ((angle - self._rotation_filter.x) % 360) - 180
target = 1.0 if abs(dst_from_current) <= self.LINES_ANGLE_INCREMENT * 5 else 0.0
if not self._face_detected:
target = 0.0
# Reduce all line lengths when looking center
if self._looking_center:
target = np.interp(self._looking_center_filter.x, [0.0, 1.0], [target, 0.45])
f.update(target)
self._draw_line(angle, f, self._looking_center)
def _draw_line(self, angle: int, f: FirstOrderFilter, grey: bool):
line_length = self._rect.width / 6
line_length = round(np.interp(f.x, [0.0, 1.0], [0, line_length]))
line_offset = self._rect.width / 2 - line_length * 2 # ensure line ends within rect
center_x = self._rect.x + self._rect.width / 2
center_y = self._rect.y + self._rect.height / 2
start_x = center_x + (line_offset + line_length) * math.cos(math.radians(angle))
start_y = center_y + (line_offset + line_length) * math.sin(math.radians(angle))
end_x = start_x + line_length * math.cos(math.radians(angle))
end_y = start_y + line_length * math.sin(math.radians(angle))
color = ACTIVE_ACCENT
if grey:
color = rl.Color(166, 166, 166, 255)
if f.x > 0.01:
rl.draw_line_ex((start_x, start_y), (end_x, end_y), 12, color)
def get_driver_data(self):
sm = ui_state.sm
dm_state = sm["driverMonitoringState"]
self._is_active = dm_state.isActiveMode
self._is_rhd = dm_state.isRHD
self._face_detected = dm_state.faceDetected
driverstate = sm["driverStateV2"]
driver_data = driverstate.rightDriverData if self._is_rhd else driverstate.leftDriverData
return driver_data
def _update_state(self):
# Get monitoring state
driver_data = self.get_driver_data()
driver_orient = driver_data.faceOrientation
if len(driver_orient) != 3:
return
# Calibrate orientation so looking straight ahead at the road (instead of at the device) reads
# (0, 0), using live calibration. Makes the cone point in the correct direction. (stock PR #37149)
sm = ui_state.sm
if sm.valid['extrinsicsCalibration'] and len(sm['extrinsicsCalibration'].rpyCalib) == 3:
cal_rpy = sm['extrinsicsCalibration'].rpyCalib
else:
cal_rpy = [0.0, 0.0, 0.0]
_, pitch, yaw = face_orientation_from_net(driver_orient, driver_data.facePosition, cal_rpy)
yaw = -yaw # undo sign flip in face_orientation_from_net to match UI convention
pitch = self._pitch_filter.update(pitch)
yaw = self._yaw_filter.update(yaw)
# hysteresis on looking center
if abs(pitch) < LOOKING_CENTER_THRESHOLD_LOWER and abs(yaw) < LOOKING_CENTER_THRESHOLD_LOWER:
self._looking_center = True
elif abs(pitch) > LOOKING_CENTER_THRESHOLD_UPPER or abs(yaw) > LOOKING_CENTER_THRESHOLD_UPPER:
self._looking_center = False
self._looking_center_filter.update(1 if self._looking_center else 0)
if DEBUG:
pitchd = math.degrees(pitch)
yawd = math.degrees(yaw)
rl.draw_line_ex((0, 100), (200, 100), 3, rl.RED)
rl.draw_line_ex((0, 120), (200, 120), 3, rl.RED)
pitch_x = 100 + pitchd
yaw_x = 100 + yawd
rl.draw_circle(int(pitch_x), 100, 5, rl.GREEN)
rl.draw_circle(int(yaw_x), 120, 5, rl.GREEN)
# filter head rotation, handling wrap-around (bias pitch up since calib/DM pose isn't exact,
# and halve yaw sensitivity)
rotation = math.degrees(math.atan2((pitch + math.radians(6)) * 2, yaw))
angle_diff = rotation - self._rotation_filter.x
angle_diff = ((angle_diff + 180) % 360) - 180
self._rotation_filter.update(self._rotation_filter.x + angle_diff)
if not self.should_draw:
self._fade_filter.update(0.0)
elif not self.effective_active:
self._fade_filter.update(0.35)
else:
self._fade_filter.update(1.0)

View File

@@ -0,0 +1,279 @@
import pyray as rl
from dataclasses import dataclass
from iqpilot.common.constants import CV
from iqpilot.selfdrive.ui.mici.onroad.torque_bar import TorqueBar
from iqpilot.selfdrive.ui.ui_state import ui_state, UIStatus
from iqpilot.system.ui.lib.application import gui_app, FontWeight
from iqpilot.system.ui.lib.raylib_compat import draw_circle_gradient
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
from iqpilot.common.filter_simple import FirstOrderFilter
from iqpilot.cereal import log
EventName = log.OnroadEvent.EventName
# Constants
SET_SPEED_NA = 255
KM_TO_MILE = 0.621371
CRUISE_DISABLED_CHAR = ''
SET_SPEED_PERSISTENCE = 2.5 # seconds
@dataclass(frozen=True)
class FontSizes:
current_speed: int = 176
speed_unit: int = 66
max_speed: int = 36
set_speed: int = 112
@dataclass(frozen=True)
class Colors:
WHITE = rl.WHITE
WHITE_TRANSLUCENT = rl.Color(255, 255, 255, 200)
FONT_SIZES = FontSizes()
COLORS = Colors()
class TurnIntent(Widget):
FADE_IN_ANGLE = 30 # degrees
def __init__(self):
super().__init__()
self._pre = False
self._turn_intent_direction: int = 0
self._turn_intent_alpha_filter = FirstOrderFilter(0, 0.05, 1 / gui_app.target_fps)
self._turn_intent_rotation_filter = FirstOrderFilter(0, 0.1, 1 / gui_app.target_fps)
self._txt_turn_intent_left: rl.Texture = gui_app.texture('icons_mici/turn_intent_left.png', 50, 20)
self._txt_turn_intent_right: rl.Texture = gui_app.texture('icons_mici/turn_intent_right.png', 50, 20)
def _render(self, _):
if self._turn_intent_alpha_filter.x > 1e-2:
turn_intent_texture = self._txt_turn_intent_right if self._turn_intent_direction == 1 else self._txt_turn_intent_left
src_rect = rl.Rectangle(0, 0, turn_intent_texture.width, turn_intent_texture.height)
dest_rect = rl.Rectangle(self._rect.x + self._rect.width / 2, self._rect.y + self._rect.height / 2,
turn_intent_texture.width, turn_intent_texture.height)
origin = (turn_intent_texture.width / 2, self._rect.height / 2)
color = rl.Color(255, 255, 255, int(255 * self._turn_intent_alpha_filter.x))
rl.draw_texture_pro(turn_intent_texture, src_rect, dest_rect, origin, self._turn_intent_rotation_filter.x, color)
def _update_state(self) -> None:
sm = ui_state.sm
left = any(e.name == EventName.preLaneChangeLeft for e in sm['onroadEvents'])
right = any(e.name == EventName.preLaneChangeRight for e in sm['onroadEvents'])
if left or right:
# pre lane change
if not self._pre:
self._turn_intent_rotation_filter.x = self.FADE_IN_ANGLE if left else -self.FADE_IN_ANGLE
self._pre = True
self._turn_intent_direction = -1 if left else 1
self._turn_intent_alpha_filter.update(1)
self._turn_intent_rotation_filter.update(0)
elif any(e.name == EventName.laneChange for e in sm['onroadEvents']):
# fade out and rotate away
self._pre = False
self._turn_intent_alpha_filter.update(0)
if self._turn_intent_direction == 0:
# unknown. missed pre frame?
self._turn_intent_rotation_filter.update(0)
else:
self._turn_intent_rotation_filter.update(self._turn_intent_direction * self.FADE_IN_ANGLE)
else:
# didn't complete lane change, just hide
self._pre = False
self._turn_intent_direction = 0
self._turn_intent_alpha_filter.update(0)
self._turn_intent_rotation_filter.update(0)
class HudRenderer(Widget):
def __init__(self):
super().__init__()
"""Initialize the HUD renderer."""
self.is_cruise_set: bool = False
self.is_cruise_available: bool = True
self.set_speed: float = SET_SPEED_NA
self._set_speed_changed_time: float = 0
self.speed: float = 0.0
self.v_ego_cluster_seen: bool = False
self._engaged: bool = False
self._can_draw_top_icons = True
self._show_wheel_critical = False
self._font_bold: rl.Font = gui_app.font(FontWeight.BOLD)
self._font_medium: rl.Font = gui_app.font(FontWeight.MEDIUM)
self._font_semi_bold: rl.Font = gui_app.font(FontWeight.SEMI_BOLD)
self._font_display: rl.Font = gui_app.font(FontWeight.DISPLAY)
self._turn_intent = TurnIntent()
self._torque_bar = TorqueBar()
self._txt_wheel: rl.Texture = gui_app.texture('icons_mici/wheel.png', 50, 50)
self._txt_wheel_critical: rl.Texture = gui_app.texture('icons_mici/wheel_critical.png', 50, 50)
self._txt_exclamation_point: rl.Texture = gui_app.texture('icons_mici/exclamation_point.png', 44, 44)
self._wheel_alpha_filter = FirstOrderFilter(0, 0.05, 1 / gui_app.target_fps)
self._wheel_y_filter = FirstOrderFilter(0, 0.1, 1 / gui_app.target_fps)
self._set_speed_alpha_filter = FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps)
def set_wheel_critical_icon(self, critical: bool):
"""Set the wheel icon to critical or normal state."""
self._show_wheel_critical = critical
def set_can_draw_top_icons(self, can_draw_top_icons: bool):
"""Set whether to draw the top part of the HUD."""
self._can_draw_top_icons = can_draw_top_icons
def drawing_top_icons(self) -> bool:
# whether we're drawing any top icons currently
return bool(self._set_speed_alpha_filter.x > 1e-2)
def _update_state(self) -> None:
"""Update HUD state based on car state and controls state."""
sm = ui_state.sm
if sm.recv_frame["carState"] < ui_state.started_frame:
self.is_cruise_set = False
self.set_speed = SET_SPEED_NA
self.speed = 0.0
return
controls_state = sm['controlsState']
car_state = sm['carState']
v_cruise_cluster = car_state.vCruiseCluster
set_speed = (
controls_state.vCruiseDEPRECATED if v_cruise_cluster == 0.0 else v_cruise_cluster
)
engaged = sm['selfdriveState'].enabled
if (set_speed != self.set_speed and engaged) or (engaged and not self._engaged):
self._set_speed_changed_time = rl.get_time()
self._engaged = engaged
self.set_speed = set_speed
self.is_cruise_set = 0 < self.set_speed < SET_SPEED_NA
self.is_cruise_available = self.set_speed != -1
v_ego_cluster = car_state.vEgoCluster
self.v_ego_cluster_seen = self.v_ego_cluster_seen or v_ego_cluster != 0.0
v_ego = v_ego_cluster if self.v_ego_cluster_seen else car_state.vEgo
speed_conversion = CV.MS_TO_KPH if ui_state.is_metric else CV.MS_TO_MPH
self.speed = max(0.0, v_ego * speed_conversion)
def _render(self, rect: rl.Rectangle) -> None:
"""Render HUD elements to the screen."""
self._torque_bar.render(rect)
if self.is_cruise_set:
self._draw_set_speed(rect)
self._draw_steering_wheel(rect)
def _draw_steering_wheel(self, rect: rl.Rectangle) -> None:
wheel_txt = self._txt_wheel_critical if self._show_wheel_critical else self._txt_wheel
bsm_detected = self._has_blind_spot_detected() if gui_app.iqpilot_ui() else False
if self._show_wheel_critical:
self._wheel_alpha_filter.update(255)
self._wheel_y_filter.update(0)
else:
if ui_state.status == UIStatus.DISENGAGED or bsm_detected:
self._wheel_alpha_filter.update(0)
self._wheel_y_filter.update(wheel_txt.height / 2)
else:
self._wheel_alpha_filter.update(255 * 0.9)
self._wheel_y_filter.update(0)
# pos
pos_x = int(rect.x + 21 + wheel_txt.width / 2)
pos_y = int(rect.y + rect.height - 14 - wheel_txt.height / 2 + self._wheel_y_filter.x)
rotation = -ui_state.sm['carState'].steeringAngleDeg
turn_intent_margin = 25
self._turn_intent.render(rl.Rectangle(
pos_x - wheel_txt.width / 2 - turn_intent_margin,
pos_y - wheel_txt.height / 2 - turn_intent_margin,
wheel_txt.width + turn_intent_margin * 2,
wheel_txt.height + turn_intent_margin * 2,
))
src_rect = rl.Rectangle(0, 0, wheel_txt.width, wheel_txt.height)
dest_rect = rl.Rectangle(pos_x, pos_y, wheel_txt.width, wheel_txt.height)
origin = (wheel_txt.width / 2, wheel_txt.height / 2)
# color and draw
color = rl.Color(255, 255, 255, int(self._wheel_alpha_filter.x))
rl.draw_texture_pro(wheel_txt, src_rect, dest_rect, origin, rotation, color)
if self._show_wheel_critical:
# Draw exclamation point icon
EXCLAMATION_POINT_SPACING = 10
exclamation_pos_x = pos_x - self._txt_exclamation_point.width / 2 + wheel_txt.width / 2 + EXCLAMATION_POINT_SPACING
exclamation_pos_y = pos_y - self._txt_exclamation_point.height / 2
rl.draw_texture(self._txt_exclamation_point, int(exclamation_pos_x), int(exclamation_pos_y), rl.WHITE)
def _draw_set_speed(self, rect: rl.Rectangle) -> None:
"""Draw the MAX speed indicator box."""
alpha = self._set_speed_alpha_filter.update(0 < rl.get_time() - self._set_speed_changed_time < SET_SPEED_PERSISTENCE and
self._can_draw_top_icons and self._engaged)
if alpha < 1e-2:
return
x = rect.x
y = rect.y
# draw drop shadow
circle_radius = 162 // 2
draw_circle_gradient(int(x + circle_radius), int(y + circle_radius), circle_radius,
rl.Color(0, 0, 0, int(255 / 2 * alpha)), rl.BLANK)
set_speed_color = rl.Color(255, 255, 255, int(255 * 0.9 * alpha))
max_color = rl.Color(255, 255, 255, int(255 * 0.9 * alpha))
set_speed = self.set_speed
if self.is_cruise_set and not ui_state.is_metric:
set_speed *= KM_TO_MILE
set_speed_text = CRUISE_DISABLED_CHAR if not self.is_cruise_set else str(round(set_speed))
rl.draw_text_ex(
self._font_display,
set_speed_text,
rl.Vector2(x + 13 + 4, y + 3 - 8 - 3 + 4),
FONT_SIZES.set_speed,
0,
set_speed_color,
)
max_text = tr("MAX")
rl.draw_text_ex(
self._font_semi_bold,
max_text,
rl.Vector2(x + 25, y + FONT_SIZES.set_speed - 7 + 4),
FONT_SIZES.max_speed,
0,
max_color,
)
def _draw_current_speed(self, rect: rl.Rectangle) -> None:
"""Draw the current vehicle speed and unit."""
speed_text = str(round(self.speed))
speed_text_size = measure_text_cached(self._font_bold, speed_text, FONT_SIZES.current_speed)
speed_pos = rl.Vector2(rect.x + rect.width / 2 - speed_text_size.x / 2, 180 - speed_text_size.y / 2)
rl.draw_text_ex(self._font_bold, speed_text, speed_pos, FONT_SIZES.current_speed, 0, COLORS.WHITE)
unit_text = tr("km/h") if ui_state.is_metric else tr("mph")
unit_text_size = measure_text_cached(self._font_medium, unit_text, FONT_SIZES.speed_unit)
unit_pos = rl.Vector2(rect.x + rect.width / 2 - unit_text_size.x / 2, 290 - unit_text_size.y / 2)
rl.draw_text_ex(self._font_medium, unit_text, unit_pos, FONT_SIZES.speed_unit, 0, COLORS.WHITE_TRANSLUCENT)

View File

@@ -0,0 +1,502 @@
import colorsys
import numpy as np
import pyray as rl
from iqpilot.cereal import messaging, car
from dataclasses import dataclass, field
from iqpilot.common.params import Params
from iqpilot.common.filter_simple import FirstOrderFilter
from iqpilot.selfdrive.locationd.calibrationd import HEIGHT_INIT
from iqpilot.ui.onroad.hud_overlays import ChevronMetrics
from iqpilot.ui.onroad.lead_confidence import driving_confidence
from iqpilot.selfdrive.locationd.calibration_helpers import get_render_path_height
from iqpilot.selfdrive.ui.ui_state import ui_state, UIStatus
from iqpilot.selfdrive.ui.mici.onroad import blend_colors
from iqpilot.system.ui.lib.application import gui_app
from iqpilot.system.ui.lib.shader_polygon import draw_polygon, Gradient
from iqpilot.system.ui.widgets import Widget
CLIP_MARGIN = 500
MIN_DRAW_DISTANCE = 10.0
MAX_DRAW_DISTANCE = 100.0
THROTTLE_COLORS = [
rl.Color(13, 248, 122, 102), # HSLF(148/360, 0.94, 0.51, 0.4)
rl.Color(114, 255, 92, 89), # HSLF(112/360, 1.0, 0.68, 0.35)
rl.Color(114, 255, 92, 0), # HSLF(112/360, 1.0, 0.68, 0.0)
]
NO_THROTTLE_COLORS = [
rl.Color(242, 242, 242, 102), # HSLF(148/360, 0.0, 0.95, 0.4)
rl.Color(242, 242, 242, 89), # HSLF(112/360, 0.0, 0.95, 0.35)
rl.Color(242, 242, 242, 0), # HSLF(112/360, 0.0, 0.95, 0.0)
]
LANE_LINE_COLORS = {
UIStatus.DISENGAGED: rl.Color(200, 200, 200, 255),
UIStatus.OVERRIDE: rl.Color(255, 255, 255, 255),
UIStatus.ENGAGED: rl.Color(0, 255, 64, 255),
}
@dataclass
class ModelPoints:
raw_points: np.ndarray = field(default_factory=lambda: np.empty((0, 3), dtype=np.float32))
projected_points: np.ndarray = field(default_factory=lambda: np.empty((0, 2), dtype=np.float32))
@dataclass
class LeadVehicle:
center: tuple[float, float] | None = None
radius: float = 0.0
sz: float = 0.0
fill_alpha: int = 0
class ModelRenderer(Widget):
def __init__(self):
super().__init__()
self.chevron_metrics = ChevronMetrics()
self._lead_orb = gui_app.texture("icons/lead_orb.png", 256, 256)
self._longitudinal_control = False
self._experimental_mode = False
self._blend_filter = FirstOrderFilter(1.0, 0.25, 1 / gui_app.target_fps)
self._prev_allow_throttle = True
self._lane_line_probs = np.zeros(4, dtype=np.float32)
self._road_edge_stds = np.zeros(2, dtype=np.float32)
self._lead_vehicles = [LeadVehicle(), LeadVehicle()]
self._path_offset_z = HEIGHT_INIT[0]
# Initialize ModelPoints objects
self._path = ModelPoints()
self._lane_lines = [ModelPoints() for _ in range(4)]
self._road_edges = [ModelPoints() for _ in range(2)]
self._acceleration_x = np.empty((0,), dtype=np.float32)
self._acceleration_x_filter = FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps)
self._acceleration_x_filter2 = FirstOrderFilter(0.0, 1, 1 / gui_app.target_fps)
self._torque_filter = FirstOrderFilter(0, 0.1, 1 / gui_app.target_fps)
self._ll_color_filter = FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps)
# Transform matrix (3x3 for car space to screen space)
self._car_space_transform = np.zeros((3, 3), dtype=np.float32)
self._transform_dirty = True
self._clip_region = None
self._counter = -1
self._camera_offset = ui_state.params.get("CameraOffset", return_default=True) if ui_state.active_bundle else 0.0
self._exp_gradient = Gradient(
start=(0.0, 1.0), # Bottom of path
end=(0.0, 0.0), # Top of path
colors=[],
stops=[],
)
# Get longitudinal control setting from car parameters
if car_params := Params().get("CarParams"):
cp = messaging.log_from_bytes(car_params, car.CarParams)
self._longitudinal_control = cp.openpilotLongitudinalControl
def set_transform(self, transform: np.ndarray):
self._car_space_transform = transform.astype(np.float32)
self._transform_dirty = True
def _render(self, rect: rl.Rectangle):
sm = ui_state.sm
driving_confidence.update()
if self._counter % 180 == 0: # This runs at 60fps, so we query every 3 seconds
self._camera_offset = ui_state.params.get("CameraOffset", return_default=True) if ui_state.active_bundle else 0.0
self._counter += 1
self._torque_filter.update(-ui_state.sm['carOutput'].actuatorsOutput.torque)
# Check if data is up-to-date
if (sm.recv_frame["extrinsicsCalibration"] < ui_state.started_frame or
sm.recv_frame["modelV2"] < ui_state.started_frame):
return
# Set up clipping region
self._clip_region = rl.Rectangle(
rect.x - CLIP_MARGIN, rect.y - CLIP_MARGIN, rect.width + 2 * CLIP_MARGIN, rect.height + 2 * CLIP_MARGIN
)
# Update state
self._experimental_mode = sm['selfdriveState'].experimentalMode
live_calib = sm['extrinsicsCalibration']
self._path_offset_z = get_render_path_height(live_calib)
if sm.updated['carParams']:
self._longitudinal_control = sm['carParams'].openpilotLongitudinalControl
model = sm['modelV2']
radar_state = sm['radarState'] if sm.valid['radarState'] else None
lead_one = radar_state.leadOne if radar_state else None
render_lead_indicator = self._longitudinal_control and radar_state is not None
# Update model data when needed
model_updated = sm.updated['modelV2']
if model_updated or sm.updated['radarState'] or self._transform_dirty:
if model_updated:
self._update_raw_points(model)
path_x_array = self._path.raw_points[:, 0]
if path_x_array.size == 0:
return
self._update_model(lead_one, path_x_array)
if render_lead_indicator:
self._update_leads(radar_state, path_x_array)
self._transform_dirty = False
# Draw elements (hide when disengaged)
if ui_state.status != UIStatus.DISENGAGED:
self._draw_lane_lines()
self._draw_path(sm)
if render_lead_indicator and radar_state:
self._draw_lead_indicator()
self.chevron_metrics.draw_lead_status(sm, radar_state, self._rect, self._lead_vehicles)
def _update_raw_points(self, model):
"""Update raw 3D points from model data"""
self._path.raw_points = np.array([model.position.x, np.array(model.position.y) + self._camera_offset, model.position.z], dtype=np.float32).T
for i, lane_line in enumerate(model.laneLines):
self._lane_lines[i].raw_points = np.array([lane_line.x, np.array(lane_line.y) + self._camera_offset, lane_line.z], dtype=np.float32).T
for i, road_edge in enumerate(model.roadEdges):
self._road_edges[i].raw_points = np.array([road_edge.x, np.array(road_edge.y) + self._camera_offset, road_edge.z], dtype=np.float32).T
self._lane_line_probs = np.array(model.laneLineProbs, dtype=np.float32)
self._road_edge_stds = np.array(model.roadEdgeStds, dtype=np.float32)
self._acceleration_x = np.array(model.acceleration.x, dtype=np.float32)
def _update_leads(self, radar_state, path_x_array):
"""Update positions of lead vehicles"""
self._lead_vehicles = [LeadVehicle(), LeadVehicle()]
leads = [radar_state.leadOne, radar_state.leadTwo]
for i, lead_data in enumerate(leads):
if lead_data and lead_data.status:
d_rel, y_rel, v_rel = lead_data.dRel, lead_data.yRel, lead_data.vRel
idx = self._get_path_length_idx(path_x_array, d_rel)
# Get z-coordinate from path at the lead vehicle position
z = self._path.raw_points[idx, 2] if idx < len(self._path.raw_points) else 0.0
point = self._map_to_screen(d_rel, -y_rel + self._camera_offset, z + self._path_offset_z)
if point:
self._lead_vehicles[i] = self._update_lead_vehicle(d_rel, v_rel, point, self._rect)
def _update_model(self, lead, path_x_array):
"""Update model visualization data based on model message"""
max_distance = np.clip(path_x_array[-1], MIN_DRAW_DISTANCE, MAX_DRAW_DISTANCE)
max_idx = self._get_path_length_idx(self._lane_lines[0].raw_points[:, 0], max_distance)
# Update lane lines using raw points
line_width_factor = 0.12
for i, lane_line in enumerate(self._lane_lines):
if i in (1, 2):
line_width_factor = 0.16
lane_line.projected_points = self._map_line_to_polygon(
lane_line.raw_points, line_width_factor * self._lane_line_probs[i], 0.0, max_idx
)
# Update road edges using raw points
for road_edge in self._road_edges:
road_edge.projected_points = self._map_line_to_polygon(road_edge.raw_points, line_width_factor, 0.0, max_idx)
# Update path using raw points
if lead and lead.status:
lead_d = lead.dRel * 2.0
max_distance = np.clip(lead_d - min(lead_d * 0.35, 10.0), 0.0, max_distance)
soon_acceleration = self._acceleration_x[len(self._acceleration_x) // 4] if len(self._acceleration_x) > 0 else 0
self._acceleration_x_filter.update(soon_acceleration)
self._acceleration_x_filter2.update(soon_acceleration)
# make path width wider/thinner when initially braking/accelerating
if self._experimental_mode and False:
high_pass_acceleration = self._acceleration_x_filter.x - self._acceleration_x_filter2.x
y_off = np.interp(high_pass_acceleration, [-1, 0, 1], [0.9 * 2, 0.9, 0.9 / 2])
else:
y_off = 0.9
max_idx = self._get_path_length_idx(path_x_array, max_distance)
self._path.projected_points = self._map_line_to_polygon(
self._path.raw_points, y_off, self._path_offset_z, max_idx, allow_invert=False
)
self._update_experimental_gradient()
def _update_experimental_gradient(self):
"""Pre-calculate experimental mode gradient colors"""
if not self._experimental_mode:
return
# reconstruct absolute (screen) points so the rect-space cull below stays correct
path_pts = self._path.projected_points + np.array([self._rect.x, self._rect.y], dtype=np.float32)
max_len = min(len(path_pts) // 2, len(self._acceleration_x))
segment_colors = []
gradient_stops = []
i = 0
while i < max_len:
# Some points (screen space) are out of frame (rect space)
track_y = path_pts[i][1]
if track_y < self._rect.y or track_y > (self._rect.y + self._rect.height):
i += 1
continue
# Calculate color based on acceleration (0 is bottom, 1 is top)
lin_grad_point = 1 - (track_y - self._rect.y) / self._rect.height
# speed up: 120, slow down: 0
path_hue = np.clip(60 + self._acceleration_x[i] * 35, 0, 120)
saturation = min(abs(self._acceleration_x[i] * 1.5), 1)
lightness = np.interp(saturation, [0.0, 1.0], [0.95, 0.62])
alpha = np.interp(lin_grad_point, [0.75 / 2.0, 0.75], [0.4, 0.0])
# Use HSL to RGB conversion
color = self._hsla_to_color(path_hue / 360.0, saturation, lightness, alpha)
gradient_stops.append(lin_grad_point)
segment_colors.append(color)
# Skip a point, unless next is last
i += 1 + (1 if (i + 2) < max_len else 0)
# Store the gradient in the path object
self._exp_gradient.colors = segment_colors
self._exp_gradient.stops = gradient_stops
def _update_lead_vehicle(self, d_rel, v_rel, point, rect):
speed_buff, lead_buff = 10.0, 40.0
# Calculate fill alpha
fill_alpha = 0
if d_rel < lead_buff:
fill_alpha = 255 * (1.0 - (d_rel / lead_buff))
if v_rel < 0:
fill_alpha += 255 * (-1 * (v_rel / speed_buff))
fill_alpha = min(fill_alpha, 255)
# Calculate size and position. Distance-scaled orb radius (closer lead -> bigger orb).
sz = np.clip((25 * 30) / (d_rel / 3 + 30), 15.0, 30.0) * 1
radius = sz * 1.1
# point is in absolute screen coords; clamp against the rect's absolute bounds so the orb stays
# fully on-screen (rect-relative bounds mis-placed it when the camera pane is offset, e.g. split nav)
x = np.clip(point[0], rect.x + radius, rect.x + rect.width - radius)
y = np.clip(point[1], rect.y + radius, rect.y + rect.height - radius)
return LeadVehicle(center=(float(x), float(y)), radius=float(radius), sz=float(sz), fill_alpha=int(fill_alpha))
def _get_ll_color(self, prob: float, adjacent: bool, left: bool):
alpha = np.clip(prob, 0.0, 0.7)
if adjacent:
_base_color = LANE_LINE_COLORS.get(ui_state.status, LANE_LINE_COLORS[UIStatus.DISENGAGED])
color = rl.Color(_base_color.r, _base_color.g, _base_color.b, int(alpha * 255))
# turn adjacent lls orange if torque is high
torque = self._torque_filter.x
high_torque = abs(torque) > 0.6
if high_torque and (left == (torque > 0)):
color = blend_colors(
color,
rl.Color(255, 115, 0, int(alpha * 255)), # orange
np.interp(abs(torque), [0.6, 0.8], [0.0, 1.0])
)
else:
color = rl.Color(255, 255, 255, int(alpha * 255))
if ui_state.status == UIStatus.DISENGAGED:
color = rl.Color(0, 0, 0, int(alpha * 255))
return color
def _draw_lane_lines(self):
"""Draw lane lines and road edges"""
"""Two closest lines should be green (lane line or road edges)"""
# projected_points are origin-relative (rect.x/y kept out of the transform so it stays cached);
# translate to the view's screen position here.
offset = np.array([self._rect.x, self._rect.y], dtype=np.float32)
for i, lane_line in enumerate(self._lane_lines):
if lane_line.projected_points.size == 0:
continue
color = self._get_ll_color(float(self._lane_line_probs[i]), i in (1, 2), i in (0, 1))
draw_polygon(self._rect, lane_line.projected_points + offset, color)
for i, road_edge in enumerate(self._road_edges):
if road_edge.projected_points.size == 0:
continue
# if closest lane lines are not confident, make road edges green
color = self._get_ll_color(float(1.0 - self._road_edge_stds[i]), float(self._lane_line_probs[i + 1]) < 0.25, i == 0)
draw_polygon(self._rect, road_edge.projected_points + offset, color)
def _draw_path(self, sm):
"""Draw path with dynamic coloring based on mode and throttle state."""
if not self._path.projected_points.size:
return
# projected_points are origin-relative; translate to the view's screen position
path_pts = self._path.projected_points + np.array([self._rect.x, self._rect.y], dtype=np.float32)
allow_throttle = sm['longitudinalPlan'].allowThrottle or not self._longitudinal_control
self._blend_filter.update(int(allow_throttle))
if self._experimental_mode:
# Draw with acceleration coloring
if ui_state.status == UIStatus.DISENGAGED:
draw_polygon(self._rect, path_pts, rl.Color(0, 0, 0, 90))
elif len(self._exp_gradient.colors) > 1:
draw_polygon(self._rect, path_pts, gradient=self._exp_gradient)
else:
draw_polygon(self._rect, path_pts, rl.Color(255, 255, 255, 30))
else:
# Blend throttle/no throttle colors based on transition
blend_factor = round(self._blend_filter.x * 100) / 100
blended_colors = self._blend_colors(NO_THROTTLE_COLORS, THROTTLE_COLORS, blend_factor)
gradient = Gradient(
start=(0.0, 1.0), # Bottom of path
end=(0.0, 0.0), # Top of path
colors=blended_colors,
stops=[0.0, 0.5, 1.0],
)
if ui_state.status == UIStatus.DISENGAGED:
draw_polygon(self._rect, path_pts, rl.Color(0, 0, 0, 90))
else:
draw_polygon(self._rect, path_pts, gradient=gradient)
def _draw_lead_indicator(self):
tint, _ = driving_confidence.colors()
src = rl.Rectangle(0, 0, self._lead_orb.width, self._lead_orb.height)
for lead in self._lead_vehicles:
if lead.center is None:
continue
cx, cy = lead.center
r = lead.radius
alpha = int(np.clip(140 + 115 * (lead.fill_alpha / 255.0), 0, 255))
dest = rl.Rectangle(cx, cy, r * 2.0, r * 2.0)
rl.draw_texture_pro(self._lead_orb, src, dest, rl.Vector2(r, r), 0.0, rl.Color(tint.r, tint.g, tint.b, alpha))
@staticmethod
def _get_path_length_idx(pos_x_array: np.ndarray, path_height: float) -> int:
"""Get the index corresponding to the given path height"""
if len(pos_x_array) == 0:
return 0
indices = np.where(pos_x_array <= path_height)[0]
return indices[-1] if indices.size > 0 else 0
def _map_to_screen(self, in_x, in_y, in_z):
"""Project a point in car space to screen space"""
input_pt = np.array([in_x, in_y, in_z])
pt = self._car_space_transform @ input_pt
if abs(pt[2]) < 1e-6:
return None
x, y = pt[0] / pt[2], pt[1] / pt[2]
clip = self._clip_region
if not (clip.x <= x <= clip.x + clip.width and clip.y <= y <= clip.y + clip.height):
return None
return (x, y)
def _map_line_to_polygon(self, line: np.ndarray, y_off: float, z_off: float, max_idx: int, allow_invert: bool = True) -> np.ndarray:
"""Convert 3D line to 2D polygon for rendering."""
if line.shape[0] == 0:
return np.empty((0, 2), dtype=np.float32)
# Slice points and filter non-negative x-coordinates
points = line[:max_idx + 1]
points = points[points[:, 0] >= 0]
if points.shape[0] == 0:
return np.empty((0, 2), dtype=np.float32)
N = points.shape[0]
# Generate left and right 3D points in one array using broadcasting
offsets = np.array([[0, -y_off, z_off], [0, y_off, z_off]], dtype=np.float32)
points_3d = points[None, :, :] + offsets[:, None, :] # Shape: 2xNx3
points_3d = points_3d.reshape(2 * N, 3) # Shape: (2*N)x3
# Transform all points to projected space in one operation
proj = self._car_space_transform @ points_3d.T # Shape: 3x(2*N)
proj = proj.reshape(3, 2, N)
left_proj = proj[:, 0, :]
right_proj = proj[:, 1, :]
# Filter points where z is sufficiently large
valid_proj = (np.abs(left_proj[2]) >= 1e-6) & (np.abs(right_proj[2]) >= 1e-6)
if not np.any(valid_proj):
return np.empty((0, 2), dtype=np.float32)
# Compute screen coordinates
left_screen = left_proj[:2, valid_proj] / left_proj[2, valid_proj][None, :]
right_screen = right_proj[:2, valid_proj] / right_proj[2, valid_proj][None, :]
# Define clip region bounds
clip = self._clip_region
x_min, x_max = clip.x, clip.x + clip.width
y_min, y_max = clip.y, clip.y + clip.height
# Filter points within clip region
left_in_clip = (
(left_screen[0] >= x_min) & (left_screen[0] <= x_max) &
(left_screen[1] >= y_min) & (left_screen[1] <= y_max)
)
right_in_clip = (
(right_screen[0] >= x_min) & (right_screen[0] <= x_max) &
(right_screen[1] >= y_min) & (right_screen[1] <= y_max)
)
both_in_clip = left_in_clip & right_in_clip
if not np.any(both_in_clip):
return np.empty((0, 2), dtype=np.float32)
# Select valid and clipped points
left_screen = left_screen[:, both_in_clip]
right_screen = right_screen[:, both_in_clip]
# Handle Y-coordinate inversion on hills
if not allow_invert and left_screen.shape[1] > 1:
y = left_screen[1, :] # y-coordinates
keep = y == np.minimum.accumulate(y)
if not np.any(keep):
return np.empty((0, 2), dtype=np.float32)
left_screen = left_screen[:, keep]
right_screen = right_screen[:, keep]
return np.vstack((left_screen.T, right_screen[:, ::-1].T)).astype(np.float32)
@staticmethod
def _hsla_to_color(h, s, l, a):
rgb = colorsys.hls_to_rgb(h, l, s)
return rl.Color(
int(rgb[0] * 255),
int(rgb[1] * 255),
int(rgb[2] * 255),
int(a * 255)
)
@staticmethod
def _blend_colors(begin_colors, end_colors, t):
if t >= 1.0:
return end_colors
if t <= 0.0:
return begin_colors
inv_t = 1.0 - t
return [rl.Color(
int(inv_t * start.r + t * end.r),
int(inv_t * start.g + t * end.g),
int(inv_t * start.b + t * end.b),
int(inv_t * start.a + t * end.a)
) for start, end in zip(begin_colors, end_colors, strict=True)]

View File

@@ -0,0 +1,270 @@
import math
import time
from functools import wraps
from collections import OrderedDict
import numpy as np
import pyray as rl
from iqpilot.selfdrive.ui.mici.onroad import blend_colors
from iqpilot.selfdrive.ui.ui_state import ui_state, UIStatus
from iqpilot.system.ui.lib.application import gui_app
from iqpilot.system.ui.lib.shader_polygon import draw_polygon, Gradient
from iqpilot.system.ui.widgets import Widget
from iqpilot.common.filter_simple import FirstOrderFilter
# TODO: arc_bar_pts doesn't consider rounded end caps part of the angle span
TORQUE_ANGLE_SPAN = 12.7
ANGLE_ARC_MAX_DEG = 45.0
TORQUE_REST_OFFSET = 22
TORQUE_REST_HEIGHT = 14
DEBUG = False
def quantized_lru_cache(maxsize=128):
def decorator(func):
cache = OrderedDict()
@wraps(func)
def wrapper(r_mid, thickness, a0_deg, a1_deg, **kwargs):
# Quantize inputs: balanced for smoothness vs cache effectiveness. The arc is computed at
# the origin and translated at the call site, so cx/cy are NOT part of the key — that keeps
# the cache hot while the bar translates during a scroll/transition (stock PR #37946).
key = (round(r_mid),
round(thickness), # 1px precision for smoother height transitions
round(a0_deg * 10) / 10, # 0.1° precision for smoother angle transitions
round(a1_deg * 10) / 10,
tuple(sorted(kwargs.items())))
if key in cache:
cache.move_to_end(key)
else:
if len(cache) >= maxsize:
cache.popitem(last=False)
result = func(r_mid, thickness, a0_deg, a1_deg, **kwargs)
cache[key] = result
return cache[key]
return wrapper
return decorator
@quantized_lru_cache(maxsize=256)
def arc_bar_pts(r_mid: float, thickness: float,
a0_deg: float, a1_deg: float,
*, max_points: int = 100, cap_segs: int = 10,
cap_radius: float = 7, px_per_seg: float = 2.0) -> np.ndarray:
"""Return Nx2 np.float32 points for a single closed polygon (rounded thick arc), centered at origin.
The caller translates the returned points by (cx, cy) so this can stay cached while the bar moves."""
def get_cap(left: bool, a_deg: float):
# end cap at a1: center (a1), sweep a1→a1+180 (skip endpoints to avoid dupes)
# quarter arc (outer corner) at a1 with fixed pixel radius cap_radius
nx, ny = math.cos(math.radians(a_deg)), math.sin(math.radians(a_deg)) # outward normal
tx, ty = -ny, nx # tangent (CCW)
mx, my = nx * r_mid, ny * r_mid # mid-point at a1 (origin-centered)
if DEBUG:
rl.draw_circle(int(mx), int(my), 4, rl.PURPLE)
ex = mx + nx * (half - cap_radius)
ey = my + ny * (half - cap_radius)
if DEBUG:
rl.draw_circle(int(ex), int(ey), 2, rl.WHITE)
# sweep 90° in the local (t,n) frame: from outer edge toward inside
if not left:
alpha = np.deg2rad(np.linspace(90, 0, cap_segs + 2))[1:-1]
else:
alpha = np.deg2rad(np.linspace(180, 90, cap_segs + 2))[1:-1]
cap_end = np.c_[ex + np.cos(alpha) * cap_radius * tx + np.sin(alpha) * cap_radius * nx,
ey + np.cos(alpha) * cap_radius * ty + np.sin(alpha) * cap_radius * ny]
# bottom quarter (inner corner) at a1
ex2 = mx + nx * (-half + cap_radius)
ey2 = my + ny * (-half + cap_radius)
if DEBUG:
rl.draw_circle(int(ex2), int(ey2), 2, rl.WHITE)
if not left:
alpha2 = np.deg2rad(np.linspace(0, -90, cap_segs + 1))[:-1] # include 0 once, exclude -90
else:
alpha2 = np.deg2rad(np.linspace(90 - 90 - 90, 0 - 90 - 90, cap_segs + 1))[:-1]
cap_end_bot = np.c_[ex2 + np.cos(alpha2) * cap_radius * tx + np.sin(alpha2) * cap_radius * nx,
ey2 + np.cos(alpha2) * cap_radius * ty + np.sin(alpha2) * cap_radius * ny]
# append to the top quarter
if not left:
cap_end = np.vstack((cap_end, cap_end_bot))
else:
cap_end = np.vstack((cap_end_bot, cap_end))
return cap_end
if a1_deg < a0_deg:
a0_deg, a1_deg = a1_deg, a0_deg
half = thickness * 0.5
cap_radius = min(cap_radius, half)
span = max(1e-3, a1_deg - a0_deg)
# pick arc segment count from arc length, clamp to shader points[] budget
arc_len = r_mid * math.radians(span)
arc_segs = max(6, int(arc_len / px_per_seg))
max_arc = (max_points - (4 * cap_segs + 3)) // 2
arc_segs = max(6, min(arc_segs, max_arc))
# outer arc a0→a1
ang_o = np.deg2rad(np.linspace(a0_deg, a1_deg, arc_segs + 1))
outer = np.c_[np.cos(ang_o) * (r_mid + half),
np.sin(ang_o) * (r_mid + half)]
# end cap at a1
cap_end = get_cap(False, a1_deg)
# inner arc a1→a0
ang_i = np.deg2rad(np.linspace(a1_deg, a0_deg, arc_segs + 1))
inner = np.c_[np.cos(ang_i) * (r_mid - half),
np.sin(ang_i) * (r_mid - half)]
# start cap at a0
cap_start = get_cap(True, a0_deg)
pts = np.vstack((outer, cap_end, inner, cap_start, outer[:1])).astype(np.float32)
# Rotate to start from middle of cap for proper triangulation
pts = np.roll(pts, cap_segs, axis=0)
if DEBUG:
n = len(pts)
idx = int(time.monotonic() * 12) % max(1, n) # speed: 12 pts/sec
for i, (x, y) in enumerate(pts):
j = (i - idx) % n # rotate the gradient
t = j / n
color = rl.Color(255, int(255 * (1 - t)), int(255 * t), 255)
rl.draw_circle(int(x), int(y), 2, color)
return pts
class TorqueBar(Widget):
def __init__(self, demo: bool = False, scale: float = 1.0, always: bool = False):
super().__init__()
self._demo = demo
self._scale = scale
self._always = always
self._torque_filter = FirstOrderFilter(0, 0.1, 1 / gui_app.target_fps)
self._torque_line_alpha_filter = FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps)
@staticmethod
def resting_bottom(rect: rl.Rectangle, scale: float = 1.0) -> float:
"""Lower edge of the arc at zero torque, the bar's lowest resting position."""
return rect.y + rect.height - TORQUE_REST_OFFSET * scale
def update_filter(self, value: float):
"""Update the torque filter value (for demo mode)."""
self._torque_filter.update(value)
def _update_state(self):
if self._demo:
return
# torque line
if ui_state.sm['controlsState'].lateralControlState.which() == 'angleState':
controls_state = ui_state.sm['controlsState']
car_control = ui_state.sm['carControl']
if not car_control.latActive:
self._torque_filter.update(0.0)
else:
desired_angle = controls_state.lateralControlState.angleState.steeringAngleDesiredDeg
angle_offset = ui_state.sm['vehicleParameters'].angleOffsetAverageDeg
# Angle-control cars should render the steering arc from the requested angle
# directly, not from curvature/lateral acceleration, which collapses at low speed.
# Subtract the vehicleParameters angle offset so the bar reads zero when going straight
# despite sensor misalignment.
self._torque_filter.update(np.clip(-(desired_angle - angle_offset) / ANGLE_ARC_MAX_DEG, -1, 1))
else:
self._torque_filter.update(-ui_state.sm['carOutput'].actuatorsOutput.torque)
def _render(self, rect: rl.Rectangle) -> None:
# adjust y pos with torque
torque_line_offset = np.interp(abs(self._torque_filter.x), [0.5, 1], [TORQUE_REST_OFFSET * self._scale, 26 * self._scale])
torque_line_height = np.interp(abs(self._torque_filter.x), [0.5, 1], [TORQUE_REST_HEIGHT * self._scale, 56 * self._scale])
# animate alpha and angle span
if not self._demo:
self._torque_line_alpha_filter.update(ui_state.status not in (UIStatus.DISENGAGED, UIStatus.LONG_ONLY))
else:
self._torque_line_alpha_filter.update(1.0)
torque_line_bg_alpha = np.interp(abs(self._torque_filter.x), [0.5, 1.0], [0.25, 0.5])
torque_line_bg_color = rl.Color(255, 255, 255, int(255 * torque_line_bg_alpha * self._torque_line_alpha_filter.x))
if ui_state.status not in (UIStatus.ENGAGED, UIStatus.LAT_ONLY) and not self._demo:
torque_line_bg_color = rl.Color(255, 255, 255, int(255 * 0.15 * self._torque_line_alpha_filter.x))
# draw curved line polygon torque bar
torque_line_radius = 1200 * self._scale
top_angle = -90
torque_bg_angle_span = self._torque_line_alpha_filter.x * TORQUE_ANGLE_SPAN
torque_start_angle = top_angle - torque_bg_angle_span / 2
torque_end_angle = top_angle + torque_bg_angle_span / 2
# centerline radius & center (you already have these values)
mid_r = torque_line_radius + torque_line_height / 2
cx = rect.x + rect.width / 2 + 8 # offset 8px to right of camera feed
cy = rect.y + rect.height + torque_line_radius - torque_line_offset
# arc_bar_pts is origin-centered + cached; translate to (cx, cy) here so the cache stays hot
# while the bar slides during a scroll/transition.
offset = np.array([cx, cy], dtype=np.float32)
# draw bg torque indicator line
bg_pts = arc_bar_pts(mid_r, torque_line_height, torque_start_angle, torque_end_angle, cap_radius=7 * self._scale) + offset
draw_polygon(rect, bg_pts, color=torque_line_bg_color)
# draw torque indicator line
a0s = top_angle
a1s = a0s + torque_bg_angle_span / 2 * self._torque_filter.x
sl_pts = arc_bar_pts(mid_r, torque_line_height, a0s, a1s, cap_radius=7 * self._scale) + offset
# draw beautiful gradient from center to 65% of the bg torque bar width
start_grad_pt = cx / rect.width
if self._torque_filter.x < 0:
end_grad_pt = (cx * (1 - 0.65) + (min(bg_pts[:, 0]) * 0.65)) / rect.width
else:
end_grad_pt = (cx * (1 - 0.65) + (max(bg_pts[:, 0]) * 0.65)) / rect.width
# Fade to the requested accent colors as we approach max torque.
start_color = blend_colors(
rl.Color(255, 255, 255, int(255 * 0.9 * self._torque_line_alpha_filter.x)),
rl.Color(255, 200, 0, int(255 * self._torque_line_alpha_filter.x)), # yellow (match stock)
max(0, abs(self._torque_filter.x) - 0.75) * 4,
)
end_color = blend_colors(
rl.Color(255, 255, 255, int(255 * 0.9 * self._torque_line_alpha_filter.x)),
rl.Color(255, 115, 0, int(255 * self._torque_line_alpha_filter.x)), # orange (match stock)
max(0, abs(self._torque_filter.x) - 0.75) * 4,
)
if ui_state.status not in (UIStatus.ENGAGED, UIStatus.LAT_ONLY) and not self._demo:
start_color = end_color = rl.Color(255, 255, 255, int(255 * 0.35 * self._torque_line_alpha_filter.x))
gradient = Gradient(
start=(start_grad_pt, 0),
end=(end_grad_pt, 0),
colors=[
start_color,
end_color,
],
stops=[0.0, 1.0],
)
draw_polygon(rect, sl_pts, gradient=gradient)
# draw center torque bar dot
if abs(self._torque_filter.x) < 0.5:
dot_y = self._rect.y + self._rect.height - torque_line_offset - torque_line_height / 2
rl.draw_circle(int(cx), int(dot_y), (10 // 2 * self._scale),
rl.Color(182, 182, 182, int(255 * 0.9 * self._torque_line_alpha_filter.x)))

View File

@@ -0,0 +1,834 @@
"""
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
"""
import math
import time
import pyray as rl
from typing import Union
from enum import Enum
from collections.abc import Callable
from iqpilot.system.ui.widgets import Widget
from iqpilot.system.ui.widgets.label import MiciLabel
from iqpilot.system.ui.widgets.scroller import DO_ZOOM
from iqpilot.system.ui.lib.text_measure import measure_text_cached
from iqpilot.system.ui.lib.application import gui_app, FontWeight, MousePos
from iqpilot.common.filter_simple import BounceFilter
from iqpilot.system.ui.lib.multilang import tr
try:
from iqpilot.common.params import Params
except ImportError:
Params = None
try:
from iqpilot.ui.theme import NeonTheme
except ImportError:
# Fallback theme if iqpilot layer not available
class _FallbackTheme:
def glow(self, alpha=255): return rl.Color(0, 255, 245, alpha)
def glow_mid(self, alpha=130): return rl.Color(0, 255, 245, alpha)
def glow_outer(self, alpha=45): return rl.Color(0, 255, 245, alpha)
def bg(self): return rl.Color(0, 26, 25, 255)
def bg_pressed(self): return rl.Color(0, 33, 32, 255)
NeonTheme = _FallbackTheme()
SCROLLING_SPEED_PX_S = 50
COMPLICATION_SIZE = 36
LABEL_COLOR = rl.Color(255, 255, 255, int(255 * 0.9))
LABEL_HORIZONTAL_PADDING = 40
COMPLICATION_GREY = rl.Color(0xAA, 0xAA, 0xAA, 255)
PRESSED_SCALE = 1.15 if DO_ZOOM else 1.07
class ScrollState(Enum):
PRE_SCROLL = 0
SCROLLING = 1
POST_SCROLL = 2
class BigCircleButton(Widget):
def __init__(self, icon: str, red: bool = False, icon_size: tuple[int, int] = (64, 53), icon_offset: tuple[int, int] = (0, 0)):
super().__init__()
self._red = red
self._icon_offset = icon_offset
# State
self.set_rect(rl.Rectangle(0, 0, 180, 180))
self._press_state_enabled = True
self._scale_filter = BounceFilter(1.0, 0.1, 1 / gui_app.target_fps)
# Icons
self._txt_icon = gui_app.texture(icon, *icon_size)
self._txt_btn_disabled_bg = gui_app.texture("icons_mici/buttons/button_circle_disabled.png", 180, 180)
self._txt_btn_bg = gui_app.texture("icons_mici/buttons/button_circle.png", 180, 180)
self._txt_btn_pressed_bg = gui_app.texture("icons_mici/buttons/button_circle_hover.png", 180, 180)
self._txt_btn_red_bg = gui_app.texture("icons_mici/buttons/button_circle_red.png", 180, 180)
self._txt_btn_red_pressed_bg = gui_app.texture("icons_mici/buttons/button_circle_red_hover.png", 180, 180)
def set_enable_pressed_state(self, pressed: bool):
self._press_state_enabled = pressed
def _render(self, _):
# draw background
txt_bg = self._txt_btn_bg if not self._red else self._txt_btn_red_bg
if not self.enabled:
txt_bg = self._txt_btn_disabled_bg
elif self.is_pressed and self._press_state_enabled:
txt_bg = self._txt_btn_pressed_bg if not self._red else self._txt_btn_red_pressed_bg
scale = self._scale_filter.update(PRESSED_SCALE if self.is_pressed and self._press_state_enabled else 1.0)
btn_x = self._rect.x + (self._rect.width * (1 - scale)) / 2
btn_y = self._rect.y + (self._rect.height * (1 - scale)) / 2
rl.draw_texture_ex(txt_bg, (btn_x, btn_y), 0, scale, rl.WHITE)
# draw icon
icon_color = rl.WHITE if self.enabled else rl.Color(255, 255, 255, int(255 * 0.35))
rl.draw_texture(self._txt_icon, int(self._rect.x + (self._rect.width - self._txt_icon.width) / 2 + self._icon_offset[0]),
int(self._rect.y + (self._rect.height - self._txt_icon.height) / 2 + self._icon_offset[1]), icon_color)
class BigCircleToggle(BigCircleButton):
def __init__(self, icon: str, toggle_callback: Callable | None = None, icon_size: tuple[int, int] = (64, 53), icon_offset: tuple[int, int] = (0, 0)):
super().__init__(icon, False, icon_size=icon_size, icon_offset=icon_offset)
self._toggle_callback = toggle_callback
# State
self._checked = False
# Icons
self._txt_toggle_enabled = gui_app.texture("icons_mici/buttons/toggle_dot_enabled.png", 66, 66)
self._txt_toggle_disabled = gui_app.texture("icons_mici/buttons/toggle_dot_disabled.png", 66, 66)
def set_checked(self, checked: bool):
self._checked = checked
def _handle_mouse_release(self, mouse_pos: MousePos):
super()._handle_mouse_release(mouse_pos)
self._checked = not self._checked
if self._toggle_callback:
self._toggle_callback(self._checked)
def _render(self, _):
super()._render(_)
# draw status icon
rl.draw_texture(self._txt_toggle_enabled if self._checked else self._txt_toggle_disabled,
int(self._rect.x + (self._rect.width - self._txt_toggle_enabled.width) / 2),
int(self._rect.y + 5), rl.WHITE)
class BigButton(Widget):
"""A lightweight stand-in for the Qt BigButton, drawn & updated each frame."""
def __init__(self, text: str, value: str = "", icon: Union[str, rl.Texture] = "", icon_size: tuple[int, int] = (64, 64)):
super().__init__()
self.set_rect(rl.Rectangle(0, 0, 402, 180))
self.text = text
self.value = value
self._icon_size = icon_size
self.set_icon(icon)
self._scale_filter = BounceFilter(1.0, 0.1, 1 / gui_app.target_fps)
self._rotate_icon_t: float | None = None
self._label_font = gui_app.font(FontWeight.DISPLAY)
self._value_font = gui_app.font(FontWeight.ROMAN)
self._label = MiciLabel(text, font_size=self._get_label_font_size(), width=int(self._rect.width - LABEL_HORIZONTAL_PADDING * 2),
font_weight=FontWeight.DISPLAY, color=LABEL_COLOR,
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM, wrap_text=True)
self._sub_label = MiciLabel(value, font_size=COMPLICATION_SIZE, width=int(self._rect.width - LABEL_HORIZONTAL_PADDING * 2),
font_weight=FontWeight.ROMAN, color=COMPLICATION_GREY,
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM, wrap_text=True)
self._load_images()
# internal state
self._scroll_offset = 0 # in pixels
self._needs_scroll = measure_text_cached(self._label_font, text, self._get_label_font_size()).x + 25 > self._rect.width
self._scroll_timer = 0
self._scroll_state = ScrollState.PRE_SCROLL
def set_icon(self, icon: Union[str, rl.Texture]):
self._txt_icon = gui_app.texture(icon, *self._icon_size) if isinstance(icon, str) and len(icon) else icon
def set_rotate_icon(self, rotate: bool):
if rotate and self._rotate_icon_t is not None:
return
self._rotate_icon_t = rl.get_time() if rotate else None
def _load_images(self):
self._txt_default_bg = gui_app.texture("icons_mici/buttons/button_rectangle.png", 402, 180)
self._txt_pressed_bg = gui_app.texture("icons_mici/buttons/button_rectangle_pressed.png", 402, 180)
self._txt_disabled_bg = gui_app.texture("icons_mici/buttons/button_rectangle_disabled.png", 402, 180)
self._txt_hover_bg = gui_app.texture("icons_mici/buttons/button_rectangle_hover.png", 402, 180)
def _get_label_font_size(self):
if len(self.text) < 12:
font_size = 64
elif len(self.text) < 17:
font_size = 48
elif len(self.text) < 20:
font_size = 42
else:
font_size = 36
if self.value:
font_size -= 20
return font_size
def set_text(self, text: str):
self.text = text
self._label.set_text(text)
def set_value(self, value: str):
self.value = value
self._sub_label.set_text(value)
def get_value(self) -> str:
return self.value
def get_text(self):
return self.text
def _update_state(self):
# hold on text for a bit, scroll, hold again, reset
if self._needs_scroll:
"""`dt` should be seconds since last frame (rl.get_frame_time())."""
# TODO: this comment is generated by GPT, prob wrong and misused
dt = rl.get_frame_time()
self._scroll_timer += dt
if self._scroll_state == ScrollState.PRE_SCROLL:
if self._scroll_timer < 0.5:
return
self._scroll_state = ScrollState.SCROLLING
self._scroll_timer = 0
elif self._scroll_state == ScrollState.SCROLLING:
self._scroll_offset -= SCROLLING_SPEED_PX_S * dt
# reset when text has completely left the button + 50 px gap
# TODO: use global constant for 30+30 px gap
# TODO: add std Widget padding option integrated into the self._rect
full_len = measure_text_cached(self._label_font, self.text, self._get_label_font_size()).x + 30 + 30
if self._scroll_offset < (self._rect.width - full_len):
self._scroll_state = ScrollState.POST_SCROLL
self._scroll_timer = 0
elif self._scroll_state == ScrollState.POST_SCROLL:
# wait for a bit before starting to scroll again
if self._scroll_timer < 0.75:
return
self._scroll_state = ScrollState.PRE_SCROLL
self._scroll_timer = 0
self._scroll_offset = 0
def _render(self, _):
# draw _txt_default_bg
txt_bg = self._txt_default_bg
if not self.enabled:
txt_bg = self._txt_disabled_bg
elif self.is_pressed:
txt_bg = self._txt_hover_bg
scale = self._scale_filter.update(PRESSED_SCALE if self.is_pressed else 1.0)
btn_x = self._rect.x + (self._rect.width * (1 - scale)) / 2
btn_y = self._rect.y + (self._rect.height * (1 - scale)) / 2
rl.draw_texture_ex(txt_bg, (btn_x, btn_y), 0, scale, rl.WHITE)
# LABEL ------------------------------------------------------------------
lx = self._rect.x + LABEL_HORIZONTAL_PADDING
ly = btn_y + self._rect.height - 33 # - 40# - self._get_label_font_size() / 2
if self.value:
self._sub_label.set_position(lx, ly)
ly -= self._sub_label.font_size + 9
self._sub_label.render()
label_color = LABEL_COLOR if self.enabled else rl.Color(255, 255, 255, int(255 * 0.35))
self._label.set_color(label_color)
self._label.set_position(lx, ly)
self._label.render()
# ICON -------------------------------------------------------------------
if self._txt_icon:
rotation = 0
if self._rotate_icon_t is not None:
rotation = (rl.get_time() - self._rotate_icon_t) * 180
# drop top right with 30px padding
x = self._rect.x + self._rect.width - 30 - self._txt_icon.width / 2
y = self._rect.y + 30 + self._txt_icon.height / 2
source_rec = rl.Rectangle(0, 0, self._txt_icon.width, self._txt_icon.height)
dest_rec = rl.Rectangle(int(x), int(y), self._txt_icon.width, self._txt_icon.height)
origin = rl.Vector2(self._txt_icon.width / 2, self._txt_icon.height / 2)
rl.draw_texture_pro(self._txt_icon, source_rec, dest_rec, origin, rotation, rl.WHITE)
class BigToggle(BigButton):
def __init__(self, text: str, value: str = "", initial_state: bool = False, toggle_callback: Callable | None = None):
super().__init__(text, value, "")
self._checked = initial_state
self._toggle_callback = toggle_callback
self._label.set_font_size(48)
def _load_images(self):
super()._load_images()
self._txt_enabled_toggle = gui_app.texture("icons_mici/buttons/toggle_pill_enabled.png", 84, 66)
self._txt_disabled_toggle = gui_app.texture("icons_mici/buttons/toggle_pill_disabled.png", 84, 66)
def set_checked(self, checked: bool):
self._checked = checked
def _handle_mouse_release(self, mouse_pos: MousePos):
super()._handle_mouse_release(mouse_pos)
self._checked = not self._checked
if self._toggle_callback:
self._toggle_callback(self._checked)
def _draw_pill(self, x: float, y: float, checked: bool):
# draw toggle icon top right
if checked:
rl.draw_texture(self._txt_enabled_toggle, int(x), int(y), rl.WHITE)
else:
rl.draw_texture(self._txt_disabled_toggle, int(x), int(y), rl.WHITE)
def _render(self, _):
super()._render(_)
x = self._rect.x + self._rect.width - self._txt_enabled_toggle.width
y = self._rect.y
self._draw_pill(x, y, self._checked)
class BigMultiToggle(BigToggle):
def __init__(self, text: str, options: list[str], toggle_callback: Callable | None = None,
select_callback: Callable | None = None):
super().__init__(text, "", toggle_callback=toggle_callback)
assert len(options) > 0
self._options = options
self._select_callback = select_callback
self._label.set_width(int(self._rect.width - LABEL_HORIZONTAL_PADDING * 2 - self._txt_enabled_toggle.width))
# TODO: why isn't this automatic?
self._label.set_font_size(self._get_label_font_size())
self.set_value(self._options[0])
def _get_label_font_size(self):
font_size = super()._get_label_font_size()
return font_size - 6
def _handle_mouse_release(self, mouse_pos: MousePos):
super()._handle_mouse_release(mouse_pos)
cur_idx = self._options.index(self.value)
new_idx = (cur_idx + 1) % len(self._options)
self.set_value(self._options[new_idx])
if self._select_callback:
self._select_callback(self.value)
def _render(self, _):
BigButton._render(self, _)
checked_idx = self._options.index(self.value)
x = self._rect.x + self._rect.width - self._txt_enabled_toggle.width
y = self._rect.y
for i in range(len(self._options)):
self._draw_pill(x, y, checked_idx == i)
y += 35
class BigMultiParamToggle(BigMultiToggle):
def __init__(self, text: str, param: str, options: list[str], toggle_callback: Callable | None = None,
select_callback: Callable | None = None):
super().__init__(text, options, toggle_callback, select_callback)
self._param = param
self._params = Params()
self._load_value()
def _load_value(self):
self.set_value(self._options[self._params.get(self._param) or 0])
def _handle_mouse_release(self, mouse_pos: MousePos):
super()._handle_mouse_release(mouse_pos)
new_idx = self._options.index(self.value)
self._params.put_nonblocking(self._param, new_idx)
class BigParamControl(BigToggle):
def __init__(self, text: str, param: str, toggle_callback: Callable | None = None):
super().__init__(text, "", toggle_callback=toggle_callback)
self.param = param
self.params = Params()
self.set_checked(self.params.get_bool(self.param, False))
def _handle_mouse_release(self, mouse_pos: MousePos):
super()._handle_mouse_release(mouse_pos)
self.params.put_bool(self.param, self._checked)
def refresh(self):
self.set_checked(self.params.get_bool(self.param, False))
# TODO: param control base class
class BigCircleParamControl(BigCircleToggle):
def __init__(self, icon: str, param: str, toggle_callback: Callable | None = None, icon_size: tuple[int, int] = (64, 53),
icon_offset: tuple[int, int] = (0, 0)):
super().__init__(icon, toggle_callback, icon_size=icon_size, icon_offset=icon_offset)
self._param = param
self.params = Params()
self.set_checked(self.params.get_bool(self._param, False))
def _handle_mouse_release(self, mouse_pos: MousePos):
super()._handle_mouse_release(mouse_pos)
self.params.put_bool(self._param, self._checked)
def refresh(self):
self.set_checked(self.params.get_bool(self._param, False))
_CHIP_BG = rl.Color(0x30, 0x30, 0x30, 230)
_CHIP_TEXT_COLOR = rl.Color(0xDD, 0xDD, 0xDD, 255)
_CHIP_H = 28
_CHIP_FONT_SIZE = 18
_CHIP_H_PAD = 10
_CHIP_V_PAD = 5
_CHIP_RADIUS = 0.5
_CHIP_SPACING = 8
_NEON_CORNER_ROUND = 0.28
_NEON_CARD_PAD = 18
def _draw_neon_glow_halo(rect: rl.Rectangle, intensity: float = 1.0):
segs = 8
roundness = _NEON_CORNER_ROUND
for expand, alpha in [
(14, int(18 * intensity)),
(12, int(28 * intensity)),
(10, int(40 * intensity)),
(8, int(55 * intensity)),
(6, int(72 * intensity)),
(4, int(95 * intensity)),
(2, int(120 * intensity)),
]:
ex = rl.Rectangle(
rect.x - expand, rect.y - expand,
rect.width + expand * 2, rect.height + expand * 2,
)
rl.draw_rectangle_rounded(ex, roundness, segs, NeonTheme.glow_outer(alpha))
def _draw_neon_glow_border(rect: rl.Rectangle, intensity: float = 1.0):
segs = 8
roundness = _NEON_CORNER_ROUND
rl.draw_rectangle_rounded_lines_ex(rect, roundness, segs, 2.5,
NeonTheme.glow(int(255 * intensity)))
rl.draw_rectangle_rounded_lines_ex(rect, roundness, segs, 1.0,
rl.Color(255, 255, 255, int(180 * intensity)))
def _neon_title_font_size(text: str, has_chips: bool) -> int:
if len(text) < 10:
size = 52
elif len(text) < 14:
size = 42
elif len(text) < 18:
size = 36
else:
size = 30
if has_chips:
size = min(size, 36)
return size
class NeonBigButton(Widget):
CARD_W = 310
CARD_H = 160
def __init__(self, title: str, chips: list[str] | None = None,
click_callback: Callable | None = None):
super().__init__()
self._title_text = title
self._chips: list[str] = chips or []
self._click_callback = click_callback
self._born: float = time.monotonic()
self.set_rect(rl.Rectangle(0, 0, self.CARD_W, self.CARD_H))
self._label = MiciLabel(
title,
font_size=_neon_title_font_size(title, bool(chips)),
width=self.CARD_W - _NEON_CARD_PAD * 2,
font_weight=FontWeight.DISPLAY,
color=LABEL_COLOR,
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP,
wrap_text=True,
elide_right=False,
)
self._chip_font = gui_app.font(FontWeight.MEDIUM)
def set_chips(self, chips: list[str]):
self._chips = chips
# Re-size title now that we know if chips are present
self._label.set_font_size(_neon_title_font_size(self._title_text, bool(chips)))
def set_click_callback(self, cb: Callable | None):
self._click_callback = cb
def _glow_intensity(self) -> float:
t = time.monotonic() - self._born
return 0.65 + 0.35 * (0.5 + 0.5 * math.sin(t * 5.0))
def _render_chips(self, start_x: float, start_y: float):
x = start_x
for chip in self._chips:
text_w = int(measure_text_cached(self._chip_font, chip, _CHIP_FONT_SIZE).x)
w = text_w + _CHIP_H_PAD * 2
rl.draw_rectangle_rounded(rl.Rectangle(x, start_y, w, _CHIP_H),
_CHIP_RADIUS, 4, _CHIP_BG)
text_y = start_y + (_CHIP_H - _CHIP_FONT_SIZE) // 2
rl.draw_text_ex(
self._chip_font,
chip,
rl.Vector2(x + _CHIP_H_PAD, text_y),
_CHIP_FONT_SIZE,
0,
_CHIP_TEXT_COLOR,
)
x += w + _CHIP_SPACING
def _render(self, rect: rl.Rectangle) -> None:
intensity = self._glow_intensity()
_draw_neon_glow_halo(rect, intensity)
bg = NeonTheme.bg_pressed() if self.is_pressed else NeonTheme.bg()
rl.draw_rectangle_rounded(rect, _NEON_CORNER_ROUND, 6, bg)
_draw_neon_glow_border(rect, intensity)
chips_h = (_CHIP_H + 6) if self._chips else 0
title_rect = rl.Rectangle(
rect.x + _NEON_CARD_PAD,
rect.y + _NEON_CARD_PAD,
rect.width - _NEON_CARD_PAD * 2,
rect.height - _NEON_CARD_PAD - chips_h,
)
label_color = LABEL_COLOR if self.enabled else rl.Color(255, 255, 255, int(255 * 0.35))
self._label.set_color(label_color)
self._label.render(title_rect)
if self._chips:
chip_y = rect.y + rect.height - _CHIP_H - _NEON_CARD_PAD // 2
self._render_chips(rect.x + _NEON_CARD_PAD, chip_y)
def _handle_mouse_release(self, mouse_pos: MousePos):
super()._handle_mouse_release(mouse_pos)
if self._click_callback:
self._click_callback()
class NeonBigParamToggle(NeonBigButton):
def __init__(self, title: str, param: str,
sub_chips: list[str] | None = None,
toggle_callback: Callable | None = None):
super().__init__(title, chips=[tr("disabled")]) # pre-set so layout reserves chip space
self._param = param
self._params = Params() if Params else None
self._sub_chips: list[str] = sub_chips or []
self._toggle_callback = toggle_callback
self._checked: bool = False
self._load_value()
self._rebuild_chips()
def set_sub_chips(self, sub_chips: list[str]):
self._sub_chips = sub_chips
self._rebuild_chips()
def _load_value(self):
if self._params:
self._checked = self._params.get_bool(self._param, False)
def _rebuild_chips(self):
state = "enabled" if self._checked else "disabled"
self.set_chips([state] + self._sub_chips)
def _glow_intensity(self) -> float:
return super()._glow_intensity() if self._checked else 0.25
def _render(self, rect: rl.Rectangle) -> None:
super()._render(rect)
if not self._checked:
rl.draw_rectangle_rounded(rect, _NEON_CORNER_ROUND, 6, rl.Color(0, 0, 0, 120))
def _handle_mouse_release(self, mouse_pos: MousePos):
self._checked = not self._checked
if self._params:
self._params.put_bool(self._param, self._checked)
self._rebuild_chips()
if self._toggle_callback:
self._toggle_callback(self._checked)
def refresh(self):
self._load_value()
self._rebuild_chips()
class NeonBigCircleParamControl(BigCircleParamControl):
def __init__(self, icon: str, param: str,
toggle_callback: Callable | None = None,
icon_size: tuple[int, int] = (64, 53),
icon_offset: tuple[int, int] = (0, 0)):
super().__init__(icon, param, toggle_callback=toggle_callback,
icon_size=icon_size, icon_offset=icon_offset)
self._born = time.monotonic()
def _glow_intensity(self) -> float:
if not self._checked:
return 0.25
t = time.monotonic() - self._born
return 0.65 + 0.35 * (0.5 + 0.5 * math.sin(t * 5.0))
def _render(self, rect: rl.Rectangle):
intensity = self._glow_intensity()
roundness = 1.0
segs = 12
for expand, alpha in [
(12, int(14 * intensity)),
(10, int(22 * intensity)),
(8, int(35 * intensity)),
(6, int(50 * intensity)),
(4, int(70 * intensity)),
(2, int(95 * intensity)),
]:
ex = rl.Rectangle(rect.x - expand, rect.y - expand,
rect.width + expand * 2, rect.height + expand * 2)
rl.draw_rectangle_rounded(ex, roundness, segs, NeonTheme.glow_outer(alpha))
super()._render(rect)
rl.draw_rectangle_rounded_lines_ex(rect, roundness, segs, 2.5,
NeonTheme.glow(int(255 * intensity)))
rl.draw_rectangle_rounded_lines_ex(rect, roundness, segs, 1.0,
rl.Color(255, 255, 255, int(160 * intensity)))
class NeonBigMultiToggle(NeonBigButton):
def __init__(self, title: str, options: list[str],
select_callback: Callable | None = None):
assert len(options) > 0
super().__init__(title, chips=[options[0]])
self._options = options
self._value = options[0]
self._select_callback = select_callback
def set_value(self, value: str):
if value in self._options:
self._value = value
else:
self._value = self._options[0]
self.set_chips([self._value])
def get_value(self) -> str:
return self._value
def _handle_mouse_release(self, mouse_pos: MousePos):
super()._handle_mouse_release(mouse_pos)
idx = self._options.index(self._value)
self._value = self._options[(idx + 1) % len(self._options)]
self.set_chips([self._value])
if self._select_callback:
self._select_callback(self._value)
class NeonBigMultiParamToggle(NeonBigMultiToggle):
def __init__(self, title: str, param: str, options: list[str],
select_callback: Callable | None = None):
super().__init__(title, options, select_callback)
self._param = param
self._params = Params() if Params else None
self._load_value()
def _load_value(self):
if self._params:
try:
idx = int(self._params.get(self._param) or 0)
idx = max(0, min(idx, len(self._options) - 1))
except (TypeError, ValueError):
idx = 0
self.set_value(self._options[idx])
def _handle_mouse_release(self, mouse_pos: MousePos):
super()._handle_mouse_release(mouse_pos)
idx = self._options.index(self._value)
if self._params:
self._params.put_nonblocking(self._param, idx)
def refresh(self):
self._load_value()
class NeonMappedParamToggle(NeonBigMultiToggle):
def __init__(self, title: str, param: str, options: list[str], values: list[int],
select_callback: Callable | None = None):
super().__init__(title, options, select_callback)
assert len(options) == len(values)
self._param = param
self._values = values
self._params = Params() if Params else None
self._load_value()
def _load_value(self):
if self._params:
try:
current = int(self._params.get(self._param, return_default=True) or 0)
idx = self._values.index(current)
except (TypeError, ValueError):
idx = 0
self.set_value(self._options[idx])
def _handle_mouse_release(self, mouse_pos: MousePos):
super()._handle_mouse_release(mouse_pos)
idx = self._options.index(self._value)
if self._params:
self._params.put_nonblocking(self._param, self._values[idx])
def refresh(self):
self._load_value()
class NeonFloatMappedParamToggle(NeonBigMultiToggle):
def __init__(self, title: str, param: str, options: list[str], values: list[float],
select_callback: Callable | None = None):
super().__init__(title, options, select_callback)
assert len(options) == len(values)
self._param = param
self._values = values
self._params = Params() if Params else None
self._load_value()
def _load_value(self):
if self._params:
try:
current_val = float(self._params.get(self._param, return_default=True) or 0)
idx = min(range(len(self._values)), key=lambda i: abs(self._values[i] - current_val))
except (TypeError, ValueError):
idx = 1 if len(self._values) > 1 else 0
self.set_value(self._options[idx])
def _handle_mouse_release(self, mouse_pos: MousePos):
super()._handle_mouse_release(mouse_pos)
idx = self._options.index(self._value)
if self._params:
self._params.put_nonblocking(self._param, str(self._values[idx]))
def refresh(self):
self._load_value()
class DrumPickerButton(NeonBigButton):
def __init__(self, title: str, options: list[str]):
super().__init__(title, chips=[""])
self._options = options
def _read_current(self) -> str:
raise NotImplementedError
def _write_value(self, value: str):
raise NotImplementedError
def refresh(self):
self.set_chips([self._read_current()])
def _on_picked(self, value: str):
self._write_value(value)
self.set_chips([value])
def _handle_mouse_release(self, mouse_pos: MousePos):
# Bypass NeonBigButton cycle — open drum picker instead
Widget._handle_mouse_release(self, mouse_pos)
if getattr(self, '_swiping_away', False):
return
from iqpilot.selfdrive.ui.mici.widgets.drum_picker import DrumPickerDialog
from iqpilot.system.ui.lib.application import gui_app as _app
dlg = DrumPickerDialog(
title=self._title_text.lower(),
options=self._options,
current=self._read_current(),
confirm_callback=self._on_picked,
)
_app.set_modal_overlay(dlg)
class DrumParamButton(DrumPickerButton):
def __init__(self, title: str, param: str, options: list[str]):
self._param = param
self._params = Params() if Params else None
super().__init__(title, options)
self.refresh()
def _read_current(self) -> str:
try:
idx = int(self._params.get(self._param, return_default=True) or 0)
idx = max(0, min(idx, len(self._options) - 1))
except (TypeError, ValueError):
idx = 0
return self._options[idx]
def _write_value(self, value: str):
if value in self._options and self._params:
self._params.put_nonblocking(self._param, self._options.index(value))
class DrumMappedParamButton(DrumPickerButton):
def __init__(self, title: str, param: str, options: list[str], values: list[int]):
assert len(options) == len(values)
self._param = param
self._values = values
self._params = Params() if Params else None
super().__init__(title, options)
self.refresh()
def _read_current(self) -> str:
try:
raw = int(self._params.get(self._param, return_default=True) or 0)
idx = min(range(len(self._values)), key=lambda i: abs(self._values[i] - raw))
except (TypeError, ValueError):
idx = 0
return self._options[idx]
def _write_value(self, value: str):
if value in self._options and self._params:
idx = self._options.index(value)
self._params.put_nonblocking(self._param, int(self._values[idx]))
class DrumFloatMappedParamButton(DrumPickerButton):
def __init__(self, title: str, param: str, options: list[str], values: list[float]):
assert len(options) == len(values)
self._param = param
self._values = values
self._params = Params() if Params else None
super().__init__(title, options)
self.refresh()
def _read_current(self) -> str:
try:
raw = float(self._params.get(self._param, return_default=True) or 0)
idx = min(range(len(self._values)), key=lambda i: abs(self._values[i] - raw))
except (TypeError, ValueError):
idx = 0
return self._options[idx]
def _write_value(self, value: str):
if value in self._options and self._params:
idx = self._options.index(value)
self._params.put_nonblocking(self._param, float(self._values[idx]))

View File

@@ -0,0 +1,417 @@
"""
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
"""
import abc
import math
import pyray as rl
from typing import Union
from collections.abc import Callable
from typing import cast
from iqpilot.system.ui.widgets import Widget, NavWidget
from iqpilot.system.ui.widgets.label import UnifiedLabel, gui_label
from iqpilot.system.ui.widgets.mici_keyboard import MiciKeyboard
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.lib.application import gui_app, FontWeight, MousePos, MouseEvent
from iqpilot.system.ui.widgets.scroller import Scroller
from iqpilot.system.ui.widgets.slider import RedBigSlider, BigSlider
from iqpilot.common.filter_simple import FirstOrderFilter
from iqpilot.selfdrive.ui.mici.widgets.button import BigButton
from iqpilot.selfdrive.ui.mici.widgets.side_button import SideButton
DEBUG = False
PADDING = 20
class BigDialogBase(NavWidget, abc.ABC):
def __init__(self, right_btn: str | None = None, right_btn_callback: Callable | None = None):
super().__init__()
self.set_rect(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
self.set_back_callback(gui_app.pop_widget)
self._right_btn = None
if right_btn:
def right_btn_callback_wrapper():
gui_app.pop_widget()
if right_btn_callback:
right_btn_callback()
self._right_btn = SideButton(right_btn)
self._right_btn.set_click_callback(right_btn_callback_wrapper)
# move to right side
self._right_btn._rect.x = self._rect.x + self._rect.width - self._right_btn._rect.width
def _layout(self) -> None:
rl.draw_rectangle_rec(rl.Rectangle(0, 0, gui_app.width, gui_app.height), rl.Color(8, 9, 10, 255))
def _render(self, _):
if self._right_btn:
self._right_btn.set_position(self._right_btn._rect.x, self._rect.y)
self._right_btn.render()
class BigDialog(BigDialogBase):
def __init__(self,
title: str,
description: str,
right_btn: str | None = None,
right_btn_callback: Callable | None = None):
super().__init__(right_btn, right_btn_callback)
self._title = title
self._description = description
def _render(self, _):
super()._render(_)
# draw title
# TODO: we desperately need layouts
# TODO: coming up with these numbers manually is a pain and not scalable
# TODO: no clue what any of these numbers mean. VBox and HBox would remove all of this shite
max_width = self._rect.width - PADDING * 2
if self._right_btn:
max_width -= self._right_btn._rect.width
title_wrapped = '\n'.join(wrap_text(gui_app.font(FontWeight.BOLD), self._title, 50, int(max_width)))
title_size = measure_text_cached(gui_app.font(FontWeight.BOLD), title_wrapped, 50)
text_x_offset = 0
title_rect = rl.Rectangle(int(self._rect.x + text_x_offset + PADDING),
int(self._rect.y + PADDING),
int(max_width),
int(title_size.y))
gui_label(title_rect, title_wrapped, 50, font_weight=FontWeight.BOLD,
alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER)
# draw description
desc_wrapped = '\n'.join(wrap_text(gui_app.font(FontWeight.MEDIUM), self._description, 30, int(max_width)))
desc_size = measure_text_cached(gui_app.font(FontWeight.MEDIUM), desc_wrapped, 30)
desc_rect = rl.Rectangle(int(self._rect.x + text_x_offset + PADDING),
int(self._rect.y + self._rect.height / 3),
int(max_width),
int(desc_size.y))
# TODO: text align doesn't seem to work properly with newlines
gui_label(desc_rect, desc_wrapped, 30, font_weight=FontWeight.MEDIUM,
alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER)
class BigConfirmationDialogV2(BigDialogBase):
def __init__(self, title: str, icon: str, red: bool = False,
exit_on_confirm: bool = True,
confirm_callback: Callable | None = None):
super().__init__()
self._confirm_callback = confirm_callback
self._exit_on_confirm = exit_on_confirm
icon_txt = gui_app.texture(icon, 64, 53)
self._slider: BigSlider | RedBigSlider
if red:
self._slider = RedBigSlider(title, icon_txt, confirm_callback=self._on_confirm)
else:
self._slider = BigSlider(title, icon_txt, confirm_callback=self._on_confirm)
self._slider.set_enabled(lambda: self.enabled and not self._swiping_away) # self.enabled for nav stack
def _on_confirm(self):
if self._exit_on_confirm:
gui_app.pop_widget()
if self._confirm_callback:
self._confirm_callback()
def _update_state(self):
super()._update_state()
if self._swiping_away and not self._slider.confirmed:
self._slider.reset()
def _render(self, _):
self._slider.render(self._rect)
class BigInputDialog(BigDialogBase):
BACK_TOUCH_AREA_PERCENTAGE = 0.2
BACKSPACE_RATE = 25 # hz
TEXT_INPUT_SIZE = 35
def __init__(self,
hint: str,
default_text: str = "",
minimum_length: int = 1,
confirm_callback: Callable[[str], None] | None = None):
super().__init__(None, None)
self._hint_label = UnifiedLabel(hint, font_size=35, text_color=rl.Color(255, 255, 255, int(255 * 0.35)),
font_weight=FontWeight.MEDIUM)
self._keyboard = MiciKeyboard()
self._keyboard.set_text(default_text)
self._keyboard.set_enabled(lambda: self.enabled) # for nav stack
self._minimum_length = minimum_length
self._backspace_held_time: float | None = None
self._backspace_img = gui_app.texture("icons_mici/settings/keyboard/backspace.png", 42, 36)
self._backspace_img_alpha = FirstOrderFilter(0, 0.05, 1 / gui_app.target_fps)
self._enter_img = gui_app.texture("icons_mici/settings/keyboard/confirm.png", 42, 36)
self._enter_img_alpha = FirstOrderFilter(0, 0.05, 1 / gui_app.target_fps)
# rects for top buttons
self._top_left_button_rect = rl.Rectangle(0, 0, 0, 0)
self._top_right_button_rect = rl.Rectangle(0, 0, 0, 0)
def confirm_callback_wrapper():
text = self._keyboard.text()
gui_app.pop_widget()
if confirm_callback:
confirm_callback(text)
self._confirm_callback = confirm_callback_wrapper
def _update_state(self):
super()._update_state()
last_mouse_event = gui_app.last_mouse_event
if last_mouse_event.left_down and rl.check_collision_point_rec(last_mouse_event.pos, self._top_right_button_rect) and self._backspace_img_alpha.x > 1:
if self._backspace_held_time is None:
self._backspace_held_time = rl.get_time()
if rl.get_time() - self._backspace_held_time > 0.5:
if gui_app.frame % round(gui_app.target_fps / self.BACKSPACE_RATE) == 0:
self._keyboard.backspace()
else:
self._backspace_held_time = None
def _render(self, _):
# draw current text so far below everything. text floats left but always stays in view
text = self._keyboard.text()
candidate_char = self._keyboard.get_candidate_character()
text_size = measure_text_cached(gui_app.font(FontWeight.ROMAN), text + candidate_char or self._hint_label.text, self.TEXT_INPUT_SIZE)
bg_block_margin = 5
text_x = PADDING * 2 + self._enter_img.width + bg_block_margin
text_field_rect = rl.Rectangle(text_x, int(self._rect.y + PADDING) - bg_block_margin,
int(self._rect.width - text_x - PADDING * 2 - self._enter_img.width) - bg_block_margin * 2,
int(text_size.y))
# draw text input
# push text left with a gradient on left side if too long
if text_size.x > text_field_rect.width:
text_x -= text_size.x - text_field_rect.width
rl.begin_scissor_mode(int(text_field_rect.x), int(text_field_rect.y), int(text_field_rect.width), int(text_field_rect.height))
rl.draw_text_ex(gui_app.font(FontWeight.ROMAN), text, rl.Vector2(text_x, text_field_rect.y), self.TEXT_INPUT_SIZE, 0, rl.WHITE)
# draw grayed out character user is hovering over
if candidate_char:
candidate_char_size = measure_text_cached(gui_app.font(FontWeight.ROMAN), candidate_char, self.TEXT_INPUT_SIZE)
rl.draw_text_ex(gui_app.font(FontWeight.ROMAN), candidate_char,
rl.Vector2(min(text_x + text_size.x, text_field_rect.x + text_field_rect.width) - candidate_char_size.x, text_field_rect.y),
self.TEXT_INPUT_SIZE, 0, rl.Color(255, 255, 255, 128))
rl.end_scissor_mode()
# draw gradient on left side to indicate more text
if text_size.x > text_field_rect.width:
rl.draw_rectangle_gradient_h(int(text_field_rect.x), int(text_field_rect.y), 80, int(text_field_rect.height),
rl.BLACK, rl.BLANK)
# draw cursor
if text:
blink_alpha = (math.sin(rl.get_time() * 6) + 1) / 2
cursor_x = min(text_x + text_size.x + 3, text_field_rect.x + text_field_rect.width)
rl.draw_rectangle_rounded(rl.Rectangle(int(cursor_x), int(text_field_rect.y), 4, int(text_size.y)),
1, 4, rl.Color(255, 255, 255, int(255 * blink_alpha)))
# draw backspace icon with nice fade
self._backspace_img_alpha.update(255 * bool(text))
if self._backspace_img_alpha.x > 1:
color = rl.Color(255, 255, 255, int(self._backspace_img_alpha.x))
rl.draw_texture(self._backspace_img, int(self._rect.width - self._enter_img.width - 15), int(text_field_rect.y), color)
if not text and self._hint_label.text and not candidate_char:
# draw description if no text entered yet and not drawing candidate char
self._hint_label.render(text_field_rect)
# TODO: move to update state
# make rect take up entire area so it's easier to click
self._top_left_button_rect = rl.Rectangle(self._rect.x, self._rect.y, text_field_rect.x, self._rect.height - self._keyboard.get_keyboard_height())
self._top_right_button_rect = rl.Rectangle(text_field_rect.x + text_field_rect.width, self._rect.y,
self._rect.width - (text_field_rect.x + text_field_rect.width), self._top_left_button_rect.height)
self._enter_img_alpha.update(255 if (len(text) >= self._minimum_length) else 255 * 0.35)
if self._enter_img_alpha.x > 1:
color = rl.Color(255, 255, 255, int(self._enter_img_alpha.x))
rl.draw_texture(self._enter_img, int(self._rect.x + 15), int(text_field_rect.y), color)
# keyboard goes over everything
self._keyboard.render(self._rect)
# draw debugging rect bounds
if DEBUG:
rl.draw_rectangle_lines_ex(text_field_rect, 1, rl.Color(100, 100, 100, 255))
rl.draw_rectangle_lines_ex(self._top_right_button_rect, 1, rl.Color(0x12, 0x97, 0x91, 0xFF))
rl.draw_rectangle_lines_ex(self._top_left_button_rect, 1, rl.Color(0x12, 0x97, 0x91, 0xFF))
def _handle_mouse_press(self, mouse_pos: MousePos):
super()._handle_mouse_press(mouse_pos)
# TODO: need to track where press was so enter and back can activate on release rather than press
# or turn into icon widgets :eyes_open:
# handle backspace icon click
if rl.check_collision_point_rec(mouse_pos, self._top_right_button_rect) and self._backspace_img_alpha.x > 254:
self._keyboard.backspace()
elif rl.check_collision_point_rec(mouse_pos, self._top_left_button_rect) and self._enter_img_alpha.x > 254:
# handle enter icon click
self._confirm_callback()
class BigDialogOptionButton(Widget):
HEIGHT = 64
SELECTED_HEIGHT = 74
def __init__(self, option: str):
super().__init__()
self.option = option
self.set_rect(rl.Rectangle(0, 0, int(gui_app.width / 2 + 220), self.HEIGHT))
self._selected = False
self._label = UnifiedLabel(option, font_size=70, text_color=rl.Color(255, 255, 255, int(255 * 0.58)),
font_weight=FontWeight.DISPLAY_REGULAR, alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE,
scroll=True)
def show_event(self):
super().show_event()
self._label.reset_scroll()
def set_selected(self, selected: bool):
self._selected = selected
self._rect.height = self.SELECTED_HEIGHT if selected else self.HEIGHT
def _render(self, _):
if DEBUG:
rl.draw_rectangle_lines_ex(self._rect, 1, rl.Color(0x12, 0x97, 0x91, 0xFF))
# FIXME: offset x by -45 because scroller centers horizontally
if self._selected:
self._label.set_font_size(self.SELECTED_HEIGHT)
self._label.set_color(rl.Color(255, 255, 255, int(255 * 0.9)))
self._label.set_font_weight(FontWeight.DISPLAY)
else:
self._label.set_font_size(self.HEIGHT)
self._label.set_color(rl.Color(255, 255, 255, int(255 * 0.58)))
self._label.set_font_weight(FontWeight.DISPLAY_REGULAR)
self._label.render(self._rect)
class BigMultiOptionDialog(BigDialogBase):
BACK_TOUCH_AREA_PERCENTAGE = 0.1
def __init__(self, options: list[str], default: str | None,
right_btn: str | None = 'check', right_btn_callback: Callable[[], None] | None = None):
super().__init__(right_btn, right_btn_callback=right_btn_callback)
self._options = options
if default is not None:
assert default in options
self._default_option: str | None = default
self._selected_option: str = self._default_option or (options[0] if len(options) > 0 else "")
self._last_selected_option: str = self._selected_option
# Widget doesn't differentiate between click and drag
self._can_click = True
self._scroller = Scroller([], horizontal=False, pad_start=100, pad_end=100, spacing=0, snap_items=True)
if self._right_btn is not None:
self._scroller.set_enabled(lambda: not cast(Widget, self._right_btn).is_pressed)
for option in options:
self._scroller.add_widget(BigDialogOptionButton(option))
def show_event(self):
super().show_event()
self._scroller.show_event()
if self._default_option is not None:
self._on_option_selected(self._default_option)
def get_selected_option(self) -> str:
return self._selected_option
def _on_option_selected(self, option: str):
y_pos = 0.0
for btn in self._scroller._items:
btn = cast(BigDialogOptionButton, btn)
if btn.option == option:
rect_center_y = self._rect.y + self._rect.height / 2
if btn._selected:
height = btn.rect.height
else:
# when selecting an option under current, account for changing heights
btn_center_y = btn.rect.y + btn.rect.height / 2 # not accurate, just to determine direction
height_offset = BigDialogOptionButton.SELECTED_HEIGHT - BigDialogOptionButton.HEIGHT
height = (BigDialogOptionButton.HEIGHT - height_offset) if rect_center_y < btn_center_y else BigDialogOptionButton.SELECTED_HEIGHT
y_pos = rect_center_y - (btn.rect.y + height / 2)
break
self._scroller.scroll_to(-y_pos)
def _selected_option_changed(self):
pass
def _handle_mouse_press(self, mouse_pos: MousePos):
super()._handle_mouse_press(mouse_pos)
self._can_click = True
def _handle_mouse_event(self, mouse_event: MouseEvent) -> None:
super()._handle_mouse_event(mouse_event)
# # TODO: add generic _handle_mouse_click handler to Widget
if not self._scroller.scroll_panel.is_touch_valid():
self._can_click = False
def _handle_mouse_release(self, mouse_pos: MousePos):
super()._handle_mouse_release(mouse_pos)
if not self._can_click:
return
# select current option
for btn in self._scroller._items:
btn = cast(BigDialogOptionButton, btn)
if btn.option == self._selected_option:
self._on_option_selected(btn.option)
break
def _update_state(self):
super()._update_state()
# get selection by whichever button is closest to center
center_y = self._rect.y + self._rect.height / 2
closest_btn = (None, float('inf'))
for btn in self._scroller._items:
dist_y = abs((btn.rect.y + btn.rect.height / 2) - center_y)
if dist_y < closest_btn[1]:
closest_btn = (btn, dist_y)
if closest_btn[0]:
for btn in self._scroller._items:
btn.set_selected(btn.option == closest_btn[0].option)
self._selected_option = closest_btn[0].option
# Signal to subclasses if selection changed
if self._selected_option != self._last_selected_option:
self._selected_option_changed()
self._last_selected_option = self._selected_option
def _render(self, _):
super()._render(_)
self._scroller.render(self._rect)
class BigDialogButton(BigButton):
def __init__(self, text: str, value: str = "", icon: Union[str, rl.Texture] = "", description: str = ""):
super().__init__(text, value, icon)
self._description = description
def _handle_mouse_release(self, mouse_pos: MousePos):
super()._handle_mouse_release(mouse_pos)
dlg = BigDialog(self.text, self._description)
gui_app.push_widget(dlg)

View File

@@ -0,0 +1,265 @@
"""
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos
DrumPickerDialog — iOS-style drum-roll value selector.
Layout (matches concept image):
- Title label centred at top with a white underline
- 5 visible values: [n-2] [n-1] [ N ] [n+1] [n+2]
- Centre value: large bold white, flanked by two thin vertical bars
- Outer values fade with distance (alpha 0.35 → 0.22 → 0.10)
- Optional unit label below centre
- Drag left/right or tap arrows to change value; confirm on release / tap elsewhere
"""
import pyray as rl
from collections.abc import Callable
from iqpilot.common.filter_simple import BounceFilter, FirstOrderFilter
from iqpilot.system.ui.lib.application import gui_app, FontWeight, MousePos, MouseEvent
from iqpilot.system.ui.lib.text_measure import measure_text_cached
from iqpilot.system.ui.widgets import DialogResult
from iqpilot.selfdrive.ui.mici.widgets.dialog import BigDialogBase
try:
from iqpilot.ui.theme import NeonTheme
except ImportError:
class _FT:
def glow(self, a=255): return rl.Color(0, 255, 245, a)
def glow_outer(self, a=45): return rl.Color(0, 255, 245, a)
NeonTheme = _FT()
# ── Visual constants ───────────────────────────────────────────────────────────
_CENTRE_FONT_SIZE = 36 # fits comfortably inside the slot walls
_SIDE1_FONT_SIZE = 30 # one step away
_SIDE2_FONT_SIZE = 20 # two steps away
_UNIT_FONT_SIZE = 18
_TITLE_FONT_SIZE = 24
_CENTRE_ALPHA = 255
_SIDE1_ALPHA = int(255 * 0.45)
_SIDE2_ALPHA = int(255 * 0.18)
_BAR_W = 2 # vertical separator bar width
_BAR_H_FRAC = 0.55 # bar height as fraction of dialog height
_SLOT_W = 90 # width of the centre slot (determines bar positions)
_SLOT_SPACING = 115 # horizontal distance between adjacent value centres
# 1 index = 1 slot spacing in pixels — drag exactly one slot width to move one step
_DRAG_SCALE = 1.0 / _SLOT_SPACING
class DrumPickerDialog(BigDialogBase):
"""
Full-screen drum-roll value picker.
Drag left/right to scroll values; releasing snaps and calls confirm_callback
immediately (live preview) but keeps the dialog open.
Swipe down (back gesture) to dismiss.
"""
def __init__(self,
title: str,
options: list[str],
current: str,
unit: str = "",
confirm_callback: Callable[[str], None] | None = None):
super().__init__()
assert len(options) > 0
self._title = title
self._options = options
self._unit = unit
self._confirm_callback = confirm_callback
# Current index with a smooth bounce filter for animation
try:
idx = options.index(current)
except ValueError:
idx = 0
dt = 1 / gui_app.target_fps
self._idx: float = float(idx)
self._idx_filter = BounceFilter(float(idx), 0.06, dt, bounce=3)
self._idx_filter.x = float(idx)
# Press/release zoom scale — smoothly goes 1.0 → 1.12 on touch-down,
# back to 1.0 on release, giving a tactile "grab" feel.
self._scale_target: float = 1.0
self._scale_filter = FirstOrderFilter(1.0, 0.04, dt)
# Drag state
self._drag_start_x: float | None = None
self._drag_start_idx: float = float(idx)
self._dragging: bool = False
# Pre-load fonts
self._font_display = gui_app.font(FontWeight.DISPLAY)
self._font_medium = gui_app.font(FontWeight.MEDIUM)
self._font_roman = gui_app.font(FontWeight.ROMAN)
# ── Helpers ────────────────────────────────────────────────────────────────
def _current_idx(self) -> int:
return max(0, min(round(self._idx_filter.x), len(self._options) - 1))
def selected_value(self) -> str:
return self._options[self._current_idx()]
def _clamp_idx(self, v: float) -> float:
return max(0.0, min(v, float(len(self._options) - 1)))
# ── Input ──────────────────────────────────────────────────────────────────
def _handle_mouse_press(self, mouse_pos: MousePos):
super()._handle_mouse_press(mouse_pos)
# Kill any in-progress bounce so the grab starts from exactly
# where the value visually is right now — no jump on first drag.
snapped = float(round(self._idx_filter.x))
self._idx = snapped
self._idx_filter.x = snapped
self._idx_filter.velocity.x = 0.0 # kill bounce velocity
self._drag_start_x = mouse_pos.x
self._drag_start_idx = snapped
self._dragging = False
self._scale_target = 1.12 # zoom in on touch-down
def _handle_mouse_event(self, mouse_event: MouseEvent):
super()._handle_mouse_event(mouse_event)
if self._drag_start_x is None:
return
delta = self._drag_start_x - mouse_event.pos.x # drag left = higher idx
if abs(delta) > 8:
self._dragging = True
if self._dragging:
self._idx = self._clamp_idx(self._drag_start_idx + delta * _DRAG_SCALE)
self._idx_filter.x = self._idx # snap immediately while dragging
def _handle_mouse_release(self, mouse_pos: MousePos):
super()._handle_mouse_release(mouse_pos)
# Swipe-down = dismiss, calling callback with final value first
if self._swiping_away:
self._idx = float(round(self._idx_filter.x))
if self._confirm_callback:
self._confirm_callback(self.selected_value())
self._drag_start_x = None
self._dragging = False
self._ret = DialogResult.CONFIRM
return
# Normal release: set the snap target and let BounceFilter glide there.
# Do NOT hard-set _idx_filter.x — that would teleport instead of animate.
self._idx = float(round(self._idx_filter.x))
self._drag_start_x = None
self._dragging = False
self._scale_target = 1.0 # zoom back out on release
if self._confirm_callback:
self._confirm_callback(self.selected_value())
# ── Update ─────────────────────────────────────────────────────────────────
def _update_state(self):
super()._update_state()
# While dragging, filter is slaved directly to _idx (instant follow).
# On release, _dragging=False so filter animates toward _idx with bounce.
if self._dragging:
self._idx_filter.x = self._idx
self._idx_filter.velocity.x = 0.0
else:
self._idx_filter.update(self._idx)
self._scale_filter.update(self._scale_target)
# ── Render ─────────────────────────────────────────────────────────────────
def _render(self, _) -> DialogResult:
rect = self._rect
cx = rect.x + rect.width / 2
cy = rect.y + rect.height / 2
# ── Dark background ───────────────────────────────────────────────────────
rl.draw_rectangle(int(rect.x), int(rect.y), int(rect.width), int(rect.height),
rl.Color(0, 0, 0, 230))
# ── Vertical separator bars (drawn first so title renders above) ──────────
bar_h = int(rect.height * _BAR_H_FRAC)
bar_y = int(cy - bar_h / 2)
bar_col = NeonTheme.glow(60)
rl.draw_rectangle(int(cx - _SLOT_W / 2), bar_y, _BAR_W, bar_h, bar_col)
rl.draw_rectangle(int(cx + _SLOT_W / 2), bar_y, _BAR_W, bar_h, bar_col)
# ── Title — centred above the bars, no underline ──────────────────────────
title_w = int(measure_text_cached(self._font_medium, self._title, _TITLE_FONT_SIZE).x)
tx = int(cx - title_w / 2)
ty = int(rect.y + (bar_y - rect.y) / 2 - _TITLE_FONT_SIZE / 2)
rl.draw_text_ex(self._font_medium, self._title,
rl.Vector2(tx, ty), _TITLE_FONT_SIZE, 0,
rl.Color(255, 255, 255, 220))
# ── Value row ─────────────────────────────────────────────────────────────
animated_idx = self._idx_filter.x
n = len(self._options)
# centre_int is always the settled target — never flips mid-animation.
# frac drives pixel offset only (how far filter still needs to travel).
centre_int = int(self._idx)
frac = animated_idx - centre_int
for offset in [-2, -1, 0, 1, 2]:
# Which option index lives in this visual slot?
opt_idx = centre_int + offset
if opt_idx < 0 or opt_idx >= n:
continue
# Pixel offset from screen centre: slot position minus fractional drift
draw_x_off = (offset - frac) * _SLOT_SPACING
label = self._options[opt_idx]
abs_offset = abs(offset - frac) # fractional distance from visual centre
# Font size and alpha interpolated by distance
if abs_offset < 0.5:
font_sz = int(_SIDE1_FONT_SIZE + (_CENTRE_FONT_SIZE - _SIDE1_FONT_SIZE) * (1 - abs_offset * 2))
alpha = int(_SIDE1_ALPHA + (_CENTRE_ALPHA - _SIDE1_ALPHA) * (1 - abs_offset * 2))
weight = FontWeight.DISPLAY
elif abs_offset < 1.5:
t = abs_offset - 0.5
font_sz = int(_SIDE2_FONT_SIZE + (_SIDE1_FONT_SIZE - _SIDE2_FONT_SIZE) * (1 - t))
alpha = int(_SIDE2_ALPHA + (_SIDE1_ALPHA - _SIDE2_ALPHA) * (1 - t))
weight = FontWeight.DISPLAY
else:
font_sz = _SIDE2_FONT_SIZE
alpha = _SIDE2_ALPHA
weight = FontWeight.DISPLAY
# Apply press-zoom scale to the centre value only
scale = 1.0 + (self._scale_filter.x - 1.0) * max(0.0, 1.0 - abs_offset * 2)
font_sz = max(8, int(font_sz * scale))
font = gui_app.font(weight)
tw = int(measure_text_cached(font, label, font_sz).x)
th = font_sz
draw_x = int(cx + draw_x_off - tw / 2)
draw_y = int(cy - th / 2)
rl.draw_text_ex(font, label,
rl.Vector2(draw_x, draw_y),
font_sz, 0,
rl.Color(255, 255, 255, alpha))
# ── Unit label below centre ───────────────────────────────────────────────
if self._unit:
uw = int(measure_text_cached(self._font_roman, self._unit, _UNIT_FONT_SIZE).x)
rl.draw_text_ex(self._font_roman, self._unit,
rl.Vector2(int(cx - uw / 2), int(cy + _CENTRE_FONT_SIZE / 2 + 8)),
_UNIT_FONT_SIZE, 0,
rl.Color(255, 255, 255, 140))
# ── Subtle neon glow on centre slot ───────────────────────────────────────
slot_rect = rl.Rectangle(cx - _SLOT_W / 2 - 1, bar_y, _SLOT_W + 2, bar_h)
rl.draw_rectangle_gradient_h(
int(slot_rect.x), int(slot_rect.y),
int(slot_rect.width // 2), int(slot_rect.height),
rl.BLANK, NeonTheme.glow_outer(40),
)
rl.draw_rectangle_gradient_h(
int(slot_rect.x + slot_rect.width // 2), int(slot_rect.y),
int(slot_rect.width // 2), int(slot_rect.height),
NeonTheme.glow_outer(40), rl.BLANK,
)
return self._ret

View File

@@ -0,0 +1,196 @@
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.selfdrive.ui.ui_state import ui_state
from iqpilot.system.ui.widgets import NavWidget
from iqpilot.system.ui.lib.application import FontWeight, gui_app
from iqpilot.system.ui.widgets.label import MiciLabel
from iqpilot.system.ui.lib.multilang import tr
class PairingDialog(NavWidget):
"""Dialog for device pairing with QR code."""
QR_REFRESH_INTERVAL = 300 # 5 minutes in seconds
def __init__(self):
super().__init__()
self.set_back_callback(lambda: gui_app.set_modal_overlay(None))
self._params = Params()
self._qr_texture: rl.Texture | None = None
self._last_qr_generation = float("-inf")
self._txt_pair = gui_app.texture("icons_mici/settings/device/pair.png", 84, 64)
self._pair_label = MiciLabel(tr("pair with Konn3kt"), 48, font_weight=FontWeight.BOLD,
color=rl.Color(255, 255, 255, int(255 * 0.9)), line_height=40, wrap_text=True)
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=0)
qr.add_data(url)
qr.make(fit=True)
pil_img = qr.make_image(fill_color="white", back_color="black").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 as e:
cloudlog.warning(f"QR code generation failed: {e}")
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):
super()._update_state()
if ui_state.prime_state.is_paired():
self._playing_dismiss_animation = True
def _render(self, rect: rl.Rectangle) -> int:
self._check_qr_refresh()
self._render_qr_code()
label_x = self._rect.x + 8 + self._rect.height + 24
self._pair_label.set_width(int(self._rect.width - label_x))
self._pair_label.set_position(label_x, self._rect.y + 16)
self._pair_label.render()
rl.draw_texture_ex(self._txt_pair, rl.Vector2(label_x, self._rect.y + self._rect.height - self._txt_pair.height - 16),
0.0, 1.0, rl.Color(255, 255, 255, int(255 * 0.35)))
return -1
def _render_qr_code(self) -> None:
if not self._qr_texture:
error_font = gui_app.font(FontWeight.BOLD)
rl.draw_text_ex(
error_font, "QR Code Error", rl.Vector2(self._rect.x + 20, self._rect.y + self._rect.height // 2 - 15), 30, 0.0, rl.RED
)
return
scale = self._rect.height / self._qr_texture.height
pos = rl.Vector2(self._rect.x + 8, self._rect.y)
rl.draw_texture_ex(self._qr_texture, pos, 0.0, scale, 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,31 @@
import pyray as rl
from iqpilot.system.ui.widgets import Widget
from iqpilot.system.ui.lib.application import gui_app
# ---------------------------------------------------------------------------
# Constants extracted from the original Qt style
# ---------------------------------------------------------------------------
# TODO: this should be corrected, but Scroller relies on this being incorrect :/
WIDTH, HEIGHT = 112, 240
class SideButton(Widget):
def __init__(self, btn_type: str):
super().__init__()
self.type = btn_type
self.set_rect(rl.Rectangle(0, 0, WIDTH, HEIGHT))
# load pre-rendered button images
if btn_type not in ("check", "back"):
btn_type = "back"
btn_img_path = f"icons_mici/buttons/button_side_{btn_type}.png"
btn_img_pressed_path = f"icons_mici/buttons/button_side_{btn_type}_pressed.png"
self._txt_btn, self._txt_btn_back = gui_app.texture(btn_img_path, 100, 224), gui_app.texture(btn_img_pressed_path, 100, 224)
def _render(self, _) -> bool:
x = int(self._rect.x + 12)
y = int(self._rect.y + (self._rect.height - self._txt_btn.height) / 2)
rl.draw_texture(self._txt_btn if not self.is_pressed else self._txt_btn_back,
x, y, rl.WHITE)
return False

View File

@@ -0,0 +1,538 @@
"""
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
"""
import math
import pyray as rl
from typing import Union
from enum import Enum
from collections.abc import Callable
from iqpilot.system.ui.widgets import Widget
from iqpilot.system.ui.widgets.label import UnifiedLabel
from iqpilot.system.ui.widgets.scroller import DO_ZOOM
from iqpilot.system.ui.lib.application import gui_app, FontWeight, MousePos
from iqpilot.common.filter_simple import BounceFilter
from iqpilot.ui.theme import NeonTheme
try:
from iqpilot.common.params import Params, UnknownKeyName
except ImportError:
Params = None
class UnknownKeyName(Exception):
pass
SCROLLING_SPEED_PX_S = 50
COMPLICATION_SIZE = 36
LABEL_COLOR = rl.Color(255, 255, 255, int(255 * 0.9))
COMPLICATION_GREY = rl.Color(0xAA, 0xAA, 0xAA, 255)
PRESSED_SCALE = 1.15 if DO_ZOOM else 1.07
_FORCE_ACCENT_RGB = None
_ACCENT_ROUND = 0.34
_ACCENT_INSET = 6
_GLOW_OUT = 3
_GLOW_IN = 12
_GLOW_IN_IDLE = 4
_GLOW_OUT_ALPHA = 32
_GLOW_IN_ALPHA = 80
_RIM_ALPHA = 200
_GLOW_SEGS = 16
_GLOW_CORNER_SEGS = 16
def _accent_rgb() -> tuple[int, int, int]:
if _FORCE_ACCENT_RGB is not None:
return _FORCE_ACCENT_RGB
c = NeonTheme.glow(255)
return (c.r, c.g, c.b)
def _inset(rect: rl.Rectangle, px: float) -> rl.Rectangle:
return rl.Rectangle(rect.x + px, rect.y + px, rect.width - 2 * px, rect.height - 2 * px)
_BOX_BG = rl.Color(0x08, 0x09, 0x0A, 255)
_BOX_BG_PRESSED = rl.Color(0x16, 0x18, 0x1A, 255)
_BOX_BG_DISABLED = rl.Color(0x05, 0x06, 0x07, 255)
def _draw_accent_box(rect: rl.Rectangle, enabled: bool, pressed: bool):
"""Clean dark rounded box + smooth teal glow (matches the concept). Same roundness
for box and glow → no seam. `rect` is the final box rect."""
base = 1.0 if enabled else 0.4
r, g, b = _accent_rgb()
# keep every concentric ring's corner radius offset by exactly its inset, so the
# rings stay parallel at the corners too (a fixed roundness fraction would shrink
# the corner radius unevenly and leave a dark seam in each corner)
shorter = min(rect.width, rect.height)
base_radius = _ACCENT_ROUND * shorter / 2.0
def _round_for(short_side: float, radius: float) -> float:
return max(0.0, min(1.0, 2.0 * radius / short_side)) if short_side > 0 else 0.0
for px in range(_GLOW_OUT, 0, -1):
f = px / _GLOW_OUT
a = int(_GLOW_OUT_ALPHA * base * (1.0 - f) ** 2.0)
if a <= 0:
continue
ex = rl.Rectangle(rect.x - px, rect.y - px, rect.width + 2 * px, rect.height + 2 * px)
rl.draw_rectangle_rounded(ex, _round_for(shorter + 2 * px, base_radius + px), _GLOW_SEGS, rl.Color(r, g, b, a))
bg = _BOX_BG_PRESSED if pressed else (_BOX_BG if enabled else _BOX_BG_DISABLED)
rl.draw_rectangle_rounded(rect, _round_for(shorter, base_radius), _GLOW_SEGS, bg)
for px in range(_GLOW_IN, 0, -1):
f = px / _GLOW_IN
a = int(_GLOW_IN_ALPHA * base * (1.0 - f) ** 1.8)
if a <= 0:
continue
rl.draw_rectangle_rounded_lines_ex(_inset(rect, px), _round_for(shorter - 2 * px, base_radius - px),
_GLOW_CORNER_SEGS, 3, rl.Color(r, g, b, a))
rl.draw_rectangle_rounded_lines_ex(rect, _round_for(shorter, base_radius), _GLOW_CORNER_SEGS, 2,
rl.Color(r, g, b, int(_RIM_ALPHA * base)))
_CIRCLE_RED_RGB = (0xE0, 0x3A, 0x3A)
def _draw_accent_circle(cx: float, cy: float, radius: float, enabled: bool, red: bool = False, pressed: bool = False):
"""Circular version of the box accent: teal (or red) rim + inward glow, matching the boxes."""
base = 1.0 if enabled else 0.4
r, g, b = _CIRCLE_RED_RGB if red else _accent_rgb()
c = rl.Vector2(cx, cy)
segs = _GLOW_CORNER_SEGS * 2
for px in range(_GLOW_OUT, 0, -1):
a = int(_GLOW_OUT_ALPHA * base * (1.0 - px / _GLOW_OUT) ** 2.0)
if a > 0:
rl.draw_ring(c, radius + px - 1.0, radius + px + 1.0, 0, 360, segs, rl.Color(r, g, b, a))
glow_in = _GLOW_IN if pressed else _GLOW_IN_IDLE
for px in range(glow_in, 0, -1):
a = int(_GLOW_IN_ALPHA * base * (1.0 - px / glow_in) ** 1.8)
if a > 0:
rl.draw_ring(c, radius - px - 1.5, radius - px + 1.5, 0, 360, segs, rl.Color(r, g, b, a))
rl.draw_ring(c, radius - 1.5, radius + 1.5, 0, 360, segs, rl.Color(r, g, b, int(_RIM_ALPHA * base)))
class ScrollState(Enum):
PRE_SCROLL = 0
SCROLLING = 1
POST_SCROLL = 2
class BigCircleButton(Widget):
def __init__(self, icon: rl.Texture, red: bool = False, icon_offset: tuple[int, int] = (0, 0)):
super().__init__()
self._red = red
self._icon_offset = icon_offset
self.set_rect(rl.Rectangle(0, 0, 180, 180))
self._scale_filter = BounceFilter(1.0, 0.1, 1 / gui_app.target_fps)
self._click_delay = 0.075
self._txt_icon = icon
self._txt_btn_disabled_bg = gui_app.texture("icons_mici/buttons/button_circle_disabled.png", 180, 180)
self._txt_btn_bg = gui_app.texture("icons_mici/buttons/button_circle.png", 180, 180)
self._txt_btn_pressed_bg = gui_app.texture("icons_mici/buttons/button_circle_pressed.png", 180, 180)
self._txt_btn_red_bg = gui_app.texture("icons_mici/buttons/button_circle_red.png", 180, 180)
self._txt_btn_red_pressed_bg = gui_app.texture("icons_mici/buttons/button_circle_red_pressed.png", 180, 180)
def _draw_content(self, btn_x: float, btn_y: float, btn_width: float, btn_height: float):
icon_color = rl.Color(255, 255, 255, int(255 * 0.9)) if self.enabled else rl.Color(255, 255, 255, int(255 * 0.35))
rl.draw_texture_ex(self._txt_icon, (btn_x + (btn_width - self._txt_icon.width) / 2 + self._icon_offset[0],
btn_y + (btn_height - self._txt_icon.height) / 2 + self._icon_offset[1]), 0, 1.0, icon_color)
def _render(self, _):
txt_bg = self._txt_btn_bg if not self._red else self._txt_btn_red_bg
if not self.enabled:
txt_bg = self._txt_btn_disabled_bg
elif self.is_pressed:
txt_bg = self._txt_btn_pressed_bg if not self._red else self._txt_btn_red_pressed_bg
scale = self._scale_filter.update(PRESSED_SCALE if self.is_pressed else 1.0)
btn_x = self._rect.x + (self._rect.width * (1 - scale)) / 2
btn_y = self._rect.y + (self._rect.height * (1 - scale)) / 2
rl.draw_texture_ex(txt_bg, (btn_x, btn_y), 0, scale, rl.WHITE)
cx = btn_x + self._rect.width * scale / 2.0
cy = btn_y + self._rect.height * scale / 2.0
_draw_accent_circle(cx, cy, self._rect.width * scale / 2.0 - _ACCENT_INSET, self.enabled, self._red, self.is_pressed)
self._draw_content(btn_x, btn_y, self._rect.width * scale, self._rect.height * scale)
class BigCircleToggle(BigCircleButton):
def __init__(self, icon: rl.Texture, toggle_callback: Callable | None = None, icon_offset: tuple[int, int] = (0, 0)):
super().__init__(icon, False, icon_offset=icon_offset)
self._toggle_callback = toggle_callback
self._checked = False
self._txt_toggle_enabled = gui_app.texture("icons_mici/buttons/toggle_dot_enabled.png", 66, 66)
self._txt_toggle_disabled = gui_app.texture("icons_mici/buttons/toggle_dot_disabled.png", 66, 66)
def set_checked(self, checked: bool):
self._checked = checked
def _handle_mouse_release(self, mouse_pos: MousePos):
super()._handle_mouse_release(mouse_pos)
self._checked = not self._checked
if self._toggle_callback:
self._toggle_callback(self._checked)
def _draw_content(self, btn_x: float, btn_y: float, btn_width: float, btn_height: float):
super()._draw_content(btn_x, btn_y, btn_width, btn_height)
rl.draw_texture_ex(self._txt_toggle_enabled if self._checked else self._txt_toggle_disabled,
(btn_x + (btn_width - self._txt_toggle_enabled.width) / 2, btn_y + 5),
0, 1.0, rl.WHITE)
class BigButton(Widget):
LABEL_HORIZONTAL_PADDING = 40
LABEL_VERTICAL_PADDING = 23
"""A lightweight stand-in for the Qt BigButton, drawn & updated each frame."""
def __init__(self, text: str, value: str = "", icon: Union[rl.Texture, None] = None, scroll: bool = False):
super().__init__()
self.set_rect(rl.Rectangle(0, 0, 402, 180))
self.text = text
self.value = value
self._txt_icon = icon
self._scroll = scroll
self._press_effect_enabled = True
self._scale_filter = BounceFilter(1.0, 0.1, 1 / gui_app.target_fps)
self._click_delay = 0.075
self._shake_start: float | None = None
self._grow_animation_until: float | None = None
self._rotate_icon_t: float | None = None
self._label = UnifiedLabel(text, font_size=self._get_label_font_size(), font_weight=FontWeight.BOLD,
text_color=LABEL_COLOR, alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM, scroll=scroll,
line_height=0.9)
self._sub_label = UnifiedLabel(value, font_size=COMPLICATION_SIZE, font_weight=FontWeight.ROMAN,
text_color=COMPLICATION_GREY,
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM,
wrap_text=False, scroll=True)
self._update_label_layout()
self._load_images()
def set_icon(self, icon: Union[rl.Texture, None]):
self._txt_icon = icon
def set_rotate_icon(self, rotate: bool):
if rotate and self._rotate_icon_t is not None:
return
self._rotate_icon_t = rl.get_time() if rotate else None
def set_press_effect_enabled(self, enabled: bool) -> None:
self._press_effect_enabled = enabled
def set_scroll_active(self, active: bool) -> None:
self._label.set_scroll_active(active)
def _load_images(self):
self._txt_default_bg = gui_app.texture("icons_mici/buttons/button_rectangle.png", 402, 180)
self._txt_pressed_bg = gui_app.texture("icons_mici/buttons/button_rectangle_pressed.png", 402, 180)
self._txt_disabled_bg = gui_app.texture("icons_mici/buttons/button_rectangle_disabled.png", 402, 180)
def set_touch_valid_callback(self, touch_callback: Callable[[], bool]) -> None:
super().set_touch_valid_callback(lambda: touch_callback() and self._grow_animation_until is None)
def _width_hint(self) -> int:
icon_size = self._txt_icon.width if self._txt_icon and self._scroll and self.value else 0
return int(self._rect.width - self.LABEL_HORIZONTAL_PADDING * 2 - icon_size)
def _get_label_font_size(self):
if len(self.text) <= 18:
return 48
else:
return 42
def _update_label_layout(self):
self._label.set_font_size(self._get_label_font_size())
if self.value:
self._label.set_alignment_vertical(rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP)
else:
self._label.set_alignment_vertical(rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM)
def set_text(self, text: str):
self.text = text
self._label.set_text(text)
self._update_label_layout()
def set_value(self, value: str):
self.value = value
self._sub_label.set_text(value)
self._update_label_layout()
def get_value(self) -> str:
return self.value
def get_text(self):
return self.text
def trigger_shake(self):
self._shake_start = rl.get_time()
def trigger_grow_animation(self, duration: float = 0.65):
self._grow_animation_until = rl.get_time() + duration
@property
def _shake_offset(self) -> float:
SHAKE_DURATION = 0.5
SHAKE_AMPLITUDE = 24.0
SHAKE_FREQUENCY = 32.0
if self._shake_start is None:
return 0.0
t = rl.get_time() - self._shake_start
if t > SHAKE_DURATION:
return 0.0
decay = 1.0 - t / SHAKE_DURATION
return decay * SHAKE_AMPLITUDE * math.sin(t * SHAKE_FREQUENCY)
def set_position(self, x: float, y: float) -> None:
super().set_position(x + self._shake_offset, y)
def _handle_background(self) -> tuple[rl.Texture, float, float, float]:
if self._grow_animation_until is not None:
if rl.get_time() >= self._grow_animation_until:
self._grow_animation_until = None
txt_bg = self._txt_default_bg
if not self.enabled:
txt_bg = self._txt_disabled_bg
elif self.is_pressed and self._press_effect_enabled:
txt_bg = self._txt_pressed_bg
pressed_scale = self.is_pressed and self._press_effect_enabled
animate_scale = pressed_scale or self._grow_animation_until is not None
scale = self._scale_filter.update(PRESSED_SCALE if animate_scale else 1.0)
btn_x = self._rect.x + (self._rect.width * (1 - scale)) / 2
btn_y = self._rect.y + (self._rect.height * (1 - scale)) / 2
return txt_bg, btn_x, btn_y, scale
def _draw_content(self, btn_x: float, btn_y: float, btn_width: float, btn_height: float):
label_x = btn_x + self.LABEL_HORIZONTAL_PADDING
label_color = LABEL_COLOR if self.enabled else rl.Color(255, 255, 255, int(255 * 0.35))
self._label.set_color(label_color)
label_rect = rl.Rectangle(label_x, btn_y + self.LABEL_VERTICAL_PADDING, self._width_hint(),
btn_height - self.LABEL_VERTICAL_PADDING * 2)
self._label.render(label_rect)
if self.value:
label_y = btn_y + self.LABEL_VERTICAL_PADDING + self._label.get_content_height(self._width_hint())
sub_label_height = btn_y + btn_height - self.LABEL_VERTICAL_PADDING - label_y
sub_label_rect = rl.Rectangle(label_x, label_y, self._width_hint(), sub_label_height)
self._sub_label.render(sub_label_rect)
if self._txt_icon:
rotation = 0
if self._rotate_icon_t is not None:
rotation = (rl.get_time() - self._rotate_icon_t) * 180
x = btn_x + btn_width - 30 - self._txt_icon.width / 2
y = btn_y + 30 + self._txt_icon.height / 2
source_rec = rl.Rectangle(0, 0, self._txt_icon.width, self._txt_icon.height)
dest_rec = rl.Rectangle(x, y, self._txt_icon.width, self._txt_icon.height)
origin = rl.Vector2(self._txt_icon.width / 2, self._txt_icon.height / 2)
rl.draw_texture_pro(self._txt_icon, source_rec, dest_rec, origin, rotation, rl.Color(255, 255, 255, int(255 * 0.9)))
def _render(self, _):
txt_bg, btn_x, btn_y, scale = self._handle_background()
cell = rl.Rectangle(btn_x, btn_y, self._rect.width * scale, self._rect.height * scale)
box_rect = _inset(cell, _ACCENT_INSET)
_draw_accent_box(box_rect, self.enabled, self.is_pressed)
# Clip each card's content to its own bounds so long/scrolling labels from one
# tile cannot bleed into neighboring tiles in the horizontal scroller.
content_rect = _inset(box_rect, 2)
rl.begin_scissor_mode(int(content_rect.x), int(content_rect.y), int(content_rect.width), int(content_rect.height))
self._draw_content(btn_x, btn_y, cell.width, cell.height)
rl.end_scissor_mode()
class BigToggle(BigButton):
def __init__(self, text: str, value: str = "", initial_state: bool = False, toggle_callback: Callable | None = None):
super().__init__(text, value, "")
self._checked = initial_state
self._toggle_callback = toggle_callback
def _load_images(self):
super()._load_images()
self._txt_enabled_toggle = gui_app.texture("icons_mici/buttons/toggle_pill_enabled.png", 84, 66)
self._txt_disabled_toggle = gui_app.texture("icons_mici/buttons/toggle_pill_disabled.png", 84, 66)
def set_checked(self, checked: bool):
self._checked = checked
def _width_hint(self) -> int:
return int(self._rect.width - self.LABEL_HORIZONTAL_PADDING * 2 - self._txt_enabled_toggle.width)
def _handle_mouse_release(self, mouse_pos: MousePos):
super()._handle_mouse_release(mouse_pos)
self._checked = not self._checked
if self._toggle_callback:
self._toggle_callback(self._checked)
def _draw_pill(self, x: float, y: float, checked: bool):
if checked:
rl.draw_texture_ex(self._txt_enabled_toggle, (x, y), 0, 1.0, rl.WHITE)
else:
rl.draw_texture_ex(self._txt_disabled_toggle, (x, y), 0, 1.0, rl.WHITE)
def _draw_content(self, btn_x: float, btn_y: float, btn_width: float, btn_height: float):
super()._draw_content(btn_x, btn_y, btn_width, btn_height)
x = btn_x + btn_width - self._txt_enabled_toggle.width
y = btn_y
self._draw_pill(x, y, self._checked)
class BigMultiToggle(BigToggle):
def __init__(self, text: str, options: list[str], toggle_callback: Callable | None = None,
select_callback: Callable | None = None):
super().__init__(text, "", toggle_callback=toggle_callback)
assert len(options) > 0
self._options = options
self._select_callback = select_callback
self.set_value(self._options[0])
def _width_hint(self) -> int:
return int(self._rect.width - self.LABEL_HORIZONTAL_PADDING * 2 - self._txt_enabled_toggle.width)
def _handle_mouse_release(self, mouse_pos: MousePos):
super()._handle_mouse_release(mouse_pos)
cur_idx = self._options.index(self.value)
new_idx = (cur_idx + 1) % len(self._options)
self.set_value(self._options[new_idx])
if self._select_callback:
self._select_callback(self.value)
def _draw_content(self, btn_x: float, btn_y: float, btn_width: float, btn_height: float):
BigButton._draw_content(self, btn_x, btn_y, btn_width, btn_height)
checked_idx = self._options.index(self.value)
x = btn_x + btn_width - self._txt_enabled_toggle.width
y = btn_y
for i in range(len(self._options)):
self._draw_pill(x, y, checked_idx == i)
y += 35
class GreyBigButton(BigButton):
"""Users should manage newlines with this class themselves"""
LABEL_HORIZONTAL_PADDING = 30
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.set_touch_valid_callback(lambda: False)
self._rect.width = 476
self._label.set_font_size(36)
self._label.set_font_weight(FontWeight.BOLD)
self._label.set_line_height(1.0)
self._sub_label.set_font_size(36)
self._sub_label.set_text_color(rl.Color(255, 255, 255, int(255 * 0.9)))
self._sub_label.set_font_weight(FontWeight.DISPLAY_REGULAR)
self._sub_label.set_alignment_vertical(rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE if not self._label.text else
rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM)
self._sub_label.set_line_height(0.95)
@property
def LABEL_VERTICAL_PADDING(self):
return BigButton.LABEL_VERTICAL_PADDING if self._label.text else 18
def _width_hint(self) -> int:
return int(self._rect.width - self.LABEL_HORIZONTAL_PADDING * 2)
def _get_label_font_size(self):
return 36
def _render(self, _):
rl.draw_rectangle_rounded(self._rect, 0.4, 10, rl.Color(255, 255, 255, int(255 * 0.15)))
self._draw_content(self._rect.x, self._rect.y, self._rect.width, self._rect.height)
class BigMultiParamToggle(BigMultiToggle):
def __init__(self, text: str, param: str, options: list[str], toggle_callback: Callable | None = None,
select_callback: Callable | None = None):
super().__init__(text, options, toggle_callback, select_callback)
self._param = param
self._params = Params()
self._load_value()
def _load_value(self):
self.set_value(self._options[self._params.get(self._param) or 0])
def _handle_mouse_release(self, mouse_pos: MousePos):
super()._handle_mouse_release(mouse_pos)
new_idx = self._options.index(self.value)
self._params.put(self._param, new_idx)
class BigParamControl(BigToggle):
def __init__(self, text: str, param: str, toggle_callback: Callable | None = None):
super().__init__(text, "", toggle_callback=toggle_callback)
self.param = param
self.params = Params()
self.set_checked(self._read_bool())
def _read_bool(self) -> bool:
try:
return self.params.get_bool(self.param)
except UnknownKeyName:
return False
def _handle_mouse_release(self, mouse_pos: MousePos):
super()._handle_mouse_release(mouse_pos)
try:
self.params.put_bool(self.param, self._checked)
except UnknownKeyName:
pass
def refresh(self):
self.set_checked(self._read_bool())
class BigCircleParamControl(BigCircleToggle):
def __init__(self, icon: rl.Texture, param: str, toggle_callback: Callable | None = None,
icon_offset: tuple[int, int] = (0, 0)):
super().__init__(icon, toggle_callback, icon_offset=icon_offset)
self._param = param
self.params = Params()
self.set_checked(self._read_bool())
def _read_bool(self) -> bool:
try:
return self.params.get_bool(self._param)
except UnknownKeyName:
return False
def _handle_mouse_release(self, mouse_pos: MousePos):
super()._handle_mouse_release(mouse_pos)
try:
self.params.put_bool(self._param, self._checked)
except UnknownKeyName:
pass
def refresh(self):
self.set_checked(self._read_bool())

View File

@@ -0,0 +1,264 @@
"""
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
"""
import abc
import math
import pyray as rl
from typing import Union
from collections.abc import Callable
from iqpilot.system.ui.widgets.nav_widget import NavWidget
from iqpilot.system.ui.lib.raylib_compat import draw_rectangle_gradient_ex
from iqpilot.system.ui.widgets.label import UnifiedLabel
from iqpilot.system.ui.widgets.mici_keyboard import MiciKeyboard
from iqpilot.system.ui.lib.text_measure import measure_text_cached
from iqpilot.system.ui.lib.application import gui_app, FontWeight, MousePos
from iqpilot.system.ui.widgets.slider import RedBigSlider, BigSlider
from iqpilot.common.filter_simple import FirstOrderFilter
from iqpilot.selfdrive.ui.mici.widgets.stock_button import BigCircleButton, BigButton, GreyBigButton
DEBUG = False
PADDING = 20
class BigDialogBase(NavWidget, abc.ABC):
def __init__(self):
super().__init__()
self.set_rect(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
class BigDialog(BigDialogBase):
def __init__(self, title: str, description: str, icon: Union[rl.Texture, None] = None):
super().__init__()
self._card = GreyBigButton(title, description, icon)
def _render(self, _):
self._card.render(rl.Rectangle(
self._rect.x + self._rect.width / 2 - self._card.rect.width / 2,
self._rect.y + self._rect.height / 2 - self._card.rect.height / 2,
self._card.rect.width,
self._card.rect.height,
))
class BigConfirmationDialog(BigDialogBase):
def __init__(self, title: str, icon: rl.Texture, confirm_callback: Callable[[], None],
exit_on_confirm: bool = True, red: bool = False):
super().__init__()
self._confirm_callback = confirm_callback
self._exit_on_confirm = exit_on_confirm
self._slider: BigSlider | RedBigSlider
if red:
self._slider = self._child(RedBigSlider(title, icon, confirm_callback=self._on_confirm))
else:
self._slider = self._child(BigSlider(title, icon, confirm_callback=self._on_confirm))
self._slider.set_enabled(lambda: self.enabled and not self.is_dismissing) # for nav stack + NavWidget
def _on_confirm(self):
if self._exit_on_confirm:
self.dismiss(self._confirm_callback)
elif self._confirm_callback:
self._confirm_callback()
def _update_state(self):
super()._update_state()
if self.is_dismissing and not self._slider.confirmed:
self._slider.reset()
def _render(self, _):
self._slider.render(self._rect)
class BigInputDialog(BigDialogBase):
BACK_TOUCH_AREA_PERCENTAGE = 1.0
BACKSPACE_RATE = 25 # hz
TEXT_INPUT_SIZE = 35
INTRO_DURATION_S = 0.14
INTRO_OFFSET_Y = 20
def __init__(self,
hint: str,
default_text: str = "",
minimum_length: int = 1,
confirm_callback: Callable[[str], None] | None = None,
auto_return_to_letters: str = ""):
super().__init__()
self._hint_label = UnifiedLabel(hint, font_size=35, text_color=rl.Color(255, 255, 255, int(255 * 0.35)),
font_weight=FontWeight.MEDIUM)
self._keyboard = MiciKeyboard(auto_return_to_letters=auto_return_to_letters)
self._keyboard.set_text(default_text)
self._keyboard.set_enabled(lambda: self.enabled and not self.is_dismissing) # for nav stack + NavWidget
self._minimum_length = minimum_length
self._backspace_held_time: float | None = None
self._backspace_img = gui_app.texture("icons_mici/settings/keyboard/backspace.png", 42, 36)
self._backspace_img_alpha = FirstOrderFilter(0, 0.05, 1 / gui_app.target_fps)
self._enter_img = gui_app.texture("icons_mici/settings/keyboard/enter.png", 76, 62)
self._enter_disabled_img = gui_app.texture("icons_mici/settings/keyboard/enter_disabled.png", 76, 62)
self._enter_img_alpha = FirstOrderFilter(0, 0.05, 1 / gui_app.target_fps)
# rects for top buttons
self._top_left_button_rect = rl.Rectangle(0, 0, 0, 0)
self._top_right_button_rect = rl.Rectangle(0, 0, 0, 0)
self._intro_started_at = 0.0
def confirm_callback_wrapper():
text = self._keyboard.text()
self.dismiss((lambda: confirm_callback(text)) if confirm_callback else None)
self._confirm_callback = confirm_callback_wrapper
def show_event(self):
super().show_event()
self.settle_to_top()
self._intro_started_at = rl.get_time()
def _intro_progress(self) -> float:
if self._intro_started_at <= 0.0:
return 1.0
return min(max((rl.get_time() - self._intro_started_at) / self.INTRO_DURATION_S, 0.0), 1.0)
def _update_state(self):
super()._update_state()
if self.is_dismissing:
self._backspace_held_time = None
return
last_mouse_event = gui_app.last_mouse_event
if last_mouse_event.left_down and rl.check_collision_point_rec(last_mouse_event.pos, self._top_right_button_rect) and self._backspace_img_alpha.x > 1:
if self._backspace_held_time is None:
self._backspace_held_time = rl.get_time()
if rl.get_time() - self._backspace_held_time > 0.5:
if gui_app.frame % round(gui_app.target_fps / self.BACKSPACE_RATE) == 0:
self._keyboard.backspace()
else:
self._backspace_held_time = None
def _render(self, _):
intro_progress = self._intro_progress()
intro_offset_y = (1.0 - intro_progress) * self.INTRO_OFFSET_Y
intro_alpha = max(int(255 * intro_progress), 1)
# draw current text so far below everything. text floats left but always stays in view
text = self._keyboard.text()
candidate_char = self._keyboard.get_candidate_character()
text_size = measure_text_cached(gui_app.font(FontWeight.ROMAN), text + candidate_char or self._hint_label.text, self.TEXT_INPUT_SIZE)
bg_block_margin = 5
text_x = PADDING / 2 + self._enter_img.width + PADDING
text_field_rect = rl.Rectangle(text_x, self._rect.y + PADDING - bg_block_margin + intro_offset_y,
self._rect.width - text_x * 2,
text_size.y)
# draw text input
# push text left with a gradient on left side if too long
if text_size.x > text_field_rect.width:
text_x -= text_size.x - text_field_rect.width
rl.begin_scissor_mode(int(text_field_rect.x), int(text_field_rect.y), int(text_field_rect.width), int(text_field_rect.height))
rl.draw_text_ex(gui_app.font(FontWeight.ROMAN), text, rl.Vector2(text_x, text_field_rect.y), self.TEXT_INPUT_SIZE, 0,
rl.Color(255, 255, 255, intro_alpha))
# draw grayed out character user is hovering over
if candidate_char:
candidate_char_size = measure_text_cached(gui_app.font(FontWeight.ROMAN), candidate_char, self.TEXT_INPUT_SIZE)
rl.draw_text_ex(gui_app.font(FontWeight.ROMAN), candidate_char,
rl.Vector2(min(text_x + text_size.x, text_field_rect.x + text_field_rect.width) - candidate_char_size.x, text_field_rect.y),
self.TEXT_INPUT_SIZE, 0, rl.Color(255, 255, 255, int(128 * intro_progress)))
rl.end_scissor_mode()
# draw gradient on left side to indicate more text
if text_size.x > text_field_rect.width:
draw_rectangle_gradient_ex(rl.Rectangle(text_field_rect.x, text_field_rect.y, 80, text_field_rect.height),
rl.BLACK, rl.BLANK, rl.BLANK, rl.BLACK)
# draw cursor
blink_alpha = (math.sin(rl.get_time() * 6) + 1) / 2
if text:
cursor_x = min(text_x + text_size.x + 3, text_field_rect.x + text_field_rect.width)
else:
cursor_x = text_field_rect.x - 6
rl.draw_rectangle_rounded(rl.Rectangle(cursor_x, text_field_rect.y, 4, text_size.y),
1, 4, rl.Color(255, 255, 255, int(255 * blink_alpha * intro_progress)))
# draw backspace icon with nice fade
self._backspace_img_alpha.update(255 * bool(text))
if self._backspace_img_alpha.x > 1:
color = rl.Color(255, 255, 255, int(self._backspace_img_alpha.x * intro_progress))
rl.draw_texture_ex(self._backspace_img, rl.Vector2(self._rect.width - self._backspace_img.width - 27, self._rect.y + 14 + intro_offset_y), 0.0, 1.0, color)
if not text and self._hint_label.text and not candidate_char:
# draw description if no text entered yet and not drawing candidate char
hint_rect = rl.Rectangle(text_field_rect.x, text_field_rect.y,
self._rect.width - text_field_rect.x - PADDING,
text_field_rect.height)
self._hint_label.set_color(rl.Color(255, 255, 255, int(255 * 0.35 * intro_progress)))
self._hint_label.render(hint_rect)
# TODO: move to update state
# make rect take up entire area so it's easier to click
self._top_left_button_rect = rl.Rectangle(self._rect.x, self._rect.y, text_field_rect.x, self._rect.height - self._keyboard.get_keyboard_height())
self._top_right_button_rect = rl.Rectangle(text_field_rect.x + text_field_rect.width, self._rect.y,
self._rect.width - (text_field_rect.x + text_field_rect.width), self._top_left_button_rect.height)
# draw enter button
self._enter_img_alpha.update(255 if len(text) >= self._minimum_length else 0)
color = rl.Color(255, 255, 255, int(self._enter_img_alpha.x * intro_progress))
rl.draw_texture_ex(self._enter_img, rl.Vector2(self._rect.x + PADDING / 2, self._rect.y + intro_offset_y), 0.0, 1.0, color)
color = rl.Color(255, 255, 255, int((255 - self._enter_img_alpha.x) * intro_progress))
rl.draw_texture_ex(self._enter_disabled_img, rl.Vector2(self._rect.x + PADDING / 2, self._rect.y + intro_offset_y), 0.0, 1.0, color)
# keyboard goes over everything
self._keyboard.render(rl.Rectangle(self._rect.x, self._rect.y + intro_offset_y, self._rect.width, self._rect.height))
# draw debugging rect bounds
if DEBUG:
rl.draw_rectangle_lines_ex(text_field_rect, 1, rl.Color(100, 100, 100, 255))
rl.draw_rectangle_lines_ex(self._top_right_button_rect, 1, rl.Color(0, 255, 0, 255))
rl.draw_rectangle_lines_ex(self._top_left_button_rect, 1, rl.Color(0, 255, 0, 255))
def _handle_mouse_press(self, mouse_pos: MousePos):
super()._handle_mouse_press(mouse_pos)
# TODO: need to track where press was so enter and back can activate on release rather than press
# or turn into icon widgets :eyes_open:
if self.is_dismissing:
return
# handle backspace icon click
if rl.check_collision_point_rec(mouse_pos, self._top_right_button_rect) and self._backspace_img_alpha.x > 254:
self._keyboard.backspace()
elif rl.check_collision_point_rec(mouse_pos, self._top_left_button_rect) and self._enter_img_alpha.x > 254:
# handle enter icon click
self._confirm_callback()
class BigDialogButton(BigButton):
def __init__(self, text: str, value: str = "", icon: Union[str, rl.Texture] = "", description: str = ""):
super().__init__(text, value, icon)
self._description = description
def _handle_mouse_release(self, mouse_pos: MousePos):
super()._handle_mouse_release(mouse_pos)
dlg = BigDialog(self.text, self._description)
gui_app.push_widget(dlg)
class BigConfirmationCircleButton(BigCircleButton):
def __init__(self, title: str, icon: rl.Texture, confirm_callback: Callable[[], None], exit_on_confirm: bool = True,
red: bool = False, icon_offset: tuple[int, int] = (0, 0)):
super().__init__(icon, red, icon_offset)
def show_confirm_dialog():
gui_app.push_widget(BigConfirmationDialog(title, icon, confirm_callback,
exit_on_confirm=exit_on_confirm, red=red))
self.set_click_callback(show_confirm_dialog)

View File

@@ -0,0 +1,195 @@
"""
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
"""
import os
import pyray as rl
import qrcode
import numpy as np
import time
import jwt
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.selfdrive.ui.ui_state import ui_state
from iqpilot.system.ui.widgets.nav_widget import NavWidget
from iqpilot.system.ui.lib.application import FontWeight, gui_app
from iqpilot.system.ui.widgets.label import UnifiedLabel
from iqpilot.system.ui.lib.multilang import tr
class PairingDialog(NavWidget):
"""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._txt_pair = gui_app.texture("icons_mici/settings/device/pair.png", 33, 60)
self._pair_label = UnifiedLabel(tr("pair with Konn3kt"), font_size=48, font_weight=FontWeight.BOLD, line_height=0.8)
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=0)
qr.add_data(url)
qr.make(fit=True)
pil_img = qr.make_image(fill_color="white", back_color="black").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 as e:
cloudlog.warning(f"QR code generation failed: {e}")
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):
super()._update_state()
if ui_state.prime_state.is_paired() and not self.is_dismissing:
self.dismiss()
def _render(self, rect: rl.Rectangle):
self._check_qr_refresh()
self._render_qr_code()
label_x = self._rect.x + 8 + self._rect.height + 24
self._pair_label.set_max_width(int(self._rect.width - label_x))
self._pair_label.set_position(label_x, self._rect.y + 16)
self._pair_label.render()
rl.draw_texture_ex(self._txt_pair, rl.Vector2(label_x, self._rect.y + self._rect.height - self._txt_pair.height - 16),
0.0, 1.0, rl.Color(255, 255, 255, int(255 * 0.35)))
def _render_qr_code(self) -> None:
if not self._qr_texture:
error_font = gui_app.font(FontWeight.BOLD)
rl.draw_text_ex(
error_font, "QR Code Error", rl.Vector2(self._rect.x + 20, self._rect.y + self._rect.height // 2 - 15), 30, 0.0, rl.RED
)
return
scale = self._rect.height / self._qr_texture.height
pos = rl.Vector2(round(self._rect.x + 8), round(self._rect.y))
rl.draw_texture_ex(self._qr_texture, pos, 0.0, scale, rl.WHITE)
def __del__(self):
if self._qr_texture and self._qr_texture.id != 0:
rl.unload_texture(self._qr_texture)
if __name__ == "__main__":
gui_app.init_window("pairing device")
pairing = PairingDialog()
gui_app.push_widget(pairing)
try:
for _ in gui_app.render():
pass
finally:
del pairing