IQ.Pilot Prebuilt Release @ 27f668a

This commit is contained in:
IQ.Lvbs CI [bot]
2026-09-03 18:23:24 -05:00
commit b073c5182b
2554 changed files with 679696 additions and 0 deletions

1
iqpilot/selfdrive/ui/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
installer/installers/*

View File

@@ -0,0 +1 @@
UI_BORDER_SIZE = 30 #

View File

@@ -0,0 +1,35 @@
"""
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos
Sound-suppression policy for Soundd. The driver's "IQAlertSilence" param, when set,
mutes routine chimes and keeps only a named allow-list of safety-critical cues
audible. The param is re-sampled on a fixed poll interval rather than every frame.
"""
from iqpilot.cereal import car
from iqpilot.common.params import Params
# policy expressed as data: names resolved against the enum at construction so the
# allow-list reads as configuration instead of a hardcoded wall of enum accesses
_KEEP_AUDIBLE_WHEN_QUIET = ("warningSoft", "warningImmediate", "promptDistracted", "promptRepeat")
_SUPPRESS_PARAM = "IQAlertSilence"
_POLL_INTERVAL = 50
class AlertSoundFilter:
def __init__(self):
self._store = Params()
self._cue = car.CarControl.HUDControl.AudibleAlert
self._allow_when_quiet = frozenset(getattr(self._cue, n) for n in _KEEP_AUDIBLE_WHEN_QUIET)
self._suppressing = self._store.get_bool(_SUPPRESS_PARAM)
self._poll = 0
def refresh(self) -> None:
self._poll = (self._poll + 1) % _POLL_INTERVAL
if self._poll == 0:
self._suppressing = self._store.get_bool(_SUPPRESS_PARAM)
def permits(self, alert) -> bool:
has_cue = alert != self._cue.none
if not self._suppressing:
return has_cue
return has_cue and alert in self._allow_when_quiet

View File

@@ -0,0 +1,70 @@
#!/usr/bin/env python3
import iqpilot.cereal.messaging as messaging
from iqpilot.common.params import Params
from iqpilot.common.swaglog import cloudlog
from iqpilot.cereal import car
from iqpilot.system.micd import SAMPLE_RATE, SAMPLE_BUFFER
FEEDBACK_MAX_DURATION = 10.0
ButtonType = car.CarState.ButtonEvent.Type
def main():
params = Params()
pm = messaging.PubMaster(['userBookmark', 'audioFeedback'])
sm = messaging.SubMaster(['rawAudioData', 'bookmarkButton'])
should_record_audio = False
block_num = 0
waiting_for_release = False
early_stop_triggered = False
while True:
sm.update()
should_send_bookmark = False
if False and sm.updated['carState'] and sm['carState'].canValid and not sm['iqState'].aol.available:
for be in sm['carState'].buttonEvents:
if be.type == ButtonType.lkas:
if be.pressed:
if not should_record_audio:
if params.get_bool("RecordAudioFeedback"):
should_record_audio = True
block_num = 0
waiting_for_release = False
early_stop_triggered = False
cloudlog.info("LKAS button pressed - starting 10-second audio feedback")
else:
should_send_bookmark = True
cloudlog.info("LKAS button pressed - bookmarking")
elif should_record_audio and not waiting_for_release:
waiting_for_release = True
elif waiting_for_release:
waiting_for_release = False
early_stop_triggered = True
cloudlog.info("LKAS button released - ending recording early")
if should_record_audio and sm.updated['rawAudioData']:
raw_audio = sm['rawAudioData']
msg = messaging.new_message('audioFeedback', valid=True)
msg.audioFeedback.audio.data = raw_audio.data
msg.audioFeedback.audio.sampleRate = raw_audio.sampleRate
msg.audioFeedback.blockNum = block_num
block_num += 1
if (block_num * SAMPLE_BUFFER / SAMPLE_RATE) >= FEEDBACK_MAX_DURATION or early_stop_triggered:
should_send_bookmark = True
should_record_audio = False
early_stop_triggered = False
cloudlog.info("10-second recording completed or second button press - stopping audio feedback")
pm.send('audioFeedback', msg)
if sm.updated['bookmarkButton']:
cloudlog.info("Bookmark button pressed!")
should_send_bookmark = True
if should_send_bookmark:
msg = messaging.new_message('userBookmark', valid=True)
pm.send('userBookmark', msg)
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,4 @@
#!/usr/bin/env bash
cd /data/openpilot
exec ./launch_openpilot.sh

Binary file not shown.

View File

View File

@@ -0,0 +1,957 @@
import time
import os
import pyray as rl
from collections.abc import Callable
from enum import IntEnum
from iqpilot.cereal import log
from iqpilot.common.params import Params
from iqpilot.common.basedir import BASEDIR
from iqpilot.selfdrive.ui.widgets.offroad_alerts import UpdateAlert, OffroadAlert
from iqpilot.selfdrive.ui.widgets.setup import SetupWidget
from iqpilot.selfdrive.ui.widgets.inspire_widget import InspireWidget
from iqpilot.selfdrive.ui.widgets.map_panel_widget import MapPanelWidget
from iqpilot.ui.layouts.settings.drive_history import TripsLayout
from iqpilot.selfdrive.ui.layouts.sidebar import NETWORK_TYPES
from iqpilot.selfdrive.ui.lib.wifi_ssid import current_ssid
from iqpilot.selfdrive.ui.ui_state import ui_state
from iqpilot.system.ui.lib.text_measure import measure_text_cached
from iqpilot.system.ui.lib.application import gui_app, FontWeight, MouseEvent, MousePos
from iqpilot.system.ui.lib.multilang import tr, trn
from iqpilot.system.ui.lib.wrap_text import wrap_text
from iqpilot.system.ui.widgets.label import gui_label, UnifiedLabel
from iqpilot.system.ui.widgets import Widget
from iqpilot.system.ui.widgets.button import Button, ButtonStyle
STATUS_BAR_HEIGHT = 120
HEAD_BUTTON_FONT_SIZE = 40
CONTENT_MARGIN = 40
SPACING = 25
TILE_GAP = 24
STATS_PANEL_VERTICAL_INSET = TILE_GAP
REFRESH_INTERVAL = 10.0
CHANGELOG_REFRESH_INTERVAL = 15.0
HOLD_THRESHOLD = 0.6 # seconds to trigger the panel picker
PANEL_KEY = "HomePanelWidget"
PANEL_CHANGELOG = "changelog"
PANEL_STATS = "stats"
PANEL_MAP = "map"
PANEL_INSPIRE = "inspire"
PICKER_BG = rl.Color(20, 21, 26, 230)
PICKER_CARD = rl.Color(34, 36, 44, 255)
PICKER_CARD_HOVER = rl.Color(48, 51, 60, 255)
PICKER_BORDER = rl.Color(255, 255, 255, 30)
PICKER_TEAL = rl.Color(16, 185, 169, 255)
PICKER_SEL_BORDER = rl.Color(16, 185, 169, 200)
ThermalStatus = log.DeviceState.ThermalStatus
NetworkType = log.DeviceState.NetworkType
# Status-light severity colors
STATUS_GOOD = rl.Color(16, 185, 169, 255) # teal
STATUS_WARN = rl.Color(245, 166, 35, 255) # orange
STATUS_DANGER = rl.Color(226, 72, 58, 255) # red
class ChangelogWidget(Widget):
PANEL_BG_COLOR = rl.Color(34, 36, 42, 255)
PANEL_BORDER = rl.Color(255, 255, 255, 26)
BODY_COLOR = rl.Color(235, 235, 235, 255)
HEADING_COLOR = rl.Color(255, 255, 255, 255)
def __init__(self):
super().__init__()
self._show_all = False
self._latest_text = ""
self._all_text = ""
self._render_latest: list[dict] = []
self._render_all: list[dict] = []
self._wrap_width = 0
self._last_load = 0.0
self._scroll_px = 0.0
self._max_scroll = 0.0
self._is_dragging = False
self._drag_last_y = 0.0
self._text_rect = rl.Rectangle(0, 0, 0, 0)
self._btn_rect = rl.Rectangle(0, 0, 0, 0)
self._latest_btn = Button("Latest", self._show_latest, button_style=ButtonStyle.PRIMARY, font_size=28)
self._all_btn = Button("All", self._show_all_logs, button_style=ButtonStyle.NORMAL, font_size=28)
self._load_changelog(force=True)
def show_event(self):
self._load_changelog(force=True)
def _show_latest(self) -> None:
self._show_all = False
self._scroll_px = 0.0
self._is_dragging = False
def _show_all_logs(self) -> None:
self._show_all = True
self._scroll_px = 0.0
self._is_dragging = False
def _load_changelog(self, force: bool = False) -> None:
now = time.monotonic()
if not force and (now - self._last_load) < CHANGELOG_REFRESH_INTERVAL:
return
self._last_load = now
paths = [os.path.join(BASEDIR, "iqpilot", "docs", "CHANGELOG.md")]
content = ""
for p in paths:
try:
with open(p, encoding="utf-8") as f:
content = f.read().strip()
if content:
break
except OSError:
pass
if not content:
content = "No changelog found.\n\nAdd iqpilot/docs/CHANGELOG.md."
ordered = self._reorder_sections_newest_first(content)
self._latest_text = self._build_latest_text(ordered)
self._all_text = ordered
self._render_latest = []
self._render_all = []
self._wrap_width = 0
def _reorder_sections_newest_first(self, content: str) -> str:
lines = content.splitlines()
intro: list[str] = []
sections: list[list[str]] = []
current: list[str] | None = None
for line in lines:
if line.startswith("## "):
if current is not None:
sections.append(current)
current = [line]
else:
if current is None:
intro.append(line)
else:
current.append(line)
if current is not None:
sections.append(current)
out: list[str] = []
if intro:
out.extend(intro)
out.append("")
for i, section in enumerate(reversed(sections)):
out.extend(section)
if i != len(sections) - 1:
out.append("")
return "\n".join(out).strip()
def _build_latest_text(self, content: str) -> str:
lines = content.splitlines()
if not lines:
return "No updates available."
out: list[str] = []
section_count = 0
for line in lines:
if line.startswith("## "):
section_count += 1
if section_count > 2:
break
out.append(line)
return "\n".join(out).strip() or content
@staticmethod
def _clean_inline_markdown(text: str) -> str:
return text.replace("**", "").replace("`", "").strip()
def _build_render_lines(self, text: str, width: int) -> list[dict]:
lines: list[dict] = []
for raw in text.splitlines():
stripped = raw.strip()
if not stripped:
lines.append({"text": "", "font_size": 16, "font_weight": FontWeight.NORMAL, "indent": 0, "color": self.BODY_COLOR, "height": 18})
continue
font_size = 34
font_weight = FontWeight.NORMAL
indent = 0
color = self.BODY_COLOR
text_line = stripped
extra_spacing = 0
if stripped.startswith("### "):
text_line = self._clean_inline_markdown(stripped[4:])
font_size = 34
font_weight = FontWeight.BOLD
color = self.HEADING_COLOR
extra_spacing = 8
elif stripped.startswith("## "):
text_line = self._clean_inline_markdown(stripped[3:])
font_size = 38
font_weight = FontWeight.BOLD
color = self.HEADING_COLOR
extra_spacing = 10
elif stripped.startswith("# "):
text_line = self._clean_inline_markdown(stripped[2:])
font_size = 42
font_weight = FontWeight.BOLD
color = self.HEADING_COLOR
extra_spacing = 12
elif stripped.startswith(("- ", "* ")):
text_line = "" + self._clean_inline_markdown(stripped[2:])
font_size = 34
indent = 8
else:
text_line = self._clean_inline_markdown(stripped)
font = gui_app.font(font_weight)
wrapped = wrap_text(font, text_line, font_size, max(50, width - indent))
if not wrapped:
wrapped = [text_line]
for i, w in enumerate(wrapped):
line_indent = indent if i == 0 else indent + 18
line_h = int(font_size * 1.15)
lines.append({
"text": w,
"font_size": font_size,
"font_weight": font_weight,
"indent": line_indent,
"color": color,
"height": line_h,
})
if extra_spacing > 0:
lines.append({"text": "", "font_size": extra_spacing, "font_weight": FontWeight.NORMAL, "indent": 0, "color": self.BODY_COLOR, "height": extra_spacing})
return lines
def _ensure_wrapped(self, text_w: int):
if text_w <= 0:
return
if self._wrap_width == text_w and self._render_latest and self._render_all:
return
self._wrap_width = text_w
self._render_latest = self._build_render_lines(self._latest_text, text_w)
self._render_all = self._build_render_lines(self._all_text, text_w)
@staticmethod
def _total_height(lines: list[dict]) -> int:
return sum(line["height"] for line in lines)
def _clamp_scroll(self):
self._scroll_px = max(0.0, min(self._max_scroll, self._scroll_px))
def _handle_mouse_press(self, mouse_pos: MousePos):
if rl.check_collision_point_rec(mouse_pos, self._text_rect):
self._is_dragging = True
self._drag_last_y = mouse_pos.y
def _handle_mouse_event(self, mouse_event):
if not self._is_dragging or not mouse_event.left_down:
return
dy = mouse_event.pos.y - self._drag_last_y
self._drag_last_y = mouse_event.pos.y
self._scroll_px -= dy
self._clamp_scroll()
def _handle_mouse_release(self, mouse_pos: MousePos):
self._is_dragging = False
def _render(self, rect: rl.Rectangle):
self._load_changelog()
rl.draw_rectangle_rounded(rect, 0.06, 24, self.PANEL_BG_COLOR)
rl.draw_rectangle_rounded_lines_ex(rect, 0.06, 24, 2, self.PANEL_BORDER)
title_rect = rl.Rectangle(rect.x + 36, rect.y + 24, rect.width - 72, 50)
gui_label(title_rect, "Latest Updates", 44, rl.WHITE, font_weight=FontWeight.BOLD, alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT)
btn_w = 150
btn_h = 54
btn_gap = 12
self._btn_rect = rl.Rectangle(rect.x + rect.width - (btn_w * 2) - btn_gap - 36, rect.y + 84, (btn_w * 2) + btn_gap, btn_h)
latest_rect = rl.Rectangle(self._btn_rect.x, self._btn_rect.y, btn_w, btn_h)
all_rect = rl.Rectangle(self._btn_rect.x + btn_w + btn_gap, self._btn_rect.y, btn_w, btn_h)
self._latest_btn.set_button_style(ButtonStyle.PRIMARY if not self._show_all else ButtonStyle.NORMAL)
self._all_btn.set_button_style(ButtonStyle.PRIMARY if self._show_all else ButtonStyle.NORMAL)
self._latest_btn.render(latest_rect)
self._all_btn.render(all_rect)
self._text_rect = rl.Rectangle(rect.x + 36, rect.y + 154, rect.width - 72, rect.height - 182)
self._ensure_wrapped(int(self._text_rect.width))
lines = self._render_all if self._show_all else self._render_latest
total_h = self._total_height(lines)
self._max_scroll = max(0.0, total_h - self._text_rect.height)
self._clamp_scroll()
# Clip content region and draw formatted lines
rl.begin_scissor_mode(int(self._text_rect.x), int(self._text_rect.y), int(self._text_rect.width), int(self._text_rect.height))
y = self._text_rect.y - self._scroll_px
for line in lines:
line_h = line["height"]
if line["text"] and (y + line_h) >= self._text_rect.y and y <= (self._text_rect.y + self._text_rect.height):
font = gui_app.font(line["font_weight"])
x = self._text_rect.x + line["indent"]
rl.draw_text_ex(font, line["text"], rl.Vector2(x, y), line["font_size"], 0, line["color"])
y += line_h
rl.end_scissor_mode()
if self._max_scroll > 0.0:
hint = "Drag to scroll"
hint_size = measure_text_cached(gui_app.font(FontWeight.NORMAL), hint, 24)
hint_x = self._text_rect.x + self._text_rect.width - hint_size.x
hint_y = rect.y + rect.height - 12 - hint_size.y
rl.draw_text_ex(gui_app.font(FontWeight.NORMAL), hint, rl.Vector2(hint_x, hint_y), 24, 0, rl.Color(180, 180, 180, 220))
def _format_updater_description(description: str | None) -> str:
brand = "IQ.Pilot"
if not description:
return brand
cleaned = description.strip()
lower = cleaned.lower()
if lower.startswith("iqpilot"):
cleaned = cleaned[len("iqpilot"):].lstrip(" -:/")
if cleaned.lower().startswith(brand.lower()):
return cleaned
return f"{brand} {cleaned}" if cleaned else brand
class LauncherTile(Widget):
"""A large offroad launcher tile: teal-gradient icon over a dark rounded card, label beneath."""
BG = rl.Color(34, 36, 42, 255)
BG_PRESSED = rl.Color(50, 53, 61, 255)
BORDER = rl.Color(255, 255, 255, 26)
def __init__(self, icon_path: str, label: str | Callable[[], str], on_click: Callable[[], None] | None = None):
super().__init__()
self._label = label
self._icon_path = icon_path
self._icon = gui_app.texture(icon_path, 256, 256, keep_aspect_ratio=True)
if on_click is not None:
self.set_click_callback(on_click)
def set_label(self, label: str | Callable[[], str]) -> None:
self._label = label
def set_icon_path(self, icon_path: str) -> None:
if icon_path != self._icon_path:
self._icon_path = icon_path
self._icon = gui_app.texture(icon_path, 256, 256, keep_aspect_ratio=True)
def _render(self, rect: rl.Rectangle):
pressed = self.is_pressed
rl.draw_rectangle_rounded(rect, 0.16, 24, self.BG_PRESSED if pressed else self.BG)
rl.draw_rectangle_rounded_lines_ex(rect, 0.16, 24, 2, self.BORDER)
# Icon centered in the upper portion of the tile
icon_size = min(rect.width, rect.height) * 0.44
cx = rect.x + rect.width / 2
cy = rect.y + rect.height * 0.40
icon_dst = rl.Rectangle(cx - icon_size / 2, cy - icon_size / 2, icon_size, icon_size)
src = rl.Rectangle(0, 0, self._icon.width, self._icon.height)
rl.draw_texture_pro(self._icon, src, icon_dst, rl.Vector2(0, 0), 0, rl.WHITE)
# Label, centered beneath the icon
font = gui_app.font(FontWeight.MEDIUM)
label_size = 48
label = self._label() if callable(self._label) else self._label
ts = measure_text_cached(font, label, label_size)
label_x = rect.x + (rect.width - ts.x) / 2
label_y = rect.y + rect.height * 0.75
rl.draw_text_ex(font, label, rl.Vector2(int(label_x), int(label_y)), label_size, 0, rl.WHITE)
class HomeLayoutState(IntEnum):
HOME = 0
UPDATE = 1
ALERTS = 2
class HomeLayout(Widget):
def __init__(self):
super().__init__()
self.params = Params()
self.update_alert = UpdateAlert()
self.offroad_alert = OffroadAlert()
self._layout_widgets = {HomeLayoutState.UPDATE: self.update_alert, HomeLayoutState.ALERTS: self.offroad_alert}
self.current_state = HomeLayoutState.HOME
self.last_refresh = 0
self.settings_callback: Callable[[], None] | None = None
self.stats_callback: Callable[[], None] | None = None
self.nav_callback: Callable[[], None] | None = None
self.routes_callback: Callable[[], None] | None = None
self.update_available = False
self.alert_count = 0
self._version_text = ""
self._version_commit_text = ""
self._status_version_label = UnifiedLabel("", font_size=44, font_weight=FontWeight.MEDIUM,
text_color=rl.Color(185, 185, 190, 255),
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE,
wrap_text=False, scroll=True)
self._prev_update_available = False
self._prev_alerts_present = False
# Status-bar state
self._status_color = STATUS_GOOD
self._status_word = tr("READY")
self._expanded = False
self._expanded_metrics: list[tuple[str, str, rl.Color]] = []
self._net_type = NETWORK_TYPES.get(NetworkType.none)
self._on_wifi = False
self._battery_pct: int | None = None
self._battery_charging = False
self.status_bar_rect = rl.Rectangle(0, 0, 0, 0)
self.content_rect = rl.Rectangle(0, 0, 0, 0)
self.left_column_rect = rl.Rectangle(0, 0, 0, 0)
self.right_column_rect = rl.Rectangle(0, 0, 0, 0)
self._tile_rects: list[rl.Rectangle] = []
self.update_notif_rect = rl.Rectangle(0, 0, 200, 60)
self.alert_notif_rect = rl.Rectangle(0, 0, 220, 60)
self._setup_widget = SetupWidget()
self._changelog_widget = ChangelogWidget()
self._inspire_widget = InspireWidget()
self._map_panel_widget = MapPanelWidget()
self._stats_panel_widget = TripsLayout()
# Right-panel widget selection
saved = self.params.get(PANEL_KEY) or ""
self._panel_widget = saved if saved in (PANEL_CHANGELOG, PANEL_STATS, PANEL_MAP, PANEL_INSPIRE) else PANEL_CHANGELOG
# Hold-to-pick
self._press_start: float | None = None
self._press_origin: tuple[float, float] = (0.0, 0.0)
self._press_scrolled = False # True once the current press moved — don't restart timer
self._show_picker = False
self._picker_hover: str | None = None
self._picker_ignore_release = False # swallow the release that opened the picker
# Status-bar icons (white, tinted at draw time)
self._icon_wifi = gui_app.texture("icons/iq/wifi.png", 64, 64, keep_aspect_ratio=True)
self._icon_battery = gui_app.texture("icons/iq/battery.png", 72, 72, keep_aspect_ratio=True)
_net_base = "icons_mici/settings/network/"
self._cell_strength_icons = [
gui_app.texture(f"{_net_base}cell_strength_none.png", 64, 64, keep_aspect_ratio=True),
gui_app.texture(f"{_net_base}cell_strength_low.png", 64, 64, keep_aspect_ratio=True),
gui_app.texture(f"{_net_base}cell_strength_low.png", 64, 64, keep_aspect_ratio=True),
gui_app.texture(f"{_net_base}cell_strength_medium.png", 64, 64, keep_aspect_ratio=True),
gui_app.texture(f"{_net_base}cell_strength_high.png", 64, 64, keep_aspect_ratio=True),
gui_app.texture(f"{_net_base}cell_strength_full.png", 64, 64, keep_aspect_ratio=True),
]
# wifi has no "high" variant: none/low/medium/full only
self._wifi_strength_icons = [
gui_app.texture(f"{_net_base}wifi_strength_none.png", 64, 64, keep_aspect_ratio=True),
gui_app.texture(f"{_net_base}wifi_strength_low.png", 64, 64, keep_aspect_ratio=True),
gui_app.texture(f"{_net_base}wifi_strength_low.png", 64, 64, keep_aspect_ratio=True),
gui_app.texture(f"{_net_base}wifi_strength_medium.png", 64, 64, keep_aspect_ratio=True),
gui_app.texture(f"{_net_base}wifi_strength_full.png", 64, 64, keep_aspect_ratio=True),
gui_app.texture(f"{_net_base}wifi_strength_full.png", 64, 64, keep_aspect_ratio=True),
]
self._net_strength = 0
# Launcher tiles
self._tiles = [
LauncherTile("icons/iq/tile_settings.png", lambda: tr("Settings"), self._on_settings_tile),
LauncherTile("icons/iq/tile_stats.png", lambda: tr("Stats"), self._on_stats_tile),
LauncherTile("icons/iq/tile_nav.png", lambda: tr("Navigation"), self._on_nav_tile),
LauncherTile("icons/iq/tile_routes.png", lambda: tr("Routes"), self._on_routes_tile),
]
self._setup_callbacks()
def show_event(self):
self._changelog_widget.show_event()
self.last_refresh = time.monotonic()
self._refresh()
def _setup_callbacks(self):
self.update_alert.set_dismiss_callback(lambda: self._set_state(HomeLayoutState.HOME))
self.offroad_alert.set_dismiss_callback(lambda: self._set_state(HomeLayoutState.HOME))
def set_settings_callback(self, callback: Callable):
self.settings_callback = callback
def set_stats_callback(self, callback: Callable):
self.stats_callback = callback
def set_nav_callback(self, callback: Callable):
self.nav_callback = callback
def set_routes_callback(self, callback: Callable):
self.routes_callback = callback
# Tile actions
def _on_settings_tile(self):
if self.settings_callback:
self.settings_callback()
def _on_stats_tile(self):
if self.stats_callback:
self.stats_callback()
def _on_nav_tile(self):
if self.nav_callback:
self.nav_callback()
def _on_routes_tile(self):
if self.routes_callback:
self.routes_callback()
def _set_state(self, state: HomeLayoutState):
# propagate show/hide events
if state != self.current_state:
if state in self._layout_widgets:
self._layout_widgets[state].show_event()
if self.current_state in self._layout_widgets:
self._layout_widgets[self.current_state].hide_event()
self.current_state = state
def _render(self, rect: rl.Rectangle):
current_time = time.monotonic()
if current_time - self.last_refresh >= REFRESH_INTERVAL:
self._refresh()
self.last_refresh = current_time
self._render_status_bar()
if self.current_state == HomeLayoutState.HOME:
self._render_home_content()
elif self.current_state == HomeLayoutState.UPDATE:
self.update_alert.render(self.content_rect)
elif self.current_state == HomeLayoutState.ALERTS:
self.offroad_alert.render(self.content_rect)
def _update_state(self):
self.status_bar_rect = rl.Rectangle(
self._rect.x + CONTENT_MARGIN, self._rect.y + CONTENT_MARGIN,
self._rect.width - 2 * CONTENT_MARGIN, STATUS_BAR_HEIGHT
)
content_y = self._rect.y + CONTENT_MARGIN + STATUS_BAR_HEIGHT + SPACING
content_height = self._rect.height - CONTENT_MARGIN - STATUS_BAR_HEIGHT - SPACING - CONTENT_MARGIN
self.content_rect = rl.Rectangle(
self._rect.x + CONTENT_MARGIN, content_y, self._rect.width - 2 * CONTENT_MARGIN, content_height
)
right_width = min(820, self.content_rect.width * 0.46)
left_width = self.content_rect.width - right_width - SPACING
self.left_column_rect = rl.Rectangle(self.content_rect.x, self.content_rect.y, left_width, self.content_rect.height)
self.right_column_rect = rl.Rectangle(
self.content_rect.x + left_width + SPACING, self.content_rect.y, right_width, self.content_rect.height
)
# 2x2 tile grid
tile_w = (self.left_column_rect.width - TILE_GAP) / 2
tile_h = (self.left_column_rect.height - TILE_GAP) / 2
self._tile_rects = []
for i in range(4):
col = i % 2
row = i // 2
tx = self.left_column_rect.x + col * (tile_w + TILE_GAP)
ty = self.left_column_rect.y + row * (tile_h + TILE_GAP)
self._tile_rects.append(rl.Rectangle(tx, ty, tile_w, tile_h))
self._update_status_info()
self._update_hold()
def _clear_panel_hold(self):
self._press_start = None
self._press_scrolled = False
def _update_hold(self):
if not ui_state.prime_state.is_paired() or self._show_picker:
self._clear_panel_hold()
return
if self._press_start is None or self._press_scrolled:
return
if time.monotonic() - self._press_start >= HOLD_THRESHOLD:
self._show_picker = True
self._press_start = None
self._picker_ignore_release = True
def _update_status_info(self):
# Read the expanded-status toggle every frame (not gated by deviceState updates)
self._expanded = self.params.get_bool("IQExpandedStatus")
sm = ui_state.sm
# Network + battery — updated from cached state every frame so values are never stale
_ds_cached = sm['deviceState']
self._net_type = NETWORK_TYPES.get(_ds_cached.networkType.raw, self._net_type)
self._on_wifi = _ds_cached.networkType.raw in (NetworkType.wifi, NetworkType.ethernet)
_strength = _ds_cached.networkStrength
self._net_strength = max(0, min(5, _strength.raw + 1)) if _strength.raw > 0 else 0
if sm.updated['deviceState']:
ds = sm['deviceState']
# Battery (not present on all hardware/messages)
try:
self._battery_pct = int(ds.batteryPercent)
self._battery_charging = bool(ds.batteryStatus == "Charging")
except (AttributeError, ValueError):
self._battery_pct = None
# Status pill — computed every frame from cached state so it's always current.
# severity: 1 = warning (orange), 2 = error (red). No issues -> READY (teal).
_ds = sm['deviceState']
issues: list[tuple[int, str]] = []
_ts = _ds.thermalStatus
if _ts == ThermalStatus.red:
issues.append((2, tr("TEMP HIGH")))
elif _ts == ThermalStatus.yellow:
issues.append((1, tr("TEMP OK")))
if ui_state.panda_type == log.PandaState.PandaType.unknown:
issues.append((2, tr("UNAVAILABLE")))
_last_ping = _ds.lastAthenaPingTime
if _last_ping == 0:
issues.append((1, tr("KONN3KT OFFLINE")))
elif time.monotonic_ns() - _last_ping >= 80_000_000_000:
issues.append((2, tr("KONN3KT ERROR")))
if issues:
severity, word = max(issues, key=lambda i: i[0])
else:
severity, word = 0, tr("READY")
self._status_color = (STATUS_GOOD, STATUS_WARN, STATUS_DANGER)[severity]
self._status_word = word
# Expanded status (classic-UI style) — rebuilt every frame from cached state
if self._expanded:
ds = sm['deviceState']
ts = ds.thermalStatus
if ts == ThermalStatus.green:
temp = (tr("TEMP"), tr("GOOD"), STATUS_GOOD)
elif ts == ThermalStatus.yellow:
temp = (tr("TEMP"), tr("OK"), STATUS_WARN)
else:
temp = (tr("TEMP"), tr("HIGH"), STATUS_DANGER)
if ui_state.panda_type == log.PandaState.PandaType.unknown:
veh = (tr("VEHICLE"), tr("NO PANDA"), STATUS_DANGER)
else:
veh = (tr("VEHICLE"), tr("ONLINE"), STATUS_GOOD)
last_ping = ds.lastAthenaPingTime
if last_ping == 0:
kon = (tr("KONN3KT"), tr("OFFLINE"), STATUS_WARN)
elif time.monotonic_ns() - last_ping < 80_000_000_000:
kon = (tr("KONN3KT"), tr("ONLINE"), STATUS_GOOD)
else:
kon = (tr("KONN3KT"), tr("ERROR"), STATUS_DANGER)
self._expanded_metrics = [temp, veh, kon]
else:
self._expanded_metrics = []
def _handle_mouse_press(self, mouse_pos: MousePos):
if (self.current_state == HomeLayoutState.HOME and ui_state.prime_state.is_paired()
and not self._show_picker and rl.check_collision_point_rec(mouse_pos, self.right_column_rect)):
self._press_start = time.monotonic()
self._press_origin = (mouse_pos.x, mouse_pos.y)
self._press_scrolled = False
def _handle_mouse_event(self, mouse_event: MouseEvent):
if self._press_start is None or self._press_scrolled or not mouse_event.left_down:
return
dx = mouse_event.pos.x - self._press_origin[0]
dy = mouse_event.pos.y - self._press_origin[1]
if dx * dx + dy * dy > 18 * 18:
self._press_start = None
self._press_scrolled = True
def _handle_mouse_release(self, mouse_pos: MousePos):
self._clear_panel_hold()
if self._show_picker:
if self._picker_ignore_release:
self._picker_ignore_release = False
return
if self._picker_hover is not None:
self._panel_widget = self._picker_hover
self.params.put(PANEL_KEY, self._panel_widget)
if self._panel_widget == PANEL_INSPIRE:
self._inspire_widget.show_event()
elif self._panel_widget == PANEL_CHANGELOG:
self._changelog_widget.show_event()
self._show_picker = False
self._picker_hover = None
return
super()._handle_mouse_release(mouse_pos)
if self.update_available and rl.check_collision_point_rec(mouse_pos, self.update_notif_rect):
self._set_state(HomeLayoutState.UPDATE)
elif self.alert_count > 0 and rl.check_collision_point_rec(mouse_pos, self.alert_notif_rect):
self._set_state(HomeLayoutState.ALERTS)
def _render_status_bar(self):
rect = self.status_bar_rect
cy = rect.y + rect.height / 2
font_bold = gui_app.font(FontWeight.BOLD)
font = gui_app.font(FontWeight.MEDIUM)
word_fs = 50
text_fs = 44
pad = 36
gap = 26
light_d = 54 # status light region width
# --- measure the left cluster so we can wrap it in a rounded pill ---
# The summary word (READY / KONN3KT ERROR / ...) duplicates the expanded TEMP/VEHICLE/KONN3KT
# chips, so drop it when expanded — the color-coded status dot still conveys severity.
show_word = not (self._expanded and self._expanded_metrics)
word_w = measure_text_cached(font_bold, self._status_word, word_fs).x if show_word else 0
net_text = current_ssid(self._on_wifi) or tr(self._net_type)
net_w = measure_text_cached(font, net_text, text_fs).x
_sig_icons = self._wifi_strength_icons if self._on_wifi else self._cell_strength_icons
_icon_signal = _sig_icons[min(self._net_strength, len(_sig_icons) - 1)]
cluster_w = pad + light_d + gap + (word_w + gap if show_word else 0) + _icon_signal.width + 14 + net_w
batt_text = None
if self._battery_pct is not None:
batt_text = f"{self._battery_pct}%"
cluster_w += gap + self._icon_battery.width + 10 + measure_text_cached(font, batt_text, text_fs).x
cluster_w += pad
pill = rl.Rectangle(rect.x, rect.y, cluster_w, rect.height)
rl.draw_rectangle_rounded(pill, 0.5, 20, rl.Color(28, 30, 36, 255))
rl.draw_rectangle_rounded_lines_ex(pill, 0.5, 20, 2, rl.Color(255, 255, 255, 28))
# status light dot with a faint halo
x = rect.x + pad
halo = rl.Color(self._status_color.r, self._status_color.g, self._status_color.b, 70)
rl.draw_circle(int(x + light_d / 2), int(cy), 32, halo)
rl.draw_circle(int(x + light_d / 2), int(cy), 22, self._status_color)
x += light_d + gap
# status word (summary) — hidden when the expanded chips already show the breakdown
if show_word:
word_size = measure_text_cached(font_bold, self._status_word, word_fs)
rl.draw_text_ex(font_bold, self._status_word, rl.Vector2(int(x), int(cy - word_size.y / 2)), word_fs, 0, rl.WHITE)
x += word_w + gap
# network: signal icon (strength-aware) + type
rl.draw_texture(_icon_signal, int(x), int(cy - _icon_signal.height / 2), rl.WHITE)
x += _icon_signal.width + 14
net_size = measure_text_cached(font, net_text, text_fs)
rl.draw_text_ex(font, net_text, rl.Vector2(int(x), int(cy - net_size.y / 2)), text_fs, 0, rl.Color(215, 215, 215, 255))
x += net_w + gap
# battery, if available
if batt_text is not None:
rl.draw_texture(self._icon_battery, int(x), int(cy - self._icon_battery.height / 2), rl.WHITE)
x += self._icon_battery.width + 10
batt_size = measure_text_cached(font, batt_text, text_fs)
rl.draw_text_ex(font, batt_text, rl.Vector2(int(x), int(cy - batt_size.y / 2)), text_fs, 0, rl.Color(215, 215, 215, 255))
right_x = rect.x + rect.width
version_fs = 44
version_text = self._version_commit_text if self._expanded else self._version_text
version_size = measure_text_cached(font, version_text, version_fs)
version_available = max(1.0, right_x - x - gap)
version_w = min(version_size.x, version_available)
version_rect = rl.Rectangle(right_x - version_w, rect.y, version_w, rect.height)
# --- expanded status chips (classic-UI style), gated by the Visuals toggle ---
if self._expanded and self._expanded_metrics:
chip_x = rect.x + cluster_w + gap
chip_h = rect.height - 16
chip_pad = 22
dot_r = 11
label_fs = 30
max_x = version_rect.x - 30
for label, value, color in self._expanded_metrics:
ctext = f"{label} {value}"
tw = measure_text_cached(font, ctext, label_fs).x
chip_w = chip_pad + dot_r * 2 + 14 + tw + chip_pad
if chip_x + chip_w > max_x:
break
chip = rl.Rectangle(chip_x, rect.y + 8, chip_w, chip_h)
rl.draw_rectangle_rounded(chip, 0.5, 16, rl.Color(28, 30, 36, 255))
rl.draw_rectangle_rounded_lines_ex(chip, 0.5, 16, 2, rl.Color(255, 255, 255, 22))
ccx = chip.x + chip_pad + dot_r
rl.draw_circle(int(ccx), int(cy), dot_r, color)
rl.draw_text_ex(font, ctext, rl.Vector2(int(ccx + dot_r + 14), int(cy - label_fs / 2 - 3)), label_fs, 0, rl.WHITE)
chip_x += chip_w + 16
# --- right cluster: version (small), then notification chips to its left ---
if version_size.x <= version_rect.width:
rl.draw_text_ex(font, version_text, rl.Vector2(int(right_x - version_size.x), int(cy - version_size.y / 2)),
version_fs, 0, rl.Color(185, 185, 190, 255))
else:
self._status_version_label.set_text(version_text)
self._status_version_label.render(version_rect)
chip_x = version_rect.x - 30
if self.alert_count > 0:
self.alert_notif_rect = rl.Rectangle(chip_x - self.alert_notif_rect.width, cy - 30, self.alert_notif_rect.width, 60)
self._draw_chip(self.alert_notif_rect, trn("{} ALERT", "{} ALERTS", self.alert_count).format(self.alert_count),
rl.Color(226, 72, 58, 255) if self.current_state != HomeLayoutState.ALERTS else rl.Color(245, 92, 78, 255))
chip_x = self.alert_notif_rect.x - 16
if self.update_available:
self.update_notif_rect = rl.Rectangle(chip_x - self.update_notif_rect.width, cy - 30, self.update_notif_rect.width, 60)
self._draw_chip(self.update_notif_rect, tr("UPDATE"),
rl.Color(54, 77, 239, 255) if self.current_state != HomeLayoutState.UPDATE else rl.Color(75, 95, 255, 255))
def _draw_chip(self, chip_rect: rl.Rectangle, text: str, color: rl.Color):
rl.draw_rectangle_rounded(chip_rect, 0.4, 10, color)
font = gui_app.font(FontWeight.MEDIUM)
text_size = measure_text_cached(font, text, HEAD_BUTTON_FONT_SIZE)
text_x = chip_rect.x + (chip_rect.width - text_size.x) / 2
text_y = chip_rect.y + (chip_rect.height - text_size.y) / 2
rl.draw_text_ex(font, text, rl.Vector2(int(text_x), int(text_y)), HEAD_BUTTON_FONT_SIZE, 0, rl.WHITE)
def _render_home_content(self):
# left: 2x2 launcher tiles
for tile, tile_rect in zip(self._tiles, self._tile_rects, strict=False):
tile.render(tile_rect)
# right: setup until paired, then the user-chosen widget
if not ui_state.prime_state.is_paired():
self._setup_widget.render(self.right_column_rect)
return
self._render_right_panel()
if self._show_picker:
self._render_picker()
def _render_right_panel(self):
rect = self.right_column_rect
if self._panel_widget == PANEL_STATS:
stats_rect = rl.Rectangle(
rect.x,
rect.y + STATS_PANEL_VERTICAL_INSET,
rect.width,
max(1, rect.height - STATS_PANEL_VERTICAL_INSET * 2),
)
self._stats_panel_widget.render(stats_rect)
elif self._panel_widget == PANEL_MAP:
self._map_panel_widget.render(rect)
elif self._panel_widget == PANEL_INSPIRE:
self._inspire_widget.render(rect)
else:
self._changelog_widget.render(rect)
# Hold-progress ring drawn over the panel while user is holding
if self._press_start is not None and not self._show_picker:
held = time.monotonic() - self._press_start
progress = min(1.0, held / HOLD_THRESHOLD)
cx = int(rect.x + rect.width / 2)
cy = int(rect.y + rect.height / 2)
rl.draw_ring(rl.Vector2(cx, cy), 34, 42, -90, -90 + 360 * progress, 40, rl.Color(16, 185, 169, 180))
def _render_picker(self):
rect = self.right_column_rect
rl.draw_rectangle_rounded(rect, 0.06, 24, PICKER_BG)
options = [
(PANEL_CHANGELOG, "Changelog", "Latest updates"),
(PANEL_STATS, "Stats", "Your drive history"),
(PANEL_MAP, "Map", "Last known location"),
(PANEL_INSPIRE, "Inspiration", "Daily message"),
]
TITLE_FS = 58
SUB_FS = 38
CARD_PAD_V = 36
CARD_PAD_H = 32
DOT_MARGIN = 24
card_h = TITLE_FS + 12 + SUB_FS + CARD_PAD_V * 2
outer_pad = 20
gap = 12
n = len(options)
total_cards_h = n * card_h + (n - 1) * gap
start_y = rect.y + (rect.height - total_cards_h) / 2
mp = rl.get_mouse_position()
self._picker_hover = None
font_title = gui_app.font(FontWeight.BOLD)
font_sub = gui_app.font(FontWeight.MEDIUM)
for i, (key, title, sub) in enumerate(options):
cy_card = start_y + i * (card_h + gap)
card = rl.Rectangle(rect.x + outer_pad, cy_card, rect.width - outer_pad * 2, card_h)
hovered = rl.check_collision_point_rec(mp, card)
if hovered:
self._picker_hover = key
is_sel = key == self._panel_widget
bg = PICKER_CARD_HOVER if hovered else PICKER_CARD
rl.draw_rectangle_rounded(card, 0.14, 16, bg)
border = PICKER_SEL_BORDER if is_sel else PICKER_BORDER
rl.draw_rectangle_rounded_lines_ex(card, 0.14, 16, 2 if is_sel else 1, border)
dot_cx = int(card.x + CARD_PAD_H)
dot_cy = int(card.y + card_h / 2)
if is_sel:
rl.draw_circle(dot_cx, dot_cy, 8, PICKER_TEAL)
else:
rl.draw_circle(dot_cx, dot_cy, 8, rl.Color(255, 255, 255, 35))
rl.draw_ring(rl.Vector2(dot_cx, dot_cy), 5.5, 8, 0, 360, 24, rl.Color(255, 255, 255, 55))
tx = int(card.x + CARD_PAD_H + DOT_MARGIN + 6)
title_y = int(card.y + CARD_PAD_V)
sub_y = int(title_y + TITLE_FS + 8)
title_col = rl.WHITE if is_sel else rl.Color(210, 210, 215, 255)
sub_col = PICKER_TEAL if is_sel else rl.Color(130, 130, 138, 255)
rl.draw_text_ex(font_title, title, rl.Vector2(tx, title_y), TITLE_FS, 0, title_col)
rl.draw_text_ex(font_sub, sub, rl.Vector2(tx, sub_y), SUB_FS, 0, sub_col)
# Hint centered below the cards
hint = "Tap a widget to switch • tap outside to dismiss"
font_hint = gui_app.font(FontWeight.MEDIUM)
hw = measure_text_cached(font_hint, hint, 24).x
hint_y = int(start_y + total_cards_h + 18)
rl.draw_text_ex(font_hint, hint,
rl.Vector2(int(rect.x + (rect.width - hw) / 2), hint_y),
24, 0, rl.Color(110, 110, 118, 220))
def _refresh(self):
self._version_text, self._version_commit_text = self._get_version_texts()
update_available = self.update_alert.refresh()
alert_count = self.offroad_alert.refresh()
alerts_present = alert_count > 0
# Show panels on transition from no alert/update to any alerts/update
if not update_available and not alerts_present:
self._set_state(HomeLayoutState.HOME)
elif update_available and ((not self._prev_update_available) or (not alerts_present and self.current_state == HomeLayoutState.ALERTS)):
self._set_state(HomeLayoutState.UPDATE)
elif alerts_present and ((not self._prev_alerts_present) or (not update_available and self.current_state == HomeLayoutState.UPDATE)):
self._set_state(HomeLayoutState.ALERTS)
self.update_available = update_available
self.alert_count = alert_count
self._prev_update_available = update_available
self._prev_alerts_present = alerts_present
def _get_version_texts(self) -> tuple[str, str]:
description = self.params.get("UpdaterCurrentDescription")
version_text = _format_updater_description(description)
if description:
parts = [part.strip() for part in description.split(" / ")]
if len(parts) >= 3 and parts[2]:
return version_text, parts[2]
return version_text, version_text

View File

@@ -0,0 +1,484 @@
import pyray as rl
from dataclasses import dataclass
from enum import Enum, IntEnum, auto
import iqpilot.cereal.messaging as messaging
from iqpilot.system.ui.lib.application import gui_app, MouseEvent
from iqpilot.selfdrive.ui.layouts.sidebar import Sidebar, SIDEBAR_WIDTH
from iqpilot.selfdrive.ui.layouts.home import HomeLayout
from iqpilot.selfdrive.ui.layouts.stats import StatsLayout
from iqpilot.selfdrive.ui.layouts.nav import NavLayout
from iqpilot.selfdrive.ui.layouts.routes import RoutesLayout
from iqpilot.selfdrive.ui.layouts.video_player import VideoPlayerLayout
from iqpilot.selfdrive.ui.layouts.settings_hub import SettingsHubLayout
from iqpilot.selfdrive.ui.layouts.settings.settings import PanelType
from iqpilot.selfdrive.ui.onroad.augmented_road_view import AugmentedRoadView
from iqpilot.selfdrive.ui.ui_state import device, ui_state
from iqpilot.system.ui.widgets import Widget
from iqpilot.selfdrive.ui.layouts.onboarding import OnboardingWindow
class MainState(IntEnum):
HOME = 0
SETTINGS = 1
ONROAD = 2
STATS = 3
NAV = 4
ROUTES = 5
VIDEO = 6
OFFROAD_TRANSITION_SECONDS = 0.30
TRANSITION_SURFACE_OVERSCAN = 4
TRANSITION_SURFACE_BG = rl.Color(10, 10, 10, 255)
# iOS-style swipe-from-the-left-edge-to-go-back, generalized across the whole offroad UI (settings
# hub still owns its own panel->grid swipe; this covers the top-level pages).
EDGE_SWIPE_ZONE = 80 # px from the left edge a swipe-back must start within
EDGE_SWIPE_ARM_DISTANCE = 8 # px of rightward movement before we commit to a swipe
EDGE_SWIPE_BLOCK_VERTICAL = 60 # px of vertical movement (under arm distance) that cancels it
EDGE_SWIPE_COMPLETE_FRACTION = 0.3 # fraction of width dragged to complete the pop on release
SWIPE_SETTLE_SECONDS = 0.16 # animate from the release point to done/cancelled (no snap)
class MenuTransitionState(Enum):
IDLE = auto()
PUSHING = auto()
POPPING = auto()
@dataclass
class MenuTransition:
state: MenuTransitionState = MenuTransitionState.IDLE
t: float = 0.0
duration: float = OFFROAD_TRANSITION_SECONDS
from_screen: object | None = None
to_screen: object | None = None
@property
def active(self) -> bool:
return self.state != MenuTransitionState.IDLE
def _clamp(x: float, lo: float = 0.0, hi: float = 1.0) -> float:
return max(lo, min(hi, x))
def _lerp(a: float, b: float, t: float) -> float:
return a + (b - a) * t
def _ease_out_cubic(x: float) -> float:
x = _clamp(x)
return 1.0 - pow(1.0 - x, 3.0)
def _ease_emphasized(x: float) -> float:
# ease-in-out-cubic: gentle acceleration into the slide, graceful deceleration into place
x = _clamp(x)
if x < 0.5:
return 4.0 * x * x * x
return 1.0 - pow(-2.0 * x + 2.0, 3.0) / 2.0
# Depth cues for the page push/pop (iOS-style): the underneath page parallaxes a fraction of the
# way, dims into the background, and the top card casts a soft shadow off its leading edge.
TRANSITION_PARALLAX = 0.28
TRANSITION_MAX_DIM = 0.5
TRANSITION_SHADOW_W = 32
class MainLayout(Widget):
def __init__(self):
super().__init__()
self._pm = messaging.PubMaster(['bookmarkButton'])
self._sidebar = Sidebar()
# The offroad launcher owns the full screen; the sidebar is reserved for onroad.
self._sidebar.set_visible(False)
self._current_mode = MainState.HOME
self._prev_onroad = False
# Initialize layouts
self._layouts = {
MainState.HOME: HomeLayout(),
MainState.SETTINGS: SettingsHubLayout(),
MainState.ONROAD: AugmentedRoadView(),
MainState.STATS: StatsLayout(),
MainState.NAV: NavLayout(),
MainState.ROUTES: RoutesLayout(),
MainState.VIDEO: VideoPlayerLayout(),
}
self._sidebar_rect = rl.Rectangle(0, 0, 0, 0)
self._content_rect = rl.Rectangle(0, 0, 0, 0)
self._transition = MenuTransition()
# Edge-swipe-back drag state
self._swipe_start_pos = None
self._swipe_active = False
self._swipe_blocked = False
self._swipe_dx = 0.0
# Release-settle animation (glide to done/cancelled instead of snapping)
self._swipe_settling = False
self._swipe_settle_from = 0.0
self._swipe_settle_to = 0.0
self._swipe_settle_t = 0.0
self._swipe_render_target: MainState | None = None
self._swipe_completing = False
# Set callbacks
self._setup_callbacks()
self._onboarding_window = OnboardingWindow()
if not self._onboarding_window.completed:
gui_app.set_modal_overlay(self._onboarding_window)
def _render(self, _):
self._handle_onroad_transition()
self._render_main_content()
def _setup_callbacks(self):
self._sidebar.set_callbacks(on_settings=self._on_settings_clicked,
on_flag=self._on_bookmark_clicked,
open_settings=lambda: self.open_settings(PanelType.TOGGLES))
self._layouts[MainState.HOME].set_settings_callback(self.open_settings)
self._layouts[MainState.HOME].set_stats_callback(self.open_stats)
self._layouts[MainState.HOME].set_nav_callback(self.open_nav)
self._layouts[MainState.HOME].set_routes_callback(self.open_routes)
self._layouts[MainState.SETTINGS].set_callbacks(on_close=self._set_mode_for_state)
self._layouts[MainState.STATS].set_on_back(self._set_mode_for_state)
self._layouts[MainState.NAV].set_on_back(self._set_mode_for_state)
self._layouts[MainState.ROUTES].set_on_back(self._set_mode_for_state)
self._layouts[MainState.ROUTES].set_on_play(self.open_video)
self._layouts[MainState.VIDEO].set_on_back(self.open_routes)
self._layouts[MainState.ONROAD].set_click_callback(self._on_onroad_clicked)
device.add_interactive_timeout_callback(self._on_interactive_timeout)
def _update_layout_rects(self):
self._sidebar_rect = rl.Rectangle(self._rect.x, self._rect.y, SIDEBAR_WIDTH, self._rect.height)
x_offset = SIDEBAR_WIDTH if self._sidebar.is_visible else 0
self._content_rect = rl.Rectangle(self._rect.x + x_offset, self._rect.y, self._rect.width - x_offset, self._rect.height)
def _handle_onroad_transition(self):
if ui_state.started != self._prev_onroad:
self._prev_onroad = ui_state.started
self._set_mode_for_state()
def _car_stationary(self) -> bool:
if not ui_state.sm.valid["carState"]:
return False
return ui_state.sm["carState"].vEgo < 0.1
def _on_interactive_timeout(self):
# The idle timeout normally returns the UI to the road view. Don't yank the user out of Settings
# while the car is stationary - e.g. a hybrid parked with the engine running to charge reads as
# onroad (ignition tracks the ICE), so this would otherwise make Settings unusable while parked.
# A moving car still returns to the road view.
if self._current_mode == MainState.SETTINGS and ui_state.started and self._car_stationary():
return
self._set_mode_for_state()
def _set_mode_for_state(self):
if ui_state.started:
# Don't hide sidebar from interactive timeout
if self._current_mode != MainState.ONROAD:
self._set_sidebar_visible(False)
self._set_current_layout(MainState.ONROAD)
else:
# Offroad launcher owns the full screen; the sidebar is reserved for onroad.
self._set_current_layout(MainState.HOME)
self._set_sidebar_visible(False)
def _set_current_layout(self, layout: MainState):
if self._transition.active:
if layout == MainState.ONROAD or ui_state.started:
self._cancel_transition()
elif layout == self._current_mode:
self._cancel_transition()
return
else:
return
if layout == self._current_mode:
return
old_mode = self._current_mode
if self._should_animate_transition(old_mode, layout):
self._start_transition(old_mode, layout)
return
self._layouts[old_mode].hide_event()
self._current_mode = layout
self._layouts[self._current_mode].show_event()
def _should_animate_transition(self, old_mode: MainState, new_mode: MainState) -> bool:
return (
not ui_state.started
and old_mode != MainState.ONROAD
and new_mode != MainState.ONROAD
and (old_mode == MainState.HOME or new_mode == MainState.HOME)
)
def _start_transition(self, old_mode: MainState, new_mode: MainState):
state = MenuTransitionState.PUSHING if old_mode == MainState.HOME else MenuTransitionState.POPPING
self._transition = MenuTransition(
state=state,
t=0.0,
duration=OFFROAD_TRANSITION_SECONDS,
from_screen=old_mode,
to_screen=new_mode,
)
self._layouts[new_mode].show_event()
def _cancel_transition(self):
pending_mode = self._transition.to_screen
if pending_mode is not None and pending_mode != self._current_mode:
self._layouts[pending_mode].hide_event()
self._transition = MenuTransition()
def _finish_transition(self):
old_mode = self._transition.from_screen
new_mode = self._transition.to_screen
if old_mode is not None:
self._layouts[old_mode].hide_event()
if new_mode is not None:
self._current_mode = new_mode
self._transition = MenuTransition()
def _render_layout(self, layout: Widget, rect: rl.Rectangle, interactive: bool = True):
if interactive:
layout.render(rect)
return
enabled = layout._enabled
layout.set_enabled(False)
try:
layout.render(rect)
finally:
layout.set_enabled(enabled)
def _render_layout_surface(self, layout: Widget, rect: rl.Rectangle, interactive: bool = True):
bg_rect = rl.Rectangle(
rect.x - TRANSITION_SURFACE_OVERSCAN,
rect.y - TRANSITION_SURFACE_OVERSCAN,
rect.width + TRANSITION_SURFACE_OVERSCAN * 2,
rect.height + TRANSITION_SURFACE_OVERSCAN * 2,
)
rl.draw_rectangle_rec(bg_rect, TRANSITION_SURFACE_BG)
self._render_layout(layout, rect, interactive)
def _render_layout_surface_translated(self, layout: Widget, rect: rl.Rectangle, x_offset: float):
translated_rect = rl.Rectangle(round(rect.x + x_offset), rect.y, rect.width, rect.height)
self._render_layout_surface(layout, translated_rect, interactive=False)
def _render_transition(self, content_rect: rl.Rectangle) -> bool:
if not self._transition.active:
return False
from_mode = self._transition.from_screen
to_mode = self._transition.to_screen
if from_mode is None or to_mode is None:
self._finish_transition()
return False
self._transition.t += rl.get_frame_time()
if self._transition.t >= self._transition.duration:
self._finish_transition()
return False
p = _clamp(self._transition.t / self._transition.duration)
e = _ease_emphasized(p)
w = content_rect.width
pushing = self._transition.state == MenuTransitionState.PUSHING
# The incoming card slides its full width on top; the other page parallaxes a fraction and dims.
if pushing:
top_mode, back_mode = to_mode, from_mode
top_x = _lerp(w, 0.0, e)
back_x = _lerp(0.0, -w * TRANSITION_PARALLAX, e)
back_dim = int(_lerp(0.0, TRANSITION_MAX_DIM, e) * 255)
else:
top_mode, back_mode = from_mode, to_mode
top_x = _lerp(0.0, w, e)
back_x = _lerp(-w * TRANSITION_PARALLAX, 0.0, e)
back_dim = int(_lerp(TRANSITION_MAX_DIM, 0.0, e) * 255)
back_rect = rl.Rectangle(round(content_rect.x + back_x), content_rect.y, content_rect.width, content_rect.height)
rl.draw_rectangle_rec(content_rect, TRANSITION_SURFACE_BG)
self._render_layout_surface_translated(self._layouts[back_mode], content_rect, back_x)
if back_dim > 0:
rl.draw_rectangle_rec(back_rect, rl.Color(0, 0, 0, back_dim))
# soft shadow cast by the top card's leading edge onto the page behind
edge_x = round(content_rect.x + top_x)
if edge_x > content_rect.x:
sh = int(min(TRANSITION_SHADOW_W, edge_x - content_rect.x))
rl.draw_rectangle_gradient_h(edge_x - sh, int(content_rect.y), sh, int(content_rect.height),
rl.Color(0, 0, 0, 0), rl.Color(0, 0, 0, 120))
self._render_layout_surface_translated(self._layouts[top_mode], content_rect, top_x)
return True
def open_settings(self, panel_type: PanelType | None = None):
self._set_current_layout(MainState.SETTINGS)
if panel_type is None:
self._layouts[MainState.SETTINGS].show_grid()
else:
self._layouts[MainState.SETTINGS].set_current_panel(panel_type)
self._set_sidebar_visible(False)
def open_stats(self):
self._set_current_layout(MainState.STATS)
self._set_sidebar_visible(False)
def open_nav(self):
self._set_current_layout(MainState.NAV)
self._set_sidebar_visible(False)
def open_routes(self):
self._set_current_layout(MainState.ROUTES)
self._set_sidebar_visible(False)
def open_video(self, route: str):
self._layouts[MainState.VIDEO].set_route(route)
self._set_current_layout(MainState.VIDEO)
self._set_sidebar_visible(False)
def _on_settings_clicked(self):
self.open_settings()
def _on_bookmark_clicked(self):
user_bookmark = messaging.new_message('bookmarkButton')
user_bookmark.valid = True
self._pm.send('bookmarkButton', user_bookmark)
def _set_sidebar_visible(self, visible: bool):
self._sidebar.set_visible(visible)
self._update_layout_rects()
def _on_onroad_clicked(self):
self._set_sidebar_visible(not self._sidebar.is_visible)
def _back_target(self) -> MainState | None:
"""The page an edge-swipe-back should return to, or None if there's nowhere to go back."""
if ui_state.started:
return None
mode = self._current_mode
if mode == MainState.VIDEO:
return MainState.ROUTES
if mode in (MainState.SETTINGS, MainState.STATS, MainState.NAV, MainState.ROUTES):
# The settings hub owns its own panel->grid swipe; only take over once it's back at the grid.
if mode == MainState.SETTINGS and getattr(self._layouts[MainState.SETTINGS], "_mode", "grid") == "panel":
return None
return MainState.HOME
return None
def _reset_swipe(self):
self._swipe_start_pos = None
self._swipe_active = False
self._swipe_blocked = False
self._swipe_dx = 0.0
def _handle_mouse_event(self, mouse_event: MouseEvent) -> None:
super()._handle_mouse_event(mouse_event)
if self._swipe_settling:
return # let the release animation finish before accepting a new gesture
if mouse_event.slot != 0 or self._transition.active or self._back_target() is None:
self._reset_swipe()
return
if mouse_event.left_pressed:
if mouse_event.pos.x - self._rect.x <= EDGE_SWIPE_ZONE:
self._swipe_start_pos = mouse_event.pos
self._swipe_active = False
self._swipe_blocked = False
self._swipe_dx = 0.0
else:
self._reset_swipe()
elif self._swipe_start_pos is not None:
if mouse_event.left_down:
dx = mouse_event.pos.x - self._swipe_start_pos.x
dy = abs(mouse_event.pos.y - self._swipe_start_pos.y)
if not self._swipe_active and not self._swipe_blocked:
if dy > EDGE_SWIPE_BLOCK_VERTICAL and dy > dx:
self._swipe_blocked = True # user is scrolling, not swiping back
elif dx > EDGE_SWIPE_ARM_DISTANCE:
self._swipe_active = True
if self._swipe_active:
self._swipe_dx = max(0.0, dx)
elif mouse_event.left_released:
if self._swipe_active:
target = self._back_target()
complete = target is not None and self._swipe_dx > self._rect.width * EDGE_SWIPE_COMPLETE_FRACTION
self._begin_swipe_settle(target, complete)
self._reset_swipe()
def _complete_back(self, target: MainState):
# The finger already dragged the page most of the way across, so switch instantly (matching the
# settings hub's swipe) rather than replaying the eased transition.
old_mode = self._current_mode
if old_mode == target:
return
self._layouts[old_mode].hide_event()
self._current_mode = target
self._layouts[target].show_event()
def _begin_swipe_settle(self, target: MainState | None, complete: bool):
# On release, glide from where the finger let go to fully-open (complete) or closed (cancel)
# instead of snapping. Needs a back page to slide behind; if there's none, just drop the drag.
if target is None:
return
self._swipe_settle_from = self._swipe_dx
self._swipe_settle_to = self._rect.width if complete else 0.0
self._swipe_render_target = target
self._swipe_completing = complete
self._swipe_settle_t = 0.0
self._swipe_settling = True
def _update_swipe_settle(self) -> bool:
"""Advance the release animation. Returns True while still animating."""
self._swipe_settle_t += rl.get_frame_time()
p = _clamp(self._swipe_settle_t / SWIPE_SETTLE_SECONDS)
self._swipe_dx = _lerp(self._swipe_settle_from, self._swipe_settle_to, _ease_out_cubic(p))
if p < 1.0:
return True
self._swipe_settling = False
target, completing = self._swipe_render_target, self._swipe_completing
self._swipe_render_target = None
self._swipe_completing = False
self._swipe_dx = 0.0
if completing and target is not None:
self._complete_back(target)
return False
def _render_swipe_drag(self, rect: rl.Rectangle, target: MainState):
# Live finger-following pop: the destination sits behind, the current page slides out right.
dx = min(self._swipe_dx, rect.width)
rl.draw_rectangle_rec(rect, TRANSITION_SURFACE_BG)
self._render_layout_surface_translated(self._layouts[target], rect, dx - rect.width)
self._render_layout_surface_translated(self._layouts[self._current_mode], rect, dx)
def _render_main_content(self):
# Render sidebar (onroad only)
if self._sidebar.is_visible:
self._sidebar.render(self._sidebar_rect)
content_rect = self._content_rect if self._sidebar.is_visible else self._rect
if self._render_transition(content_rect):
return
if self._swipe_settling:
if self._update_swipe_settle():
self._render_swipe_drag(content_rect, self._swipe_render_target)
return
# settled this frame; fall through to render the (possibly switched) current page
elif self._swipe_active:
target = self._back_target()
if target is not None:
self._render_swipe_drag(content_rect, target)
return
self._layouts[self._current_mode].render(content_rect)

View File

@@ -0,0 +1,562 @@
import threading
import time
import pyray as rl
from collections.abc import Callable
import requests
from iqpilot.common.params import Params
from iqpilot.selfdrive.ui.lib.nav_helpers import (has_mapbox_token, resolve_mapbox_token,
current_or_last_gps_position)
from iqpilot.ui.onroad.nav_map_panel import NavMapPanel
from iqpilot.ui.onroad.nav_map_utils import build_mapbox_static_url
from iqpilot.selfdrive.ui.widgets.screen_header import ScreenHeader, HEADER_HEIGHT
from iqpilot.system.ui.lib.application import gui_app, FontWeight, MousePos, GL_VERSION
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.system.ui.widgets.keyboard import Keyboard
from iqpilot.selfdrive.ui.lib import nav_search
from iqpilot.selfdrive.ui.lib.nav_search import NavSearch, SearchResult
from iqpilot.selfdrive.ui.widgets.interactive_map import InteractiveNavMap
MARGIN = 40
SPACING = 25
SEARCH_HEIGHT = 120
PILL_HEIGHT = 120
MAP_ZOOM = 15.0
MAP_RETRY_INTERVAL = 5.0
PANEL_BG = rl.Color(38, 40, 46, 255)
PANEL_BORDER = rl.Color(255, 255, 255, 38)
MUTED = rl.Color(165, 165, 170, 255)
ROUNDED_TEXTURE_VERTEX_SHADER = GL_VERSION + """
in vec3 vertexPosition;
in vec2 vertexTexCoord;
uniform mat4 mvp;
out vec2 fragTexCoord;
void main() {
fragTexCoord = vertexTexCoord;
gl_Position = mvp * vec4(vertexPosition, 1.0);
}
"""
ROUNDED_TEXTURE_FRAGMENT_SHADER = GL_VERSION + """
in vec2 fragTexCoord;
uniform sampler2D texture0;
uniform vec4 clipRect;
uniform float cornerRadius;
uniform float viewportHeight;
out vec4 fragColor;
float roundedRectDistance(vec2 p, vec2 center, vec2 halfSize, float radius) {
vec2 d = abs(p - center) - (halfSize - vec2(radius));
return length(max(d, vec2(0.0))) + min(max(d.x, d.y), 0.0) - radius;
}
void main() {
vec2 p = vec2(gl_FragCoord.x, viewportHeight - gl_FragCoord.y);
vec2 center = clipRect.xy + clipRect.zw * 0.5;
vec2 halfSize = clipRect.zw * 0.5;
float radius = min(cornerRadius, min(halfSize.x, halfSize.y));
float dist = roundedRectDistance(p, center, halfSize, radius);
float alpha = 1.0 - smoothstep(0.0, 1.25, dist);
vec4 sampled = texture(texture0, fragTexCoord);
fragColor = vec4(sampled.rgb, sampled.a * alpha);
}
"""
class _MapPreview:
"""Fetches a Mapbox static map (background thread) and caches it as a texture."""
def __init__(self):
self._params = Params()
self._session = requests.Session()
self._texture: rl.Texture | None = None
self._pending: tuple[tuple, bytes] | None = None
self._fetching = False
self._key: tuple | None = None
self._status = "idle"
self._last_attempt = 0.0
self._rounded_shader = None
self._rounded_shader_locs: dict[str, int] = {}
self._clip_rect = rl.ffi.new("float[]", [0.0, 0.0, 0.0, 0.0])
self._corner_radius = rl.ffi.new("float[]", [0.0])
self._viewport_height = rl.ffi.new("float[]", [0.0])
def has_token(self) -> bool:
return has_mapbox_token(self._params)
def _fetch(self, url: str, token: str, key: tuple):
try:
r = self._session.get(url, params={"access_token": token}, timeout=4.0)
if r.status_code == 200 and r.content:
self._pending = (key, r.content)
self._status = "ready"
else:
self._key = None
self._status = "error"
except requests.RequestException:
self._key = None
self._status = "error"
finally:
self._fetching = False
def request(self, lat: float, lon: float, bearing: float, w: float, h: float):
token = resolve_mapbox_token(self._params)
if not token or w < 20 or h < 20:
self._status = "token_missing" if not token else "idle"
return
key = (round(lat, 4), round(lon, 4))
if self._fetching or key == self._key:
return
now = time.monotonic()
if self._status == "error" and now - self._last_attempt < MAP_RETRY_INTERVAL:
return
self._last_attempt = now
self._key = key
self._fetching = True
self._status = "loading"
scale = min(1.0, 1000.0 / max(w, h))
url = build_mapbox_static_url(lat, lon, MAP_ZOOM, bearing, max(1, int(w * scale)), max(1, int(h * scale)))
threading.Thread(target=self._fetch, args=(url, token, key), daemon=True).start()
def _consume(self):
if self._pending is None:
return
_key, data = self._pending
self._pending = None
try:
ext = ".png" if data[:4] == b"\x89PNG" else ".jpg"
img = rl.load_image_from_memory(ext, data, len(data))
tex = rl.load_texture_from_image(img)
rl.unload_image(img)
rl.set_texture_filter(tex, rl.TextureFilter.TEXTURE_FILTER_BILINEAR)
if self._texture is not None:
rl.unload_texture(self._texture)
self._texture = tex
except Exception:
pass
def _ensure_rounded_shader(self):
if self._rounded_shader is not None:
return
self._rounded_shader = rl.load_shader_from_memory(ROUNDED_TEXTURE_VERTEX_SHADER, ROUNDED_TEXTURE_FRAGMENT_SHADER)
self._rounded_shader_locs = {
"clipRect": rl.get_shader_location(self._rounded_shader, "clipRect"),
"cornerRadius": rl.get_shader_location(self._rounded_shader, "cornerRadius"),
"viewportHeight": rl.get_shader_location(self._rounded_shader, "viewportHeight"),
}
def _draw_texture(self, src: rl.Rectangle, rect: rl.Rectangle, roundness: float):
if roundness <= 0:
rl.draw_texture_pro(self._texture, src, rect, rl.Vector2(0, 0), 0, rl.WHITE)
return
self._ensure_rounded_shader()
self._clip_rect[0:4] = [rect.x, rect.y, rect.width, rect.height]
self._corner_radius[0] = max(0.0, min(rect.width, rect.height) * roundness * 0.5)
self._viewport_height[0] = gui_app.height
rl.set_shader_value(
self._rounded_shader,
self._rounded_shader_locs["clipRect"],
self._clip_rect,
rl.ShaderUniformDataType.SHADER_UNIFORM_VEC4,
)
rl.set_shader_value(
self._rounded_shader,
self._rounded_shader_locs["cornerRadius"],
self._corner_radius,
rl.ShaderUniformDataType.SHADER_UNIFORM_FLOAT,
)
rl.set_shader_value(
self._rounded_shader,
self._rounded_shader_locs["viewportHeight"],
self._viewport_height,
rl.ShaderUniformDataType.SHADER_UNIFORM_FLOAT,
)
rl.begin_shader_mode(self._rounded_shader)
rl.draw_texture_pro(self._texture, src, rect, rl.Vector2(0, 0), 0, rl.WHITE)
rl.end_shader_mode()
def draw(self, rect: rl.Rectangle, roundness: float = 0.0) -> bool:
self._consume()
if self._texture is None or self._texture.id == 0:
return False
rl.begin_scissor_mode(int(rect.x), int(rect.y), int(rect.width), int(rect.height))
src = rl.Rectangle(0, 0, self._texture.width, self._texture.height)
self._draw_texture(src, rect, roundness)
rl.end_scissor_mode()
return True
def status(self) -> str:
if self._fetching:
return "loading"
return self._status
class _Pill(Widget):
"""A rounded destination shortcut: icon + label (Home / Work / Recent)."""
BG = rl.Color(38, 40, 46, 255)
BG_PRESSED = rl.Color(54, 57, 65, 255)
def __init__(self, icon_path: str, label: str, on_click: Callable[[], None] | None = None):
super().__init__()
self._label = label
self._icon = gui_app.texture(icon_path, 56, 56, keep_aspect_ratio=True)
if on_click is not None:
self.set_click_callback(on_click)
def _render(self, rect: rl.Rectangle):
rl.draw_rectangle_rounded(rect, 0.5, 20, self.BG_PRESSED if self.is_pressed else self.BG)
rl.draw_rectangle_rounded_lines_ex(rect, 0.5, 20, 2, PANEL_BORDER)
cy = rect.y + rect.height / 2
x = rect.x + 32
rl.draw_texture(self._icon, int(x), int(cy - self._icon.height / 2), rl.WHITE)
x += self._icon.width + 20
font = gui_app.font(FontWeight.MEDIUM)
ts = measure_text_cached(font, self._label, 40)
rl.draw_text_ex(font, self._label, rl.Vector2(int(x), int(cy - ts.y / 2)), 40, 0, rl.WHITE)
ROW_HEIGHT = 116
ROW_GAP = 16
RESULT_NAME = rl.Color(240, 240, 244, 255)
SEARCH_DEBOUNCE = 0.25
class NavLayout(Widget):
"""Offroad Navigate screen: destination search with live Mapbox autocomplete, Home/Work/Recent
shortcuts, and a location map preview. Picking a place writes NavigationDestination (navd routes)."""
def __init__(self):
super().__init__()
self._header = self._child(ScreenHeader(lambda: tr("Navigation")))
self._search_icon = gui_app.texture("icons/iq/search.png", 52, 52, keep_aspect_ratio=True)
self._pin_icon = gui_app.texture("icons/iq/pin.png", 90, 90, keep_aspect_ratio=True)
self._home_icon = gui_app.texture("icons/iq/home.png", 52, 52, keep_aspect_ratio=True)
self._work_icon = gui_app.texture("icons/iq/work.png", 52, 52, keep_aspect_ratio=True)
self._recent_icon = gui_app.texture("icons/iq/recent.png", 52, 52, keep_aspect_ratio=True)
self._keyboard = Keyboard(max_text_size=128, min_text_size=0)
self._map = _MapPreview()
self._imap = self._child(InteractiveNavMap())
self._search = NavSearch()
self._on_back_cb: Callable[[], None] | None = None
self._mode = "browse" # "browse" | "results"
self._purpose = "navigate" # "navigate" | "set_home" | "set_work"
self._query = ""
self._selecting = False
self._status_ts = 0.0
self._pending_exit = False
self._status_msg = ""
self._home: SearchResult | None = None
self._work: SearchResult | None = None
self._recents: list[SearchResult] = []
self._tap_targets: list[tuple[rl.Rectangle, Callable[[], None]]] = []
self._reload_favorites()
def _reload_favorites(self):
self._home = nav_search.get_home()
self._work = nav_search.get_work()
self._recents = nav_search.get_recents()
def set_on_back(self, cb: Callable[[], None]) -> None:
self._on_back_cb = cb
self._header.set_on_back(self._handle_back)
def show_event(self):
super().show_event()
self._mode = "browse"
self._status_msg = ""
self._reload_favorites()
def hide_event(self):
super().hide_event()
# Free the tile cache's GPU memory while the screen is away.
self._imap.release()
def _handle_back(self):
if self._mode == "results":
self._exit_search()
elif self._on_back_cb is not None:
self._on_back_cb()
# --- search lifecycle -------------------------------------------------------
def _open_search(self, purpose: str = "navigate"):
# Full-screen modal keyboard (same as before) — search runs when you hit Done.
self._purpose = purpose
self._search.new_session()
self._keyboard.reset(min_text_size=1)
title = {"set_home": tr("Set Home"), "set_work": tr("Set Work")}.get(purpose, tr("Search"))
self._keyboard.set_title(title, tr("Enter an address or place"))
self._keyboard.set_text(self._query if purpose == "navigate" else "")
gui_app.set_modal_overlay(self._keyboard, callback=self._on_search_done)
def _on_search_done(self, result: int):
if result != 1:
return
self._query = self._keyboard.text.strip()
if not self._query:
return
self._search.search(self._query)
self._mode = "results"
self._status_msg = ""
self._selecting = False
def _exit_search(self):
self._mode = "browse"
self._selecting = False
self._status_msg = ""
self._reload_favorites()
# --- selection (threaded: retrieve coords, then persist) --------------------
def _select_result(self, r: SearchResult):
if self._selecting:
return
self._selecting = True
self._status_msg = tr("Locating...")
threading.Thread(target=self._finish_select, args=(r, self._purpose), daemon=True).start()
def _finish_select(self, r: SearchResult, purpose: str):
full = self._search.retrieve(r)
if full is None or not full.has_coords:
self._status_msg = tr("Couldn't locate that place")
self._selecting = False
return
if purpose == "set_home":
nav_search.save_home(full)
elif purpose == "set_work":
nav_search.save_work(full)
else:
nav_search.set_destination(full.lat, full.lon, full.name)
nav_search.add_recent(full)
self._selecting = False
self._pending_exit = True
def _navigate_place(self, place: SearchResult):
if place is not None and place.has_coords:
nav_search.set_destination(place.lat, place.lon, place.name)
nav_search.add_recent(place)
self._reload_favorites()
self._dest_check_time = 0.0
self._set_status(tr("Destination set"))
def _set_status(self, msg: str):
self._status_msg = msg
self._status_ts = time.monotonic()
def _cancel_nav(self):
nav_search.cancel_navigation()
self._dest_check_time = 0.0
self._set_status(tr("Navigation canceled"))
def _remove_home(self):
nav_search.remove_home()
self._reload_favorites()
def _remove_work(self):
nav_search.remove_work()
self._reload_favorites()
def _remove_recent(self, r: SearchResult):
nav_search.remove_recent(r)
self._reload_favorites()
def _on_home(self):
self._navigate_place(self._home) if self._home is not None else self._open_search("set_home")
def _on_work(self):
self._navigate_place(self._work) if self._work is not None else self._open_search("set_work")
# --- render -----------------------------------------------------------------
def _render(self, rect: rl.Rectangle):
self._tap_targets = []
if self._pending_exit:
self._pending_exit = False
self._exit_search()
header_rect = rl.Rectangle(rect.x + MARGIN, rect.y + MARGIN, rect.width - 2 * MARGIN, HEADER_HEIGHT)
self._header.render(header_rect)
body = rl.Rectangle(rect.x + MARGIN, header_rect.y + HEADER_HEIGHT + SPACING,
rect.width - 2 * MARGIN, rect.y + rect.height - (header_rect.y + HEADER_HEIGHT + SPACING) - MARGIN)
if self._mode == "results":
self._render_results(body)
else:
self._render_browse(body)
def _row(self, rect: rl.Rectangle, icon, title: str, subtitle: str, on_tap, on_delete=None, pressed_hint=True):
hit = rl.check_collision_point_rec(rl.get_mouse_position(), rect)
bg = rl.Color(54, 57, 65, 255) if (hit and pressed_hint and rl.is_mouse_button_down(0)) else PANEL_BG
rl.draw_rectangle_rounded(rect, 0.35, 20, bg)
rl.draw_rectangle_rounded_lines_ex(rect, 0.35, 20, 2, PANEL_BORDER)
x = rect.x + 32
if icon is not None:
rl.draw_texture(icon, int(x), int(rect.y + rect.height / 2 - icon.height / 2), rl.WHITE)
x += icon.width + 24
text_w = rect.width - (x - rect.x) - (110 if on_delete is not None else 32)
font = gui_app.font(FontWeight.MEDIUM)
if subtitle:
rl.draw_text_ex(font, title, rl.Vector2(int(x), int(rect.y + 22)), 40, 0, RESULT_NAME)
sub = self._ellipsize(font, subtitle, 30, text_w)
rl.draw_text_ex(font, sub, rl.Vector2(int(x), int(rect.y + 66)), 30, 0, MUTED)
else:
ts = measure_text_cached(font, title, 42)
rl.draw_text_ex(font, title, rl.Vector2(int(x), int(rect.y + rect.height / 2 - ts.y / 2)), 42, 0, RESULT_NAME)
# Delete (×) button — appended first so a tap on it wins over the row's navigate tap.
if on_delete is not None:
cx, cy = rect.x + rect.width - 60, rect.y + rect.height / 2
del_r = rl.Rectangle(cx - 34, cy - 34, 68, 68)
dhit = rl.check_collision_point_rec(rl.get_mouse_position(), del_r)
rl.draw_circle(int(cx), int(cy), 30, rl.Color(90, 62, 66, 255) if dhit else rl.Color(60, 62, 70, 255))
rl.draw_line_ex(rl.Vector2(cx - 13, cy - 13), rl.Vector2(cx + 13, cy + 13), 4, rl.Color(230, 120, 120, 255))
rl.draw_line_ex(rl.Vector2(cx - 13, cy + 13), rl.Vector2(cx + 13, cy - 13), 4, rl.Color(230, 120, 120, 255))
self._tap_targets.append((del_r, on_delete))
if on_tap is not None:
self._tap_targets.append((rect, on_tap))
@staticmethod
def _ellipsize(font, text: str, size: int, max_w: float) -> str:
if measure_text_cached(font, text, size).x <= max_w:
return text
while text and measure_text_cached(font, text + "", size).x > max_w:
text = text[:-1]
return text + ""
def _render_browse(self, rect: rl.Rectangle):
font = gui_app.font(FontWeight.MEDIUM)
x, w = rect.x, rect.width
y = rect.y
# Search bar
bar = rl.Rectangle(x, y, w, SEARCH_HEIGHT)
rl.draw_rectangle_rounded(bar, 0.4, 20, PANEL_BG)
rl.draw_rectangle_rounded_lines_ex(bar, 0.4, 20, 2, PANEL_BORDER)
cy = bar.y + bar.height / 2
rl.draw_texture(self._search_icon, int(bar.x + 36), int(cy - self._search_icon.height / 2), MUTED)
ph = tr("Search address or place")
rl.draw_text_ex(font, ph, rl.Vector2(int(bar.x + 36 + self._search_icon.width + 24),
int(cy - 22)), 44, 0, MUTED)
self._tap_targets.append((bar, lambda: self._open_search("navigate")))
y += SEARCH_HEIGHT + SPACING
# Cancel active route (param read throttled)
now = time.monotonic()
if now - getattr(self, "_dest_check_time", 0.0) > 1.0:
self._has_dest = nav_search.has_active_destination()
self._dest_check_time = now
if getattr(self, "_has_dest", False):
cr = rl.Rectangle(x, y, w, ROW_HEIGHT)
chit = rl.check_collision_point_rec(rl.get_mouse_position(), cr)
rl.draw_rectangle_rounded(cr, 0.35, 20, rl.Color(96, 46, 48, 255) if chit else rl.Color(74, 40, 42, 255))
rl.draw_rectangle_rounded_lines_ex(cr, 0.35, 20, 2, rl.Color(210, 90, 90, 120))
label = tr("Cancel navigation")
ls = measure_text_cached(font, label, 42)
rl.draw_text_ex(font, label, rl.Vector2(int(x + 32), int(cr.y + cr.height / 2 - ls.y / 2)), 42, 0,
rl.Color(240, 180, 180, 255))
self._tap_targets.append((cr, self._cancel_nav))
y += ROW_HEIGHT + SPACING
# Home / Work
hw_gap = ROW_GAP
hw_w = (w - hw_gap) / 2
self._row(rl.Rectangle(x, y, hw_w, ROW_HEIGHT), self._home_icon, tr("Home"),
self._home.address if self._home else tr("Set home address"), self._on_home,
on_delete=(self._remove_home if self._home else None))
self._row(rl.Rectangle(x + hw_w + hw_gap, y, hw_w, ROW_HEIGHT), self._work_icon, tr("Work"),
self._work.address if self._work else tr("Set work address"), self._on_work,
on_delete=(self._remove_work if self._work else None))
y += ROW_HEIGHT + SPACING
# Recents (fit as many as room allows, leaving space for the map)
map_min = 300
for r in self._recents:
if y + ROW_HEIGHT > rect.y + rect.height - map_min - SPACING:
break
self._row(rl.Rectangle(x, y, w, ROW_HEIGHT), self._recent_icon, r.name, r.address,
(lambda r=r: self._navigate_place(r)), on_delete=(lambda r=r: self._remove_recent(r)))
y += ROW_HEIGHT + ROW_GAP
# Map preview of current location
map_rect = rl.Rectangle(x, y, w, rect.y + rect.height - y)
if map_rect.height > 120:
self._imap.render(map_rect)
if self._status_msg and time.monotonic() - self._status_ts < 2.5:
self._draw_toast(map_rect, self._status_msg)
def _draw_toast(self, area: rl.Rectangle, text: str):
font = gui_app.font(FontWeight.MEDIUM)
ts = measure_text_cached(font, text, 36)
pad = 32
pw = ts.x + pad * 2
pill = rl.Rectangle(area.x + (area.width - pw) / 2, area.y + 24, pw, 66)
rl.draw_rectangle_rounded(pill, 0.5, 20, rl.Color(20, 22, 26, 235))
rl.draw_rectangle_rounded_lines_ex(pill, 0.5, 20, 2, PANEL_BORDER)
rl.draw_text_ex(font, text, rl.Vector2(int(pill.x + pad), int(pill.y + 33 - ts.y / 2)), 36, 0, rl.WHITE)
def _render_map(self, rect: rl.Rectangle):
lat, lon, bearing, fix = current_or_last_gps_position()
if fix and self._map.has_token:
self._map.request(lat, lon, 0.0, rect.width, rect.height)
if self._map.draw(rect, roundness=0.03):
# The static map is centered on the fix, so the current location is the panel center.
cx, cy = int(rect.x + rect.width / 2), int(rect.y + rect.height / 2)
rl.draw_circle(cx, cy, 26, rl.Color(255, 255, 255, 235))
rl.draw_circle(cx, cy, 18, rl.Color(23, 134, 246, 255)) # blue location dot
return
rl.draw_rectangle_rounded(rect, 0.03, 20, rl.Color(18, 18, 20, 255))
rl.draw_rectangle_rounded_lines_ex(rect, 0.03, 20, 2, PANEL_BORDER)
pin_x = int(rect.x + (rect.width - self._pin_icon.width) / 2)
rl.draw_texture(self._pin_icon, pin_x, int(rect.y + rect.height / 2 - self._pin_icon.height), MUTED)
self._draw_center_note(rect, tr("Waiting for GPS fix..."), dy=16)
def _draw_center_note(self, rect: rl.Rectangle, text: str, dy: float = 0):
font = gui_app.font(FontWeight.MEDIUM)
ns = measure_text_cached(font, text, 40)
rl.draw_text_ex(font, text, rl.Vector2(int(rect.x + (rect.width - ns.x) / 2),
int(rect.y + rect.height / 2 + dy)), 40, 0, MUTED)
def _render_results(self, rect: rl.Rectangle):
font = gui_app.font(FontWeight.MEDIUM)
x, w = rect.x, rect.width
y = rect.y
# Query bar — tap to reopen the keyboard and refine the search.
bar = rl.Rectangle(x, y, w, SEARCH_HEIGHT)
rl.draw_rectangle_rounded(bar, 0.4, 20, PANEL_BG)
rl.draw_rectangle_rounded_lines_ex(bar, 0.4, 20, 2, PANEL_BORDER)
cy = bar.y + bar.height / 2
rl.draw_texture(self._search_icon, int(bar.x + 36), int(cy - self._search_icon.height / 2), MUTED)
rl.draw_text_ex(font, self._query or tr("Search"),
rl.Vector2(int(bar.x + 36 + self._search_icon.width + 24), int(cy - 22)), 44, 0, rl.WHITE)
self._tap_targets.append((bar, lambda: self._open_search(self._purpose)))
y += SEARCH_HEIGHT + SPACING
list_rect = rl.Rectangle(x, y, w, rect.y + rect.height - y)
results = self._search.results()
if self._selecting:
self._draw_center_note(list_rect, self._status_msg or tr("Locating..."))
return
if not results:
note = tr("Searching...") if self._search.searching else (self._status_msg or tr("No results"))
self._draw_center_note(list_rect, note)
return
for r in results:
if y + ROW_HEIGHT > list_rect.y + list_rect.height:
break
dist = f"{r.distance_m / 1609.34:.1f} mi" if r.distance_m else ""
sub = f"{r.address} · {dist}" if dist else r.address
self._row(rl.Rectangle(x, y, w, ROW_HEIGHT), self._pin_icon, r.name, sub,
(lambda r=r: self._select_result(r)))
y += ROW_HEIGHT + ROW_GAP
def _handle_mouse_release(self, mouse_pos: MousePos):
for rect, cb in self._tap_targets:
if rl.check_collision_point_rec(mouse_pos, rect):
cb()
return

View File

@@ -0,0 +1,113 @@
from enum import IntEnum
import pyray as rl
from iqpilot.system.ui.lib.application import FontWeight, gui_app
from iqpilot.system.ui.lib.multilang import tr
from iqpilot.system.ui.widgets import Widget
from iqpilot.system.ui.widgets.button import Button, ButtonStyle
from iqpilot.system.ui.widgets.label import Label
from iqpilot.selfdrive.ui.ui_state import ui_state
from iqpilot.system.version import terms_version
DEBUG = False
class OnboardingState(IntEnum):
TERMS = 0
DECLINE = 1
class TermsPage(Widget):
def __init__(self, on_accept=None, on_decline=None):
super().__init__()
self._on_accept = on_accept
self._on_decline = on_decline
self._title = Label(tr("Welcome to IQ.Pilot"), font_size=90, font_weight=FontWeight.BOLD, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT)
self._desc = Label(tr("You must accept the Terms of Service to use IQ.Pilot. Read the latest terms before continuing at https://iqlvbs.com/tos."),
font_size=90, font_weight=FontWeight.MEDIUM, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT)
self._decline_btn = Button(tr("Decline"), click_callback=on_decline)
self._accept_btn = Button(tr("Agree"), button_style=ButtonStyle.PRIMARY, click_callback=on_accept)
def _render(self, _):
welcome_x = self._rect.x + 95
welcome_y = self._rect.y + 165
welcome_rect = rl.Rectangle(welcome_x, welcome_y, self._rect.width - welcome_x, 90)
self._title.render(welcome_rect)
desc_x = welcome_x
# TODO: Label doesn't top align when wrapping
desc_y = welcome_y - 100
desc_rect = rl.Rectangle(desc_x, desc_y, self._rect.width - desc_x, self._rect.height - desc_y - 250)
self._desc.render(desc_rect)
btn_y = self._rect.y + self._rect.height - 160 - 45
btn_width = (self._rect.width - 45 * 3) / 2
self._decline_btn.render(rl.Rectangle(self._rect.x + 45, btn_y, btn_width, 160))
self._accept_btn.render(rl.Rectangle(self._rect.x + 45 * 2 + btn_width, btn_y, btn_width, 160))
if DEBUG:
rl.draw_rectangle_lines_ex(welcome_rect, 3, rl.RED)
rl.draw_rectangle_lines_ex(desc_rect, 3, rl.RED)
return -1
class DeclinePage(Widget):
def __init__(self, back_callback=None):
super().__init__()
self._text = Label(tr("You must accept the Terms of Service in order to use IQ.Pilot."),
font_size=90, font_weight=FontWeight.MEDIUM, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT)
self._back_btn = Button(tr("Back"), click_callback=back_callback)
self._uninstall_btn = Button(tr("Decline, uninstall IQ.Pilot"), button_style=ButtonStyle.DANGER,
click_callback=self._on_uninstall_clicked)
def _on_uninstall_clicked(self):
ui_state.params.put_bool("DoUninstall", True)
gui_app.request_close()
def _render(self, _):
btn_y = self._rect.y + self._rect.height - 160 - 45
btn_width = (self._rect.width - 45 * 3) / 2
self._back_btn.render(rl.Rectangle(self._rect.x + 45, btn_y, btn_width, 160))
self._uninstall_btn.render(rl.Rectangle(self._rect.x + 45 * 2 + btn_width, btn_y, btn_width, 160))
# text rect in middle of top and button
text_height = btn_y - (200 + 45)
text_rect = rl.Rectangle(self._rect.x + 165, self._rect.y + (btn_y - text_height) / 2 + 10, self._rect.width - (165 * 2), text_height)
if DEBUG:
rl.draw_rectangle_lines_ex(text_rect, 3, rl.RED)
self._text.render(text_rect)
class OnboardingWindow(Widget):
def __init__(self):
super().__init__()
self._accepted_terms: bool = ui_state.params.get("HasAcceptedTerms") == terms_version
self._state = OnboardingState.TERMS
self._terms = TermsPage(on_accept=self._on_terms_accepted, on_decline=self._on_terms_declined)
self._decline_page = DeclinePage(back_callback=self._on_decline_back)
@property
def completed(self) -> bool:
return self._accepted_terms
def _on_terms_declined(self):
self._state = OnboardingState.DECLINE
def _on_decline_back(self):
self._state = OnboardingState.TERMS
def _on_terms_accepted(self):
ui_state.params.put("HasAcceptedTerms", terms_version)
self._accepted_terms = True
gui_app.set_modal_overlay(None)
def _render(self, _):
if self._state == OnboardingState.TERMS:
self._terms.render(self._rect)
elif self._state == OnboardingState.DECLINE:
self._decline_page.render(self._rect)
return -1

View File

@@ -0,0 +1,194 @@
import threading
import pyray as rl
from collections.abc import Callable
from iqpilot.selfdrive.ui.widgets.screen_header import ScreenHeader, HEADER_HEIGHT
from iqpilot.selfdrive.ui.lib.local_routes import list_local_routes, CAMERA_LABELS, format_local_time
from iqpilot.selfdrive.ui.lib import cloud_routes_shim as cloud
from iqpilot.system.ui.lib.application import gui_app, FontWeight, MousePos
from iqpilot.system.ui.lib.multilang import tr
from iqpilot.system.ui.lib.text_measure import measure_text_cached
from iqpilot.system.ui.lib.scroll_panel import GuiScrollPanel
from iqpilot.system.ui.widgets import Widget
MARGIN = 40
SPACING = 25
ROW_HEIGHT = 156
ROW_GAP = 22
BTN_SIZE = 120
ROW_BG = rl.Color(38, 40, 46, 255)
ROW_BORDER = rl.Color(255, 255, 255, 26)
PLAY_BG = rl.Color(16, 185, 169, 255)
SUBTEXT = rl.Color(158, 162, 170, 255)
# Upload-status badge colors.
BADGE_UPLOADED = rl.Color(16, 185, 129, 255)
BADGE_UPLOADING = rl.Color(234, 179, 8, 255)
BADGE_CLOUD = rl.Color(96, 132, 214, 255)
BADGE_LOCAL = rl.Color(90, 96, 108, 255)
BADGE_TEXT = rl.Color(10, 14, 18, 255)
class RoutesLayout(Widget):
"""Offroad Routes screen: local recorded routes + konn3kt cloud upload status / cloud-only routes."""
def __init__(self):
super().__init__()
self._header = self._child(ScreenHeader(lambda: tr("Routes")))
self._play_icon = gui_app.texture("icons/iq/play.png", 56, 56, keep_aspect_ratio=True)
self._on_play: Callable[[str], None] | None = None
self._scroll_panel = GuiScrollPanel()
self._entries: list = []
self._row_hitboxes: list[tuple[rl.Rectangle, object]] = []
self._cloud_thread: threading.Thread | None = None
self._cloud_generation = 0
def set_on_back(self, cb: Callable[[], None]) -> None:
self._header.set_on_back(cb)
def set_on_play(self, cb: Callable[[str], None]) -> None:
self._on_play = cb
def show_event(self):
super().show_event()
local_routes = list_local_routes()
# Show local routes immediately, then fold in cloud upload status / cloud-only routes async.
self._entries = cloud.merge_routes(local_routes, [])
self._start_cloud_fetch(local_routes)
def _start_cloud_fetch(self, local_routes: list) -> None:
if not cloud.cloud_available():
return
self._cloud_generation += 1
generation = self._cloud_generation
def _worker():
dongle_id = cloud.get_dongle_id()
cloud_routes = cloud.list_cloud_routes(dongle_id) if dongle_id else []
if generation == self._cloud_generation:
self._entries = cloud.merge_routes(local_routes, cloud_routes)
self._cloud_thread = threading.Thread(target=_worker, daemon=True)
self._cloud_thread.start()
@staticmethod
def _entry_title(entry) -> str:
if entry.local is not None:
return entry.local.label
if entry.cloud is not None and entry.cloud.start_time > 0:
return format_local_time(entry.cloud.start_time)
return entry.name
@staticmethod
def _fmt_duration(seconds: float) -> str:
s = max(0, int(round(seconds)))
h, rem = divmod(s, 3600)
m, sec = divmod(rem, 60)
return f"{h}h {m:02d}m" if h else f"{m}:{sec:02d}"
def _entry_subtitle_parts(self, entry) -> list[str]:
if entry.local is not None:
parts = [self._fmt_duration(entry.local.duration_s)]
cams = ", ".join(CAMERA_LABELS.get(c, c).replace(" Cam", "") for c in entry.local.cameras)
if cams:
parts.append(cams)
return parts
if entry.cloud is not None:
parts = []
if entry.cloud.length_miles > 0:
parts.append(f"{entry.cloud.length_miles:.1f} mi")
parts.append(tr("Cloud only"))
return parts
return []
@staticmethod
def _draw_dotted(font, parts: list[str], x: float, y: float, size: int, color) -> None:
cx = x
for i, part in enumerate(parts):
if i > 0:
cx += 10
rl.draw_circle(int(cx), int(y + size / 2), 3, rl.Color(color.r, color.g, color.b, 150))
cx += 16
rl.draw_text_ex(font, part, rl.Vector2(int(cx), int(y)), size, 0, color)
cx += measure_text_cached(font, part, size).x
def _render(self, rect: rl.Rectangle):
header_rect = rl.Rectangle(rect.x + MARGIN, rect.y + MARGIN, rect.width - 2 * MARGIN, HEADER_HEIGHT)
self._header.render(header_rect)
x = rect.x + MARGIN
w = rect.width - 2 * MARGIN
list_top = header_rect.y + HEADER_HEIGHT + SPACING
list_rect = rl.Rectangle(x, list_top, w, rect.y + rect.height - list_top - MARGIN)
self._row_hitboxes = []
font = gui_app.font(FontWeight.MEDIUM)
if not self._entries:
note = tr("No routes recorded")
ns = measure_text_cached(font, note, 44)
rl.draw_text_ex(font, note, rl.Vector2(int(rect.x + (rect.width - ns.x) / 2), int(list_top + 80)), 44, 0,
rl.Color(150, 150, 155, 255))
return
row_stride = ROW_HEIGHT + ROW_GAP
content_rect = rl.Rectangle(list_rect.x, list_rect.y, list_rect.width, len(self._entries) * row_stride)
offset = self._scroll_panel.update(list_rect, content_rect)
rl.begin_scissor_mode(int(list_rect.x), int(list_rect.y), int(list_rect.width), int(list_rect.height))
for i, entry in enumerate(self._entries):
ry = list_rect.y + i * row_stride + offset
row = rl.Rectangle(x, ry, w, ROW_HEIGHT)
if not rl.check_collision_recs(row, list_rect):
continue
rl.draw_rectangle_rounded(row, 0.3, 20, ROW_BG)
rl.draw_rectangle_rounded_lines_ex(row, 0.3, 20, 2, ROW_BORDER)
title_size = 46
subtitle_size = 30
title = self._entry_title(entry)
subtitle_parts = self._entry_subtitle_parts(entry)
title_ts = measure_text_cached(font, title, title_size)
text_y = ry + (ROW_HEIGHT - title_ts.y - subtitle_size - 10) / 2
rl.draw_text_ex(font, title, rl.Vector2(int(x + 40), int(text_y)), title_size, 0, rl.WHITE)
self._draw_dotted(font, subtitle_parts, x + 40, text_y + title_ts.y + 10, subtitle_size, SUBTEXT)
cy = ry + ROW_HEIGHT / 2
self._draw_status_badge(entry, x + w - 40 - BTN_SIZE - 24, cy, font)
play_rect = rl.Rectangle(x + w - 40 - BTN_SIZE, cy - BTN_SIZE / 2, BTN_SIZE, BTN_SIZE)
rl.draw_circle(int(play_rect.x + BTN_SIZE / 2), int(cy), BTN_SIZE / 2, PLAY_BG)
rl.draw_texture(self._play_icon, int(play_rect.x + (BTN_SIZE - self._play_icon.width) / 2 + 4),
int(cy - self._play_icon.height / 2), rl.Color(8, 16, 16, 255))
self._row_hitboxes.append((play_rect, entry))
rl.end_scissor_mode()
def _draw_status_badge(self, entry, right_x: float, cy: float, font) -> None:
if entry.is_local and entry.upload_state == cloud.UPLOAD_UPLOADED:
label, color = tr("Uploaded"), BADGE_UPLOADED
elif entry.is_local and entry.upload_state == cloud.UPLOAD_UPLOADING:
label, color = tr("Uploading"), BADGE_UPLOADING
elif not entry.is_local and entry.is_cloud:
label, color = tr("Cloud"), BADGE_CLOUD
elif entry.is_local:
label, color = tr("On device"), BADGE_LOCAL
else:
return
fs = 24
pad = 18
tw = measure_text_cached(font, label, fs).x + pad * 2
badge = rl.Rectangle(right_x - tw, cy - 20, tw, 40)
rl.draw_rectangle_rounded(badge, 0.5, 12, color)
rl.draw_text_ex(font, label, rl.Vector2(int(badge.x + pad), int(cy - fs / 2)), fs, 0, BADGE_TEXT)
def _handle_mouse_release(self, mouse_pos: MousePos):
if not self._scroll_panel.is_touch_valid():
return
for play_rect, entry in self._row_hitboxes:
if rl.check_collision_point_rec(mouse_pos, play_rect):
if self._on_play:
self._on_play(entry.name)
return

View File

@@ -0,0 +1,9 @@
"""
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
"""
from iqpilot.selfdrive.ui.ui_state import ui_state
def restart_needed_callback(_=None):
ui_state.params.put_bool("OnroadCycleRequested", True)

View File

@@ -0,0 +1,155 @@
from iqpilot.common.params import Params
from iqpilot.selfdrive.ui.widgets.ssh_key import ssh_key_item
from iqpilot.selfdrive.ui.ui_state import ui_state
from iqpilot.system.hardware.tici.usb_storage import apply_usb_storage_state
from iqpilot.system.ui.widgets import Widget
from iqpilot.system.ui.widgets.list_view import toggle_item
from iqpilot.system.ui.widgets.scroller_tici import Scroller
from iqpilot.system.ui.lib.application import gui_app
from iqpilot.system.ui.lib.multilang import tr, tr_noop
if gui_app.iqpilot_ui():
from iqpilot.system.ui.iqwidgets.widgets.list_view import toggle_item
# Description constants
DESCRIPTIONS = {
'enable_adb': tr_noop(
"ADB (Android Debug Bridge) allows connecting to your device over USB or over the network."
),
'ssh_key': tr_noop(
"Warning: This grants SSH access to all public keys in your GitHub settings. Never enter a GitHub username " +
"other than your own. An IQ.Pilot employee will NEVER ask you to add their GitHub username."
),
'usb_storage': tr_noop(
"Exposes a snapshot of recent dashcam clips and logs as a USB drive when connected to a computer. " +
"IQ.Pilot keeps running while this is enabled."
),
'long_maneuver': tr_noop(
"Commands a scripted sequence of acceleration steps to measure longitudinal actuator response. " +
"Requires IQ.Pilot longitudinal control. Only use on a clear, closed road."
),
'lat_maneuver': tr_noop(
"Commands a scripted sequence of lateral acceleration steps to measure steering actuator response. " +
"Only use on a straight, flat, clear road."
),
}
class DeveloperLayout(Widget):
def __init__(self):
super().__init__()
self._params = Params()
self._is_release = self._params.get_bool("IsReleaseBranch")
# Build items and keep references for callbacks/state updates
self._adb_toggle = toggle_item(
lambda: tr("Enable ADB"),
description=lambda: tr(DESCRIPTIONS["enable_adb"]),
initial_state=self._params.get_bool("AdbEnabled"),
callback=self._on_enable_adb,
enabled=ui_state.is_offroad,
)
self._usb_storage_toggle = toggle_item(
lambda: tr("USB Storage"),
description=lambda: tr(DESCRIPTIONS["usb_storage"]),
initial_state=self._params.get_bool("UsbStorageEnabled"),
callback=self._on_enable_usb_storage,
enabled=ui_state.is_offroad,
)
# SSH enable toggle + SSH key management
self._ssh_toggle = toggle_item(
lambda: tr("Enable SSH"),
description="",
initial_state=self._params.get_bool("SshEnabled"),
callback=self._on_enable_ssh,
)
self._ssh_keys = ssh_key_item(lambda: tr("SSH Keys"), description=lambda: tr(DESCRIPTIONS["ssh_key"]))
self._long_maneuver_toggle = toggle_item(
lambda: tr("Longitudinal Maneuver Mode"),
description=lambda: tr(DESCRIPTIONS["long_maneuver"]),
initial_state=self._params.get_bool("LongitudinalManeuverMode"),
callback=self._on_long_maneuver_mode,
)
self._lat_maneuver_toggle = toggle_item(
lambda: tr("Lateral Maneuver Mode"),
description=lambda: tr(DESCRIPTIONS["lat_maneuver"]),
initial_state=self._params.get_bool("LateralManeuverMode"),
callback=self._on_lat_maneuver_mode,
)
self._on_enable_ui_debug(self._params.get_bool("ShowDebugInfo"))
self._scroller = Scroller([
self._adb_toggle,
self._usb_storage_toggle,
self._ssh_toggle,
self._ssh_keys,
self._long_maneuver_toggle,
self._lat_maneuver_toggle,
], line_separator=True, spacing=0)
# Toggles should be not available to change in onroad state
ui_state.add_offroad_transition_callback(self._update_toggles)
def _render(self, rect):
self._scroller.render(rect)
def show_event(self):
self._scroller.show_event()
self._update_toggles()
def _update_toggles(self):
ui_state.update_params()
for item in (self._long_maneuver_toggle, self._lat_maneuver_toggle):
item.set_visible(not self._is_release)
if ui_state.CP is not None:
self._long_maneuver_toggle.action_item.set_enabled(ui_state.has_longitudinal_control and ui_state.is_offroad())
self._lat_maneuver_toggle.action_item.set_enabled(ui_state.is_offroad())
else:
self._long_maneuver_toggle.action_item.set_enabled(False)
self._lat_maneuver_toggle.action_item.set_enabled(False)
# TODO: make a param control list item so we don't need to manage internal state as much here
# refresh toggles from params to mirror external changes
for key, item in (
("AdbEnabled", self._adb_toggle),
("UsbStorageEnabled", self._usb_storage_toggle),
("SshEnabled", self._ssh_toggle),
("LongitudinalManeuverMode", self._long_maneuver_toggle),
("LateralManeuverMode", self._lat_maneuver_toggle),
):
item.action_item.set_state(self._params.get_bool(key))
def _on_enable_ui_debug(self, state: bool):
self._params.put_bool("ShowDebugInfo", state)
gui_app.set_show_touches(state)
gui_app.set_show_fps(state)
gui_app.set_show_mouse_coords(state)
def _on_enable_adb(self, state: bool):
self._params.put_bool("AdbEnabled", state)
def _on_enable_usb_storage(self, state: bool):
apply_usb_storage_state(state)
def _on_enable_ssh(self, state: bool):
self._params.put_bool("SshEnabled", state)
def _on_long_maneuver_mode(self, state: bool):
self._params.put_bool("LongitudinalManeuverMode", state)
self._params.put_bool("JoystickDebugMode", False)
self._params.put_bool("LateralManeuverMode", False)
self._lat_maneuver_toggle.action_item.set_state(False)
def _on_lat_maneuver_mode(self, state: bool):
self._params.put_bool("LateralManeuverMode", state)
self._params.put_bool("JoystickDebugMode", False)
self._params.put_bool("ExperimentalMode", False)
self._params.put_bool("LongitudinalManeuverMode", False)
self._long_maneuver_toggle.action_item.set_state(False)

View File

@@ -0,0 +1,201 @@
import os
import math
from iqpilot.cereal import messaging, log
from iqpilot.common.basedir import BASEDIR
from iqpilot.common.params import Params
from iqpilot.common.swaglog import cloudlog
from iqpilot.selfdrive.ui.onroad.driver_camera_dialog import DriverCameraDialog
from iqpilot.selfdrive.ui.ui_state import ui_state
from iqpilot.selfdrive.ui.widgets.pairing_dialog import PairingDialog
from iqpilot.konn3kt.registration import get_cached_dongle_id
from iqpilot.system.hardware import TICI
from iqpilot.system.ui.lib.application import FontWeight, gui_app
from iqpilot.system.ui.lib.multilang import multilang, tr, tr_noop
from iqpilot.system.ui.widgets import Widget, DialogResult
from iqpilot.system.ui.widgets.confirm_dialog import ConfirmDialog, alert_dialog
from iqpilot.system.ui.widgets.html_render import HtmlModal
from iqpilot.system.ui.widgets.list_view import text_item, button_item, dual_button_item
from iqpilot.system.ui.widgets.option_dialog import MultiOptionDialog
from iqpilot.system.ui.widgets.scroller_tici import Scroller
if gui_app.iqpilot_ui():
from iqpilot.system.ui.iqwidgets.widgets.list_view import button_item
# Description constants
DESCRIPTIONS = {
'pair_device': tr_noop("Pair your device in the Konn3kt app."),
'driver_camera': tr_noop("Preview the driver facing camera to ensure that driver monitoring has good visibility. (vehicle must be off)"),
'reset_calibration': tr_noop("IQ.Pilot requires the device to be mounted within 4° left or right and within 5° up or 9° down."),
}
class DeviceLayout(Widget):
def __init__(self):
super().__init__()
self._params = Params()
self._select_language_dialog: MultiOptionDialog | None = None
self._driver_camera: DriverCameraDialog | None = None
self._pair_device_dialog: PairingDialog | None = None
self._fcc_dialog: HtmlModal | None = None
items = self._initialize_items()
self._scroller = Scroller(items, line_separator=True, spacing=0)
ui_state.add_offroad_transition_callback(self._offroad_transition)
def _initialize_items(self):
self._pair_device_btn = button_item(lambda: tr("Pair Device"), lambda: tr("PAIR"), lambda: tr(DESCRIPTIONS['pair_device']), callback=self._pair_device)
self._pair_device_btn.set_visible(lambda: not ui_state.prime_state.is_paired())
self._reset_calib_btn = button_item(lambda: tr("Reset Calibration"), lambda: tr("RESET"), lambda: tr(DESCRIPTIONS['reset_calibration']),
callback=self._reset_calibration_prompt)
self._reset_calib_btn.set_description_opened_callback(self._update_calib_description)
self._power_off_btn = dual_button_item(lambda: tr("Reboot"), lambda: tr("Power Off"),
left_callback=self._reboot_prompt, right_callback=self._power_off_prompt)
items = [
text_item(lambda: tr("Dongle ID"), lambda: get_cached_dongle_id(self._params, prefer_readonly=True) or tr("N/A")),
text_item(lambda: tr("Serial"), self._params.get("HardwareSerial") or (lambda: tr("N/A"))),
self._pair_device_btn,
button_item(lambda: tr("Driver Camera"), lambda: tr("PREVIEW"), lambda: tr(DESCRIPTIONS['driver_camera']),
callback=self._show_driver_camera, enabled=ui_state.is_offroad),
self._reset_calib_btn,
regulatory_btn := button_item(lambda: tr("Regulatory"), lambda: tr("VIEW"), callback=self._on_regulatory, enabled=ui_state.is_offroad),
button_item(lambda: tr("Change Language"), lambda: tr("CHANGE"), callback=self._show_language_dialog),
self._power_off_btn,
]
regulatory_btn.set_visible(TICI)
return items
def _offroad_transition(self):
self._power_off_btn.action_item.right_button.set_visible(ui_state.is_offroad())
def show_event(self):
self._scroller.show_event()
def _render(self, rect):
self._scroller.render(rect)
def _show_language_dialog(self):
def handle_language_selection(result: int):
if result == 1 and self._select_language_dialog:
selected_language = multilang.languages[self._select_language_dialog.selection]
multilang.change_language(selected_language)
self._update_calib_description()
self._select_language_dialog = None
self._select_language_dialog = MultiOptionDialog(tr("Select a language"), multilang.languages, multilang.codes[multilang.language],
option_font_weight=FontWeight.UNIFONT)
gui_app.set_modal_overlay(self._select_language_dialog, callback=handle_language_selection)
def _show_driver_camera(self):
if not self._driver_camera:
self._driver_camera = DriverCameraDialog()
gui_app.set_modal_overlay(self._driver_camera, callback=lambda result: setattr(self, '_driver_camera', None))
def _reset_calibration_prompt(self):
if ui_state.engaged:
gui_app.set_modal_overlay(alert_dialog(tr("Disengage to Reset Calibration")))
return
def reset_calibration(result: int):
# Check engaged again in case it changed while the dialog was open
if ui_state.engaged or result != DialogResult.CONFIRM:
return
self._params.remove("CalibrationParams")
self._params.remove("LiveTorqueParameters")
self._params.remove("LiveParameters")
self._params.remove("LiveParametersV2")
self._params.remove("LiveDelay")
self._params.put_bool("OnroadCycleRequested", True)
self._update_calib_description()
dialog = ConfirmDialog(tr("Are you sure you want to reset calibration?"), tr("Reset"))
gui_app.set_modal_overlay(dialog, callback=reset_calibration)
def _update_calib_description(self):
desc = tr(DESCRIPTIONS['reset_calibration'])
calib_bytes = self._params.get("CalibrationParams")
if calib_bytes:
try:
calib = messaging.log_from_bytes(calib_bytes, log.Event).extrinsicsCalibration
if calib.calStatus != log.ExtrinsicsCalibration.Status.uncalibrated:
pitch = math.degrees(calib.rpyCalib[1])
yaw = math.degrees(calib.rpyCalib[2])
desc += tr(" Your device is pointed {:.1f}° {} and {:.1f}° {}.").format(abs(pitch), tr("down") if pitch > 0 else tr("up"),
abs(yaw), tr("left") if yaw > 0 else tr("right"))
except Exception:
cloudlog.exception("invalid CalibrationParams")
lag_perc = 0
lag_bytes = self._params.get("LiveDelay")
if lag_bytes:
try:
lag_perc = messaging.log_from_bytes(lag_bytes, log.Event).lateralDelay.calPerc
except Exception:
cloudlog.exception("invalid LiveDelay")
if lag_perc < 100:
desc += tr("<br><br>Steering lag calibration is {}% complete.").format(lag_perc)
else:
desc += tr("<br><br>Steering lag calibration is complete.")
torque_bytes = self._params.get("LiveTorqueParameters")
if torque_bytes:
try:
torque = messaging.log_from_bytes(torque_bytes, log.Event).lateralTorqueParameters
# don't add for non-torque cars
if torque.useParams:
torque_perc = torque.calPerc
if torque_perc < 100:
desc += tr(" Steering torque response calibration is {}% complete.").format(torque_perc)
else:
desc += tr(" Steering torque response calibration is complete.")
except Exception:
cloudlog.exception("invalid LiveTorqueParameters")
desc += "<br><br>"
desc += tr("IQ.Pilot is continuously calibrating, resetting is rarely required. " +
"Resetting calibration will restart IQ.Pilot if the car is powered on.")
self._reset_calib_btn.set_description(desc)
def _reboot_prompt(self):
if ui_state.engaged:
gui_app.set_modal_overlay(alert_dialog(tr("Disengage to Reboot")))
return
dialog = ConfirmDialog(tr("Are you sure you want to reboot?"), tr("Reboot"))
gui_app.set_modal_overlay(dialog, callback=self._perform_reboot)
def _perform_reboot(self, result: int):
if not ui_state.engaged and result == DialogResult.CONFIRM:
self._params.put_bool_nonblocking("DoReboot", True)
def _power_off_prompt(self):
if ui_state.engaged:
gui_app.set_modal_overlay(alert_dialog(tr("Disengage to Power Off")))
return
dialog = ConfirmDialog(tr("Are you sure you want to power off?"), tr("Power Off"))
gui_app.set_modal_overlay(dialog, callback=self._perform_power_off)
def _perform_power_off(self, result: int):
if not ui_state.engaged and result == DialogResult.CONFIRM:
self._params.put_bool_nonblocking("DoShutdown", True)
def _pair_device(self):
if not self._pair_device_dialog:
self._pair_device_dialog = PairingDialog()
gui_app.set_modal_overlay(self._pair_device_dialog, callback=lambda result: setattr(self, '_pair_device_dialog', None))
def _on_regulatory(self):
if not self._fcc_dialog:
self._fcc_dialog = HtmlModal(os.path.join(BASEDIR, "iqpilot/selfdrive/assets/offroad/fcc.html"))
gui_app.set_modal_overlay(self._fcc_dialog)

View File

@@ -0,0 +1,170 @@
import pyray as rl
from dataclasses import dataclass
from enum import IntEnum
from collections.abc import Callable
from iqpilot.selfdrive.ui.layouts.settings.developer import DeveloperLayout
from iqpilot.selfdrive.ui.layouts.settings.device import DeviceLayout
from iqpilot.selfdrive.ui.layouts.settings.software import SoftwareLayout
from iqpilot.selfdrive.ui.layouts.settings.toggles import TogglesLayout
from iqpilot.system.ui.lib.application import gui_app, FontWeight, MousePos
from iqpilot.system.ui.lib.multilang import tr, tr_noop
from iqpilot.system.ui.lib.text_measure import measure_text_cached
from iqpilot.system.ui.lib.wifi_manager import WifiManager
from iqpilot.system.ui.widgets import Widget
from iqpilot.system.ui.widgets.network import NetworkUI
# Constants
SIDEBAR_WIDTH = 500
CLOSE_BTN_SIZE = 200
CLOSE_ICON_SIZE = 70
NAV_BTN_HEIGHT = 110
PANEL_MARGIN = 50
# Colors
SIDEBAR_COLOR = rl.BLACK
PANEL_COLOR = rl.Color(41, 41, 41, 255)
CLOSE_BTN_COLOR = rl.Color(41, 41, 41, 255)
CLOSE_BTN_PRESSED = rl.Color(59, 59, 59, 255)
TEXT_NORMAL = rl.Color(128, 128, 128, 255)
TEXT_SELECTED = rl.WHITE
class PanelType(IntEnum):
DEVICE = 0
NETWORK = 1
TOGGLES = 2
SOFTWARE = 3
DEVELOPER = 5
@dataclass
class PanelInfo:
name: str
instance: Widget
button_rect: rl.Rectangle = rl.Rectangle(0, 0, 0, 0)
class SettingsLayout(Widget):
def __init__(self):
super().__init__()
self._current_panel = PanelType.DEVICE
# Panel configuration
wifi_manager = WifiManager()
wifi_manager.set_active(False)
self._panels = {
PanelType.DEVICE: PanelInfo(tr_noop("Device"), DeviceLayout()),
PanelType.NETWORK: PanelInfo(tr_noop("Network"), NetworkUI(wifi_manager)),
PanelType.TOGGLES: PanelInfo(tr_noop("Toggles"), TogglesLayout()),
PanelType.SOFTWARE: PanelInfo(tr_noop("Software"), SoftwareLayout()),
PanelType.DEVELOPER: PanelInfo(tr_noop("Developer"), DeveloperLayout()),
}
self._font_medium = gui_app.font(FontWeight.MEDIUM)
self._close_icon = gui_app.texture("icons/close2.png", CLOSE_ICON_SIZE, CLOSE_ICON_SIZE)
# Callbacks
self._close_callback: Callable | None = None
def set_callbacks(self, on_close: Callable):
self._close_callback = on_close
def _render(self, rect: rl.Rectangle):
# Calculate layout
sidebar_rect = rl.Rectangle(rect.x, rect.y, SIDEBAR_WIDTH, rect.height)
panel_rect = rl.Rectangle(rect.x + SIDEBAR_WIDTH, rect.y, rect.width - SIDEBAR_WIDTH, rect.height)
# Draw components
self._draw_sidebar(sidebar_rect)
self._draw_current_panel(panel_rect)
def _draw_sidebar(self, rect: rl.Rectangle):
rl.draw_rectangle_rec(rect, SIDEBAR_COLOR)
# Close button
close_btn_rect = rl.Rectangle(
rect.x + (rect.width - CLOSE_BTN_SIZE) / 2, rect.y + 60, CLOSE_BTN_SIZE, CLOSE_BTN_SIZE
)
pressed = (rl.is_mouse_button_down(rl.MouseButton.MOUSE_BUTTON_LEFT) and
rl.check_collision_point_rec(rl.get_mouse_position(), close_btn_rect))
close_color = CLOSE_BTN_PRESSED if pressed else CLOSE_BTN_COLOR
rl.draw_rectangle_rounded(close_btn_rect, 1.0, 20, close_color)
icon_color = rl.Color(255, 255, 255, 255) if not pressed else rl.Color(220, 220, 220, 255)
icon_dest = rl.Rectangle(
close_btn_rect.x + (close_btn_rect.width - self._close_icon.width) / 2,
close_btn_rect.y + (close_btn_rect.height - self._close_icon.height) / 2,
self._close_icon.width,
self._close_icon.height,
)
rl.draw_texture_pro(
self._close_icon,
rl.Rectangle(0, 0, self._close_icon.width, self._close_icon.height),
icon_dest,
rl.Vector2(0, 0),
0,
icon_color,
)
# Store close button rect for click detection
self._close_btn_rect = close_btn_rect
# Navigation buttons
y = rect.y + 300
for panel_type, panel_info in self._panels.items():
button_rect = rl.Rectangle(rect.x + 50, y, rect.width - 150, NAV_BTN_HEIGHT)
# Button styling
is_selected = panel_type == self._current_panel
text_color = TEXT_SELECTED if is_selected else TEXT_NORMAL
# Draw button text (right-aligned)
panel_name = tr(panel_info.name)
text_size = measure_text_cached(self._font_medium, panel_name, 65)
text_pos = rl.Vector2(
button_rect.x + button_rect.width - text_size.x, button_rect.y + (button_rect.height - text_size.y) / 2
)
rl.draw_text_ex(self._font_medium, panel_name, text_pos, 65, 0, text_color)
# Store button rect for click detection
panel_info.button_rect = button_rect
y += NAV_BTN_HEIGHT
def _draw_current_panel(self, rect: rl.Rectangle):
rl.draw_rectangle_rounded(
rl.Rectangle(rect.x + 10, rect.y + 10, rect.width - 20, rect.height - 20), 0.04, 30, PANEL_COLOR
)
content_rect = rl.Rectangle(rect.x + PANEL_MARGIN, rect.y + 25, rect.width - (PANEL_MARGIN * 2), rect.height - 50)
# rl.draw_rectangle_rounded(content_rect, 0.03, 30, PANEL_COLOR)
panel = self._panels[self._current_panel]
if panel.instance:
panel.instance.render(content_rect)
def _handle_mouse_release(self, mouse_pos: MousePos) -> None:
# Check close button
if rl.check_collision_point_rec(mouse_pos, self._close_btn_rect):
if self._close_callback:
self._close_callback()
return
# Check navigation buttons
for panel_type, panel_info in self._panels.items():
if rl.check_collision_point_rec(mouse_pos, panel_info.button_rect):
self.set_current_panel(panel_type)
return
def set_current_panel(self, panel_type: PanelType):
if panel_type != self._current_panel:
self._panels[self._current_panel].instance.hide_event()
self._current_panel = panel_type
self._panels[self._current_panel].instance.show_event()
def show_event(self):
super().show_event()
self._panels[self._current_panel].instance.show_event()
def hide_event(self):
super().hide_event()
self._panels[self._current_panel].instance.hide_event()

View File

@@ -0,0 +1,262 @@
import os
import time
import datetime
from iqpilot.common.time_helpers import system_time_valid
from iqpilot.selfdrive.ui.ui_state import ui_state
from iqpilot.system.ui.lib.application import gui_app
from iqpilot.system.ui.lib.multilang import tr, trn
from iqpilot.system.ui.widgets import Widget, DialogResult
from iqpilot.system.ui.widgets.confirm_dialog import ConfirmDialog
from iqpilot.system.ui.widgets.list_view import button_item, text_item, ListItem
from iqpilot.system.ui.widgets.option_dialog import MultiOptionDialog
from iqpilot.system.ui.widgets.scroller_tici import Scroller
if gui_app.iqpilot_ui():
from iqpilot.system.ui.iqwidgets.widgets.list_view import button_item
# TODO: remove this. updater fails to respond on startup if time is not correct
UPDATED_TIMEOUT = 10 # seconds to wait for updated to respond
BRAND_NAME = "IQ.Pilot"
# Mapping updater internal states to translated display strings
STATE_TO_DISPLAY_TEXT = {
"checking...": tr("checking..."),
"downloading...": tr("downloading..."),
"finalizing update...": tr("finalizing update..."),
}
def format_updater_description(description: str | None) -> str:
if not description:
return BRAND_NAME
cleaned = description.strip()
lower = cleaned.lower()
if lower.startswith("iqpilot"):
cleaned = cleaned[len("iqpilot"):].lstrip(" -:/")
if cleaned.lower().startswith(BRAND_NAME.lower()):
return cleaned
return f"{BRAND_NAME} {cleaned}" if cleaned else BRAND_NAME
def time_ago(date: datetime.datetime | None) -> str:
if not date:
return tr("never")
if not system_time_valid():
return date.strftime("%a %b %d %Y")
now = datetime.datetime.now(datetime.UTC)
if date.tzinfo is None:
date = date.replace(tzinfo=datetime.UTC)
diff_seconds = int((now - date).total_seconds())
if diff_seconds < 60:
return tr("now")
if diff_seconds < 3600:
m = diff_seconds // 60
return trn("{} minute ago", "{} minutes ago", m).format(m)
if diff_seconds < 86400:
h = diff_seconds // 3600
return trn("{} hour ago", "{} hours ago", h).format(h)
if diff_seconds < 604800:
d = diff_seconds // 86400
return trn("{} day ago", "{} days ago", d).format(d)
return date.strftime("%a %b %d %Y")
class SoftwareLayout(Widget):
def __init__(self):
super().__init__()
self._onroad_label = ListItem(lambda: tr("Updates are only downloaded while the car is off."))
self._version_item = text_item(lambda: tr("Current Version"), format_updater_description(ui_state.params.get("UpdaterCurrentDescription")))
self._download_btn = button_item(lambda: tr("Download"), lambda: tr("CHECK"), callback=self._on_download_update)
# Install button is initially hidden
self._install_btn = button_item(lambda: tr("Install Update"), lambda: tr("INSTALL"), callback=self._on_install_update)
self._install_btn.set_visible(False)
# Track waiting-for-updater transition to avoid brief re-enable while still idle
self._waiting_for_updater = False
self._waiting_start_ts: float = 0.0
# Branch switcher
self._branch_btn = button_item(lambda: tr("Target Branch"), lambda: tr("SELECT"), callback=self._on_select_branch)
self._branch_btn.set_visible(not ui_state.params.get_bool("IsTestedBranch"))
self._branch_btn.action_item.set_value(ui_state.params.get("UpdaterTargetBranch") or "")
self._branch_dialog: MultiOptionDialog | None = None
# Git auth for private-branch updates (sits with the Target Branch section)
self._auth_btn = button_item(lambda: tr("Git Auth"), lambda: tr("AUTH"), callback=self._on_auth_branch)
self._auth_btn.set_visible(not ui_state.params.get_bool("IsTestedBranch"))
self._scroller = Scroller([
self._onroad_label,
self._version_item,
self._download_btn,
self._install_btn,
self._branch_btn,
self._auth_btn,
button_item(lambda: tr("Uninstall"), lambda: tr("UNINSTALL"), callback=self._on_uninstall),
], line_separator=True, spacing=0)
def show_event(self):
self._refresh_auth_value()
self._scroller.show_event()
def _refresh_auth_value(self):
try:
from iqpilot.common.git_creds import has_credentials
configured = has_credentials()
except Exception:
configured = False
self._auth_btn.action_item.set_value(tr("configured") if configured else "")
def _render(self, rect):
self._scroller.render(rect)
def _update_state(self):
# Show/hide onroad warning
self._onroad_label.set_visible(ui_state.is_onroad())
# Update current version and release notes
current_desc = format_updater_description(ui_state.params.get("UpdaterCurrentDescription"))
current_release_notes = (ui_state.params.get("UpdaterCurrentReleaseNotes") or b"").decode("utf-8", "replace")
self._version_item.action_item.set_text(current_desc)
self._version_item.set_description(current_release_notes)
# Update download button visibility and state
self._download_btn.set_visible(ui_state.is_offroad())
updater_state = ui_state.params.get("UpdaterState") or "idle"
failed_count = ui_state.params.get("UpdateFailedCount") or 0
fetch_available = ui_state.params.get_bool("UpdaterFetchAvailable")
update_available = ui_state.params.get_bool("UpdateAvailable")
if updater_state != "idle":
# Updater responded
self._waiting_for_updater = False
self._download_btn.action_item.set_enabled(False)
# Use the mapping, with a fallback to the original state string
display_text = STATE_TO_DISPLAY_TEXT.get(updater_state, updater_state)
self._download_btn.action_item.set_value(display_text)
else:
if failed_count > 0:
self._download_btn.action_item.set_value(tr("failed to check for update"))
self._download_btn.action_item.set_text(tr("CHECK"))
elif fetch_available:
self._download_btn.action_item.set_value(tr("update available"))
self._download_btn.action_item.set_text(tr("DOWNLOAD"))
else:
last_update = ui_state.params.get("LastUpdateTime")
if last_update:
formatted = time_ago(last_update)
self._download_btn.action_item.set_value(tr("up to date, last checked {}").format(formatted))
else:
self._download_btn.action_item.set_value(tr("up to date, last checked never"))
self._download_btn.action_item.set_text(tr("CHECK"))
# If we've been waiting too long without a state change, reset state
if self._waiting_for_updater and (time.monotonic() - self._waiting_start_ts > UPDATED_TIMEOUT):
self._waiting_for_updater = False
# Only enable if we're not waiting for updater to flip out of idle
self._download_btn.action_item.set_enabled(not self._waiting_for_updater)
# Update target branch button value
current_branch = ui_state.params.get("UpdaterTargetBranch") or ""
self._branch_btn.action_item.set_value(current_branch)
# Update install button
self._install_btn.set_visible(ui_state.is_offroad() and update_available)
if update_available:
new_desc = format_updater_description(ui_state.params.get("UpdaterNewDescription"))
new_release_notes = (ui_state.params.get("UpdaterNewReleaseNotes") or b"").decode("utf-8", "replace")
self._install_btn.action_item.set_text(tr("INSTALL"))
self._install_btn.action_item.set_value(new_desc)
self._install_btn.set_description(new_release_notes)
# Enable install button for testing (like Qt showEvent)
self._install_btn.action_item.set_enabled(True)
else:
self._install_btn.set_visible(False)
def _on_download_update(self):
# Check if we should start checking or start downloading
self._download_btn.action_item.set_enabled(False)
if self._download_btn.action_item.text == tr("CHECK"):
# Start checking for updates
self._waiting_for_updater = True
self._waiting_start_ts = time.monotonic()
os.system("pkill -SIGUSR1 -f system.updated.updated")
else:
# Start downloading
self._waiting_for_updater = True
self._waiting_start_ts = time.monotonic()
os.system("pkill -SIGHUP -f system.updated.updated")
def _on_uninstall(self):
def handle_uninstall_confirmation(result):
if result == DialogResult.CONFIRM:
ui_state.params.put_bool("DoUninstall", True)
dialog = ConfirmDialog(tr("Are you sure you want to uninstall?"), tr("Uninstall"))
gui_app.set_modal_overlay(dialog, callback=handle_uninstall_confirmation)
def _on_install_update(self):
# Trigger reboot to install update
self._install_btn.action_item.set_enabled(False)
ui_state.params.put_bool("DoReboot", True)
def _on_select_branch(self):
# Get available branches and order
current_git_branch = ui_state.params.get("GitBranch") or ""
branches_str = ui_state.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 = ui_state.params.get("UpdaterTargetBranch") or ""
self._branch_dialog = MultiOptionDialog(tr("Select a branch"), branches, current_target)
def handle_selection(result):
# Confirmed selection
if result == DialogResult.CONFIRM and self._branch_dialog is not None and self._branch_dialog.selection:
selection = self._branch_dialog.selection
ui_state.params.put("UpdaterTargetBranch", selection)
self._branch_btn.action_item.set_value(selection)
os.system("pkill -SIGUSR1 -f system.updated.updated")
self._branch_dialog = None
gui_app.set_modal_overlay(self._branch_dialog, callback=handle_selection)
def _on_auth_branch(self):
# Collect username then token; store encrypted and signal the updater to
# re-check (refreshing the available-branch list for private repos).
from iqpilot.system.ui.iqwidgets.widgets.list_view import open_text_prompt
from iqpilot.common import git_creds
creds = git_creds.get_credentials()
current_user = creds[0] if creds else ""
def on_username(result, username):
if result != DialogResult.CONFIRM:
return
def on_token(token_result, token):
if token_result != DialogResult.CONFIRM:
return
git_creds.set_credentials(username, token)
self._refresh_auth_value()
os.system("pkill -SIGUSR1 -f system.updated.updated")
open_text_prompt(tr("Git token / password"),
tr("leave username and token blank to clear"),
password=True, on_done=on_token)
open_text_prompt(tr("Git username"), tr("for private branch updates"),
initial=current_user, on_done=on_username)

View File

@@ -0,0 +1,343 @@
from iqpilot.cereal import log
from iqpilot.common.params import Params, UnknownKeyName
from iqpilot.system.ui.widgets import Widget
from iqpilot.system.ui.widgets.list_view import multiple_button_item, toggle_item
from iqpilot.system.ui.widgets.scroller_tici import Scroller
from iqpilot.system.ui.widgets.confirm_dialog import ConfirmDialog
from iqpilot.system.ui.lib.application import gui_app
from iqpilot.system.ui.lib.multilang import tr, tr_noop
from iqpilot.system.ui.widgets import DialogResult
from iqpilot.selfdrive.ui.ui_state import ui_state
if gui_app.iqpilot_ui():
from iqpilot.system.ui.iqwidgets.widgets.list_view import toggle_item
from iqpilot.system.ui.iqwidgets.widgets.list_view import multiple_button_item
from iqpilot.ui.layouts.settings.iq_dynamic import IQDynamicLayout
PERSONALITY_TO_INT = log.LongitudinalPersonality.schema.enumerants
PERSONALITY_DISPLAY_TO_PARAM = [PERSONALITY_TO_INT["relaxed"], PERSONALITY_TO_INT["standard"], PERSONALITY_TO_INT["aggressive"]]
PERSONALITY_PARAM_TO_DISPLAY = {param: idx for idx, param in enumerate(PERSONALITY_DISPLAY_TO_PARAM)}
# Description constants
DESCRIPTIONS = {
"OpenpilotEnabledToggle": tr_noop(
"Use the IQ.Pilot system for adaptive cruise control and lane keep driver assistance. " +
"Your attention is required at all times to use this feature."
),
"DisengageOnAccelerator": tr_noop("When enabled, pressing the accelerator pedal will disengage IQ.Pilot."),
"LongitudinalPersonality": tr_noop(
"Standard is recommended. In aggressive mode, IQ.Pilot will follow lead cars closer and be more aggressive with the gas and brake. " +
"In relaxed mode IQ.Pilot will stay further away from lead cars. On supported cars, you can cycle through these personalities with " +
"your steering wheel distance button."
),
"IQSpeedAssistMode": tr_noop(
"Controls IQ.Pilot speed limit behavior. Off disables speed limit features, Information only displays limits, Warning highlights overspeed, and Control adjusts set speed using detected limits."
),
"IsLdwEnabled": tr_noop(
"Receive alerts to steer back into the lane when your vehicle drifts over a detected lane line " +
"without a turn signal activated while driving over 31 mph (50 km/h)."
),
"AlwaysOnDM": tr_noop("Enable driver monitoring even when IQ.Pilot is not engaged."),
"DashcamEnabled": tr_noop("Record and upload driving data and video. Disabling this stops all recording! No logs, no video, no audio."),
'RecordFront': tr_noop("Upload data from the driver facing camera and help improve the driver monitoring algorithm."),
"IsMetric": tr_noop("Display speed in km/h instead of mph."),
"IQAutoUnits": tr_noop(
"Set the units from the device location. Speeds switch to km/h everywhere except the United States, " +
"the United Kingdom and Liberia, and are re-checked when you cross a border."
),
"RecordAudio": tr_noop("Record and store microphone audio while driving. The audio will be included in the dashcam video in Konn3kt."),
"LongitudinalControlMode": tr_noop(
"Choose longitudinal behavior: IQ.Pilot (IQ longitudinal + end-to-end), "
"IQ.Dynamic (IQ longitudinal + dynamic mode), IQ.Chill (IQ longitudinal + relaxed personality), "
"or Stock ACC."
),
}
class TogglesLayout(Widget):
def __init__(self):
super().__init__()
self._params = Params()
# Keep IQ.Pilot enabled by default; the UI no longer exposes this toggle.
self._params.put_bool("OpenpilotEnabledToggle", True)
# param, title, desc, icon, needs_restart
self._toggle_defs = {
"DisengageOnAccelerator": (
lambda: tr("Disengage on Accelerator Pedal"),
DESCRIPTIONS["DisengageOnAccelerator"],
"disengage_on_accelerator.png",
False,
),
"IsLdwEnabled": (
lambda: tr("Enable Lane Departure Warnings"),
DESCRIPTIONS["IsLdwEnabled"],
"warning.png",
False,
),
"DashcamEnabled": (
lambda: tr("Enable Dashcam"),
DESCRIPTIONS["DashcamEnabled"],
"camera.png",
True,
),
"RecordFront": (
lambda: tr("Record and Upload Driver Camera"),
DESCRIPTIONS["RecordFront"],
"monitoring.png",
True,
),
"RecordAudio": (
lambda: tr("Record and Upload Microphone Audio"),
DESCRIPTIONS["RecordAudio"],
"microphone.png",
True,
),
"IsMetric": (
lambda: tr("Use Metric System"),
DESCRIPTIONS["IsMetric"],
"metric.png",
False,
),
"IQAutoUnits": (
lambda: tr("Set Units From Location"),
DESCRIPTIONS["IQAutoUnits"],
"metric.png",
False,
),
}
self._long_personality_setting = multiple_button_item(
lambda: tr("Driving Personality"),
lambda: tr(DESCRIPTIONS["LongitudinalPersonality"]),
buttons=[lambda: tr("Relaxed"), lambda: tr("Standard"), lambda: tr("Aggressive")],
button_width=300,
callback=self._set_longitudinal_personality,
selected_index=PERSONALITY_PARAM_TO_DISPLAY.get(self._params.get("LongitudinalPersonality", return_default=True), 1),
icon="speed_limit.png"
)
self._speed_limit_mode_setting = multiple_button_item(
lambda: tr("Speed Limit"),
lambda: tr(DESCRIPTIONS["IQSpeedAssistMode"]),
buttons=[lambda: tr("Off"), lambda: tr("Info"), lambda: tr("Warning"), lambda: tr("Control")],
button_width=220,
callback=self._set_speed_limit_mode,
selected_index=self._params.get("IQSpeedAssistMode", return_default=True),
icon="speed_limit.png",
)
self._longitudinal_control_mode_setting = multiple_button_item(
lambda: tr("Longitudinal Control"),
lambda: tr(DESCRIPTIONS["LongitudinalControlMode"]),
buttons=[lambda: tr("Stock ACC"), lambda: tr("IQ.Chill"), lambda: tr("IQ.Dynamic"), lambda: tr("IQ.Pilot")],
button_width=250,
callback=self._set_longitudinal_control_mode,
selected_index=self._get_longitudinal_control_mode_index(),
icon="experimental_white.png",
)
self._toggles = {}
self._locked_toggles = set()
self._toggles["LongitudinalControlMode"] = self._longitudinal_control_mode_setting
self._toggles["LongitudinalPersonality"] = self._long_personality_setting
self._toggles["IQSpeedAssistMode"] = self._speed_limit_mode_setting
for param, (title, desc, icon, needs_restart) in self._toggle_defs.items():
initial_state = self._params.get_bool(param)
toggle = toggle_item(
title,
desc,
initial_state,
callback=lambda state, p=param: self._toggle_callback(state, p),
icon=icon,
)
try:
locked = self._params.get_bool(param + "Lock")
except UnknownKeyName:
locked = False
toggle.action_item.set_enabled(not locked)
# Make description callable for live translation
additional_desc = ""
if needs_restart and not locked:
additional_desc = tr("Changing this setting will restart IQ.Pilot if the car is powered on.")
toggle.set_description(lambda og_desc=toggle.description, add_desc=additional_desc: tr(og_desc) + (" " + tr(add_desc) if add_desc else ""))
# track for engaged state updates
if locked:
self._locked_toggles.add(param)
self._toggles[param] = toggle
self._scroller = Scroller(list(self._toggles.values()), line_separator=True, spacing=0)
self._iq_dynamic_panel: "IQDynamicLayout | None" = None
self._show_iq_dynamic = False
if gui_app.iqpilot_ui():
self._iq_dynamic_panel = IQDynamicLayout(self._close_iq_dynamic_panel)
ui_state.add_engaged_transition_callback(self._update_toggles)
def _update_state(self):
if ui_state.sm.updated["selfdriveState"]:
personality = PERSONALITY_TO_INT[ui_state.sm["selfdriveState"].personality]
if personality != ui_state.personality and ui_state.started:
self._long_personality_setting.action_item.set_selected_button(PERSONALITY_PARAM_TO_DISPLAY.get(personality, 1))
ui_state.personality = personality
self._speed_limit_mode_setting.action_item.set_selected_button(self._params.get("IQSpeedAssistMode", return_default=True))
def _close_iq_dynamic_panel(self):
self._show_iq_dynamic = False
def set_cruise_panel_callback(self, callback: "Callable") -> None:
"""Register callback invoked on double-click of IQ.Dynamic (button index 2)."""
action = self._longitudinal_control_mode_setting.action_item
if hasattr(action, 'set_double_click_callback'):
action.set_double_click_callback(2, callback)
def show_event(self):
self._show_iq_dynamic = False
self._scroller.show_event()
self._update_toggles()
def _update_toggles(self):
ui_state.update_params()
e2e_description = tr(
"Longitudinal Control modes:<br>" +
"IQ.Pilot features are listed below:<br>" +
"<h4>IQ.Pilot End-to-End Longitudinal Control</h4><br>" +
"Let the driving model control the gas and brakes. IQ.Pilot will drive as it thinks a human would, including stopping for red lights and stop signs. " +
"Since the driving model decides the speed to drive, the set speed will only act as an upper bound. This feature is still being improved; " +
"mistakes should be expected.<br>" +
"<h4>IQ.Dynamic</h4><br>" +
"Dynamically blends between adaptive cruise behavior and end-to-end behavior based on scene/context.<br>" +
"<h4>IQ.Chill</h4><br>" +
"Uses standard traffic-aware cruise behavior for longitudinal control.<br>" +
"<h4>New Driving Visualization</h4><br>" +
"The driving visualization will transition to the road-facing wide-angle camera at low speeds to better show some turns. " +
"The IQ.Pilot logo will also be shown in the top right corner."
)
alpha_available = bool(ui_state.CP is not None and ui_state.CP.alphaLongitudinalAvailable)
alpha_requested = self._params.get_bool("AlphaLongitudinalEnabled")
toyota_stock_long_forced = bool(
ui_state.CP is not None and
ui_state.CP.brand == "toyota" and
self._params.get_bool("IQToyotaFactoryLong")
)
iq_modes_selectable = alpha_available or alpha_requested or toyota_stock_long_forced
availability_note = ""
if ui_state.CP is None:
availability_note = tr("Vehicle longitudinal capability has not been detected yet. Start the car once to detect support.")
elif toyota_stock_long_forced:
availability_note = tr("Factory Toyota longitudinal control is currently enforced. Choose an IQ longitudinal mode to disable it.")
elif not alpha_available:
availability_note = tr("IQ longitudinal modes are unavailable for this vehicle. Stock ACC is the only available option.")
self._toggles["LongitudinalControlMode"].set_visible(True)
self._long_personality_setting.set_visible(True)
mode_index = self._get_longitudinal_control_mode_index()
longitudinal_control_item = self._toggles["LongitudinalControlMode"]
longitudinal_control_item.action_item.set_selected_button(mode_index)
longitudinal_control_item.action_item.set_enabled(not ui_state.engaged)
longitudinal_control_item.action_item.set_enabled_buttons([True, iq_modes_selectable, iq_modes_selectable, iq_modes_selectable])
description = tr(DESCRIPTIONS["LongitudinalControlMode"]) + "<br><br>" + e2e_description
if availability_note:
description += "<br><br><i>" + availability_note + "</i>"
longitudinal_control_item.set_description(description)
personality_enabled = iq_modes_selectable and mode_index in (2, 3)
self._long_personality_setting.action_item.set_enabled(personality_enabled)
# TODO: make a param control list item so we don't need to manage internal state as much here
# refresh toggles from params to mirror external changes
for param in self._toggle_defs:
self._toggles[param].action_item.set_state(self._params.get_bool(param))
# these toggles need restart, block while engaged
for toggle_def in self._toggle_defs:
if self._toggle_defs[toggle_def][3] and toggle_def not in self._locked_toggles:
self._toggles[toggle_def].action_item.set_enabled(not ui_state.engaged)
def _render(self, rect):
if self._show_iq_dynamic and self._iq_dynamic_panel is not None:
self._iq_dynamic_panel.render(rect)
else:
self._scroller.render(rect)
def _get_longitudinal_control_mode_index(self) -> int:
if not self._params.get_bool("AlphaLongitudinalEnabled"):
return 0 # Stock ACC
if not self._params.get_bool("ExperimentalMode"):
return 1 # IQ.Chill
return 2 if self._params.get_bool("IQDynamicMode") else 3 # IQ.Dynamic / IQ.Pilot
def _apply_longitudinal_control_mode(self, button_index: int):
# 0 = Stock ACC, 1 = IQ.Chill, 2 = IQ.Dynamic, 3 = IQ.Pilot
previous_alpha = self._params.get_bool("AlphaLongitudinalEnabled")
previous_toyota_stock_long = self._params.get_bool("IQToyotaFactoryLong")
if button_index == 0:
self._params.put_bool("AlphaLongitudinalEnabled", False)
self._params.put_bool("ExperimentalMode", False)
self._params.put_bool("IQDynamicMode", False)
elif button_index == 1:
self._params.put_bool("AlphaLongitudinalEnabled", True)
self._params.put_bool("ExperimentalMode", False)
self._params.put_bool("IQDynamicMode", False)
self._params.put("LongitudinalPersonality", PERSONALITY_TO_INT["relaxed"])
elif button_index == 2:
self._params.put_bool("AlphaLongitudinalEnabled", True)
self._params.put_bool("ExperimentalMode", True)
self._params.put_bool("IQDynamicMode", True)
else:
self._params.put_bool("AlphaLongitudinalEnabled", True)
self._params.put_bool("ExperimentalMode", True)
self._params.put_bool("IQDynamicMode", False)
if button_index != 0 and previous_toyota_stock_long:
self._params.put_bool("IQToyotaFactoryLong", False)
if previous_alpha != self._params.get_bool("AlphaLongitudinalEnabled") or previous_toyota_stock_long != self._params.get_bool("IQToyotaFactoryLong"):
self._params.put_bool("OnroadCycleRequested", True)
def _toggle_callback(self, state: bool, param: str):
self._params.put_bool(param, state)
if param == "IQAutoUnits" and state:
self._params.remove("IQAutoUnitsRegion")
if self._toggle_defs[param][3]:
self._params.put_bool("OnroadCycleRequested", True)
def _set_longitudinal_personality(self, button_index: int):
self._params.put("LongitudinalPersonality", PERSONALITY_DISPLAY_TO_PARAM[button_index])
def _set_speed_limit_mode(self, button_index: int):
self._params.put("IQSpeedAssistMode", button_index)
def _set_longitudinal_control_mode(self, button_index: int):
# 0 = Stock ACC, 1 = IQ.Chill, 2 = IQ.Dynamic, 3 = IQ.Pilot
if button_index == self._get_longitudinal_control_mode_index():
if button_index == 2 and self._iq_dynamic_panel is not None:
self._show_iq_dynamic = True
return
# IQ.Pilot and IQ.Dynamic both require ExperimentalMode confirmation.
if button_index in (2, 3) and not self._params.get_bool("ExperimentalModeConfirmed"):
def confirm_callback(result: int):
if result == DialogResult.CONFIRM:
self._apply_longitudinal_control_mode(button_index)
self._params.put_bool("ExperimentalModeConfirmed", True)
else:
self._toggles["LongitudinalControlMode"].action_item.set_selected_button(self._get_longitudinal_control_mode_index())
self._update_toggles()
content = (f"<h1>{self._toggles['LongitudinalControlMode'].title}</h1><br>" +
f"<p>{self._toggles['LongitudinalControlMode'].description}</p>")
dlg = ConfirmDialog(content, tr("Enable"), rich=True)
gui_app.set_modal_overlay(dlg, callback=confirm_callback)
else:
self._apply_longitudinal_control_mode(button_index)
self._update_toggles()

View File

@@ -0,0 +1,557 @@
import pyray as rl
from collections.abc import Callable
from dataclasses import dataclass
from enum import Enum, auto
from iqpilot.common.params import Params
from iqpilot.ui.layouts.settings.iq_panels import IQSettingsLayout
from iqpilot.selfdrive.ui.layouts.home import _format_updater_description
from iqpilot.selfdrive.ui.ui_state import ui_state
from iqpilot.selfdrive.ui.widgets.screen_header import ScreenHeader, HEADER_HEIGHT, BACK_BTN_SIZE
from iqpilot.system.ui.lib.application import gui_app, FontWeight, MouseEvent, MousePos
from iqpilot.system.ui.lib.multilang import tr
from iqpilot.system.ui.lib.text_measure import measure_text_cached
from iqpilot.system.ui.widgets import Widget, DialogResult
from iqpilot.system.ui.widgets.confirm_dialog import ConfirmDialog, alert_dialog
from iqpilot.system.ui.widgets.label import UnifiedLabel
# iOS-style swipe-from-the-left-edge-to-go-back, for panel -> grid only (the one place
# this layout already has a from/to slide transition to reuse for the live drag).
EDGE_SWIPE_ZONE = 80 # px from the left edge a swipe-back touch must start within
EDGE_SWIPE_ARM_DISTANCE = 8 # px of rightward movement before we commit to "this is a swipe"
EDGE_SWIPE_BLOCK_VERTICAL = 60 # px of vertical movement (while still under arm distance) that cancels it
EDGE_SWIPE_COMPLETE_FRACTION = 0.3 # fraction of screen width dragged to complete the pop on release
SWIPE_SETTLE_SECONDS = 0.16 # glide from the release point to done/cancelled (no snap)
MARGIN = 40
SPACING = 25
COLUMNS = 3
PILL_GAP = 24
PILL_MIN_HEIGHT = 150
BUBBLE_SIZE = 86
BUBBLE_RED = rl.Color(226, 60, 52, 255)
BUBBLE_RED_PRESSED = rl.Color(245, 92, 82, 255)
BUBBLE_GREY = rl.Color(70, 72, 78, 255)
BUBBLE_GREY_PRESSED = rl.Color(95, 98, 106, 255)
BUBBLE_TEAL = rl.Color(16, 185, 169, 255)
BUBBLE_TEAL_PRESSED = rl.Color(22, 210, 192, 255)
SETTINGS_TRANSITION_SECONDS = 0.28
TRANSITION_SURFACE_OVERSCAN = 4
TRANSITION_SURFACE_BG = rl.Color(10, 10, 10, 255)
class MenuTransitionState(Enum):
IDLE = auto()
PUSHING = auto()
POPPING = auto()
@dataclass
class MenuTransition:
state: MenuTransitionState = MenuTransitionState.IDLE
t: float = 0.0
duration: float = SETTINGS_TRANSITION_SECONDS
from_screen: object | None = None
to_screen: object | None = None
@property
def active(self) -> bool:
return self.state != MenuTransitionState.IDLE
def _clamp(x: float, lo: float = 0.0, hi: float = 1.0) -> float:
return max(lo, min(hi, x))
def _lerp(a: float, b: float, t: float) -> float:
return a + (b - a) * t
def _ease_out_cubic(x: float) -> float:
x = _clamp(x)
return 1.0 - pow(1.0 - x, 3.0)
def _ease_emphasized(x: float) -> float:
# ease-in-out-cubic: gentle acceleration into the slide, graceful deceleration into place
x = _clamp(x)
if x < 0.5:
return 4.0 * x * x * x
return 1.0 - pow(-2.0 * x + 2.0, 3.0) / 2.0
# Depth cues for the grid<->panel push/pop (see main.py for the same treatment).
TRANSITION_PARALLAX = 0.28
TRANSITION_MAX_DIM = 0.5
TRANSITION_SHADOW_W = 32
class SettingsPill(Widget):
"""A settings-grid button: icon + label on a rounded card, opens a sub-panel."""
BG = rl.Color(38, 40, 46, 255)
BG_PRESSED = rl.Color(54, 57, 65, 255)
BORDER = rl.Color(255, 255, 255, 38)
def __init__(self, icon_path: str, label: str | Callable[[], str], on_click: Callable[[], None]):
super().__init__()
self._label = label
self._icon = gui_app.texture(icon_path, 80, 80, keep_aspect_ratio=True) if icon_path else None
self.set_click_callback(on_click)
def _render(self, rect: rl.Rectangle):
rl.draw_rectangle_rounded(rect, 0.25, 20, self.BG_PRESSED if self.is_pressed else self.BG)
rl.draw_rectangle_rounded_lines_ex(rect, 0.25, 20, 2, self.BORDER)
font = gui_app.font(FontWeight.MEDIUM)
label_size = 50
label = self._label() if callable(self._label) else self._label
ts = measure_text_cached(font, label, label_size)
icon_w = self._icon.width if self._icon else 0
gap = 24 if self._icon else 0
group_w = icon_w + gap + ts.x
x = rect.x + (rect.width - group_w) / 2
cy = rect.y + rect.height / 2
if self._icon:
rl.draw_texture(self._icon, int(x), int(cy - self._icon.height / 2), rl.WHITE)
x += icon_w + gap
rl.draw_text_ex(font, label, rl.Vector2(int(x), int(cy - ts.y / 2)), label_size, 0, rl.WHITE)
class SettingsHubLayout(Widget):
"""Offroad Settings: a grid of pill buttons (the mockup look) that open the existing
IQ settings sub-panels. Back from a panel returns to the grid; back from the grid goes home.
"""
def __init__(self):
super().__init__()
self._params = Params()
self._settings = IQSettingsLayout() # one instance: reuse its configured panels + wiring
self._header = self._child(ScreenHeader(lambda: tr("Settings")))
self._on_close: Callable[[], None] | None = None
self._mode = "grid" # "grid" | "panel"
self._cur_panel = None
# Edge-swipe-back drag state (panel -> grid only)
self._swipe_start_pos: MousePos | None = None
self._swipe_active = False # past EDGE_SWIPE_ARM_DISTANCE, now tracking finger 1:1
self._swipe_blocked = False # vertical movement won out before we armed; ignore rest of this touch
self._swipe_dx = 0.0
# Release-settle animation (glide to grid/panel instead of snapping)
self._swipe_settling = False
self._swipe_settle_from = 0.0
self._swipe_settle_to = 0.0
self._swipe_settle_t = 0.0
self._swipe_completing = False
self._version_text = _format_updater_description(self._params.get("UpdaterCurrentDescription"))
self._version_label = UnifiedLabel("", font_size=44, font_weight=FontWeight.MEDIUM,
text_color=rl.Color(185, 185, 190, 255),
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE,
wrap_text=False, scroll=True)
self._transition = MenuTransition()
# Restart / power-off / always-offroad / night-mode bubbles in the grid header
self._restart_icon = gui_app.texture("icons/iq/restart.png", 48, 48, keep_aspect_ratio=True)
self._power_icon = gui_app.texture("icons/iq/power.png", 48, 48, keep_aspect_ratio=True)
self._offroad_icon = gui_app.texture("icons/iq/square-parking.png", 48, 48, keep_aspect_ratio=True)
self._night_icon = gui_app.texture("icons/iq/moon.png", 48, 48, keep_aspect_ratio=True)
self._bell_icon = gui_app.texture("icons/iq/bell.png", 48, 48, keep_aspect_ratio=True)
self._bell_slash_icon = gui_app.texture("icons/iq/bell-slash.png", 48, 48, keep_aspect_ratio=True)
self._restart_rect = rl.Rectangle(0, 0, 0, 0)
self._power_rect = rl.Rectangle(0, 0, 0, 0)
self._offroad_rect = rl.Rectangle(0, 0, 0, 0)
self._night_rect = rl.Rectangle(0, 0, 0, 0)
self._silent_rect = rl.Rectangle(0, 0, 0, 0)
# Build the grid from the settings panels, skipping ones hidden from navigation (e.g. Cruise).
panels = self._settings._panels
hidden = self._settings._hidden_from_sidebar
self._grid_panels = [pt for pt in panels if pt not in hidden]
self._pills = [
SettingsPill(panels[pt].icon, lambda p=pt: tr(panels[p].name), (lambda p=pt: self._open_panel(p)))
for pt in self._grid_panels
]
# Route the Toggles -> Cruise shortcut into this hub's panel view.
cruise_pt = next((pt for pt in panels if pt.name == "CRUISE"), None)
toggles_pt = next((pt for pt in panels if pt.name == "TOGGLES"), None)
if cruise_pt is not None and toggles_pt is not None:
toggles = panels[toggles_pt].instance
if hasattr(toggles, "set_cruise_panel_callback"):
toggles.set_cruise_panel_callback(lambda: self._open_panel(cruise_pt))
def set_callbacks(self, on_close: Callable[[], None] | None = None):
self._on_close = on_close
def show_grid(self):
self._mode = "grid"
self._transition = MenuTransition()
def set_current_panel(self, panel_type):
self._open_panel(panel_type)
def show_event(self):
super().show_event()
self._mode = "grid"
self._transition = MenuTransition()
self._version_text = _format_updater_description(self._params.get("UpdaterCurrentDescription"))
def _open_panel(self, panel_type):
if self._transition.active:
return
from_grid = self._mode == "grid"
self._settings.set_current_panel(panel_type)
self._cur_panel = panel_type
if from_grid and not ui_state.started:
self._start_transition("grid", "panel", MenuTransitionState.PUSHING)
else:
self._mode = "panel"
def _back_to_grid(self):
if self._mode == "panel" and not self._transition.active:
self._start_transition("panel", "grid", MenuTransitionState.POPPING)
def _reset_swipe(self):
self._swipe_start_pos = None
self._swipe_active = False
self._swipe_blocked = False
self._swipe_dx = 0.0
def _handle_mouse_event(self, mouse_event: MouseEvent) -> None:
super()._handle_mouse_event(mouse_event)
if self._swipe_settling:
return # let the release animation finish before accepting a new gesture
if mouse_event.slot != 0 or self._mode != "panel" or self._transition.active:
self._reset_swipe()
return
if mouse_event.left_pressed:
if mouse_event.pos.x - self._rect.x <= EDGE_SWIPE_ZONE:
self._swipe_start_pos = mouse_event.pos
self._swipe_active = False
self._swipe_blocked = False
self._swipe_dx = 0.0
else:
self._reset_swipe()
elif self._swipe_start_pos is not None:
if mouse_event.left_down:
dx = mouse_event.pos.x - self._swipe_start_pos.x
dy = abs(mouse_event.pos.y - self._swipe_start_pos.y)
if not self._swipe_active and not self._swipe_blocked:
if dy > EDGE_SWIPE_BLOCK_VERTICAL and dy > dx:
self._swipe_blocked = True
elif dx > EDGE_SWIPE_ARM_DISTANCE:
self._swipe_active = True
if self._swipe_active:
self._swipe_dx = max(0.0, dx)
elif mouse_event.left_released:
if self._swipe_active:
complete = self._swipe_dx > self._rect.width * EDGE_SWIPE_COMPLETE_FRACTION
self._begin_swipe_settle(complete)
self._reset_swipe()
def _start_transition(self, from_mode: str, to_mode: str, state: MenuTransitionState):
self._transition = MenuTransition(
state=state,
t=0.0,
duration=SETTINGS_TRANSITION_SECONDS,
from_screen=from_mode,
to_screen=to_mode,
)
def _render(self, rect: rl.Rectangle):
if self._render_transition(rect):
return
if self._swipe_settling:
if self._update_swipe_settle():
self._render_swipe_drag(rect)
return
# settled this frame; fall through to render the (possibly switched) mode
elif self._swipe_active and self._mode == "panel":
self._render_swipe_drag(rect)
return
self._render_mode(self._mode, rect)
def _begin_swipe_settle(self, complete: bool):
# On release, glide from where the finger let go to fully-open (complete -> grid) or closed
# (cancel -> stay on panel) instead of snapping.
self._swipe_settle_from = self._swipe_dx
self._swipe_settle_to = self._rect.width if complete else 0.0
self._swipe_completing = complete
self._swipe_settle_t = 0.0
self._swipe_settling = True
def _update_swipe_settle(self) -> bool:
"""Advance the release animation. Returns True while still animating."""
self._swipe_settle_t += rl.get_frame_time()
p = _clamp(self._swipe_settle_t / SWIPE_SETTLE_SECONDS)
self._swipe_dx = _lerp(self._swipe_settle_from, self._swipe_settle_to, _ease_out_cubic(p))
if p < 1.0:
return True
self._swipe_settling = False
completing = self._swipe_completing
self._swipe_completing = False
self._swipe_dx = 0.0
if completing:
self._mode = "grid"
self._transition = MenuTransition()
return False
def _render_swipe_drag(self, rect: rl.Rectangle):
# Live, finger-following preview of the panel -> grid pop, mirroring _render_transition's
# POPPING geometry but driven 1:1 by touch position instead of eased elapsed time.
dx = min(self._swipe_dx, rect.width)
rl.draw_rectangle_rec(rect, TRANSITION_SURFACE_BG)
self._render_mode_surface_translated("grid", rect, dx - rect.width)
self._render_mode_surface_translated("panel", rect, dx)
def _layout_rects(self, rect: rl.Rectangle) -> tuple[rl.Rectangle, rl.Rectangle]:
header_rect = rl.Rectangle(rect.x + MARGIN, rect.y + MARGIN, rect.width - 2 * MARGIN, HEADER_HEIGHT)
content_y = header_rect.y + HEADER_HEIGHT + SPACING
content_rect = rl.Rectangle(rect.x + MARGIN, content_y, rect.width - 2 * MARGIN,
rect.y + rect.height - content_y - MARGIN)
return header_rect, content_rect
def _render_mode(self, mode: str, rect: rl.Rectangle, interactive: bool = True):
if mode == "panel" and self._cur_panel is not None:
self._render_panel(rect, interactive)
else:
self._render_grid(rect, interactive)
def _render_mode_surface(self, mode: str, rect: rl.Rectangle, interactive: bool = True):
bg_rect = rl.Rectangle(
rect.x - TRANSITION_SURFACE_OVERSCAN,
rect.y - TRANSITION_SURFACE_OVERSCAN,
rect.width + TRANSITION_SURFACE_OVERSCAN * 2,
rect.height + TRANSITION_SURFACE_OVERSCAN * 2,
)
rl.draw_rectangle_rec(bg_rect, TRANSITION_SURFACE_BG)
self._render_mode(mode, rect, interactive)
def _render_mode_surface_translated(self, mode: str, rect: rl.Rectangle, x_offset: float):
translated_rect = rl.Rectangle(round(rect.x + x_offset), rect.y, rect.width, rect.height)
self._render_mode_surface(mode, translated_rect, interactive=False)
def _render_transition(self, rect: rl.Rectangle) -> bool:
if not self._transition.active:
return False
from_mode = self._transition.from_screen
to_mode = self._transition.to_screen
if from_mode is None or to_mode is None:
self._finish_transition()
return False
from_mode = str(from_mode)
to_mode = str(to_mode)
self._transition.t += rl.get_frame_time()
if self._transition.t >= self._transition.duration:
self._finish_transition()
return False
p = _clamp(self._transition.t / self._transition.duration)
e = _ease_emphasized(p)
w = rect.width
pushing = self._transition.state == MenuTransitionState.PUSHING
# The incoming card slides its full width on top; the other page parallaxes a fraction and dims.
if pushing:
top_mode, back_mode = to_mode, from_mode
top_x = _lerp(w, 0.0, e)
back_x = _lerp(0.0, -w * TRANSITION_PARALLAX, e)
back_dim = int(_lerp(0.0, TRANSITION_MAX_DIM, e) * 255)
else:
top_mode, back_mode = from_mode, to_mode
top_x = _lerp(0.0, w, e)
back_x = _lerp(-w * TRANSITION_PARALLAX, 0.0, e)
back_dim = int(_lerp(TRANSITION_MAX_DIM, 0.0, e) * 255)
back_rect = rl.Rectangle(round(rect.x + back_x), rect.y, rect.width, rect.height)
rl.draw_rectangle_rec(rect, TRANSITION_SURFACE_BG)
self._render_mode_surface_translated(back_mode, rect, back_x)
if back_dim > 0:
rl.draw_rectangle_rec(back_rect, rl.Color(0, 0, 0, back_dim))
# soft shadow cast by the top card's leading edge onto the page behind
edge_x = round(rect.x + top_x)
if edge_x > rect.x:
sh = int(min(TRANSITION_SHADOW_W, edge_x - rect.x))
rl.draw_rectangle_gradient_h(edge_x - sh, int(rect.y), sh, int(rect.height),
rl.Color(0, 0, 0, 0), rl.Color(0, 0, 0, 120))
self._render_mode_surface_translated(top_mode, rect, top_x)
return True
def _finish_transition(self):
if self._transition.to_screen is not None:
self._mode = str(self._transition.to_screen)
self._transition = MenuTransition()
def _render_widget(self, widget: Widget, rect: rl.Rectangle, interactive: bool):
if interactive:
widget.render(rect)
return
enabled = widget._enabled
widget.set_enabled(False)
try:
widget.render(rect)
finally:
widget.set_enabled(enabled)
def _render_panel(self, rect: rl.Rectangle, interactive: bool = True):
header_rect, content_rect = self._layout_rects(rect)
self._header.set_title(tr(self._settings._panels[self._cur_panel].name))
self._header.set_on_back(self._back_to_grid)
self._header.set_title_offset(0)
self._render_widget(self._header, header_rect, interactive)
panel = self._settings._panels[self._settings._current_panel].instance
enabled = panel._enabled
if not interactive:
panel.set_enabled(False)
try:
self._settings._draw_current_panel(content_rect)
finally:
if not interactive:
panel.set_enabled(enabled)
def _render_grid(self, rect: rl.Rectangle, interactive: bool = True):
header_rect, content_rect = self._layout_rects(rect)
# Grid landing
title = tr("Settings")
title_offset = 5 * BUBBLE_SIZE + 4 * 18 + 36
self._header.set_title(title)
self._header.set_on_back(self._on_close)
self._header.set_title_offset(title_offset) # make room for the bubbles
self._render_widget(self._header, header_rect, interactive)
# Restart / power-off / always-offroad bubbles, just right of the back button
cy = header_rect.y + HEADER_HEIGHT / 2
bx = header_rect.x + BACK_BTN_SIZE + 24
self._restart_rect = rl.Rectangle(bx, cy - BUBBLE_SIZE / 2, BUBBLE_SIZE, BUBBLE_SIZE)
self._power_rect = rl.Rectangle(bx + BUBBLE_SIZE + 18, cy - BUBBLE_SIZE / 2, BUBBLE_SIZE, BUBBLE_SIZE)
self._offroad_rect = rl.Rectangle(bx + 2 * (BUBBLE_SIZE + 18), cy - BUBBLE_SIZE / 2, BUBBLE_SIZE, BUBBLE_SIZE)
self._night_rect = rl.Rectangle(bx + 3 * (BUBBLE_SIZE + 18), cy - BUBBLE_SIZE / 2, BUBBLE_SIZE, BUBBLE_SIZE)
self._silent_rect = rl.Rectangle(bx + 4 * (BUBBLE_SIZE + 18), cy - BUBBLE_SIZE / 2, BUBBLE_SIZE, BUBBLE_SIZE)
mouse = rl.get_mouse_position()
for r, icon in ((self._restart_rect, self._restart_icon), (self._power_rect, self._power_icon)):
pressed = self.is_pressed and rl.check_collision_point_rec(mouse, r)
rl.draw_circle(int(r.x + BUBBLE_SIZE / 2), int(cy), BUBBLE_SIZE / 2, BUBBLE_RED_PRESSED if pressed else BUBBLE_RED)
rl.draw_texture(icon, int(r.x + (BUBBLE_SIZE - icon.width) / 2), int(cy - icon.height / 2), rl.WHITE)
# Always Offroad bubble (grey when off, teal when on)
offroad_active = ui_state.params.get_bool("IQAlwaysOffroad")
pressed = self.is_pressed and rl.check_collision_point_rec(mouse, self._offroad_rect)
offroad_color = (BUBBLE_TEAL_PRESSED if pressed else BUBBLE_TEAL) if offroad_active else (BUBBLE_GREY_PRESSED if pressed else BUBBLE_GREY)
rl.draw_circle(int(self._offroad_rect.x + BUBBLE_SIZE / 2), int(cy), BUBBLE_SIZE / 2, offroad_color)
rl.draw_texture(self._offroad_icon,
int(self._offroad_rect.x + (BUBBLE_SIZE - self._offroad_icon.width) / 2),
int(cy - self._offroad_icon.height / 2), rl.WHITE)
# Night Mode bubble (grey when off, teal when on)
night_active = ui_state.params.get_bool("NightMode")
pressed = self.is_pressed and rl.check_collision_point_rec(mouse, self._night_rect)
night_color = (BUBBLE_TEAL_PRESSED if pressed else BUBBLE_TEAL) if night_active else (BUBBLE_GREY_PRESSED if pressed else BUBBLE_GREY)
rl.draw_circle(int(self._night_rect.x + BUBBLE_SIZE / 2), int(cy), BUBBLE_SIZE / 2, night_color)
rl.draw_texture(self._night_icon,
int(self._night_rect.x + (BUBBLE_SIZE - self._night_icon.width) / 2),
int(cy - self._night_icon.height / 2), rl.WHITE)
# Silent Mode bubble (grey bell when off, red bell-with-slash when on)
silent_active = ui_state.params.get_bool("IQAlertSilence")
pressed = self.is_pressed and rl.check_collision_point_rec(mouse, self._silent_rect)
silent_color = (BUBBLE_RED_PRESSED if pressed else BUBBLE_RED) if silent_active else (BUBBLE_GREY_PRESSED if pressed else BUBBLE_GREY)
silent_icon = self._bell_slash_icon if silent_active else self._bell_icon
rl.draw_circle(int(self._silent_rect.x + BUBBLE_SIZE / 2), int(cy), BUBBLE_SIZE / 2, silent_color)
rl.draw_texture(silent_icon,
int(self._silent_rect.x + (BUBBLE_SIZE - silent_icon.width) / 2),
int(cy - silent_icon.height / 2), rl.WHITE)
# Small version label, top-right of the header row. Its scroll viewport
# starts after the title, so long branch text does not clip at the bubbles.
font = gui_app.font(FontWeight.MEDIUM)
title_font = gui_app.font(FontWeight.BOLD)
ver_fs = 44
title_size = measure_text_cached(title_font, title, 64)
ver_size = measure_text_cached(font, self._version_text, ver_fs)
title_x = header_rect.x + BACK_BTN_SIZE + 36 + title_offset
version_left = title_x + title_size.x + 36
version_right = header_rect.x + header_rect.width
version_rect = rl.Rectangle(version_left, header_rect.y, max(0, version_right - version_left), HEADER_HEIGHT)
if version_rect.width > 0:
if ver_size.x <= version_rect.width:
rl.draw_text_ex(font, self._version_text,
rl.Vector2(int(header_rect.x + header_rect.width - ver_size.x),
int(header_rect.y + (HEADER_HEIGHT - ver_size.y) / 2)),
ver_fs, 0, rl.Color(185, 185, 190, 255))
else:
self._version_label.set_text(self._version_text)
self._version_label.render(version_rect)
rows = (len(self._pills) + COLUMNS - 1) // COLUMNS
pill_w = (content_rect.width - PILL_GAP * (COLUMNS - 1)) / COLUMNS
pill_h = max(PILL_MIN_HEIGHT, (content_rect.height - PILL_GAP * (rows - 1)) / rows)
for i, pill in enumerate(self._pills):
col = i % COLUMNS
row = i // COLUMNS
px = content_rect.x + col * (pill_w + PILL_GAP)
py = content_rect.y + row * (pill_h + PILL_GAP)
self._render_widget(pill, rl.Rectangle(px, py, pill_w, pill_h), interactive)
def _handle_mouse_release(self, mouse_pos: MousePos):
if self._mode != "grid" or self._transition.active:
return
if rl.check_collision_point_rec(mouse_pos, self._restart_rect):
self._reboot_prompt()
elif rl.check_collision_point_rec(mouse_pos, self._power_rect):
self._power_off_prompt()
elif rl.check_collision_point_rec(mouse_pos, self._offroad_rect):
self._toggle_offroad_prompt()
elif rl.check_collision_point_rec(mouse_pos, self._night_rect):
ui_state.params.put_bool("NightMode", not ui_state.params.get_bool("NightMode"))
elif rl.check_collision_point_rec(mouse_pos, self._silent_rect):
ui_state.params.put_bool("IQAlertSilence", not ui_state.params.get_bool("IQAlertSilence"))
def _reboot_prompt(self):
if ui_state.engaged:
gui_app.set_modal_overlay(alert_dialog(tr("Disengage to Reboot")))
return
dialog = ConfirmDialog(tr("Are you sure you want to reboot?"), tr("Reboot"))
gui_app.set_modal_overlay(dialog, callback=self._perform_reboot)
def _perform_reboot(self, result: int):
if not ui_state.engaged and result == DialogResult.CONFIRM:
self._params.put_bool_nonblocking("DoReboot", True)
def _power_off_prompt(self):
if ui_state.engaged:
gui_app.set_modal_overlay(alert_dialog(tr("Disengage to Power Off")))
return
dialog = ConfirmDialog(tr("Are you sure you want to power off?"), tr("Power Off"))
gui_app.set_modal_overlay(dialog, callback=self._perform_power_off)
def _perform_power_off(self, result: int):
if not ui_state.engaged and result == DialogResult.CONFIRM:
self._params.put_bool_nonblocking("DoShutdown", True)
def _toggle_offroad_prompt(self):
if ui_state.engaged:
gui_app.set_modal_overlay(alert_dialog(tr("Disengage before forcing offroad")))
return
active = ui_state.params.get_bool("IQAlwaysOffroad")
msg = tr("Leave forced-offroad mode now?") if active else tr("Switch the device into forced-offroad mode?")
def _confirm(result: int):
if result == DialogResult.CONFIRM and not ui_state.engaged:
ui_state.params.put_bool("IQAlwaysOffroad", not active)
gui_app.set_modal_overlay(ConfirmDialog(msg, tr("Confirm")), callback=_confirm)

View File

@@ -0,0 +1,325 @@
import pyray as rl
import time
from dataclasses import dataclass
from collections.abc import Callable
from iqpilot.cereal import log
from iqpilot.selfdrive.ui.ui_state import ui_state
from iqpilot.selfdrive.ui.lib.wifi_ssid import current_ssid
from iqpilot.system.ui.lib.application import gui_app, FontWeight, MousePos
from iqpilot.system.ui.lib.multilang import tr, tr_noop
from iqpilot.system.ui.lib.text_measure import measure_text_cached
from iqpilot.system.ui.widgets import Widget
SIDEBAR_WIDTH = 300
METRIC_HEIGHT = 120
METRIC_WIDTH = 252
METRIC_MARGIN = 24
CARD_GAP = 16
STATUS_DOT_CENTER_X = 36
STATUS_TEXT_X = 66
STATUS_TEXT_RIGHT_MARGIN = 20
STATUS_LABEL_SIZE = 26
STATUS_VALUE_SIZE = 36
STATUS_LINE_GAP = 2
NETWORK_RECT = rl.Rectangle(24, 18, 252, 76)
SETTINGS_BTN = rl.Rectangle(24, 112, 252, 112)
HOME_BTN = rl.Rectangle(60, 860, 180, 180)
ThermalStatus = log.DeviceState.ThermalStatus
NetworkType = log.DeviceState.NetworkType
# Color scheme
class Colors:
WHITE = rl.WHITE
WHITE_DIM = rl.Color(255, 255, 255, 85)
GRAY = rl.Color(84, 84, 84, 255)
# Keep these in parity with the offroad home status indicators.
GOOD = rl.Color(16, 185, 169, 255)
WARNING = rl.Color(245, 166, 35, 255)
DANGER = rl.Color(226, 72, 58, 255)
# UI elements
CARD_BG = rl.Color(28, 30, 36, 255)
CARD_BG_PRESSED = rl.Color(50, 53, 61, 255)
METRIC_BORDER = rl.Color(255, 255, 255, 22)
BOOKMARK_BG = CARD_BG
BOOKMARK_BG_PRESSED = CARD_BG_PRESSED
BUTTON_NORMAL = rl.WHITE
BUTTON_PRESSED = rl.Color(255, 255, 255, 166)
NETWORK_TYPES = {
NetworkType.none: tr_noop("--"),
NetworkType.wifi: tr_noop("Wi-Fi"),
NetworkType.ethernet: tr_noop("ETH"),
NetworkType.cell2G: tr_noop("2G"),
NetworkType.cell3G: tr_noop("3G"),
NetworkType.cell4G: tr_noop("LTE"),
NetworkType.cell5G: tr_noop("5G"),
}
@dataclass(slots=True)
class MetricData:
label: str
value: str
color: rl.Color
def update(self, label: str, value: str, color: rl.Color):
self.label = label
self.value = value
self.color = color
class Sidebar(Widget):
def __init__(self):
Widget.__init__(self)
self._net_type = NETWORK_TYPES.get(NetworkType.none)
self._net_strength = 0
self._temp_status = MetricData(tr_noop("TEMP"), tr_noop("GOOD"), Colors.GOOD)
self._panda_status = MetricData(tr_noop("VEHICLE"), tr_noop("ONLINE"), Colors.GOOD)
self._connect_status = MetricData(tr_noop("KONN3KT"), tr_noop("OFFLINE"), Colors.WARNING)
self._recording_audio = False
self._home_img = gui_app.texture("images/button_home.png", HOME_BTN.width, HOME_BTN.height)
self._konn3kt_home_logo = gui_app.texture("icons_mici/settings/konn3kt_icon.png", 104, 104)
self._settings_img = gui_app.texture("icons/iq/tile_settings.png", 92, 92, keep_aspect_ratio=True)
self._mic_img = gui_app.texture("icons_mici/microphone.png", 26, 30, keep_aspect_ratio=True)
self._live_view_img = gui_app.texture("icons/live_view.png", 38, 32, keep_aspect_ratio=True)
self._live_streaming = False
_net_base = "icons_mici/settings/network/"
self._cell_strength_icons = [
gui_app.texture(f"{_net_base}cell_strength_none.png", 56, 56, keep_aspect_ratio=True),
gui_app.texture(f"{_net_base}cell_strength_low.png", 56, 56, keep_aspect_ratio=True),
gui_app.texture(f"{_net_base}cell_strength_low.png", 56, 56, keep_aspect_ratio=True),
gui_app.texture(f"{_net_base}cell_strength_medium.png", 56, 56, keep_aspect_ratio=True),
gui_app.texture(f"{_net_base}cell_strength_high.png", 56, 56, keep_aspect_ratio=True),
gui_app.texture(f"{_net_base}cell_strength_full.png", 56, 56, keep_aspect_ratio=True),
]
self._wifi_strength_icons = [
gui_app.texture(f"{_net_base}wifi_strength_none.png", 64, 64, keep_aspect_ratio=True),
gui_app.texture(f"{_net_base}wifi_strength_low.png", 64, 64, keep_aspect_ratio=True),
gui_app.texture(f"{_net_base}wifi_strength_low.png", 64, 64, keep_aspect_ratio=True),
gui_app.texture(f"{_net_base}wifi_strength_medium.png", 64, 64, keep_aspect_ratio=True),
gui_app.texture(f"{_net_base}wifi_strength_full.png", 64, 64, keep_aspect_ratio=True),
gui_app.texture(f"{_net_base}wifi_strength_full.png", 64, 64, keep_aspect_ratio=True),
]
self._mic_indicator_rect = rl.Rectangle(0, 0, 0, 0)
self._font_regular = gui_app.font(FontWeight.MEDIUM)
self._font_bold = gui_app.font(FontWeight.BOLD)
# Callbacks
self._on_settings_click: Callable | None = None
self._on_flag_click: Callable | None = None
self._open_settings_callback: Callable | None = None
def set_callbacks(self, on_settings: Callable | None = None, on_flag: Callable | None = None,
open_settings: Callable | None = None):
self._on_settings_click = on_settings
self._on_flag_click = on_flag
self._open_settings_callback = open_settings
def _render(self, rect: rl.Rectangle):
# Background
rl.draw_rectangle_rec(rect, rl.BLACK)
self._draw_buttons(rect)
self._draw_network_indicator(rect)
self._draw_metrics(rect)
def _update_state(self):
sm = ui_state.sm
device_state = sm['deviceState']
self._recording_audio = ui_state.recording_audio
# Konn3kt Live View is streaming to a viewer (hephaestusd sets this while a session is live)
self._live_streaming = ui_state.params.get_bool("IsLiveStreaming")
self._update_network_status(device_state)
self._update_temperature_status(device_state)
self._update_connection_status(device_state)
self._update_panda_status()
def _update_network_status(self, device_state):
self._net_type = NETWORK_TYPES.get(device_state.networkType.raw, tr_noop("Unknown"))
strength = device_state.networkStrength
self._net_strength = max(0, min(5, strength.raw + 1)) if strength.raw > 0 else 0
def _update_temperature_status(self, device_state):
thermal_status = device_state.thermalStatus
if thermal_status == ThermalStatus.green:
self._temp_status.update(tr_noop("TEMP"), tr_noop("GOOD"), Colors.GOOD)
elif thermal_status == ThermalStatus.yellow:
self._temp_status.update(tr_noop("TEMP"), tr_noop("OK"), Colors.WARNING)
else:
self._temp_status.update(tr_noop("TEMP"), tr_noop("HIGH"), Colors.DANGER)
def _update_connection_status(self, device_state):
last_ping = device_state.lastAthenaPingTime
if last_ping == 0:
self._connect_status.update(tr_noop("KONN3KT"), tr_noop("OFFLINE"), Colors.WARNING)
elif time.monotonic_ns() - last_ping < 80_000_000_000: # 80 seconds in nanoseconds
self._connect_status.update(tr_noop("KONN3KT"), tr_noop("ONLINE"), Colors.GOOD)
else:
self._connect_status.update(tr_noop("KONN3KT"), tr_noop("ERROR"), Colors.DANGER)
def _update_panda_status(self):
if ui_state.panda_type == log.PandaState.PandaType.unknown:
self._panda_status.update(tr_noop("VEHICLE"), tr_noop("NO PANDA"), Colors.DANGER)
else:
self._panda_status.update(tr_noop("VEHICLE"), tr_noop("ONLINE"), Colors.GOOD)
def _handle_mouse_release(self, mouse_pos: MousePos):
if rl.check_collision_point_rec(mouse_pos, SETTINGS_BTN):
if self._on_settings_click:
self._on_settings_click()
elif rl.check_collision_point_rec(mouse_pos, HOME_BTN) and ui_state.started:
if self._on_flag_click:
self._on_flag_click()
elif self._recording_audio and rl.check_collision_point_rec(mouse_pos, self._mic_indicator_rect):
if self._open_settings_callback:
self._open_settings_callback()
def _draw_buttons(self, rect: rl.Rectangle):
mouse_pos = rl.get_mouse_position()
mouse_down = self.is_pressed and rl.is_mouse_button_down(rl.MouseButton.MOUSE_BUTTON_LEFT)
# Settings button
settings_down = mouse_down and rl.check_collision_point_rec(mouse_pos, SETTINGS_BTN)
rl.draw_rectangle_rounded(SETTINGS_BTN, 0.28, 18, Colors.CARD_BG_PRESSED if settings_down else Colors.CARD_BG)
rl.draw_rectangle_rounded_lines_ex(SETTINGS_BTN, 0.28, 18, 2, rl.Color(255, 255, 255, 28))
tint = Colors.BUTTON_PRESSED if settings_down else Colors.BUTTON_NORMAL
rl.draw_texture(self._settings_img,
int(SETTINGS_BTN.x + (SETTINGS_BTN.width - self._settings_img.width) / 2),
int(SETTINGS_BTN.y + (SETTINGS_BTN.height - self._settings_img.height) / 2),
tint)
# Home/Flag button
flag_pressed = mouse_down and rl.check_collision_point_rec(mouse_pos, HOME_BTN)
if ui_state.started:
center_x = HOME_BTN.x + HOME_BTN.width / 2
center_y = HOME_BTN.y + HOME_BTN.height / 2
radius = min(HOME_BTN.width, HOME_BTN.height) * 0.5
fill = Colors.BOOKMARK_BG_PRESSED if flag_pressed else Colors.BOOKMARK_BG
tint = Colors.BUTTON_PRESSED if flag_pressed else Colors.BUTTON_NORMAL
rl.draw_circle(int(center_x), int(center_y), radius, fill)
self._draw_bookmark_icon(center_x, center_y, tint, fill)
else:
center_x = HOME_BTN.x + HOME_BTN.width / 2
center_y = HOME_BTN.y + HOME_BTN.height / 2
radius = min(HOME_BTN.width, HOME_BTN.height) * 0.47
circle_color = Colors.BUTTON_PRESSED if mouse_down and rl.check_collision_point_rec(mouse_pos, HOME_BTN) else Colors.BUTTON_NORMAL
rl.draw_circle(int(center_x), int(center_y), radius, circle_color)
logo_x = center_x - self._konn3kt_home_logo.width / 2
logo_y = center_y - self._konn3kt_home_logo.height / 2
rl.draw_texture(self._konn3kt_home_logo, int(logo_x), int(logo_y), rl.WHITE)
# Status indicators (right-anchored row): Live View (when streaming to konn3kt) sits to the LEFT
# of the Microphone (when recording audio). Each shows independently. Mic keeps its tap target.
slot_w, slot_h, gap = 70, 38, 12
slot_y = rect.y + 240
row_right = rect.x + rect.width - 36 # right edge of the rightmost slot
indicators = []
if self._live_streaming:
indicators.append(("live", self._live_view_img))
if self._recording_audio:
indicators.append(("mic", self._mic_img))
total_w = len(indicators) * slot_w + max(0, len(indicators) - 1) * gap
slot_x = row_right - total_w
for kind, img in indicators:
slot = rl.Rectangle(slot_x, slot_y, slot_w, slot_h)
icon_x = int(slot.x + (slot_w - img.width) / 2)
icon_y = int(slot.y + (slot_h - img.height) / 2)
if kind == "mic":
self._mic_indicator_rect = slot
mic_pressed = mouse_down and rl.check_collision_point_rec(mouse_pos, slot)
tint = rl.Color(255, 255, 255, 150) if mic_pressed else rl.WHITE
rl.draw_texture(img, icon_x, icon_y, tint)
else:
rl.draw_texture(img, icon_x, icon_y, rl.WHITE)
slot_x += slot_w + gap
def _draw_network_indicator(self, rect: rl.Rectangle):
on_wifi = self._net_type == NETWORK_TYPES[NetworkType.wifi]
net_text = current_ssid(on_wifi) or tr(self._net_type)
icon_list = self._wifi_strength_icons if self._net_type in (NETWORK_TYPES[NetworkType.wifi], NETWORK_TYPES[NetworkType.ethernet]) else self._cell_strength_icons
signal_icon = icon_list[min(self._net_strength, len(icon_list) - 1)]
text_size = measure_text_cached(self._font_regular, net_text, 44)
content_w = signal_icon.width + 14 + text_size.x
icon_x = NETWORK_RECT.x + (NETWORK_RECT.width - content_w) / 2
icon_y = NETWORK_RECT.y + (NETWORK_RECT.height - signal_icon.height) / 2
rl.draw_texture(signal_icon, int(icon_x), int(icon_y), Colors.WHITE)
text_x = icon_x + signal_icon.width + 14
text_y = NETWORK_RECT.y + (NETWORK_RECT.height - text_size.y) / 2
rl.draw_text_ex(self._font_regular, net_text, rl.Vector2(int(text_x), int(text_y)), 44, 0, rl.Color(215, 215, 215, 255))
def _draw_metrics(self, rect: rl.Rectangle):
metric_count = 3
metrics_height = metric_count * METRIC_HEIGHT + (metric_count - 1) * CARD_GAP
available_top = SETTINGS_BTN.y + SETTINGS_BTN.height
available_height = HOME_BTN.y - available_top
first_metric_y = available_top + max(0, (available_height - metrics_height) / 2)
metrics = [
(self._temp_status, first_metric_y),
(self._panda_status, first_metric_y + METRIC_HEIGHT + CARD_GAP),
(self._connect_status, first_metric_y + 2 * (METRIC_HEIGHT + CARD_GAP)),
]
for metric, y_offset in metrics:
self._draw_metric(rect, metric, rect.y + y_offset)
def _draw_metric(self, rect: rl.Rectangle, metric: MetricData, y: float):
metric_rect = rl.Rectangle(rect.x + METRIC_MARGIN, y, METRIC_WIDTH, METRIC_HEIGHT)
rl.draw_rectangle_rounded(metric_rect, 0.28, 16, Colors.CARD_BG)
rl.draw_rectangle_rounded_lines_ex(metric_rect, 0.28, 16, 2, Colors.METRIC_BORDER)
dot_r = 11
label = tr(metric.label)
value = tr(metric.value)
dot_x = metric_rect.x + STATUS_DOT_CENTER_X
text_x = metric_rect.x + STATUS_TEXT_X
text_col_w = metric_rect.width - STATUS_TEXT_X - STATUS_TEXT_RIGHT_MARGIN
label_size = STATUS_LABEL_SIZE
value_size = STATUS_VALUE_SIZE
label_text_size = measure_text_cached(self._font_regular, label, label_size)
value_text_size = measure_text_cached(self._font_bold, value, value_size)
while value_text_size.x > text_col_w and value_size > 30:
value_size -= 2
value_text_size = measure_text_cached(self._font_bold, value, value_size)
text_h = label_text_size.y + value_text_size.y + STATUS_LINE_GAP
label_y = metric_rect.y + (metric_rect.height - text_h) / 2
value_y = label_y + label_text_size.y + STATUS_LINE_GAP
dot_y = label_y + text_h / 2
rl.draw_circle(int(dot_x), int(dot_y), dot_r, metric.color)
rl.begin_scissor_mode(int(text_x), int(metric_rect.y), int(text_col_w), int(metric_rect.height))
rl.draw_text_ex(self._font_regular, label, rl.Vector2(int(text_x), int(label_y)), label_size, 0, rl.Color(185, 185, 190, 255))
rl.draw_text_ex(self._font_bold, value, rl.Vector2(int(text_x), int(value_y)), value_size, 0, Colors.WHITE)
rl.end_scissor_mode()
def _draw_bookmark_icon(self, center_x: float, center_y: float, tint: rl.Color, cutout_color: rl.Color):
icon_w = 78
icon_h = 96
icon_rect = rl.Rectangle(center_x - icon_w / 2, center_y - icon_h / 2, icon_w, icon_h)
rl.draw_rectangle_rounded(icon_rect, 0.18, 14, tint)
notch_top = icon_rect.y + icon_rect.height - 31
notch_left = icon_rect.x + 10
notch_right = icon_rect.x + icon_rect.width - 10
notch_bottom = icon_rect.y + icon_rect.height
rl.draw_triangle(
rl.Vector2(center_x, notch_top),
rl.Vector2(notch_left, notch_bottom),
rl.Vector2(notch_right, notch_bottom),
cutout_color,
)

View File

@@ -0,0 +1,32 @@
import pyray as rl
from collections.abc import Callable
from iqpilot.ui.layouts.settings.drive_history import TripsLayout
from iqpilot.selfdrive.ui.widgets.screen_header import ScreenHeader, HEADER_HEIGHT
from iqpilot.system.ui.lib.multilang import tr
from iqpilot.system.ui.widgets import Widget
MARGIN = 40
SPACING = 25
class StatsLayout(Widget):
"""Offroad Stats screen: drive stats (miles / time / routes) with a back header."""
def __init__(self):
super().__init__()
self._header = self._child(ScreenHeader(lambda: tr("Stats")))
self._trips = self._child(TripsLayout())
def set_on_back(self, cb: Callable[[], None]) -> None:
self._header.set_on_back(cb)
def _render(self, rect: rl.Rectangle):
header_rect = rl.Rectangle(rect.x + MARGIN, rect.y + MARGIN, rect.width - 2 * MARGIN, HEADER_HEIGHT)
self._header.render(header_rect)
content_y = header_rect.y + HEADER_HEIGHT + SPACING
content_rect = rl.Rectangle(
rect.x + MARGIN, content_y, rect.width - 2 * MARGIN, rect.y + rect.height - content_y - MARGIN
)
self._trips.render(content_rect)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,18 @@
import time
from functools import lru_cache
from iqpilot.common.api import Api
from iqpilot.common.time_helpers import system_time_valid
TOKEN_EXPIRY_HOURS = 2
@lru_cache(maxsize=1)
def _get_token(dongle_id: str, t: int):
if not system_time_valid():
raise RuntimeError("System time is not valid, cannot generate token")
return Api(dongle_id).get_token(expiry_hours=TOKEN_EXPIRY_HOURS)
def get_token(dongle_id: str):
return _get_token(dongle_id, int(time.monotonic() / (TOKEN_EXPIRY_HOURS / 2 * 60 * 60)))

View File

@@ -0,0 +1,101 @@
"""Public, konn3kt-agnostic shim to the private cloud route client.
"""
from __future__ import annotations
from functools import cache
UPLOAD_NONE = "none"
UPLOAD_UPLOADING = "uploading"
UPLOAD_UPLOADED = "uploaded"
@cache
def _load_cloud():
try:
from iqpilot.system.proprietary_runtime._verified_import import import_verified_module
return import_verified_module("iqpilot_hephaestusd_private",
"iqpilot_private.konn3kt.hephaestus.cloud_routes")
except Exception:
return None
def cloud_available() -> bool:
return _load_cloud() is not None
def get_dongle_id() -> str | None:
cloud = _load_cloud()
if cloud is None:
return None
try:
return cloud.get_dongle_id()
except Exception:
return None
def list_cloud_routes(dongle_id: str) -> list:
cloud = _load_cloud()
if cloud is None:
return []
try:
return cloud.list_cloud_routes(dongle_id)
except Exception:
return []
def cloud_route_road_segments(dongle_id: str, fullname: str) -> list:
cloud = _load_cloud()
if cloud is None:
return []
try:
return cloud.cloud_route_road_segments(dongle_id, fullname)
except Exception:
return []
def cloud_route_camera_urls(dongle_id: str, fullname: str, camera: str = "road") -> list:
cloud = _load_cloud()
if cloud is None:
return []
try:
return cloud.cloud_route_camera_urls(dongle_id, fullname, camera)
except Exception:
return []
def request_mp4_conversion(dongle_id: str, segment_canonical_name: str, camera: str):
cloud = _load_cloud()
if cloud is None:
return None
try:
return cloud.request_mp4_conversion(dongle_id, segment_canonical_name, camera)
except Exception:
return None
def get_mp4_conversion(dongle_id: str, segment_canonical_name: str, camera: str):
cloud = _load_cloud()
if cloud is None:
return None
try:
return cloud.get_mp4_conversion(dongle_id, segment_canonical_name, camera)
except Exception:
return None
def merge_routes(local_routes: list, cloud_routes: list) -> list:
cloud = _load_cloud()
if cloud is not None:
try:
return cloud.merge_routes(local_routes, cloud_routes)
except Exception:
pass
return [_LocalOnly(local) for local in local_routes]
class _LocalOnly:
def __init__(self, local):
self.name = local.name
self.local = local
self.cloud = None
self.is_local = True
self.is_cloud = False
self.upload_state = UPLOAD_NONE

View File

@@ -0,0 +1,243 @@
from __future__ import annotations
import re
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from pathlib import Path
from iqpilot.system.hardware.hw import Paths
_utc_offset_cache: int | None = None
def utc_offset_hours() -> int:
"""Approximate local UTC offset from the device's last GPS longitude (comma devices run in UTC
with no timezone configured). ~1h imprecise (ignores DST/political borders) but auto and close;
cached for the session."""
global _utc_offset_cache
if _utc_offset_cache is not None:
return _utc_offset_cache
offset = 0
try:
from iqpilot.selfdrive.ui.lib.nav_helpers import current_or_last_gps_position
lat, lon, _, have_fix = current_or_last_gps_position()
if have_fix:
offset = int(round(lon / 15.0)) # solar offset ≈ standard-time zone
# Crude DST: add an hour in the local warm season (northern springautumn, southern inverse).
month = datetime.now(timezone.utc).month
northern_dst = 3 <= month <= 10
if (lat >= 0 and northern_dst) or (lat < 0 and not northern_dst):
offset += 1
offset = max(-12, min(14, offset))
except Exception:
offset = 0
_utc_offset_cache = offset
return offset
def format_local_time(epoch_seconds: float) -> str:
if epoch_seconds <= 0:
return "Recorded route"
dt = datetime.fromtimestamp(epoch_seconds, timezone(timedelta(hours=utc_offset_hours())))
return f"{dt.strftime('%b')} {dt.day} {dt.strftime('%I:%M %p').lstrip('0').lower()}"
# Dongleless on-device segment directory, e.g. "00000051--3141cf1d76--6".
# The stock tools/lib Route + RE parsers require a 16-hex dongle id + '|' delimiter (cloud naming)
# and reject these, which is why the Routes page was empty. We parse them directly instead.
SEGMENT_DIR_RE = re.compile(r"^(?P<route>[0-9a-f]{8}--[0-9a-z]{10})--(?P<seg>\d+)$")
# Logical camera -> on-disk VIDEO file. Order defines the player's selector order.
# Road uses qcamera.ts (H.264, ~1052x660): small enough to software-decode at hundreds of fps and
# it carries the audio track. Wide/Driver are full-res HEVC (hardware-decoded offroad). The
# streaming decoder handles both containers via ffmpeg's concat demuxer.
CAMERA_FILES: dict[str, str] = {
"road": "qcamera.ts",
"wide": "ecamera.hevc",
"driver": "dcamera.hevc",
}
CAMERA_LABELS: dict[str, str] = {
"road": "Road Cam",
"wide": "Wide Cam",
"driver": "Driver Cam",
}
# The road preview (qcamera.ts) is the only file with an audio track, and the smaller
# cloud-streamable road video. It is not a FrameReader source (TS container).
AUDIO_CAMERA_FILE = "qcamera.ts"
NOMINAL_SEGMENT_SECONDS = 60.0
@dataclass(frozen=True)
class LocalRouteInfo:
name: str
label: str
subtitle: str
segment_count: int
cameras: tuple[str, ...]
modified_at: float
duration_s: float
distance_miles: float | None = None
def _scan_segments(root: Path) -> dict[str, dict[int, Path]]:
"""Group dongleless segment dirs under `root` by route id -> {segment_num: dir}."""
routes: dict[str, dict[int, Path]] = {}
if not root.exists():
return routes
for child in root.iterdir():
try:
if not child.is_dir():
continue
except OSError:
continue
m = SEGMENT_DIR_RE.match(child.name)
if m is None:
continue
routes.setdefault(m.group("route"), {})[int(m.group("seg"))] = child
return routes
def _cameras_present(seg_dir: Path) -> tuple[str, ...]:
present = []
for cam, filename in CAMERA_FILES.items():
try:
if (seg_dir / filename).exists():
present.append(cam)
except OSError:
continue
return tuple(present)
def _format_route_time(ts: float) -> str:
return format_local_time(ts)
def _format_duration(seconds: float) -> str:
s = max(0, int(round(seconds)))
h, rem = divmod(s, 3600)
m, sec = divmod(rem, 60)
if h:
return f"{h}h {m:02d}m"
return f"{m}:{sec:02d}"
def local_route_camera_paths(route_name: str, camera: str = "road", log_root: str | Path | None = None) -> list[str]:
"""Ordered per-segment file paths for one camera of a local route (for FrameReader)."""
root = Path(log_root or Paths.log_root())
segments = _scan_segments(root).get(route_name, {})
filename = CAMERA_FILES.get(camera, CAMERA_FILES["road"])
paths: list[str] = []
for seg_num in sorted(segments):
path = segments[seg_num] / filename
try:
if path.exists():
paths.append(path.as_posix())
except OSError:
continue
return paths
def local_route_audio_paths(route_name: str, log_root: str | Path | None = None) -> list[str]:
"""Ordered per-segment qcamera.ts paths (the only files with an audio track)."""
root = Path(log_root or Paths.log_root())
segments = _scan_segments(root).get(route_name, {})
paths: list[str] = []
for seg_num in sorted(segments):
path = segments[seg_num] / AUDIO_CAMERA_FILE
try:
if path.exists():
paths.append(path.as_posix())
except OSError:
continue
return paths
def local_route_qlog_paths(route_name: str, log_root: str | Path | None = None) -> list[str]:
"""Ordered per-segment qlog paths for a local route."""
root = Path(log_root or Paths.log_root())
segments = _scan_segments(root).get(route_name, {})
paths: list[str] = []
for seg_num in sorted(segments):
for name in ("qlog.zst", "qlog"):
path = segments[seg_num] / name
try:
if path.exists():
paths.append(path.as_posix())
break
except OSError:
continue
return paths
def compute_route_distance_miles(route_name: str, log_root: str | Path | None = None) -> float:
"""Total driven distance (miles) by integrating carState.vEgo over the route's qlogs.
Uses vEgo (not GPS) so it still works on cars with broken GPS. Decimated qlog cadence is
plenty for a distance total. This reads every segment's qlog, so callers should run it off the
UI thread."""
from iqpilot.tools.lib.logreader import LogReader
total_m = 0.0
for qlog_path in local_route_qlog_paths(route_name, log_root):
last_t: float | None = None
try:
for msg in LogReader(qlog_path):
if msg.which() != "carState":
continue
t = msg.logMonoTime * 1e-9
v = float(msg.carState.vEgo)
# Guard against segment boundaries / gaps: only integrate contiguous samples.
if last_t is not None and 0.0 < t - last_t < 1.0:
total_m += v * (t - last_t)
last_t = t
except Exception:
continue
return total_m * 0.000621371
def get_local_route(route_name: str, log_root: str | Path | None = None) -> LocalRouteInfo | None:
root = Path(log_root or Paths.log_root())
segments = _scan_segments(root).get(route_name)
if not segments:
return None
return _build_info(route_name, segments)
def _build_info(route_name: str, segments: dict[int, Path]) -> LocalRouteInfo:
seg_nums = sorted(segments)
mtimes = []
for seg_num in seg_nums:
try:
mtimes.append(segments[seg_num].stat().st_mtime)
except OSError:
pass
modified_at = max(mtimes) if mtimes else 0.0
started_at = min(mtimes) if mtimes else 0.0
# Cameras available anywhere in the route (union across segments).
cameras: list[str] = []
for cam in CAMERA_FILES:
if any((segments[s] / CAMERA_FILES[cam]).exists() for s in seg_nums):
cameras.append(cam)
segment_count = len(seg_nums)
duration_s = segment_count * NOMINAL_SEGMENT_SECONDS
cam_names = ", ".join(CAMERA_LABELS[c] for c in cameras) if cameras else "no cameras"
subtitle = f"{_format_duration(duration_s)} · {cam_names}"
return LocalRouteInfo(
name=route_name,
label=_format_route_time(started_at),
subtitle=subtitle,
segment_count=segment_count,
cameras=tuple(cameras),
modified_at=modified_at,
duration_s=duration_s,
)
def list_local_routes(log_root: str | Path | None = None, limit: int = 100) -> list[LocalRouteInfo]:
root = Path(log_root or Paths.log_root())
routes = _scan_segments(root)
infos = [_build_info(name, segs) for name, segs in routes.items()]
infos.sort(key=lambda info: info.modified_at, reverse=True)
return infos[:limit]

View File

@@ -0,0 +1,35 @@
from __future__ import annotations
from collections.abc import Iterable
FALLBACK_MOTDS = ("Drive safely. Stay focused.",)
_BUNDLE_NAME = "iqpilot_hephaestusd_private"
_MODULE_NAME = "iqpilot_private.konn3kt.hephaestus.motd"
def _dongle_id() -> str | None:
try:
from iqpilot.common.params import Params
return Params().get("DongleId", encoding="utf-8")
except Exception:
return None
def _clean_messages(messages: object) -> list[str]:
if not isinstance(messages, Iterable) or isinstance(messages, (str, bytes)):
return []
return [message.strip() for message in messages if isinstance(message, str) and message.strip()]
def load_motds(dongle_id: str | None = None) -> list[str]:
try:
from iqpilot.system.proprietary_runtime._verified_import import import_verified_module
module = import_verified_module(_BUNDLE_NAME, _MODULE_NAME)
messages = module.messages_for_dongle(_dongle_id() if dongle_id is None else dongle_id)
cleaned = _clean_messages(messages)
if cleaned:
return cleaned
except Exception:
pass
return list(FALLBACK_MOTDS)

View File

@@ -0,0 +1,151 @@
import json
import platform
from pathlib import Path
from iqpilot.common.params import Params
_MAPBOX_DEFAULT_HELPER_UNAVAILABLE = False
_GPS_SERVICES = ("gpsLocationExternal", "gpsLocation")
_POSITION_PARAM_KEYS = ("LastGPSPosition", "LastGPSPositionIQLoc")
def _decode_param(value) -> str:
if isinstance(value, bytes):
return value.decode("utf-8", errors="ignore").strip()
if isinstance(value, str):
return value.strip()
return ""
def resolve_mapbox_token(params: Params | None = None) -> str:
global _MAPBOX_DEFAULT_HELPER_UNAVAILABLE
params = params or Params()
token = _decode_param(params.get("MapboxToken"))
if token:
return token
if _MAPBOX_DEFAULT_HELPER_UNAVAILABLE:
return ""
try:
from iqpilot.system.proprietary_runtime._verified_import import import_verified_module
runtime_common = import_verified_module("iqpilot_navd_private", "iqpilot_private.navd.runtime_common")
except Exception:
_MAPBOX_DEFAULT_HELPER_UNAVAILABLE = True
return ""
for args in ((params,), ()):
try:
token = _decode_param(runtime_common.ensure_default_mapbox_token(*args))
except TypeError:
continue
except Exception:
token = ""
if not token:
token = _decode_param(params.get("MapboxToken"))
if token:
return token
return _decode_param(params.get("MapboxToken"))
def has_mapbox_token(params: Params | None = None) -> bool:
return bool(resolve_mapbox_token(params))
def _valid_lat_lon(lat: float, lon: float) -> bool:
return abs(lat) <= 90.0 and abs(lon) <= 180.0 and (abs(lat) > 1e-4 or abs(lon) > 1e-4)
def _float_field(data: dict, *names: str) -> float:
for name in names:
if name in data:
return float(data.get(name) or 0.0)
return 0.0
def _position_from_json(raw) -> tuple[float, float, float, bool]:
text = _decode_param(raw)
if not text:
return 0.0, 0.0, 0.0, False
try:
data = json.loads(text)
if not isinstance(data, dict):
return 0.0, 0.0, 0.0, False
lat = _float_field(data, "latitude", "lat")
lon = _float_field(data, "longitude", "lon", "lng")
if _valid_lat_lon(lat, lon):
return lat, lon, _float_field(data, "bearing", "bearingDeg"), True
except (TypeError, ValueError, json.JSONDecodeError):
pass
return 0.0, 0.0, 0.0, False
def _position_from_msg(msg, lat_name: str = "latitude", lon_name: str = "longitude",
bearing_name: str = "bearingDeg") -> tuple[float, float, float, bool]:
try:
lat = float(getattr(msg, lat_name, 0.0))
lon = float(getattr(msg, lon_name, 0.0))
if _valid_lat_lon(lat, lon):
return lat, lon, float(getattr(msg, bearing_name, 0.0)), True
except Exception:
pass
return 0.0, 0.0, 0.0, False
def _position_from_params(params: Params) -> tuple[float, float, float, bool]:
for key in _POSITION_PARAM_KEYS:
lat, lon, bearing, valid = _position_from_json(params.get(key))
if valid:
return lat, lon, bearing, True
return 0.0, 0.0, 0.0, False
def current_or_last_gps_position(params: Params | None = None) -> tuple[float, float, float, bool]:
# ui_state is imported lazily AND guarded: night-mode init constructs the ui_state singleton,
# which calls in here before the module finishes importing. In that window the import raises
# (partially initialized module) — fall back to the params path (Night Mode passes self.params),
# since there's no live GPS during boot anyway.
try:
from iqpilot.selfdrive.ui.ui_state import ui_state
except ImportError:
ui_state = None
if ui_state is not None:
for service in _GPS_SERVICES:
try:
lat, lon, bearing, valid = _position_from_msg(ui_state.sm[service])
if valid:
return lat, lon, bearing, True
except Exception:
pass
try:
lat, lon, bearing, valid = _position_from_msg(
ui_state.sm["iqNavRenderState"],
lat_name="currentLatitude",
lon_name="currentLongitude",
bearing_name="bearingDeg",
)
if valid:
return lat, lon, bearing, True
except Exception:
pass
explicit_params = params is not None
params = params or (ui_state.params if ui_state is not None else Params())
lat, lon, bearing, valid = _position_from_params(params)
if valid:
return lat, lon, bearing, True
if not explicit_params and platform.system() != "Darwin" and Path("/dev/shm/params/d").exists():
try:
lat, lon, bearing, valid = _position_from_params(Params("/dev/shm/params"))
if valid:
return lat, lon, bearing, True
except Exception:
pass
return 0.0, 0.0, 0.0, False

View File

@@ -0,0 +1,319 @@
"""Destination search + persistence for the offroad Navigate screen.
Uses Mapbox's Search Box API (the same public Mapbox service the nav map preview already calls) for
POI-and-address autocomplete biased to the device's location — so "Walmart" returns the nearest
Walmart store, not a street named Walmart, and partial addresses complete as you type. Selecting a
result writes NavigationDestination, which navd picks up to build the route.
Home/Work/Recents live in a small JSON file on /data (persistent, no new param key needed).
"""
from __future__ import annotations
import json
import threading
import uuid
from dataclasses import dataclass
from types import SimpleNamespace
import requests
from iqpilot.common.params import Params
from iqpilot.common.swaglog import cloudlog
from iqpilot.selfdrive.ui.lib.nav_helpers import resolve_mapbox_token, current_or_last_gps_position
SEARCHBOX = "https://api.mapbox.com/search/searchbox/v1"
FAVORITES_PATH = "/data/nav_favorites.json"
MAX_RESULTS = 6
MAX_RECENTS = 8
def _load_amap_client():
try:
from iqpilot.system.proprietary_runtime._verified_import import import_verified_module
return import_verified_module("iqpilot_navd_private", "iqpilot_private.navd.amap_client")
except Exception:
return None
_amap_client = _load_amap_client()
@dataclass
class SearchResult:
name: str
address: str
mapbox_id: str = ""
distance_m: float | None = None
lat: float | None = None
lon: float | None = None
provider: str = "mapbox"
@property
def has_coords(self) -> bool:
return self.lat is not None and self.lon is not None
class NavSearch:
"""Threaded, debounced Search Box autocomplete. The UI calls search() as the user types and reads
results()/searching each frame; a stale query's results are dropped so only the latest shows."""
def __init__(self):
self._params = Params()
self._session = str(uuid.uuid4())
self._lock = threading.Lock()
self._results: list[SearchResult] = []
self._seq = 0
self._last_query = ""
self._searching = False
self._amap_adcode = ""
def new_session(self) -> None:
# A Search Box "session" groups suggest+retrieve for billing; start one per search visit.
self._session = str(uuid.uuid4())
with self._lock:
self._results = []
self._last_query = ""
self._amap_adcode = ""
def results(self) -> list[SearchResult]:
with self._lock:
return list(self._results)
@property
def searching(self) -> bool:
return self._searching
def search(self, query: str) -> None:
query = query.strip()
if query == self._last_query:
return
self._last_query = query
self._seq += 1
seq = self._seq
if len(query) < 2:
with self._lock:
self._results = []
self._searching = False
return
self._searching = True
threading.Thread(target=self._do_search, args=(query, seq), daemon=True).start()
def _do_search(self, query: str, seq: int) -> None:
try:
lat, lon, _, fix = current_or_last_gps_position(self._params)
position = SimpleNamespace(latitude=lat, longitude=lon) if fix else None
use_amap = _amap_client is not None and _amap_client.is_mainland_china_configured(self._params, position)
if use_amap:
key = _amap_client.get_key(self._params)
if not self._amap_adcode and position is not None:
self._amap_adcode = _amap_client.reverse_adcode(position, key)
amap_results = _amap_client.autocomplete(
query,
key,
city=self._amap_adcode,
position=position,
)
try:
self._params.put("AmapStatus", _amap_client.status())
except Exception:
pass
results = [
SearchResult(
name=item.name,
address=item.address,
mapbox_id=item.provider_id,
lat=item.latitude,
lon=item.longitude,
provider="amap",
)
for item in amap_results[:MAX_RESULTS]
]
if seq == self._seq:
with self._lock:
self._results = results
return
token = resolve_mapbox_token(self._params)
params = {"q": query, "access_token": token, "session_token": self._session,
"limit": MAX_RESULTS, "language": "en"}
if fix:
params["proximity"] = f"{lon},{lat}"
resp = requests.get(f"{SEARCHBOX}/suggest", params=params, timeout=8)
resp.raise_for_status()
results = []
for s in resp.json().get("suggestions", []):
mid = s.get("mapbox_id")
addr = s.get("full_address") or s.get("place_formatted") or ""
# Skip brand/category refinement rows (e.g. "Walmart · Brand") — not a single routable place.
if not mid or not addr or s.get("feature_type") in ("category", "brand"):
continue
results.append(SearchResult(name=s.get("name", ""), address=addr, mapbox_id=mid,
distance_m=s.get("distance")))
if seq == self._seq:
with self._lock:
self._results = results
except Exception as e:
cloudlog.event("nav_search.suggest_failed", error=str(e))
if seq == self._seq:
with self._lock:
self._results = []
finally:
if seq == self._seq:
self._searching = False
def retrieve(self, result: SearchResult) -> SearchResult | None:
"""Resolve a suggestion's coordinates (Search Box suggest omits them by design)."""
if result.has_coords:
return result
try:
if result.provider == "amap":
if _amap_client is None:
return None
item = _amap_client.place_detail(result.mapbox_id, _amap_client.get_key(self._params))
try:
self._params.put("AmapStatus", _amap_client.status())
except Exception:
pass
if item is None or item.latitude is None or item.longitude is None:
return None
result.lat, result.lon = item.latitude, item.longitude
result.name = item.name or result.name
result.address = item.address or result.address
return result
token = resolve_mapbox_token(self._params)
resp = requests.get(f"{SEARCHBOX}/retrieve/{result.mapbox_id}",
params={"access_token": token, "session_token": self._session}, timeout=8)
resp.raise_for_status()
feats = resp.json().get("features", [])
if not feats:
return None
coords = feats[0]["geometry"]["coordinates"]
props = feats[0].get("properties", {})
result.lon, result.lat = float(coords[0]), float(coords[1])
result.name = props.get("name") or result.name
result.address = props.get("full_address") or result.address
return result
except Exception as e:
cloudlog.event("nav_search.retrieve_failed", error=str(e))
return None
# --- destination + favorites persistence -------------------------------------------------------
def set_destination(lat: float, lon: float, name: str) -> None:
"""Hand a destination to navd (it routes off NavigationDestination). Mirrors hephaestusd's
setNavDestination so a fresh route is always recomputed."""
params = Params()
params.remove("AthenaNavigationRoute")
params.put_bool("NavigationActive", False)
# NavigationDestination is a JSON-typed param: pass the object, not a pre-serialized string.
params.put("NavigationDestination", {"latitude": float(lat), "longitude": float(lon), "name": name or ""})
def cancel_navigation() -> None:
"""Clear the active route/destination so navd stops navigating."""
params = Params()
params.remove("NavigationDestination")
params.remove("AthenaNavigationRoute")
params.put_bool("NavigationActive", False)
def has_active_destination() -> bool:
try:
return bool(Params().get("NavigationDestination"))
except Exception:
return False
def _load_favorites() -> dict:
try:
with open(FAVORITES_PATH) as f:
data = json.load(f)
return data if isinstance(data, dict) else {}
except Exception:
return {}
def _save_favorites(data: dict) -> None:
try:
tmp = FAVORITES_PATH + ".tmp"
with open(tmp, "w") as f:
json.dump(data, f)
import os
os.replace(tmp, FAVORITES_PATH)
except Exception as e:
cloudlog.event("nav_search.save_favorites_failed", error=str(e))
def _place_to_result(place: dict | None) -> SearchResult | None:
if not place:
return None
try:
return SearchResult(name=place.get("name", ""), address=place.get("address", ""),
lat=float(place["lat"]), lon=float(place["lon"]))
except Exception:
return None
def get_home() -> SearchResult | None:
return _place_to_result(_load_favorites().get("home"))
def get_work() -> SearchResult | None:
return _place_to_result(_load_favorites().get("work"))
def _place_dict(r: SearchResult) -> dict:
return {"name": r.name, "address": r.address, "lat": r.lat, "lon": r.lon}
def save_home(r: SearchResult) -> None:
data = _load_favorites()
data["home"] = _place_dict(r)
_save_favorites(data)
def save_work(r: SearchResult) -> None:
data = _load_favorites()
data["work"] = _place_dict(r)
_save_favorites(data)
def remove_home() -> None:
data = _load_favorites()
data.pop("home", None)
_save_favorites(data)
def remove_work() -> None:
data = _load_favorites()
data.pop("work", None)
_save_favorites(data)
def remove_recent(r: SearchResult) -> None:
data = _load_favorites()
data["recents"] = [p for p in data.get("recents", [])
if not (abs(p.get("lat", 0) - (r.lat or 0)) < 1e-5 and abs(p.get("lon", 0) - (r.lon or 0)) < 1e-5)]
_save_favorites(data)
def get_recents() -> list[SearchResult]:
out = []
for p in _load_favorites().get("recents", []):
r = _place_to_result(p)
if r is not None:
out.append(r)
return out
def add_recent(r: SearchResult) -> None:
if not r.has_coords:
return
data = _load_favorites()
recents = [p for p in data.get("recents", [])
if not (abs(p.get("lat", 0) - r.lat) < 1e-5 and abs(p.get("lon", 0) - r.lon) < 1e-5)]
recents.insert(0, _place_dict(r))
data["recents"] = recents[:MAX_RECENTS]
_save_favorites(data)

View File

@@ -0,0 +1,144 @@
from enum import IntEnum
import os
import threading
import time
from pathlib import Path
from iqpilot.common.params import Params
from iqpilot.common.swaglog import cloudlog
from iqpilot.common.time_helpers import system_time_valid
from iqpilot.konn3kt.cloud_client import Konn3ktApi
from iqpilot.konn3kt.registration import UNREGISTERED_DONGLE_ID, get_cached_dongle_id, ensure_dev_pairing_identity
from iqpilot.system.hardware import PC
from iqpilot.system.hardware.hw import Paths
class PairState(IntEnum):
UNKNOWN = -2
UNPAIRED = -1
PAIRED = 0
class PrimeState:
FETCH_INTERVAL = 5.0 # seconds between konn3kt pairing checks
API_TIMEOUT = 10.0 # seconds for konn3kt API requests
SLEEP_INTERVAL = 0.5 # seconds to sleep between checks in the worker thread
def __init__(self):
self._params = Params()
self._lock = threading.Lock()
# Must be computed at runtime (OPENPILOT_PREFIX can change paths).
# Keep a writable fallback in /tmp in case /persist becomes read-only.
self._konn3kt_state_paths = [
Path(Paths.persist_root()) / "comma" / "konn3kt_prime_type",
Path(Paths.config_root()) / "konn3kt_prime_type",
]
if PC and os.getenv("KONN3KT_DEV_PAIRING") == "1":
try:
ensure_dev_pairing_identity(self._params, force_reset=os.getenv("KONN3KT_DEV_PAIRING_RESET") == "1")
self._write_cached_state(PairState.UNPAIRED)
except Exception as e:
cloudlog.error(f"dev pairing identity setup failed: {e}")
self.pair_state: PairState = self._load_initial_state()
self._running = False
self._thread = None
def _write_cached_state(self, pair_state: PairState) -> None:
payload = str(int(pair_state))
for path in self._konn3kt_state_paths:
try:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(payload)
return
except OSError:
continue
except Exception:
cloudlog.exception("failed to write konn3kt pairing cache")
return
cloudlog.warning("failed to write konn3kt pairing cache to any path")
def _coerce(self, value: int | None) -> PairState:
if value is None:
return PairState.UNKNOWN
if value >= 0:
return PairState.PAIRED
if value == PairState.UNPAIRED:
return PairState.UNPAIRED
return PairState.UNKNOWN
def _load_initial_state(self) -> PairState:
env_val = os.getenv("PRIME_TYPE")
if env_val is not None:
try:
return self._coerce(int(env_val))
except (ValueError, TypeError):
pass
for path in self._konn3kt_state_paths:
try:
if path.is_file():
return self._coerce(int(path.read_text().strip()))
except Exception:
cloudlog.exception("failed to read konn3kt pairing cache")
return PairState.UNKNOWN
def _refresh_pair_status(self) -> None:
dongle_id = get_cached_dongle_id(self._params, prefer_readonly=True)
if not dongle_id or dongle_id == UNREGISTERED_DONGLE_ID:
return
# the JWT can't be minted until the clock is NTP-synced; at boot skip
# quietly instead of error-spamming every retry
if not system_time_valid():
return
try:
api = Konn3ktApi(dongle_id)
resp = api.get(f"v1.1/devices/{dongle_id}", timeout=self.API_TIMEOUT, access_token=api.get_token())
if resp.status_code == 200:
paired = bool(resp.json().get("is_paired", False))
self.set_paired(paired)
elif resp.status_code == 404:
self.set_paired(False)
except Exception as e:
cloudlog.error(f"failed to fetch konn3kt pairing status: {e}")
def set_paired(self, paired: bool) -> None:
new_state = PairState.PAIRED if paired else PairState.UNPAIRED
with self._lock:
if new_state != self.pair_state:
self.pair_state = new_state
self._write_cached_state(new_state)
cloudlog.info(f"konn3kt pairing updated to {new_state}")
def _worker_thread(self) -> None:
from iqpilot.selfdrive.ui.ui_state import ui_state, device
while self._running:
if not ui_state.started and device._awake:
self._refresh_pair_status()
for _ in range(int(self.FETCH_INTERVAL / self.SLEEP_INTERVAL)):
if not self._running:
break
time.sleep(self.SLEEP_INTERVAL)
def start(self) -> None:
if self._thread and self._thread.is_alive():
return
self._running = True
self._thread = threading.Thread(target=self._worker_thread, daemon=True)
self._thread.start()
def stop(self) -> None:
self._running = False
if self._thread and self._thread.is_alive():
self._thread.join(timeout=1.0)
def is_paired(self) -> bool:
with self._lock:
return self.pair_state > PairState.UNPAIRED
def __del__(self):
self.stop()

View File

@@ -0,0 +1,36 @@
import math
from datetime import UTC, datetime
_J2000 = datetime(2000, 1, 1, 12, 0, 0, tzinfo=UTC)
SUNSET_ELEVATION_DEG = -0.833 # standard sunset/sunrise threshold, corrected for atmospheric refraction
def sun_elevation_deg(lat: float, lon: float, dt_utc: datetime | None = None) -> float:
"""Low-precision solar elevation angle (accurate to well under a degree), per the
standard Meeus-derived approximation. lat/lon in degrees (lon positive east)."""
dt_utc = dt_utc or datetime.now(UTC)
n = (dt_utc - _J2000).total_seconds() / 86400.0
mean_lon = math.radians((280.460 + 0.9856474 * n) % 360)
mean_anomaly = math.radians((357.528 + 0.9856003 * n) % 360)
ecliptic_lon = (mean_lon + math.radians(1.915) * math.sin(mean_anomaly)
+ math.radians(0.020) * math.sin(2 * mean_anomaly))
obliquity = math.radians(23.439 - 0.0000004 * n)
declination = math.asin(math.sin(obliquity) * math.sin(ecliptic_lon))
right_ascension = math.atan2(math.cos(obliquity) * math.sin(ecliptic_lon), math.cos(ecliptic_lon))
equation_of_time_deg = math.degrees(mean_lon - right_ascension)
equation_of_time_deg = (equation_of_time_deg + 180) % 360 - 180
utc_hours = dt_utc.hour + dt_utc.minute / 60 + dt_utc.second / 3600
hour_angle = math.radians(15 * (utc_hours - 12) + lon + equation_of_time_deg)
lat_rad = math.radians(lat)
elevation = math.asin(math.sin(lat_rad) * math.sin(declination)
+ math.cos(lat_rad) * math.cos(declination) * math.cos(hour_angle))
return math.degrees(elevation)
def is_after_sunset(lat: float, lon: float, dt_utc: datetime | None = None) -> bool:
return sun_elevation_deg(lat, lon, dt_utc) < SUNSET_ELEVATION_DEG

View File

@@ -0,0 +1,55 @@
import time
import threading
import subprocess
# Shared, throttled current-Wi-Fi-SSID lookup for the status bars (home pill + onroad sidebar).
# The SSID isn't in deviceState, so we shell out to NetworkManager on a background thread and cache
# the result; the render thread only ever reads the cached string.
_ssid = ""
_last_fetch = 0.0
_fetching = False
_lock = threading.Lock()
REFRESH_SECONDS = 10.0
def _read_ssid() -> str:
try:
out = subprocess.run(["nmcli", "-t", "-f", "active,ssid", "dev", "wifi"],
capture_output=True, text=True, timeout=4).stdout
for line in out.splitlines():
if line.startswith("yes:"):
name = line.split(":", 1)[1].strip()
if name:
return name
except Exception:
pass
try:
name = subprocess.run(["iwgetid", "-r"], capture_output=True, text=True, timeout=4).stdout.strip()
if name:
return name
except Exception:
pass
return ""
def _fetch() -> None:
global _ssid, _last_fetch, _fetching
name = _read_ssid()
with _lock:
_ssid = name
_last_fetch = time.monotonic()
_fetching = False
def current_ssid(on_wifi: bool) -> str:
"""Return the connected SSID (or "" if unknown / not on Wi-Fi). Kicks a throttled background
refresh; never blocks the caller."""
global _fetching
if not on_wifi:
return ""
with _lock:
if not _fetching and time.monotonic() - _last_fetch > REFRESH_SECONDS:
_fetching = True
threading.Thread(target=_fetch, daemon=True).start()
return _ssid

View File

@@ -0,0 +1,422 @@
"""
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._egpu_txt = gui_app.texture("icons_mici/egpu.png", 62, 46)
self._egpu_green_txt = gui_app.texture("icons_mici/egpu_green.png", 62, 46)
self._egpu_orange_txt = gui_app.texture("icons_mici/egpu_orange.png", 78, 46)
self._mac_txt = gui_app.texture("icons_mici/mac.png", 62, 46)
self._mac_green_txt = gui_app.texture("icons_mici/mac_green.png", 62, 46)
self._mac_orange_txt = gui_app.texture("icons_mici/mac_orange.png", 78, 46)
self._egpu_state: str | None = None
self._mac_state: str | None = None
self._egpu_progress = 0.0
self._mac_progress = 0.0
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()
self._update_dock_status()
def _update_dock_status(self):
p = ui_state.params
egpu_present = bool(getattr(ui_state.sm['deviceState'], "egpuDockPresent", False))
if not egpu_present:
self._egpu_state = None
elif any(p.get_bool(k) for k in ("Offroad_EgpuPcieUnavailable", "Offroad_EgpuOverheated",
"Offroad_EgpuFansObstructed", "Offroad_EgpuUpdateFailed",
"Offroad_EgpuNotDetected")):
self._egpu_state = "orange"
elif p.get_bool("UsbGpuLoading") and not p.get_bool("UsbGpuCompiled"):
self._egpu_state = "compiling"
try:
self._egpu_progress = max(0.0, min(1.0, float(p.get("UsbGpuSetupProgress") or 0.0)))
except (TypeError, ValueError):
self._egpu_progress = 0.0
elif p.get_bool("Offroad_EgpuUsbSlow") or p.get_bool("Offroad_EgpuUncompiled"):
self._egpu_state = "grey"
else:
self._egpu_state = "green"
mac_present = p.get_bool("MacModelPresent") or p.get_bool("MacModelReachable")
if not mac_present:
self._mac_state = None
elif p.get_bool("MacModelFault"):
self._mac_state = "orange"
elif p.get_bool("MacModelReady") or p.get_bool("MacModelActive"):
self._mac_state = "green"
else:
self._mac_state = "grey"
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(" / ")
# version/date/branch share one 536px line: the brand prefix and clock time push the branch off screen
return version.removeprefix(HOME_TITLE_TEXT).strip() or version, branch, commit, date.split(" ")[0]
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
for state, base, green, orange, progress in (
(self._egpu_state, self._egpu_txt, self._egpu_green_txt, self._egpu_orange_txt, self._egpu_progress),
(self._mac_state, self._mac_txt, self._mac_green_txt, self._mac_orange_txt, self._mac_progress)):
if state is None:
continue
y_top = int(self._rect.y + self.rect.height - base.height / 2 - Y_CENTER)
if state == "compiling":
self._draw_compile_gauge(int(last_x), y_top, base, green, progress)
last_x += base.width + ITEM_SPACING
continue
if state == "green":
tex, tint = green, rl.Color(255, 255, 255, 255)
elif state == "orange":
tex, tint = orange, rl.Color(255, 255, 255, 255)
else:
tex, tint = base, rl.Color(165, 165, 170, 235)
rl.draw_texture(tex, int(last_x), y_top, tint)
last_x += tex.width + ITEM_SPACING
def _draw_compile_gauge(self, x: int, y_top: int, base, fill, progress: float):
rl.draw_texture(base, x, y_top, rl.Color(165, 165, 170, 235))
fill_h = int(base.height * max(0.0, min(1.0, progress)))
if fill_h > 0:
rl.begin_scissor_mode(x, y_top + base.height - fill_h, base.width, fill_h)
rl.draw_texture(fill, x, y_top, rl.Color(255, 255, 255, 255))
rl.end_scissor_mode()
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,101 @@
"""
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 FollowDistanceSelector, 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
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._follow_dist = FollowDistanceSelector()
self._mode = IQModeSelector(self._follow_dist.refresh)
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._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,278 @@
"""
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 ForceOffroadButton(BigButton):
def __init__(self):
self._offroad_icon = gui_app.texture("icons/iq/square-parking.png", 64, 64)
super().__init__(tr("force\noffroad"), "", self._offroad_icon)
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._sync_from_params()
def _forced(self) -> bool:
return ui_state.params.get_bool("IQAlwaysOffroad")
def _on_click(self):
forced = self._forced()
action = tr("disable force offroad") if forced else tr("force offroad")
_engaged_confirmation_click(lambda: ui_state.params.put_bool("IQAlwaysOffroad", not forced),
action, self._offroad_icon, exit_on_confirm=False)
def _sync_from_params(self):
self.set_text(tr("disable\nforce offroad") if self._forced() else tr("force\noffroad"))
def _update_state(self):
super()._update_state()
self._sync_from_params()
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,
ForceOffroadButton(),
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,189 @@
"""
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.longitudinal_settings import (
LONGITUDINAL_MODE_DYNAMIC,
LONGITUDINAL_MODE_PILOT,
LONGITUDINAL_MODE_STOCK,
PERSONALITY_VALUES,
apply_longitudinal_mode,
get_follow_distance_state,
get_longitudinal_mode,
longitudinal_mode_needs_cycle,
next_longitudinal_mode,
set_valid_personality,
)
from iqpilot.selfdrive.ui.mici.widgets.stock_button import BigButton, BigMultiToggle, BigToggle, BigParamControl
from iqpilot.selfdrive.ui.ui_state import ui_state
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 FollowDistanceSelector(BigMultiToggle):
OPTIONS = ["aggressive", "standard", "relaxed", "stock"]
def __init__(self):
self._display_options = [tr(option) for option in self.OPTIONS]
super().__init__(tr("Follow Distance"), self._display_options)
self._params = Params()
self.refresh()
def refresh(self):
selection, enabled = get_follow_distance_state(self._params)
self.set_value(self._display_options[3 if selection is None else selection])
self.set_enabled(enabled)
def _handle_mouse_release(self, mouse_pos):
if get_longitudinal_mode(self._params) not in (LONGITUDINAL_MODE_DYNAMIC, LONGITUDINAL_MODE_PILOT):
return
BigButton._handle_mouse_release(self, mouse_pos)
selection, _ = get_follow_distance_state(self._params)
if selection is None:
self.refresh()
return
next_selection = PERSONALITY_VALUES[(PERSONALITY_VALUES.index(selection) + 1) % len(PERSONALITY_VALUES)]
set_valid_personality(self._params, next_selection)
self.set_value(self._display_options[next_selection])
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"]
def __init__(self, mode_callback=None):
self._display_options = [tr(option) for option in self.OPTIONS]
super().__init__(tr("IQ Mode"), self._display_options)
self._params = Params()
self._mode_callback = mode_callback
self._mode = LONGITUDINAL_MODE_STOCK
self._iq_modes_available = False
self.refresh()
self.set_enabled(lambda: self._next() != self._mode)
def _index(self) -> int:
return get_longitudinal_mode(self._params)
def is_dynamic(self) -> bool:
return self._mode == LONGITUDINAL_MODE_DYNAMIC
def _toyota_factory_long_forced(self) -> bool:
cp = ui_state.CP
return bool(cp is not None and cp.brand == "toyota" and self._params.get_bool("IQToyotaFactoryLong"))
def _read_iq_modes_available(self) -> bool:
cp = ui_state.CP
alpha_available = bool(cp is not None and cp.alphaLongitudinalAvailable)
return alpha_available or self._params.get_bool("AlphaLongitudinalEnabled") or self._toyota_factory_long_forced()
def _next(self) -> int:
return next_longitudinal_mode(self._mode, ui_state.is_onroad(), self._iq_modes_available)
def refresh(self):
self._mode = self._index()
self._iq_modes_available = self._read_iq_modes_available()
self.set_value(self._display_options[self._mode])
def _apply(self, idx: int):
previous = self._mode
toyota_forced = self._toyota_factory_long_forced()
apply_longitudinal_mode(self._params, idx)
if idx != LONGITUDINAL_MODE_STOCK and toyota_forced:
self._params.put_bool("IQToyotaFactoryLong", False)
if longitudinal_mode_needs_cycle(previous, idx) or (idx != LONGITUDINAL_MODE_STOCK and toyota_forced):
self._params.put_bool("OnroadCycleRequested", True)
def _handle_mouse_release(self, mouse_pos):
nxt = self._next()
if nxt == self._mode:
return
self._apply(nxt)
self.refresh()
if self._mode_callback:
self._mode_callback()

View File

@@ -0,0 +1,603 @@
"""
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
"""
import os
import re
import threading
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
def _big_options() -> list[tuple[str, str]]:
try:
from iqpilot.selfdrive.iqmodeld.emac_model_meta import big_models
return big_models(ui_state.params)
except Exception:
return []
def _big_label(key: str) -> str:
for name, display in _big_options():
if name == key:
return display
return key or "lebrowski"
def _refresh_big_catalog() -> None:
def worker():
try:
from iqpilot.selfdrive.iqmodeld.emac_model_meta import refresh_catalog
refresh_catalog(ui_state.params)
except Exception:
pass
threading.Thread(target=worker, daemon=True).start()
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._big = BigButton(tr("big model"))
self._big.set_click_callback(self._show_big_models)
self._small_on_mac = BigParamControl(tr("active model on eMac"), "IQEmacSmallModel", toggle_callback=self._small_on_mac_toggled)
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(self._reload_model_lists)
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._big, self._small_on_mac, 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 _reload_model_lists(self):
ui_state.params.put("ModelManager_LastSyncTime", 0)
_refresh_big_catalog()
def _show_big_models(self):
_refresh_big_catalog()
options = _big_options()
off = BigButton(tr("Off"))
off.set_click_callback(lambda: self._select_big(None))
btns = [off]
for key, display in options:
btn = BigButton(display)
btn.set_click_callback(lambda k=key: self._select_big(k))
btns.append(btn)
gui_app.push_widget(_ModelSelectPanel(btns))
def _select_big(self, key):
if key is None:
ui_state.params.put_bool("IQEmacEnabled", False)
else:
ui_state.params.put("IQEmacModel", key)
ui_state.params.put_bool("IQEmacEnabled", True)
gui_app.pop_widgets_to(self)
def _big_setup_progress(self) -> float | None:
p = ui_state.params
if p.get_bool("IQEmacEnabled"):
raw = p.get("MacModelDownloadProgress")
loading = not p.get_bool("MacModelReady")
else:
raw = p.get("UsbGpuSetupProgress")
loading = p.get_bool("UsbGpuLoading") and not p.get_bool("UsbGpuCompiled")
if not loading:
return None
try:
return max(0.0, min(1.0, float(raw)))
except (TypeError, ValueError):
return None
def _small_on_mac_toggled(self, checked: bool) -> None:
p = ui_state.params
if checked:
p.put_bool("IQEmacEnabled", True)
else:
p.put_bool("IQEmacEnabled", bool(p.get("IQEmacModel")))
def _small_on_mac_value(self) -> str:
try:
active = self.model_manager.activeBundle
name = _display_model_name(active) if active and active.ref else ""
except Exception:
name = ""
return f"{name} ({tr('eMac')})" if name else tr("active model")
def _big_model_value(self) -> str:
p = ui_state.params
dock = bool(getattr(ui_state.sm["deviceState"], "egpuDockPresent", False))
if p.get_bool("IQEmacEnabled") and p.get_bool("IQEmacSmallModel"):
progress = self._big_setup_progress()
value = self._small_on_mac_value()
return f"{value} {int(progress * 100)}%" if progress is not None and progress < 1.0 else value
if not p.get_bool("IQEmacEnabled") and not dock:
return tr("Off")
key = p.get("IQEmacModel")
key = key.decode() if isinstance(key, bytes) else (key or "")
label = _big_label(key)
progress = self._big_setup_progress()
if progress is not None and progress < 1.0:
return f"{label} {int(progress * 100)}%"
return label
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())
self._big.set_value(self._big_model_value())
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, self._small_on_mac):
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,365 @@
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("automatic\nupdates"), tr("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_value(tr("off") if disabled else tr("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,154 @@
"""
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._edge_guard = BigParamControl(tr("Lane Edge Guard"), "IQEdgeGuard")
self._edge_guard.set_value(tr("Blocks lane changes when a road edge is detected on the target side."))
self._continuous = BigParamControl(tr("Continuous Changes"), "LaneChangeContinuous")
self._scroller.add_widgets([self._timer, self._bsm_delay, self._edge_guard, 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._edge_guard.refresh()
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,227 @@
"""
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._vw_curvature_controller = BigParamControl(tr("curvature controller"), "EnableCurvatureController")
self._vw_smooth_steer = BigParamControl(tr("smooth steering"), "EnableSmoothSteer")
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,
self._vw_curvature_controller, self._vw_smooth_steer],
"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 _is_vw_curvature_car(self) -> bool:
from iqdbc.car.volkswagen.values import VolkswagenFlags
return bool(self._vw_flags() & (VolkswagenFlags.MEB | 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()
is_curvature_car = self._is_vw_curvature_car()
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
elif w in (self._vw_curvature_controller, self._vw_smooth_steer):
show = show and is_curvature_car
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,501 @@
import colorsys
import numpy as np
import pyray as rl
from iqpilot.cereal import 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, log_param_from_bytes
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 (cp := log_param_from_bytes(Params(), "CarParams", car.CarParams)) is not None:
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

View File

View File

@@ -0,0 +1,186 @@
import time
import pyray as rl
from dataclasses import dataclass
from iqpilot.cereal import messaging, log
from iqpilot.selfdrive.ui.ui_state import ui_state
from iqpilot.system.hardware import TICI
from iqpilot.system.ui.lib.application import gui_app, FontWeight
from iqpilot.system.ui.lib.multilang import tr
from iqpilot.system.ui.lib.text_measure import measure_text_cached
from iqpilot.system.ui.widgets import Widget
from iqpilot.system.ui.widgets.label import Label
AlertSize = log.SelfdriveState.AlertSize
AlertStatus = log.SelfdriveState.AlertStatus
ALERT_MARGIN = 40
ALERT_PADDING = 60
ALERT_LINE_SPACING = 45
ALERT_BORDER_RADIUS = 30
ALERT_FONT_SMALL = 66
ALERT_FONT_MEDIUM = 74
ALERT_FONT_BIG = 88
ALERT_HEIGHTS = {
AlertSize.small: 271,
AlertSize.mid: 420,
}
SELFDRIVE_STATE_TIMEOUT = 5 # Seconds
SELFDRIVE_UNRESPONSIVE_TIMEOUT = 10 # Seconds
# Constants
ALERT_COLORS = {
AlertStatus.normal: rl.Color(0x15, 0x15, 0x15, 0xF1), # #151515 with alpha 0xF1
AlertStatus.userPrompt: rl.Color(0xDA, 0x6F, 0x25, 0xF1), # #DA6F25 with alpha 0xF1
AlertStatus.critical: rl.Color(0xC9, 0x22, 0x31, 0xF1), # #C92231 with alpha 0xF1
}
@dataclass
class Alert:
text1: str = ""
text2: str = ""
size: int = 0
status: int = 0
# Pre-defined alert instances
ALERT_STARTUP_PENDING = Alert(
text1=tr("IQ.Pilot Unavailable"),
text2=tr("Waiting to start"),
size=AlertSize.mid,
status=AlertStatus.normal,
)
ALERT_CRITICAL_TIMEOUT = Alert(
text1=tr("TAKE CONTROL IMMEDIATELY"),
text2=tr("System Unresponsive"),
size=AlertSize.full,
status=AlertStatus.critical,
)
ALERT_CRITICAL_REBOOT = Alert(
text1=tr("System Unresponsive"),
text2=tr("Reboot Device"),
size=AlertSize.mid,
status=AlertStatus.normal,
)
class AlertRenderer(Widget):
def __init__(self):
super().__init__()
self.font_regular: rl.Font = gui_app.font(FontWeight.NORMAL)
self.font_bold: rl.Font = gui_app.font(FontWeight.BOLD)
# font size is set dynamically
self._full_text1_label = Label("", font_size=0, font_weight=FontWeight.BOLD, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER,
text_alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP)
self._full_text2_label = Label("", font_size=ALERT_FONT_BIG, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER,
text_alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP)
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
recv_frame = sm.recv_frame['selfdriveState']
if not sm.updated['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
# Don't get old alert
if recv_frame < ui_state.started_frame:
return None
event_name = ss.alertType.split('/')[0] if ss.alertType else ''
if event_name in {'selfdrivedLagging', 'commIssue', 'commIssueAvgFreq'}:
return None
# Return current alert
return Alert(text1=ss.alertText1, text2=ss.alertText2, size=ss.alertSize.raw, status=ss.alertStatus.raw)
def _render(self, rect: rl.Rectangle):
alert = self.get_alert(ui_state.sm)
if gui_app.iqpilot_ui():
ui_state.onroad_brightness_handle_alerts(ui_state.started, alert)
if not alert:
return
alert_rect = self._get_alert_rect(rect, alert.size)
self._draw_background(alert_rect, alert)
text_rect = rl.Rectangle(
alert_rect.x + ALERT_PADDING,
alert_rect.y + ALERT_PADDING,
alert_rect.width - 2 * ALERT_PADDING,
alert_rect.height - 2 * ALERT_PADDING
)
self._draw_text(text_rect, alert)
def _get_alert_rect(self, rect: rl.Rectangle, size: int) -> rl.Rectangle:
if size == AlertSize.full:
return rect
h = ALERT_HEIGHTS.get(size, rect.height)
return rl.Rectangle(rect.x + ALERT_MARGIN, rect.y + rect.height - h + ALERT_MARGIN,
rect.width - ALERT_MARGIN * 2, h - ALERT_MARGIN * 2)
def _draw_background(self, rect: rl.Rectangle, alert: Alert) -> None:
color = ALERT_COLORS.get(alert.status, ALERT_COLORS[AlertStatus.normal])
if alert.size != AlertSize.full:
roundness = ALERT_BORDER_RADIUS / (min(rect.width, rect.height) / 2)
rl.draw_rectangle_rounded(rect, roundness, 10, color)
else:
rl.draw_rectangle_rec(rect, color)
def _draw_text(self, rect: rl.Rectangle, alert: Alert) -> None:
if alert.size == AlertSize.small:
self._draw_centered(alert.text1, rect, self.font_bold, ALERT_FONT_MEDIUM)
elif alert.size == AlertSize.mid:
self._draw_centered(alert.text1, rect, self.font_bold, ALERT_FONT_BIG, center_y=False)
rect.y += ALERT_FONT_BIG + ALERT_LINE_SPACING
self._draw_centered(alert.text2, rect, self.font_regular, ALERT_FONT_SMALL, center_y=False)
else:
is_long = len(alert.text1) > 15
font_size1 = 132 if is_long else 177
top_offset = 200 if is_long or '\n' in alert.text1 else 270
title_rect = rl.Rectangle(rect.x, rect.y + top_offset, rect.width, 600)
self._full_text1_label.set_font_size(font_size1)
self._full_text1_label.set_text(alert.text1)
self._full_text1_label.render(title_rect)
bottom_offset = 361 if is_long else 420
subtitle_rect = rl.Rectangle(rect.x, rect.y + rect.height - bottom_offset, rect.width, 300)
self._full_text2_label.set_text(alert.text2)
self._full_text2_label.render(subtitle_rect)
def _draw_centered(self, text, rect, font, font_size, center_y=True, color=rl.WHITE) -> None:
text_size = measure_text_cached(font, text, font_size)
x = rect.x + (rect.width - text_size.x) / 2
y = rect.y + ((rect.height - text_size.y) / 2 if center_y else 0)
rl.draw_text_ex(font, text, rl.Vector2(x, y), font_size, 0, color)

View File

@@ -0,0 +1,312 @@
import time
import numpy as np
import pyray as rl
from iqpilot.cereal import log, messaging
from iqpilot.cereal.visionipc import VisionStreamType
from iqpilot.selfdrive.ui import UI_BORDER_SIZE
from iqpilot.selfdrive.ui.ui_state import ui_state, UIStatus
from iqpilot.selfdrive.ui.onroad.alert_renderer import AlertRenderer
from iqpilot.selfdrive.ui.onroad.driver_state import DriverStateRenderer as BaseDriverStateRenderer, BTN_SIZE
from iqpilot.selfdrive.ui.onroad.hud_renderer import HudRenderer as BaseHudRenderer
from iqpilot.selfdrive.ui.onroad.model_renderer import ModelRenderer
from iqpilot.selfdrive.ui.onroad.environment_renderer import EnvironmentRenderer
from iqpilot.selfdrive.ui.onroad.cameraview import CameraView
from iqpilot.system.ui.lib.application import gui_app
from iqpilot.common.issue_debug import log_issue_limited
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 iqpilot.ui.onroad.augmented_road_view import BORDER_COLORS_IQ, AugmentedRoadViewIQ
from iqpilot.ui.onroad.driver_state import DriverStateRendererIQ
from iqpilot.ui.onroad.hud_renderer import IQHudRenderer
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"]
BORDER_COLORS = {
UIStatus.DISENGAGED: rl.Color(0x12, 0x28, 0x39, 0xFF), # Blue for disengaged state
UIStatus.OVERRIDE: rl.Color(0x89, 0x92, 0x8D, 0xFF), # Gray for override state
UIStatus.ENGAGED: rl.Color(0x0C, 0x94, 0x96, 0xFF),
**BORDER_COLORS_IQ,
}
WIDE_CAM_MAX_SPEED = 10.0 # m/s (22 mph)
ROAD_CAM_MIN_SPEED = 15.0 # m/s (34 mph)
INF_POINT = np.array([1000.0, 0.0, 0.0])
class AugmentedRoadView(CameraView, AugmentedRoadViewIQ):
def __init__(self, stream_type: VisionStreamType = VisionStreamType.VISION_STREAM_ROAD):
CameraView.__init__(self, "camerad", stream_type)
AugmentedRoadViewIQ.__init__(self)
self._set_placeholder_color(BORDER_COLORS[UIStatus.DISENGAGED])
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 = (0, 0.0, 0.0, stream_type)
self._cached_matrix: np.ndarray | None = None
self._content_rect = rl.Rectangle()
self._split_nav_available = False
self.model_renderer = ModelRenderer()
self.environment_renderer = EnvironmentRenderer()
self.alert_renderer = AlertRenderer()
self._hud_renderer = IQHudRenderer()
self.driver_state_renderer = DriverStateRendererIQ()
self._split_nav_available = hasattr(self._hud_renderer, "render_split_nav")
# debug
self._pm = messaging.PubMaster(['uiDebug'])
def _render(self, rect):
# Only render when system is started to avoid invalid data access
start_draw = time.monotonic()
if not ui_state.started:
return
self._switch_stream_if_needed(ui_state.sm)
# Update calibration before rendering
self._update_calibration()
# Create inner content area with border padding
full_content_rect = rl.Rectangle(
rect.x + UI_BORDER_SIZE,
rect.y + UI_BORDER_SIZE,
rect.width - 2 * UI_BORDER_SIZE,
rect.height - 2 * UI_BORDER_SIZE,
)
split_nav_enabled = bool(getattr(self._hud_renderer, "split_nav_enabled", lambda: False)())
if split_nav_enabled:
split_width = full_content_rect.width * 0.5
camera_rect = rl.Rectangle(full_content_rect.x, full_content_rect.y, split_width, full_content_rect.height)
map_rect = rl.Rectangle(full_content_rect.x + split_width, full_content_rect.y, full_content_rect.width - split_width, full_content_rect.height)
else:
camera_rect = full_content_rect
map_rect = None
self._content_rect = camera_rect
if map_rect is not None:
self._hud_renderer.render_split_nav(map_rect)
rl.draw_line_ex(
rl.Vector2(map_rect.x, map_rect.y + 20),
rl.Vector2(map_rect.x, map_rect.y + map_rect.height - 20),
2.0,
rl.Color(255, 255, 255, 20),
)
# 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(camera_rect.x),
int(camera_rect.y),
int(camera_rect.width),
int(camera_rect.height)
)
# Render the base camera view
super()._render(camera_rect)
# Draw all UI overlays
self.model_renderer.render(camera_rect)
self.environment_renderer.render(camera_rect)
AugmentedRoadViewIQ.update_fade_out_bottom_overlay(self, camera_rect)
self._hud_renderer.render(camera_rect)
# Custom UI extension point - add custom overlays here
# Use self._content_rect for positioning within camera bounds
# End clipping region
rl.end_scissor_mode()
if hasattr(self._hud_renderer, "render_full_width_overlays"):
self._hud_renderer.render_full_width_overlays(full_content_rect)
self.alert_renderer.render(full_content_rect)
self.driver_state_renderer.render(full_content_rect)
# Draw colored border based on driving state
self._draw_border(rect)
# publish uiDebug
draw_time_ms = (time.monotonic() - start_draw) * 1000
if draw_time_ms > 40.0:
log_issue_limited(
"ui_draw_slow",
"ui",
f"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)
def _handle_mouse_press(self, mouse_pos):
dm = self.driver_state_renderer
if ui_state.has_longitudinal_control and dm.is_visible:
dx = mouse_pos.x - dm.position_x
dy = mouse_pos.y - dm.position_y
if dx * dx + dy * dy <= (BTN_SIZE / 2) ** 2:
dm.cycle_personality()
return
if not self._hud_renderer.user_interacting() and self._click_callback is not None:
self._click_callback()
def _handle_mouse_release(self, _):
# We only call click callback on press if not interacting with HUD
pass
def _draw_border(self, rect: rl.Rectangle):
rl.draw_rectangle_lines_ex(rect, UI_BORDER_SIZE, rl.BLACK)
border_roundness = 0.12
border_color = BORDER_COLORS.get(ui_state.status, BORDER_COLORS[UIStatus.DISENGAGED])
border_rect = rl.Rectangle(rect.x + UI_BORDER_SIZE, rect.y + UI_BORDER_SIZE,
rect.width - 2 * UI_BORDER_SIZE, rect.height - 2 * UI_BORDER_SIZE)
aol = ui_state.sm["iqState"].aol
if aol.active and not ui_state.sm["selfdriveState"].enabled:
bottom_only_height = max(int(UI_BORDER_SIZE * 4), 60)
clip_y = int(rect.y + rect.height - bottom_only_height)
rl.begin_scissor_mode(int(rect.x), clip_y, int(rect.width), bottom_only_height)
rl.draw_rectangle_rounded_lines_ex(border_rect, border_roundness, 10, UI_BORDER_SIZE, border_color)
rl.end_scissor_mode()
else:
rl.draw_rectangle_rounded_lines_ex(border_rect, border_roundness, 10, UI_BORDER_SIZE, border_color)
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, 0.0, self.stream_type)
self._cached_matrix = None
def _calc_frame_matrix(self, rect: rl.Rectangle) -> np.ndarray:
# Check if we can use cached matrix
cache_key = (
ui_state.sm.recv_frame['extrinsicsCalibration'],
self._content_rect.width,
self._content_rect.height,
self.stream_type
)
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
zoom = 2.0 if is_wide_camera else 1.1
# Calculate transforms for vanishing point
calib_transform = intrinsic @ calibration
kep = calib_transform @ INF_POINT
# Calculate center points and dimensions
x, y = self._content_rect.x, self._content_rect.y
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, -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]
])
video_transform = np.array([
[zoom, 0.0, (w / 2 + x - x_offset) - (cx * zoom)],
[0.0, zoom, (h / 2 + y - y_offset) - (cy * zoom)],
[0.0, 0.0, 1.0]
])
self.model_renderer.set_transform(video_transform @ calib_transform)
self.model_renderer.set_frame_transform(video_transform, is_wide_camera)
self.environment_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,367 @@
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
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;
void main() {
vec4 color = texture(texture0, fragTexCoord);
fragColor = vec4(pow(color.rgb, vec3(1.0/1.28)), color.a);
}
"""
else:
FRAME_FRAGMENT_SHADER = VERSION + """
in vec2 fragTexCoord;
uniform sampler2D texture0;
uniform sampler2D texture1;
out vec4 fragColor;
void main() {
float y = texture(texture0, fragTexCoord).r;
vec2 uv = texture(texture1, fragTexCoord).ra - 0.5;
fragColor = vec4(y + 1.402*uv.y, y - 0.344*uv.x - 0.714*uv.y, y + 1.772*uv.x, 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.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.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)
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)
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 _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,111 @@
import numpy as np
import pyray as rl
from iqpilot.cereal.visionipc import VisionStreamType
from iqpilot.selfdrive.ui.onroad.cameraview import CameraView
from iqpilot.selfdrive.ui.onroad.driver_state import DriverStateRenderer
from iqpilot.selfdrive.ui.ui_state import ui_state, device
from iqpilot.system.ui.lib.application import gui_app, FontWeight
from iqpilot.system.ui.lib.multilang import tr
from iqpilot.system.ui.widgets.label import gui_label
class DriverCameraDialog(CameraView):
def __init__(self):
super().__init__("camerad", VisionStreamType.VISION_STREAM_DRIVER)
self.driver_state_renderer = DriverStateRenderer()
# TODO: this can grow unbounded, should be given some thought
device.add_interactive_timeout_callback(lambda: gui_app.set_modal_overlay(None))
ui_state.params.put_bool("IsDriverViewEnabled", True)
def hide_event(self):
super().hide_event()
ui_state.params.put_bool("IsDriverViewEnabled", False)
self.close()
def _handle_mouse_release(self, _):
super()._handle_mouse_release(_)
gui_app.set_modal_overlay(None)
def __del__(self):
self.close()
def _render(self, rect):
super()._render(rect)
if not self.frame:
gui_label(
rect,
tr("camera starting"),
font_size=100,
font_weight=FontWeight.BOLD,
alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER,
)
return -1
self._draw_face_detection(rect)
self.driver_state_renderer.render(rect)
return -1
def _draw_face_detection(self, rect: rl.Rectangle) -> None:
driver_state = ui_state.sm["driverStateV2"]
is_rhd = driver_state.wheelOnRightProb > 0.5
driver_data = driver_state.rightDriverData if is_rhd else driver_state.leftDriverData
face_detect = driver_data.faceProb > 0.7
if not face_detect:
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
fbox_x = int(1080.0 - 1714.0 * face_x)
fbox_y = int(-135.0 + (504.0 + abs(face_x) * 112.0) + (1205.0 - abs(face_x) * 724.0) * face_y)
box_size = 220
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,
10,
10,
line_color,
)
def _calc_frame_matrix(self, rect: rl.Rectangle) -> np.ndarray:
driver_view_ratio = 2.0
# Get stream dimensions
if self.frame:
stream_width = self.frame.width
stream_height = self.frame.height
else:
# Default values if frame not available
stream_width = 1928
stream_height = 1208
yscale = stream_height * driver_view_ratio / stream_width
xscale = yscale * rect.height / rect.width * stream_width / stream_height
return np.array([
[xscale, 0.0, 0.0],
[0.0, yscale, 0.0],
[0.0, 0.0, 1.0]
])
if __name__ == "__main__":
gui_app.init_window("Driver Camera View")
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,231 @@
import numpy as np
import pyray as rl
from iqpilot.cereal import log
from dataclasses import dataclass
from iqpilot.selfdrive.ui import UI_BORDER_SIZE
from iqpilot.selfdrive.ui.ui_state import ui_state
from iqpilot.system.ui.lib.application import gui_app
from iqpilot.system.ui.widgets import Widget
AlertSize = log.SelfdriveState.AlertSize
# Default 3D coordinates for face keypoints as a NumPy array
DEFAULT_FACE_KPTS_3D = np.array([
[-5.98, -51.20, 8.00], [-17.64, -49.14, 8.00], [-23.81, -46.40, 8.00], [-29.98, -40.91, 8.00],
[-32.04, -37.49, 8.00], [-34.10, -32.00, 8.00], [-36.16, -21.03, 8.00], [-36.16, 6.40, 8.00],
[-35.47, 10.51, 8.00], [-32.73, 19.43, 8.00], [-29.30, 26.29, 8.00], [-24.50, 33.83, 8.00],
[-19.01, 41.37, 8.00], [-14.21, 46.17, 8.00], [-12.16, 47.54, 8.00], [-4.61, 49.60, 8.00],
[4.99, 49.60, 8.00], [12.53, 47.54, 8.00], [14.59, 46.17, 8.00], [19.39, 41.37, 8.00],
[24.87, 33.83, 8.00], [29.67, 26.29, 8.00], [33.10, 19.43, 8.00], [35.84, 10.51, 8.00],
[36.53, 6.40, 8.00], [36.53, -21.03, 8.00], [34.47, -32.00, 8.00], [32.42, -37.49, 8.00],
[30.36, -40.91, 8.00], [24.19, -46.40, 8.00], [18.02, -49.14, 8.00], [6.36, -51.20, 8.00],
[-5.98, -51.20, 8.00],
], dtype=np.float32)
# UI constants
BTN_SIZE = 192
IMG_SIZE = 144
ARC_LENGTH = 133
ARC_THICKNESS_DEFAULT = 6.7
ARC_THICKNESS_EXTEND = 12.0
SCALES_POS = np.array([0.9, 0.4, 0.4], dtype=np.float32)
SCALES_NEG = np.array([0.7, 0.4, 0.4], dtype=np.float32)
ARC_POINT_COUNT = 37 # Number of points in the arc
ARC_ANGLES = np.linspace(0.0, np.pi, ARC_POINT_COUNT, dtype=np.float32)
@dataclass
class ArcData:
"""Data structure for arc rendering parameters."""
x: float
y: float
width: float
height: float
thickness: float
class DriverStateRenderer(Widget):
def __init__(self):
super().__init__()
# Initial state with NumPy arrays
self.face_kpts_draw = DEFAULT_FACE_KPTS_3D.copy()
self.is_active = False
self.is_rhd = False
self.dm_fade_state = 0.0
self.driver_pose_vals = np.zeros(3, dtype=np.float32)
self.driver_pose_diff = np.zeros(3, dtype=np.float32)
self.driver_pose_sins = np.zeros(3, dtype=np.float32)
self.driver_pose_coss = np.zeros(3, dtype=np.float32)
self.face_keypoints_transformed = np.zeros((DEFAULT_FACE_KPTS_3D.shape[0], 2), dtype=np.float32)
self.position_x: float = 0.0
self.position_y: float = 0.0
self.h_arc_data = None
self.v_arc_data = None
# Pre-allocate drawing arrays
self.face_lines = [rl.Vector2(0, 0) for _ in range(len(DEFAULT_FACE_KPTS_3D))]
self.h_arc_lines = [rl.Vector2(0, 0) for _ in range(ARC_POINT_COUNT)]
self.v_arc_lines = [rl.Vector2(0, 0) for _ in range(ARC_POINT_COUNT)]
# Load the driver face icon
self.dm_img = gui_app.texture("icons/driver_face.png", IMG_SIZE, IMG_SIZE)
# Colors
self.white_color = rl.Color(255, 255, 255, 255)
self.arc_color = rl.Color(26, 242, 66, 255)
self.engaged_color = rl.Color(0x0C, 0x94, 0x96, 0xFF)
self.disengaged_color = rl.Color(139, 139, 139, 255)
self.set_visible(lambda: (ui_state.sm["selfdriveState"].alertSize == AlertSize.none and
ui_state.sm.recv_frame["driverStateV2"] > ui_state.started_frame))
def _render(self, rect):
# Set opacity based on active state
opacity = 0.65 if self.is_active else 0.2
# Draw background circle
rl.draw_circle(int(self.position_x), int(self.position_y), BTN_SIZE // 2, rl.Color(0, 0, 0, 70))
# Draw face icon
icon_pos = rl.Vector2(self.position_x - self.dm_img.width // 2, self.position_y - self.dm_img.height // 2)
rl.draw_texture_v(self.dm_img, icon_pos, rl.Color(255, 255, 255, int(255 * opacity)))
# Draw face outline
self.white_color.a = int(255 * opacity)
rl.draw_spline_linear(self.face_lines, len(self.face_lines), 5.2, self.white_color)
# Set arc color based on engaged state
self.arc_color = self.engaged_color if ui_state.engaged else self.disengaged_color
self.arc_color.a = int(0.4 * 255 * (1.0 - self.dm_fade_state)) # Fade out when inactive
# Draw arcs
if self.h_arc_data:
rl.draw_spline_linear(self.h_arc_lines, len(self.h_arc_lines), self.h_arc_data.thickness, self.arc_color)
if self.v_arc_data:
rl.draw_spline_linear(self.v_arc_lines, len(self.v_arc_lines), self.v_arc_data.thickness, self.arc_color)
def _update_state(self):
"""Update the driver monitoring state based on model data"""
sm = ui_state.sm
if not self.is_visible:
return
# Get monitoring state
dm_state = sm["driverMonitoringState"]
self.is_active = dm_state.isActiveMode
self.is_rhd = dm_state.isRHD
# Update fade state (smoother transition between active/inactive)
fade_target = 0.0 if self.is_active else 0.5
self.dm_fade_state = np.clip(self.dm_fade_state + 0.2 * (fade_target - self.dm_fade_state), 0.0, 1.0)
# Get driver orientation data from appropriate camera
driverstate = sm["driverStateV2"]
driver_data = driverstate.rightDriverData if self.is_rhd else driverstate.leftDriverData
driver_orient = driver_data.faceOrientation
# Update pose values with scaling and smoothing
driver_orient = np.array(driver_orient)
scales = np.where(driver_orient < 0, SCALES_NEG, SCALES_POS)
v_this = driver_orient * scales
self.driver_pose_diff = np.abs(self.driver_pose_vals - v_this)
self.driver_pose_vals = 0.8 * v_this + 0.2 * self.driver_pose_vals # Smooth changes
# Apply fade to rotation and compute sin/cos
rotation_amount = self.driver_pose_vals * (1.0 - self.dm_fade_state)
self.driver_pose_sins = np.sin(rotation_amount)
self.driver_pose_coss = np.cos(rotation_amount)
# Create rotation matrix for 3D face model
sin_y, sin_x, sin_z = self.driver_pose_sins
cos_y, cos_x, cos_z = self.driver_pose_coss
r_xyz = np.array(
[
[cos_x * cos_z, cos_x * sin_z, -sin_x],
[-sin_y * sin_x * cos_z - cos_y * sin_z, -sin_y * sin_x * sin_z + cos_y * cos_z, -sin_y * cos_x],
[cos_y * sin_x * cos_z - sin_y * sin_z, cos_y * sin_x * sin_z + sin_y * cos_z, cos_y * cos_x],
]
)
# Transform face keypoints using vectorized matrix multiplication
self.face_kpts_draw = DEFAULT_FACE_KPTS_3D @ r_xyz.T
self.face_kpts_draw[:, 2] = self.face_kpts_draw[:, 2] * (1.0 - self.dm_fade_state) + 8 * self.dm_fade_state
# Pre-calculate the transformed keypoints
kp_depth = (self.face_kpts_draw[:, 2] - 8) / 120.0 + 1.0
self.face_keypoints_transformed = self.face_kpts_draw[:, :2] * kp_depth[:, None]
# Pre-calculate all drawing elements
self._pre_calculate_drawing_elements()
def _pre_calculate_drawing_elements(self):
"""Pre-calculate all drawing elements based on the current rectangle"""
# Calculate icon position (bottom-left or bottom-right)
width, height = self._rect.width, self._rect.height
offset = UI_BORDER_SIZE + BTN_SIZE // 2
self.position_x = self._rect.x + (width - offset if self.is_rhd else offset)
self.position_y = self._rect.y + height - offset
# Pre-calculate the face lines positions
positioned_keypoints = self.face_keypoints_transformed + np.array([self.position_x, self.position_y])
for i in range(len(positioned_keypoints)):
self.face_lines[i].x = positioned_keypoints[i][0]
self.face_lines[i].y = positioned_keypoints[i][1]
# Calculate arc dimensions based on head rotation
delta_x = -self.driver_pose_sins[1] * ARC_LENGTH / 2.0 # Horizontal movement
delta_y = -self.driver_pose_sins[0] * ARC_LENGTH / 2.0 # Vertical movement
# Horizontal arc
h_width = abs(delta_x)
self.h_arc_data = self._calculate_arc_data(
delta_x, h_width, self.position_x, self.position_y - ARC_LENGTH / 2,
self.driver_pose_sins[1], self.driver_pose_diff[1], is_horizontal=True
)
# Vertical arc
v_height = abs(delta_y)
self.v_arc_data = self._calculate_arc_data(
delta_y, v_height, self.position_x - ARC_LENGTH / 2, self.position_y,
self.driver_pose_sins[0], self.driver_pose_diff[0], is_horizontal=False
)
def _calculate_arc_data(
self, delta: float, size: float, x: float, y: float, sin_val: float, diff_val: float, is_horizontal: bool
):
"""Calculate arc data and pre-compute arc points."""
if size <= 0:
return None
thickness = ARC_THICKNESS_DEFAULT + ARC_THICKNESS_EXTEND * min(1.0, diff_val * 5.0)
start_angle = (90 if sin_val > 0 else -90) if is_horizontal else (0 if sin_val > 0 else 180)
x = min(x + delta, x) if is_horizontal else x
y = y if is_horizontal else min(y + delta, y)
arc_data = ArcData(
x=x,
y=y,
width=size if is_horizontal else ARC_LENGTH,
height=ARC_LENGTH if is_horizontal else size,
thickness=thickness,
)
# Pre-calculate arc points
angles = ARC_ANGLES + np.deg2rad(start_angle)
center_x = x + arc_data.width / 2
center_y = y + arc_data.height / 2
radius_x = arc_data.width / 2
radius_y = arc_data.height / 2
x_coords = center_x + np.cos(angles) * radius_x
y_coords = center_y - np.sin(angles) * radius_y
arc_lines = self.h_arc_lines if is_horizontal else self.v_arc_lines
for i, (x_coord, y_coord) in enumerate(zip(x_coords, y_coords, strict=True)):
arc_lines[i].x = x_coord
arc_lines[i].y = y_coord
return arc_data

View File

@@ -0,0 +1,141 @@
import numpy as np
import pyray as rl
from iqpilot.cereal import custom
from iqpilot.selfdrive.ui.ui_state import ui_state
from iqpilot.system.ui.lib.application import gui_app
from iqpilot.system.ui.widgets import Widget
MODE_OFF = 0
MODE_OVERLAY = 1
MODE_REPLACE = 2
_ENV_LABEL = custom.IQEnvironment.Object.Label
_OBJECT_COLORS = {
_ENV_LABEL.car: (40, 210, 200),
_ENV_LABEL.truck: (40, 210, 200),
_ENV_LABEL.bus: (40, 210, 200),
_ENV_LABEL.motorcycle: (90, 220, 255),
_ENV_LABEL.bicycle: (90, 220, 255),
_ENV_LABEL.person: (255, 210, 90),
_ENV_LABEL.stopSign: (255, 60, 45),
_ENV_LABEL.trafficLight: (255, 190, 0),
}
_BOX_EDGES = (
(0, 1), (1, 3), (3, 2), (2, 0),
(4, 5), (5, 7), (7, 6), (6, 4),
(0, 4), (1, 5), (2, 6), (3, 7),
)
GRID_HALF_WIDTH = 12.0
GRID_MAX_DISTANCE = 90.0
GRID_STEP = 6.0
class EnvironmentRenderer(Widget):
def __init__(self):
Widget.__init__(self)
self._car_space_transform = np.zeros((3, 3), dtype=np.float32)
self._mode = MODE_OFF
self._counter = 0
def set_transform(self, transform: np.ndarray):
self._car_space_transform = transform.astype(np.float32)
def _project(self, pt: np.ndarray):
p = self._car_space_transform @ pt
if abs(p[2]) < 1e-6:
return None
return p[0] / p[2], p[1] / p[2]
def _in_rect(self, x: float, y: float) -> bool:
r = self._rect
return r.x - 400 <= x <= r.x + r.width + 400 and r.y - 400 <= y <= r.y + r.height + 400
def _render(self, rect: rl.Rectangle):
sm = ui_state.sm
if self._counter % 30 == 0:
self._mode = int(ui_state.params.get("EnvironmentView", return_default=True) or 0) if ui_state.active_bundle else 0
self._counter += 1
if self._mode == MODE_OFF:
return
if sm.recv_frame["extrinsicsCalibration"] < ui_state.started_frame:
return
if self._mode == MODE_REPLACE:
self._draw_backdrop(rect)
self._draw_ground_grid()
if sm.valid["modelV2"]:
self._draw_model_scene(sm["modelV2"])
if sm.alive["iqEnvironment"] and sm.valid["iqEnvironment"]:
self._draw_objects(sm["iqEnvironment"])
def _draw_backdrop(self, rect: rl.Rectangle):
rl.draw_rectangle_gradient_v(int(rect.x), int(rect.y), int(rect.width), int(rect.height),
rl.Color(14, 17, 22, 255), rl.Color(6, 8, 11, 255))
def _draw_ground_grid(self):
col = rl.Color(60, 70, 82, 90)
dist = GRID_STEP
while dist <= GRID_MAX_DISTANCE:
a = self._project(np.array([dist, -GRID_HALF_WIDTH, 0.0]))
b = self._project(np.array([dist, GRID_HALF_WIDTH, 0.0]))
if a and b and self._in_rect(*a) and self._in_rect(*b):
rl.draw_line_ex(rl.Vector2(*a), rl.Vector2(*b), 1.5, col)
dist += GRID_STEP
for off in np.arange(-GRID_HALF_WIDTH, GRID_HALF_WIDTH + 0.1, 3.0):
a = self._project(np.array([GRID_STEP, float(off), 0.0]))
b = self._project(np.array([GRID_MAX_DISTANCE, float(off), 0.0]))
if a and b and self._in_rect(*a) and self._in_rect(*b):
rl.draw_line_ex(rl.Vector2(*a), rl.Vector2(*b), 1.5, col)
def _draw_polyline(self, xs, ys, zs, color, thick):
pts = []
for x, y, z in zip(xs, ys, zs, strict=False):
if x < 0:
continue
s = self._project(np.array([x, y, z], dtype=np.float32))
if s and self._in_rect(*s):
pts.append(rl.Vector2(*s))
for i in range(len(pts) - 1):
rl.draw_line_ex(pts[i], pts[i + 1], thick, color)
def _draw_model_scene(self, model):
for i, lane in enumerate(model.laneLines):
a = int(np.clip(model.laneLineProbs[i], 0.0, 0.9) * 255)
self._draw_polyline(lane.x, lane.y, lane.z, rl.Color(235, 235, 235, a), 3.0)
for edge in model.roadEdges:
self._draw_polyline(edge.x, edge.y, edge.z, rl.Color(230, 70, 70, 180), 3.0)
pos = model.position
self._draw_polyline(pos.x, pos.y, pos.z, rl.Color(40, 210, 200, 220), 6.0)
def _draw_objects(self, env):
for obj in env.objects:
self._draw_box(obj)
def _draw_box(self, obj):
hx, hy = obj.length / 2.0, obj.width / 2.0
base = np.array([
[obj.x - hx, obj.y - hy, obj.z], [obj.x - hx, obj.y + hy, obj.z],
[obj.x + hx, obj.y - hy, obj.z], [obj.x + hx, obj.y + hy, obj.z],
[obj.x - hx, obj.y - hy, obj.z + obj.height], [obj.x - hx, obj.y + hy, obj.z + obj.height],
[obj.x + hx, obj.y - hy, obj.z + obj.height], [obj.x + hx, obj.y + hy, obj.z + obj.height],
], dtype=np.float32)
screen = []
for corner in base:
s = self._project(corner)
if s is None or not self._in_rect(*s):
return
screen.append(s)
r, g, b = _OBJECT_COLORS.get(obj.label, (40, 210, 200))
a = int(np.clip(obj.prob, 0.3, 1.0) * 210)
floor = [rl.Vector2(*screen[i]) for i in (0, 1, 3, 2)]
rl.draw_triangle(floor[0], floor[1], floor[2], rl.Color(r, g, b, a // 5))
rl.draw_triangle(floor[0], floor[2], floor[3], rl.Color(r, g, b, a // 5))
for i, j in _BOX_EDGES:
rl.draw_line_ex(rl.Vector2(*screen[i]), rl.Vector2(*screen[j]), 2.0, rl.Color(r, g, b, a))

View File

@@ -0,0 +1,87 @@
import time
import pyray as rl
from iqpilot.common.params import Params
from iqpilot.selfdrive.ui.ui_state import ui_state
from iqpilot.system.ui.lib.application import gui_app
from iqpilot.system.ui.widgets import Widget
class ExpButton(Widget):
def __init__(self, button_size: int, icon_size: int):
super().__init__()
self._params = Params()
self._experimental_mode: bool = False
self._iq_dynamic_mode: bool = False
self._engageable: bool = False
# State hold mechanism
self._hold_duration = 2.0 # seconds
self._held_mode: tuple | None = None # (experimental, iq_dynamic) or None
self._hold_end_time: float | None = None
self._white_color: rl.Color = rl.Color(255, 255, 255, 255)
self._black_bg: rl.Color = rl.Color(0, 0, 0, 166)
self._txt_wheel: rl.Texture = gui_app.texture('icons/chffr_wheel.png', icon_size, icon_size)
self._txt_standard: rl.Texture = gui_app.texture('icons_mici/iqstandard_mode_tizi.png', icon_size, icon_size)
self._txt_pilot: rl.Texture = gui_app.texture('icons_mici/experimental_mode_tizi.png', icon_size, icon_size)
self._txt_dyn: rl.Texture = gui_app.texture('icons_mici/iqdynamic_mode_tizi.png', icon_size, icon_size)
self._rect = rl.Rectangle(0, 0, button_size, button_size)
def set_rect(self, rect: rl.Rectangle) -> None:
self._rect.x, self._rect.y = rect.x, rect.y
def _update_state(self) -> None:
selfdrive_state = ui_state.sm["selfdriveState"]
self._experimental_mode = selfdrive_state.experimentalMode
self._iq_dynamic_mode = self._params.get_bool("IQDynamicMode")
self._engageable = selfdrive_state.engageable or selfdrive_state.enabled
def _handle_mouse_release(self, _):
super()._handle_mouse_release(_)
if not self._is_toggle_allowed():
return
exp, dyn = self._current_mode()
# Cycle: IQ.Chill → IQ.Dynamic → IQ.Pilot → IQ.Chill
if not exp:
new_exp, new_dyn = True, True
elif dyn:
new_exp, new_dyn = True, False
else:
new_exp, new_dyn = False, False
self._params.put_bool("ExperimentalMode", new_exp)
self._params.put_bool("IQDynamicMode", new_dyn)
self._held_mode = (new_exp, new_dyn)
self._hold_end_time = time.monotonic() + self._hold_duration
def _render(self, rect: rl.Rectangle) -> None:
center_x = int(self._rect.x + self._rect.width // 2)
center_y = int(self._rect.y + self._rect.height // 2)
self._white_color.a = 180 if self.is_pressed or not self._engageable else 255
exp, dyn = self._current_mode()
if not ui_state.has_longitudinal_control:
texture = self._txt_wheel
elif exp and dyn:
texture = self._txt_dyn
elif exp:
texture = self._txt_pilot
else:
texture = self._txt_standard
rl.draw_circle(center_x, center_y, self._rect.width / 2, self._black_bg)
rl.draw_texture(texture, center_x - texture.width // 2, center_y - texture.height // 2, self._white_color)
def _current_mode(self) -> tuple:
now = time.monotonic()
if self._hold_end_time and now < self._hold_end_time:
return self._held_mode
if self._hold_end_time and now >= self._hold_end_time:
self._hold_end_time = self._held_mode = None
return (self._experimental_mode, self._iq_dynamic_mode)
def _is_toggle_allowed(self):
if not self._params.get_bool("ExperimentalModeConfirmed"):
return False
return ui_state.has_longitudinal_control

View File

@@ -0,0 +1,251 @@
import pyray as rl
from dataclasses import dataclass
from iqpilot.common.constants import CV
from iqpilot.selfdrive.ui.onroad.exp_button import ExpButton
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.multilang import tr
from iqpilot.system.ui.lib.text_measure import measure_text_cached
from iqpilot.system.ui.widgets import Widget
# Constants
SET_SPEED_NA = 255
KM_TO_MILE = 0.621371
CRUISE_DISABLED_CHAR = ''
@dataclass(frozen=True)
class UIConfig:
header_height: int = 300
border_size: int = 30
button_size: int = 192
set_speed_width_metric: int = 186
set_speed_width_imperial: int = 174
set_speed_height: int = 228
wheel_icon_size: int = 144
@dataclass(frozen=True)
class FontSizes:
current_speed: int = 176
speed_unit: int = 66
max_speed: int = 28
set_speed: int = 74
limit_speed: int = 64
limit_offset: int = 30
limit_unit: int = 22
limit_label: int = 24
@dataclass(frozen=True)
class Colors:
WHITE = rl.WHITE
DISENGAGED = rl.Color(145, 155, 149, 255)
OVERRIDE = rl.Color(145, 155, 149, 255) # Added
ENGAGED = rl.Color(0x0C, 0x94, 0x96, 0xFF)
LIMIT_ENGAGED = rl.Color(0x27, 0xF5, 0xD3, 0xFF)
DISENGAGED_BG = rl.Color(0, 0, 0, 153)
OVERRIDE_BG = rl.Color(145, 155, 149, 204)
ENGAGED_BG = rl.Color(128, 216, 166, 204)
GREY = rl.Color(166, 166, 166, 255)
DARK_GREY = rl.Color(114, 114, 114, 255)
BLACK_TRANSLUCENT = rl.Color(0, 0, 0, 166)
WHITE_TRANSLUCENT = rl.Color(255, 255, 255, 200)
BORDER_TRANSLUCENT = rl.Color(255, 255, 255, 75)
HEADER_GRADIENT_START = rl.Color(0, 0, 0, 114)
HEADER_GRADIENT_END = rl.BLANK
UI_CONFIG = UIConfig()
FONT_SIZES = FontSizes()
COLORS = Colors()
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.speed: float = 0.0
self.v_ego_cluster_seen: bool = False
self.limit_speed_text: str = "---"
self.limit_offset_text: str = ""
self.limit_available: bool = False
self._font_semi_bold: rl.Font = gui_app.font(FontWeight.SEMI_BOLD)
self._font_bold: rl.Font = gui_app.font(FontWeight.BOLD)
self._font_medium: rl.Font = gui_app.font(FontWeight.MEDIUM)
self._exp_button: ExpButton = ExpButton(UI_CONFIG.button_size, UI_CONFIG.wheel_icon_size)
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
self.set_speed = (
controls_state.vCruiseDEPRECATED if v_cruise_cluster == 0.0 else v_cruise_cluster
)
self.is_cruise_set = 0 < self.set_speed < SET_SPEED_NA
self.is_cruise_available = self.set_speed != -1
if self.is_cruise_set and not ui_state.is_metric:
self.set_speed *= KM_TO_MILE
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."""
# Draw the header background
rl.draw_rectangle_gradient_v(
int(rect.x),
int(rect.y),
int(rect.width),
UI_CONFIG.header_height,
COLORS.HEADER_GRADIENT_START,
COLORS.HEADER_GRADIENT_END,
)
if self.is_cruise_available:
self._draw_set_speed(rect)
self._draw_current_speed(rect)
button_x = rect.x + rect.width - UI_CONFIG.border_size - UI_CONFIG.button_size
button_y = rect.y + UI_CONFIG.border_size
self._exp_button.render(rl.Rectangle(button_x, button_y, UI_CONFIG.button_size, UI_CONFIG.button_size))
def user_interacting(self) -> bool:
return self._exp_button.is_pressed
def _draw_set_speed(self, rect: rl.Rectangle) -> None:
"""Draw the compact stacked LIMIT / MAX speed indicator box."""
set_speed_width = UI_CONFIG.set_speed_width_metric if ui_state.is_metric else UI_CONFIG.set_speed_width_imperial
x = rect.x + 60 + (UI_CONFIG.set_speed_width_imperial - set_speed_width) // 2
y = rect.y + 45
set_speed_rect = rl.Rectangle(x, y, set_speed_width, UI_CONFIG.set_speed_height)
rl.draw_rectangle_rounded(set_speed_rect, 0.35, 10, COLORS.BLACK_TRANSLUCENT)
rl.draw_rectangle_rounded_lines_ex(set_speed_rect, 0.35, 10, 6, COLORS.BORDER_TRANSLUCENT)
split_y = y + 112
rl.draw_line_ex(
rl.Vector2(x + 18, split_y),
rl.Vector2(x + set_speed_width - 18, split_y),
3,
COLORS.BORDER_TRANSLUCENT,
)
max_color = COLORS.DARK_GREY
set_speed_color = COLORS.WHITE
limit_value_color = COLORS.WHITE if self.limit_available else COLORS.DARK_GREY
if self.is_cruise_set:
if ui_state.status == UIStatus.ENGAGED:
max_color = COLORS.ENGAGED
if self.limit_available:
limit_value_color = COLORS.LIMIT_ENGAGED
elif ui_state.status == UIStatus.DISENGAGED:
max_color = COLORS.DISENGAGED
elif ui_state.status == UIStatus.OVERRIDE:
max_color = COLORS.OVERRIDE
limit_value_text = self.limit_speed_text
if len(limit_value_text) <= 2:
limit_value_size = FONT_SIZES.limit_speed
elif len(limit_value_text) == 3:
limit_value_size = 56
else:
limit_value_size = 48
limit_value_width = measure_text_cached(self._font_bold, limit_value_text, limit_value_size).x
limit_offset_text = self.limit_offset_text if self.limit_available else ""
limit_offset_width = 0.0
if limit_offset_text:
limit_offset_width = measure_text_cached(self._font_semi_bold, limit_offset_text, FONT_SIZES.limit_offset).x + 8
limit_value_x = x + (set_speed_width - limit_value_width - limit_offset_width) / 2
rl.draw_text_ex(
self._font_bold,
limit_value_text,
rl.Vector2(limit_value_x, y + 14),
limit_value_size,
0,
limit_value_color,
)
if limit_offset_text:
rl.draw_text_ex(
self._font_semi_bold,
limit_offset_text,
rl.Vector2(limit_value_x + limit_value_width + 8, y + 22),
FONT_SIZES.limit_offset,
0,
COLORS.WHITE_TRANSLUCENT,
)
if self.limit_available:
limit_unit_text = tr("LIMIT")
limit_unit_width = measure_text_cached(self._font_medium, limit_unit_text, FONT_SIZES.limit_unit).x
rl.draw_text_ex(
self._font_medium,
limit_unit_text,
rl.Vector2(x + (set_speed_width - limit_unit_width) / 2, y + 78),
FONT_SIZES.limit_unit,
0,
COLORS.WHITE_TRANSLUCENT,
)
else:
limit_label_text = tr("LIMIT")
limit_label_width = measure_text_cached(self._font_semi_bold, limit_label_text, FONT_SIZES.limit_label).x
rl.draw_text_ex(
self._font_semi_bold,
limit_label_text,
rl.Vector2(x + (set_speed_width - limit_label_width) / 2, y + 74),
FONT_SIZES.limit_label,
0,
COLORS.GREY,
)
max_text = tr("MAX")
max_text_width = measure_text_cached(self._font_semi_bold, max_text, FONT_SIZES.max_speed).x
rl.draw_text_ex(
self._font_semi_bold,
max_text,
rl.Vector2(x + (set_speed_width - max_text_width) / 2, y + 118),
FONT_SIZES.max_speed,
0,
max_color,
)
set_speed_text = CRUISE_DISABLED_CHAR if not self.is_cruise_set else str(round(self.set_speed))
speed_text_width = measure_text_cached(self._font_bold, set_speed_text, FONT_SIZES.set_speed).x
rl.draw_text_ex(
self._font_bold,
set_speed_text,
rl.Vector2(x + (set_speed_width - speed_text_width) / 2, y + 136),
FONT_SIZES.set_speed,
0,
set_speed_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,612 @@
import colorsys
import numpy as np
import pyray as rl
from iqpilot.cereal import car, custom
from dataclasses import dataclass, field
from iqpilot.common.filter_simple import FirstOrderFilter
from iqpilot.common.params import Params
from iqpilot.selfdrive.locationd.calibrationd import HEIGHT_INIT
from iqpilot.selfdrive.locationd.calibration_helpers import get_render_path_height
from iqpilot.selfdrive.ui.ui_state import ui_state, log_param_from_bytes
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.ui.onroad.hud_overlays import ChevronMetrics
from iqpilot.ui.onroad.lead_confidence import driving_confidence
CLIP_MARGIN = 500
MIN_DRAW_DISTANCE = 10.0
MAX_DRAW_DISTANCE = 100.0
_VT_LABEL = custom.IQVehicleTracks.Track.Label
VEHICLE_TRACK_LABELS = (_VT_LABEL.car, _VT_LABEL.motorcycle, _VT_LABEL.bus, _VT_LABEL.truck)
SIGN_TRACK_COLORS = {
_VT_LABEL.stopSign: rl.Color(255, 60, 45, 200),
_VT_LABEL.trafficLight: rl.Color(255, 190, 0, 200),
}
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)
]
@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
@dataclass
class VisionDot:
x: float
y: float
tx: float
ty: float
radius: float
tradius: float
alpha: float = 0.0
talpha: float = 1.0
rgb: tuple[int, int, int] | None = None
_VD_EASE = 0.4
class ModelRenderer(Widget):
def __init__(self):
Widget.__init__(self)
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._track_dots: list[LeadVehicle] = []
self._vision_dots: list[VisionDot] = []
self._vt_frame = -1
self._frame_transform: np.ndarray | None = None
self._frame_transform_wide = False
self._path_offset_z = HEIGHT_INIT[0]
self._counter = -1
self._camera_offset = ui_state.params.get("CameraOffset", return_default=True) if ui_state.active_bundle else 0.0
self._ambient_dots = bool(ui_state.params.get("AmbientTrackDots", return_default=True))
# 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)
# 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._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 (cp := log_param_from_bytes(Params(), "CarParams", car.CarParams)) is not None:
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 set_frame_transform(self, transform: np.ndarray, is_wide: bool):
self._frame_transform = transform.astype(np.float32)
self._frame_transform_wide = is_wide
def _render(self, rect: rl.Rectangle):
sm = ui_state.sm
driving_confidence.update()
# 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 self._counter % 60 == 0:
self._camera_offset = ui_state.params.get("CameraOffset", return_default=True) if ui_state.active_bundle else 0.0
self._ambient_dots = bool(ui_state.params.get("AmbientTrackDots", return_default=True))
self._counter += 1
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)
if self._ambient_dots:
self._update_track_dots(sm, radar_state, path_x_array)
else:
self._track_dots = []
self._transform_dirty = False
# Draw elements
self._draw_lane_lines()
self._draw_path(sm)
if self._ambient_dots:
self._update_vision_dots(sm)
self._draw_vision_dots()
if render_lead_indicator and radar_state:
if self._ambient_dots:
self._draw_track_dots()
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_track_dots(self, sm, radar_state, path_x_array):
self._track_dots = []
if not sm.valid['radarTracks']:
return
lead_track_ids = {lead.radarTrackId for lead in (radar_state.leadOne, radar_state.leadTwo)
if lead.status and lead.radarTrackId >= 0}
for pt in sm['radarTracks'].points:
if pt.trackId in lead_track_ids or pt.dRel < 1.0 or abs(pt.yRel) > 10.0:
continue
idx = self._get_path_length_idx(path_x_array, pt.dRel)
z = self._path.raw_points[idx, 2] if idx < len(self._path.raw_points) else 0.0
point = self._map_to_screen(pt.dRel, -pt.yRel + self._camera_offset, z + self._path_offset_z)
if point is None:
continue
sz = np.clip((25 * 30) / (pt.dRel / 3 + 30), 15.0, 30.0) * 1.1
radius = sz * 1.1
x, y = point
if not (self._rect.x <= x <= self._rect.x + self._rect.width and
self._rect.y <= y <= self._rect.y + self._rect.height):
continue
self._track_dots.append(LeadVehicle(center=(float(x), float(y)), radius=float(radius), sz=float(sz)))
def _update_vision_dots(self, sm):
# iqVehicleTracks arrives at a few Hz; the dots are eased toward the latest
# detection every render frame so they glide instead of teleporting.
hidden = (self._frame_transform is None or
not sm.alive['iqVehicleTracks'] or not sm.valid['iqVehicleTracks'])
if not hidden:
vt = sm['iqVehicleTracks']
hidden = bool(vt.wide) != self._frame_transform_wide or vt.frameWidth == 0 or vt.frameHeight == 0
if hidden:
for d in self._vision_dots:
d.talpha = 0.0
elif vt.frameId != self._vt_frame:
self._vt_frame = vt.frameId
self._retarget_vision_dots(vt)
for d in self._vision_dots:
d.x += (d.tx - d.x) * _VD_EASE
d.y += (d.ty - d.y) * _VD_EASE
d.radius += (d.tradius - d.radius) * _VD_EASE
d.alpha += (d.talpha - d.alpha) * _VD_EASE
self._vision_dots = [d for d in self._vision_dots if d.alpha > 0.02 or d.talpha > 0.0]
def _retarget_vision_dots(self, vt):
fw, fh = vt.frameWidth, vt.frameHeight
m = self._frame_transform
occupied = [d.center for d in self._lead_vehicles + self._track_dots if d.center is not None]
targets = []
for t in vt.tracks:
is_vehicle = t.label in VEHICLE_TRACK_LABELS
sign_color = SIGN_TRACK_COLORS.get(t.label)
if not is_vehicle and sign_color is None:
continue
cx_f = (t.x1 + t.x2) / 2.0 * fw
cy_f = (t.y1 + t.y2) / 2.0 * fh
x = m[0, 0] * cx_f + m[0, 2]
y = m[1, 1] * cy_f + m[1, 2]
if not (self._rect.x <= x <= self._rect.x + self._rect.width and
self._rect.y <= y <= self._rect.y + self._rect.height):
continue
box_h = (t.y2 - t.y1) * fh * m[1, 1]
radius = float(np.clip(box_h * 0.35, 14.0, 40.0))
if sign_color is not None:
targets.append((x, y, min(radius, 22.0), (sign_color.r, sign_color.g, sign_color.b)))
elif not any((x - ox) ** 2 + (y - oy) ** 2 < (radius * 2.2) ** 2 for ox, oy in occupied):
targets.append((x, y, radius, None))
dots = self._vision_dots
used = [False] * len(dots)
for tx, ty, tr, rgb in targets:
best, best_d2 = -1, 1e18
for i, d in enumerate(dots):
if used[i] or (d.rgb is None) != (rgb is None):
continue
d2 = (d.x - tx) ** 2 + (d.y - ty) ** 2
if d2 < best_d2:
best, best_d2 = i, d2
if best >= 0 and best_d2 <= (max(tr, dots[best].radius) * 3.0) ** 2:
d = dots[best]
used[best] = True
d.tx, d.ty, d.tradius, d.talpha, d.rgb = tx, ty, tr, 1.0, rgb
else:
dots.append(VisionDot(x=tx, y=ty, tx=tx, ty=ty, radius=tr, tradius=tr, alpha=0.0, talpha=1.0, rgb=rgb))
used.append(True)
for i, d in enumerate(dots):
if not used[i]:
d.talpha = 0.0
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
for i, lane_line in enumerate(self._lane_lines):
lane_line.projected_points = self._map_line_to_polygon(
lane_line.raw_points, 0.025 * self._lane_line_probs[i], 0.0, max_idx, max_distance
)
# 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, 0.025, 0.0, max_idx, max_distance)
# 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)
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, 0.9, self._path_offset_z, max_idx, max_distance, allow_invert=False
)
self._update_experimental_gradient()
def _update_experimental_gradient(self):
"""Pre-calculate experimental mode gradient colors"""
if not self._experimental_mode:
return
max_len = min(len(self._path.projected_points) // 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 = self._path.projected_points[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 = Gradient(
start=(0.0, 1.0), # Bottom of path
end=(0.0, 0.0), # Top of path
colors=segment_colors,
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) * 2.35
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 _draw_lane_lines(self):
"""Draw lane lines and road edges"""
for i, lane_line in enumerate(self._lane_lines):
if lane_line.projected_points.size == 0:
continue
alpha = np.clip(self._lane_line_probs[i], 0.0, 0.7)
color = rl.Color(255, 255, 255, int(alpha * 255))
draw_polygon(self._rect, lane_line.projected_points, color)
for i, road_edge in enumerate(self._road_edges):
if road_edge.projected_points.size == 0:
continue
alpha = np.clip(1.0 - self._road_edge_stds[i], 0.0, 1.0)
color = rl.Color(255, 0, 0, int(alpha * 255))
draw_polygon(self._rect, road_edge.projected_points, 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
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 len(self._exp_gradient.colors) > 1:
draw_polygon(self._rect, self._path.projected_points, gradient=self._exp_gradient)
else:
draw_polygon(self._rect, self._path.projected_points, 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],
)
draw_polygon(self._rect, self._path.projected_points, gradient=gradient)
# concentric layers (outer faint -> inner bright) build a soft center-out glow using only
# draw_circle, which is signature-stable across raylib versions (draw_circle_gradient is not)
_VD_GLOW = ((1.0, 26), (0.72, 38), (0.48, 54), (0.26, 78))
def _draw_vision_dots(self):
for dot in self._vision_dots:
a = dot.alpha
if a <= 0.02:
continue
x, y = int(dot.x), int(dot.y)
cr, cg, cb = (40, 210, 200) if dot.rgb is None else dot.rgb
for frac, base in self._VD_GLOW:
rl.draw_circle(x, y, dot.radius * frac, rl.Color(cr, cg, cb, int(base * a)))
def _draw_track_dots(self):
src = rl.Rectangle(0, 0, self._lead_orb.width, self._lead_orb.height)
for dot in self._track_dots:
cx, cy = dot.center
r = dot.radius
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(255, 255, 255, 90))
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_distance: float) -> int:
"""Get the index corresponding to the given path distance"""
if len(pos_x_array) == 0:
return 0
indices = np.where(pos_x_array <= path_distance)[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, max_distance: float, 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]
# Interpolate around max_idx so path end is smooth. Only when max_distance genuinely
if 0 < max_idx < line.shape[0] - 1:
p0 = line[max_idx]
p1 = line[max_idx + 1]
x0, x1 = p0[0], p1[0]
if x0 <= max_distance <= x1:
interp_y = np.interp(max_distance, [x0, x1], [p0[1], p1[1]])
interp_z = np.interp(max_distance, [x0, x1], [p0[2], p1[2]])
interp_point = np.array([max_distance, interp_y, interp_z], dtype=points.dtype)
points = np.concatenate((points, interp_point[None, :]), axis=0)
points = points[points[:, 0] >= 0.5]
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,265 @@
from collections import deque
import math
import numpy as np
import time
import threading
import wave
from iqpilot.cereal import car, messaging, custom
from iqpilot.common.basedir import BASEDIR
from iqpilot.common.filter_simple import FirstOrderFilter
from iqpilot.common.realtime import Ratekeeper
from iqpilot.common.utils import retry
from iqpilot.common.swaglog import cloudlog
from iqpilot.system import micd
from iqpilot.system.hardware import HARDWARE
from iqpilot.selfdrive.ui.alert_sound_filter import AlertSoundFilter
SAMPLE_RATE = 48000
SAMPLE_BUFFER = 4096 # (approx 100ms)
WEBRTC_AUDIO_BUFFER_FRAMES = SAMPLE_RATE # 1s max queued remote audio
WEBRTC_AUDIO_PREBUFFER_FRAMES = SAMPLE_RATE // 5 # 200ms before starting remote audio playback
MAX_VOLUME = 1.0
MIN_VOLUME = 0.1
ALERT_RAMP_TIME = 4 # seconds to ramp to max volume for warningImmediate
SELFDRIVE_STATE_TIMEOUT = 5 # 5 seconds
FILTER_DT = 1. / (micd.SAMPLE_RATE / micd.FFT_SAMPLES)
AMBIENT_DB = 30 # DB where MIN_VOLUME is applied
DB_SCALE = 30 # AMBIENT_DB + DB_SCALE is where MAX_VOLUME is applied
VOLUME_BASE = 20
if HARDWARE.get_device_type() == "tizi":
VOLUME_BASE = 10
AudibleAlert = car.CarControl.HUDControl.AudibleAlert
AudibleAlertIQ = custom.IQState.AudibleAlert
sound_list_iq: dict[int, tuple[str, int | None, float]] = {
# AudibleAlertIQ, file name, play count (none for infinite)
AudibleAlertIQ.promptSingleLow: ("prompt_single_low.wav", 1, MAX_VOLUME),
AudibleAlertIQ.promptSingleHigh: ("prompt_single_high.wav", 1, MAX_VOLUME),
}
sound_list: dict[int, tuple[str, int | None, float]] = {
# AudibleAlert, file name, play count (none for infinite)
AudibleAlert.engage: ("engage.wav", 1, MAX_VOLUME),
AudibleAlert.disengage: ("disengage.wav", 1, MAX_VOLUME),
AudibleAlert.refuse: ("refuse.wav", 1, MAX_VOLUME),
AudibleAlert.prompt: ("prompt.wav", 1, MAX_VOLUME),
AudibleAlert.promptRepeat: ("prompt.wav", None, MAX_VOLUME),
AudibleAlert.promptDistracted: ("prompt_distracted.wav", None, MAX_VOLUME),
AudibleAlert.warningSoft: ("warning_soft.wav", None, MAX_VOLUME),
AudibleAlert.warningImmediate: ("warning_immediate.wav", None, MAX_VOLUME),
**sound_list_iq,
}
def check_selfdrive_timeout_alert(sm):
ss_missing = time.monotonic() - sm.recv_time['selfdriveState']
if ss_missing > SELFDRIVE_STATE_TIMEOUT:
if sm['selfdriveState'].enabled and (ss_missing - SELFDRIVE_STATE_TIMEOUT) < 10:
return True
return False
class Soundd(AlertSoundFilter):
def __init__(self):
super().__init__()
self.load_sounds()
self.current_alert = AudibleAlert.none
self.current_volume = MIN_VOLUME
self.current_sound_frame = 0
self.ramp_start_volume = MIN_VOLUME
self.ramp_start_time = 0.
self.selfdrive_timeout_alert = False
self.spl_filter_weighted = FirstOrderFilter(0, 2.5, FILTER_DT, initialized=False)
self.webrtc_audio: deque[np.ndarray] = deque()
self.webrtc_audio_frames = 0
self.webrtc_audio_lock = threading.Lock()
self.webrtc_audio_playing = False
def load_sounds(self):
self.loaded_sounds: dict[int, np.ndarray] = {}
# Load all sounds
for sound in sound_list:
filename, play_count, volume = sound_list[sound]
with wave.open(BASEDIR + "/iqpilot/selfdrive/assets/sounds/" + filename, 'r') as wavefile:
assert wavefile.getnchannels() == 1
assert wavefile.getsampwidth() == 2
assert wavefile.getframerate() == SAMPLE_RATE
length = wavefile.getnframes()
self.loaded_sounds[sound] = np.frombuffer(wavefile.readframes(length), dtype=np.int16).astype(np.float32) / (2**16/2)
def get_sound_data(self, frames): # get "frames" worth of data from the current alert sound, looping when required
ret = np.zeros(frames, dtype=np.float32)
if self.permits(self.current_alert):
num_loops = sound_list[self.current_alert][1]
sound_data = self.loaded_sounds[self.current_alert]
written_frames = 0
current_sound_frame = self.current_sound_frame % len(sound_data)
loops = self.current_sound_frame // len(sound_data)
while written_frames < frames and (num_loops is None or loops < num_loops):
available_frames = sound_data.shape[0] - current_sound_frame
frames_to_write = min(available_frames, frames - written_frames)
ret[written_frames:written_frames+frames_to_write] = sound_data[current_sound_frame:current_sound_frame+frames_to_write]
written_frames += frames_to_write
self.current_sound_frame += frames_to_write
current_sound_frame = self.current_sound_frame % len(sound_data)
loops = self.current_sound_frame // len(sound_data)
return ret * self.current_volume
def add_webrtc_audio(self, audio_data) -> None:
data = np.frombuffer(audio_data.data, dtype=np.int16).astype(np.float32) / 32768.0
if data.size == 0:
return
with self.webrtc_audio_lock:
self.webrtc_audio.append(data)
self.webrtc_audio_frames += data.size
while self.webrtc_audio_frames > WEBRTC_AUDIO_BUFFER_FRAMES:
self.webrtc_audio_frames -= self.webrtc_audio.popleft().size
def get_webrtc_audio_data(self, frames: int) -> np.ndarray:
ret = np.zeros(frames, dtype=np.float32)
with self.webrtc_audio_lock:
if not self.webrtc_audio_playing:
if self.webrtc_audio_frames < WEBRTC_AUDIO_PREBUFFER_FRAMES:
return ret
self.webrtc_audio_playing = True
written_frames = 0
while written_frames < frames and self.webrtc_audio:
data = self.webrtc_audio[0]
frames_to_write = min(data.size, frames - written_frames)
ret[written_frames:written_frames+frames_to_write] = data[:frames_to_write]
written_frames += frames_to_write
self.webrtc_audio_frames -= frames_to_write
if frames_to_write == data.size:
self.webrtc_audio.popleft()
else:
self.webrtc_audio[0] = data[frames_to_write:]
if written_frames < frames:
self.webrtc_audio_playing = False
return ret
def webrtc_audio_thread(self) -> None:
webrtc_audio_sock = messaging.sub_sock('webrtcAudioData')
while True:
msg = messaging.recv_one(webrtc_audio_sock)
if msg is not None:
self.add_webrtc_audio(msg.webrtcAudioData)
def callback(self, data_out: np.ndarray, frames: int, time, status) -> None:
if status:
cloudlog.warning(f"soundd stream over/underflow: {status}")
audio = self.get_sound_data(frames) + self.get_webrtc_audio_data(frames)
data_out[:frames, 0] = np.clip(audio, -1.0, 1.0)
def update_alert(self, new_alert):
current_alert_played_once = self.current_alert == AudibleAlert.none or self.current_sound_frame >= len(self.loaded_sounds[self.current_alert])
if self.current_alert != new_alert and (new_alert != AudibleAlert.none or current_alert_played_once):
if new_alert == AudibleAlert.warningImmediate:
self.ramp_start_volume = self.current_volume
self.ramp_start_time = time.monotonic()
self.current_alert = new_alert
self.current_sound_frame = 0
def get_audible_alert(self, sm):
if sm.updated['selfdriveState']:
new_alert = sm['selfdriveState'].alertSound.raw
self.update_alert(new_alert)
elif check_selfdrive_timeout_alert(sm):
self.update_alert(AudibleAlert.warningImmediate)
self.selfdrive_timeout_alert = True
elif self.selfdrive_timeout_alert:
self.update_alert(AudibleAlert.none)
self.selfdrive_timeout_alert = False
def calculate_volume(self, weighted_db):
volume = ((weighted_db - AMBIENT_DB) / DB_SCALE) * (MAX_VOLUME - MIN_VOLUME) + MIN_VOLUME
return math.pow(VOLUME_BASE, (np.clip(volume, MIN_VOLUME, MAX_VOLUME) - 1))
@retry(attempts=10, delay=3)
def get_stream(self, sd):
# reload sounddevice to reinitialize portaudio
sd._terminate()
sd._initialize()
return sd.OutputStream(channels=1, samplerate=SAMPLE_RATE, callback=self.callback, blocksize=SAMPLE_BUFFER)
def soundd_thread(self):
# sounddevice must be imported after forking processes
import sounddevice as sd
sm = messaging.SubMaster(['selfdriveState', 'soundPressure'])
threading.Thread(target=self.webrtc_audio_thread, daemon=True).start()
while True:
try:
self._stream_loop(sd, sm)
except Exception:
# Some A1s wedge the audio DSP (ALSA EINVAL / ADSP_EFAILED until reboot). Dying here
# crash-loops the process and selfdrived raises a takeover alert mid-drive over alert
# sounds - stay alive and keep retrying instead; recovers if the DSP comes back.
cloudlog.exception("soundd: audio stream unavailable, retrying")
time.sleep(10)
def _stream_loop(self, sd, sm):
with self.get_stream(sd) as stream:
rk = Ratekeeper(20)
cloudlog.info(f"soundd stream started: {stream.samplerate=} {stream.channels=} {stream.dtype=} {stream.device=}, {stream.blocksize=}")
while True:
sm.update(0)
self.refresh()
if sm.updated['soundPressure'] and self.current_alert == AudibleAlert.none: # only update volume filter when not playing alert
self.spl_filter_weighted.update(sm["soundPressure"].soundPressureWeightedDb)
self.current_volume = self.calculate_volume(float(self.spl_filter_weighted.x))
self.get_audible_alert(sm)
# Ramp up immediate warning sound over 4s
if self.current_alert == AudibleAlert.warningImmediate:
elapsed = time.monotonic() - self.ramp_start_time
ramp_vol = float(np.interp(elapsed, [0, ALERT_RAMP_TIME], [self.ramp_start_volume, MAX_VOLUME]))
self.current_volume = max(self.current_volume, ramp_vol)
rk.keep_time()
assert stream.active
def main():
s = Soundd()
s.soundd_thread()
if __name__ == "__main__":
main()

9
iqpilot/selfdrive/ui/tests/.gitignore vendored Normal file
View File

@@ -0,0 +1,9 @@
test
test_translations
test_ui/report_1
test_ui/raylib_report
diff/*.mp4
diff/*.html
diff/.coverage
diff/htmlcov/

View File

View File

@@ -0,0 +1,35 @@
#!/usr/bin/env python3
import os
import sys
import time
import json
from iqpilot.common.basedir import BASEDIR
from iqpilot.common.params import Params
from iqpilot.selfdrive.selfdrived.alertmanager import set_offroad_alert
from iqpilot.system.updated.updated import parse_release_notes
if __name__ == "__main__":
params = Params()
with open(os.path.join(BASEDIR, "iqpilot/selfdrive/selfdrived/alerts_offroad.json")) as f:
offroad_alerts = json.load(f)
t = 10 if len(sys.argv) < 2 else int(sys.argv[1])
while True:
print("setting alert update")
params.put_bool("UpdateAvailable", True)
params.put("UpdaterNewReleaseNotes", parse_release_notes(BASEDIR))
time.sleep(t)
params.put_bool("UpdateAvailable", False)
# cycle through normal alerts
for a in offroad_alerts:
print("setting alert:", a)
set_offroad_alert(a, True)
time.sleep(t)
set_offroad_alert(a, False)
print("no alert")
time.sleep(t)

View File

@@ -0,0 +1,23 @@
import time
import pytest
import iqpilot.cereal.messaging as messaging
from iqpilot.system.manager.process_config import managed_processes
@pytest.mark.linux
def test_feedbackd_publishes_bookmark():
publisher = messaging.PubMaster(["bookmarkButton"])
subscriber = messaging.SubMaster(["userBookmark"])
process = managed_processes["feedbackd"]
process.start()
try:
assert publisher.wait_for_readers_to_update("bookmarkButton", timeout=5)
deadline = time.monotonic() + 5
while time.monotonic() < deadline and not subscriber.updated["userBookmark"]:
publisher.send("bookmarkButton", messaging.new_message("bookmarkButton"))
subscriber.update(100)
assert subscriber.updated["userBookmark"]
finally:
process.stop()

View File

@@ -0,0 +1,38 @@
from pathlib import Path
from iqpilot.selfdrive.ui.lib.local_routes import list_local_routes
def _touch(path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(b"")
def test_list_local_routes_from_segment_directories(tmp_path):
route_name = "00000051--3141cf1d76"
_touch(tmp_path / f"{route_name}--0" / "qcamera.ts")
_touch(tmp_path / f"{route_name}--1" / "qlog.zst")
routes = list_local_routes(tmp_path)
assert len(routes) == 1
assert routes[0].name == route_name
assert routes[0].segment_count == 2
assert routes[0].cameras == ("road",)
def test_list_local_routes_from_single_segment_directory(tmp_path):
route_name = "00000052--3141cf1d77"
_touch(tmp_path / f"{route_name}--0" / "qcamera.ts")
routes = list_local_routes(tmp_path)
assert len(routes) == 1
assert routes[0].name == route_name
assert routes[0].subtitle == "1:00 · Road Cam"
def test_list_local_routes_ignores_invalid_entries(tmp_path):
_touch(tmp_path / "not-a-route" / "fcamera.hevc")
assert list_local_routes(tmp_path) == []

View File

@@ -0,0 +1,71 @@
import json
from iqpilot.common.params import Params
from iqpilot.selfdrive.ui.lib.nav_helpers import current_or_last_gps_position, resolve_mapbox_token
def test_resolve_mapbox_token_reads_param(tmp_path):
params = Params(tmp_path.as_posix())
params.put("MapboxToken", "pk.test-token")
assert resolve_mapbox_token(params) == "pk.test-token"
def test_resolve_mapbox_token_missing_returns_empty(tmp_path):
params = Params(tmp_path.as_posix())
assert resolve_mapbox_token(params) == ""
def test_current_or_last_gps_position_uses_last_position_param(tmp_path):
params = Params(tmp_path.as_posix())
params.put("LastGPSPosition", json.dumps({
"latitude": 37.7749,
"longitude": -122.4194,
"bearing": 91.5,
}))
lat, lon, bearing, valid = current_or_last_gps_position(params)
assert valid
assert lat == 37.7749
assert lon == -122.4194
assert bearing == 91.5
def test_current_or_last_gps_position_uses_iqloc_position_param(tmp_path):
params = Params(tmp_path.as_posix())
params.put("LastGPSPositionIQLoc", json.dumps({
"latitude": 34.0522,
"longitude": -118.2437,
"bearingDeg": 12.0,
}))
lat, lon, bearing, valid = current_or_last_gps_position(params)
assert valid
assert lat == 34.0522
assert lon == -118.2437
assert bearing == 12.0
def test_current_or_last_gps_position_accepts_lat_lon_aliases(tmp_path):
params = Params(tmp_path.as_posix())
params.put("LastGPSPosition", json.dumps({
"lat": 40.7128,
"lng": -74.006,
}))
lat, lon, bearing, valid = current_or_last_gps_position(params)
assert valid
assert lat == 40.7128
assert lon == -74.006
assert bearing == 0.0
def test_current_or_last_gps_position_rejects_zero_position(tmp_path):
params = Params(tmp_path.as_posix())
params.put("LastGPSPosition", "{}")
assert current_or_last_gps_position(params) == (0.0, 0.0, 0.0, False)

View File

@@ -0,0 +1,72 @@
from types import SimpleNamespace
from iqpilot.ui.onroad.nav_map_utils import (
build_mapbox_static_url,
build_mapbox_tile_url,
choose_nav_camera,
mercator_world_px,
mercator_world_px_at_zoom,
project_nav_point,
project_nav_polyline,
tile_world_size,
)
def test_mercator_world_px_changes_with_longitude():
x1, y1 = mercator_world_px(41.8826, -87.6393, 16.0)
x2, y2 = mercator_world_px(41.8826, -87.6293, 16.0)
assert x2 > x1
assert abs(y2 - y1) < 1.0
def test_project_nav_point_centers_current_position():
x, y = project_nav_point(41.8826, -87.6393, 41.8826, -87.6393, 16.0, 90.0, 420.0, 420.0)
assert round(x, 3) == 210.0
assert round(y, 3) == 210.0
def test_project_nav_polyline_preserves_point_count():
points = [
SimpleNamespace(latitude=41.8826, longitude=-87.6422),
SimpleNamespace(latitude=41.8826, longitude=-87.6393),
SimpleNamespace(latitude=41.8830, longitude=-87.6366),
]
projected = project_nav_polyline(points, 41.8826, -87.6393, 16.0, 90.0, 420.0, 420.0)
assert len(projected) == len(points)
def test_choose_nav_camera_looks_ahead_of_vehicle():
points = [
SimpleNamespace(latitude=41.8826, longitude=-87.6393),
SimpleNamespace(latitude=41.8835, longitude=-87.6355),
]
center_lat, center_lon, zoom = choose_nav_camera(41.8826, -87.6393, 90.0, points, 420.0, 420.0, 16.2)
assert center_lon > -87.6393
assert 16.0 <= zoom <= 17.8
def test_build_mapbox_static_url_contains_expected_components():
url = build_mapbox_static_url(41.8826, -87.6393, 16.2, 90.0, 420, 420)
assert "navigation-night-v1/static/" in url
assert "-87.639300,41.882600,16.20,90.0,0/420x420@2x" in url
def test_build_mapbox_tile_url_contains_expected_components():
url = build_mapbox_tile_url(16, 10619, 24322)
assert "navigation-night-v1/tiles/256/16/10619/24322@2x" in url
def test_world_size_and_world_px_align_at_integer_zoom():
world_size = tile_world_size(16)
x, y = mercator_world_px_at_zoom(41.8826, -87.6393, 16)
assert 0.0 <= x <= world_size
assert 0.0 <= y <= world_size

View File

@@ -0,0 +1,95 @@
"""
Copyright (c) IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos
"""
from types import SimpleNamespace
from iqpilot.selfdrive.ui.lib import nav_search
class FakeParams:
def get(self, key, *args, **kwargs):
return {
"AmapWebServiceKey": "amap-key",
"OsmLocationName": "CN",
"MapboxToken": "mapbox-key",
}.get(key)
def test_china_search_uses_amap_without_calling_mapbox(monkeypatch):
search = nav_search.NavSearch.__new__(nav_search.NavSearch)
search._params = FakeParams()
search._seq = 1
search._results = []
search._searching = True
search._amap_adcode = ""
import threading
search._lock = threading.Lock()
amap_client = SimpleNamespace(
is_mainland_china_configured=lambda *args, **kwargs: True,
get_key=lambda *args, **kwargs: "amap-key",
status=lambda: "ok",
reverse_adcode=lambda *args, **kwargs: "",
autocomplete=lambda *args, **kwargs: [],
)
monkeypatch.setattr(nav_search, "_amap_client", amap_client)
monkeypatch.setattr(nav_search, "current_or_last_gps_position", lambda *_: (39.9, 116.4, 0.0, True))
monkeypatch.setattr(amap_client, "reverse_adcode", lambda *args, **kwargs: "110000")
monkeypatch.setattr(amap_client, "autocomplete", lambda *args, **kwargs: [
SimpleNamespace(
name="Tiananmen",
address="Dongcheng, Beijing",
provider_id="B000A83M61",
latitude=39.9087,
longitude=116.3975,
),
])
monkeypatch.setattr(nav_search.requests, "get", lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("Mapbox called")))
search._do_search("Tiananmen", 1)
assert len(search._results) == 1
assert search._results[0].provider == "amap"
assert search._results[0].has_coords
def test_non_china_search_preserves_mapbox_request(monkeypatch):
search = nav_search.NavSearch.__new__(nav_search.NavSearch)
search._params = FakeParams()
search._session = "session"
search._seq = 1
search._results = []
search._searching = True
search._amap_adcode = ""
import threading
search._lock = threading.Lock()
captured = {}
class Response:
def raise_for_status(self):
return None
def json(self):
return {"suggestions": []}
def get(url, *, params, timeout):
captured.update(url=url, params=params, timeout=timeout)
return Response()
monkeypatch.setattr(nav_search, "current_or_last_gps_position", lambda *_: (41.3, -90.2, 0.0, True))
monkeypatch.setattr(nav_search, "resolve_mapbox_token", lambda *_: "mapbox-key")
monkeypatch.setattr(nav_search, "_amap_client", None)
monkeypatch.setattr(nav_search.requests, "get", get)
search._do_search("Home", 1)
assert captured["url"] == f"{nav_search.SEARCHBOX}/suggest"
assert captured["params"] == {
"q": "Home",
"access_token": "mapbox-key",
"session_token": "session",
"limit": nav_search.MAX_RESULTS,
"language": "en",
"proximity": "-90.2,41.3",
}
assert captured["timeout"] == 8

View File

@@ -0,0 +1,39 @@
import sqlite3
from scripts.iqpilot.package_xyz_tiles_to_mbtiles import build_mbtiles
from scripts.iqpilot.render_raster_tiles_from_vector_mbtiles import tile_range_for_bounds
PNG_1X1 = (
b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01"
b"\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\rIDATx\x9cc\xf8\xcf"
b"\xc0\xf0\x1f\x00\x05\x00\x01\xff\x89\x99=\x1d\x00\x00\x00\x00IEND\xaeB`\x82"
)
def test_tile_range_for_bounds_returns_non_empty_ranges():
x_range, y_range = tile_range_for_bounds((-88.15, 41.65, -88.02, 41.76), 14)
assert len(list(x_range)) > 0
assert len(list(y_range)) > 0
def test_build_mbtiles_from_xyz_tiles(tmp_path):
source = tmp_path / "xyz"
tile_dir = source / "14" / "2625"
tile_dir.mkdir(parents=True)
(tile_dir / "6335@2x.png").write_bytes(PNG_1X1)
output = tmp_path / "offline.mbtiles"
build_mbtiles(source, output, bounds="-88.15,41.65,-88.02,41.76")
conn = sqlite3.connect(output)
try:
fmt = conn.execute("SELECT value FROM metadata WHERE name='format'").fetchone()[0]
bounds = conn.execute("SELECT value FROM metadata WHERE name='bounds'").fetchone()[0]
count = conn.execute("SELECT COUNT(*) FROM tiles").fetchone()[0]
finally:
conn.close()
assert fmt == "png"
assert bounds == "-88.15,41.65,-88.02,41.76"
assert count == 1

View File

@@ -0,0 +1,128 @@
import os
import sqlite3
import json
from iqpilot.ui.onroad import offline_tiles
PNG_1X1 = (
b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01"
b"\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\rIDATx\x9cc\xf8\xcf"
b"\xc0\xf0\x1f\x00\x05\x00\x01\xff\x89\x99=\x1d\x00\x00\x00\x00IEND\xaeB`\x82"
)
def _write_mbtiles(path):
conn = sqlite3.connect(path)
conn.execute("CREATE TABLE metadata (name text, value text)")
conn.execute("CREATE TABLE tiles (zoom_level integer, tile_column integer, tile_row integer, tile_data blob)")
conn.execute("INSERT INTO metadata (name, value) VALUES ('format', 'png')")
conn.execute("INSERT INTO metadata (name, value) VALUES ('minzoom', '1')")
conn.execute("INSERT INTO metadata (name, value) VALUES ('maxzoom', '3')")
conn.execute(
"INSERT INTO tiles (zoom_level, tile_column, tile_row, tile_data) VALUES (?, ?, ?, ?)",
(1, 1, offline_tiles.xyz_to_tms_y(1, 0), PNG_1X1),
)
conn.commit()
conn.close()
def test_xyz_to_tms_y():
assert offline_tiles.xyz_to_tms_y(1, 0) == 1
assert offline_tiles.xyz_to_tms_y(1, 1) == 0
def test_find_offline_mbtiles_path_from_env(tmp_path, monkeypatch):
mbtiles = tmp_path / "demo.mbtiles"
_write_mbtiles(mbtiles)
monkeypatch.setenv(offline_tiles.OFFLINE_MBTILES_ENV, str(mbtiles))
assert offline_tiles.find_offline_mbtiles_path() == mbtiles
def test_find_offline_xyz_root(tmp_path, monkeypatch):
root = tmp_path / "tiles"
(root / "15" / "10500").mkdir(parents=True)
monkeypatch.setenv(offline_tiles.OFFLINE_TILE_ROOT_ENV, str(root))
assert offline_tiles.find_offline_xyz_root() == root
def test_load_raster_tile_blob_from_mbtiles(tmp_path):
mbtiles = tmp_path / "offline.mbtiles"
_write_mbtiles(mbtiles)
conn = offline_tiles.open_mbtiles(mbtiles)
try:
assert offline_tiles.mbtiles_is_raster(conn) is True
assert offline_tiles.mbtiles_zoom_bounds(conn) == (1, 3)
assert offline_tiles.load_raster_tile_blob(conn, 1, 1, 0) == PNG_1X1
finally:
conn.close()
def test_load_raster_tile_blob_from_xyz_dir(tmp_path, monkeypatch):
root = tmp_path / "offline_tiles"
tile_path = root / "14" / "2625"
tile_path.mkdir(parents=True)
(tile_path / "6335@2x.png").write_bytes(PNG_1X1)
monkeypatch.setenv(offline_tiles.OFFLINE_TILE_ROOT_ENV, str(root))
assert offline_tiles.xyz_zoom_bounds(root) == (14, 14)
assert offline_tiles.load_raster_xyz_tile_blob(root, 14, 2625, 6335) == PNG_1X1
def test_find_offline_region_root_by_bounds(tmp_path, monkeypatch):
root = tmp_path / "offline_maps"
il = root / "regions" / "illinois"
ca = root / "regions" / "california"
(il / "tiles").mkdir(parents=True)
(ca / "tiles").mkdir(parents=True)
(il / "manifest.json").write_text(json.dumps({"mbtiles": {"bounds": "-91.6,36.9,-87.4,42.6"}}))
(ca / "manifest.json").write_text(json.dumps({"mbtiles": {"bounds": "-124.5,32.4,-114.1,42.1"}}))
monkeypatch.setenv(offline_tiles.OFFLINE_TILE_ROOT_ENV, str(root / "tiles"))
assert offline_tiles.find_offline_region_root(41.88, -87.63) == il
assert offline_tiles.find_offline_region_root(34.05, -118.24) == ca
def test_find_offline_mbtiles_path_uses_selected_region(tmp_path, monkeypatch):
root = tmp_path / "offline_maps"
il = root / "regions" / "illinois"
ca = root / "regions" / "california"
(il / "tiles").mkdir(parents=True)
(ca / "tiles").mkdir(parents=True)
(il / "manifest.json").write_text(json.dumps({"mbtiles": {"bounds": "-91.6,36.9,-87.4,42.6"}}))
(ca / "manifest.json").write_text(json.dumps({"mbtiles": {"bounds": "-124.5,32.4,-114.1,42.1"}}))
_write_mbtiles(il / "tiles" / "offline.mbtiles")
_write_mbtiles(ca / "tiles" / "offline.mbtiles")
monkeypatch.setenv(offline_tiles.OFFLINE_TILE_ROOT_ENV, str(root / "tiles"))
assert offline_tiles.find_offline_mbtiles_path(41.88, -87.63) == il / "tiles" / "offline.mbtiles"
assert offline_tiles.find_offline_mbtiles_path(34.05, -118.24) == ca / "tiles" / "offline.mbtiles"
def test_day_variant_selection(tmp_path, monkeypatch):
root = tmp_path / "offline_maps"
region = root / "regions" / "us_state.IL"
(region / "tiles").mkdir(parents=True)
(region / "manifest.json").write_text(json.dumps({"mbtiles": {"bounds": "-88.3,41.5,-87.8,41.9"}}))
_write_mbtiles(region / "tiles" / "offline.mbtiles")
monkeypatch.setenv(offline_tiles.OFFLINE_TILE_ROOT_ENV, str(root / "tiles"))
offline_tiles._region_roots_cache = None
offline_tiles._region_bounds_cache.clear()
# no day variant yet: day request falls back to the night set
night = region / "tiles" / "offline.mbtiles"
assert offline_tiles.find_offline_mbtiles_path(41.7, -88.0, day=True) == night
assert offline_tiles.find_offline_mbtiles_path(41.7, -88.0, day=False) == night
# day variant installed: day requests prefer it, night unchanged
_write_mbtiles(region / "tiles" / "offline_day.mbtiles")
assert offline_tiles.find_offline_mbtiles_path(41.7, -88.0, day=True) == region / "tiles" / "offline_day.mbtiles"
assert offline_tiles.find_offline_mbtiles_path(41.7, -88.0, day=False) == night
def test_solar_elevation_day_night():
from iqpilot.ui.onroad.nav_map_utils import solar_elevation_deg
# Chicago 2026-07-11: 18:00 UTC (1pm CDT) is day; 06:00 UTC (1am CDT) is night
noon_utc = 1783792800.0 # 2026-07-11 18:00:00 UTC
night_utc = noon_utc - 12 * 3600
assert solar_elevation_deg(41.88, -87.63, noon_utc) > 30.0
assert solar_elevation_deg(41.88, -87.63, night_utc) < -10.0

View File

@@ -0,0 +1,27 @@
import time
import pytest
from iqpilot.selfdrive.test.helpers import with_processes
from iqpilot.system.ui.lib import application
from iqpilot.system.ui.lib.utils import gui_style_color
@pytest.mark.linux
@with_processes(["ui"])
def test_raylib_ui():
"""Test initialization of the UI widgets is successful."""
time.sleep(1)
def test_style_colors_match_gui_style_abi(monkeypatch):
calls = []
monkeypatch.setattr(application.rl, "gui_set_style", lambda *args: calls.append(args))
monkeypatch.setattr(application, "gui_style_color", lambda color: 27)
application.GuiApplication._set_styles(None)
assert [call[2] for call in calls[-3:]] == [27, 27, 27]
def test_gui_style_color_uses_binding_value_type():
color = application.rl.Color(229, 229, 229, 255)
value_type = application.rl.ffi.typeof(application.rl.raylib.GuiSetStyle).args[2]
expected = int(application.rl.ffi.cast(value_type, application.rl.color_to_int(color)))
assert gui_style_color(color) == expected

View File

@@ -0,0 +1,56 @@
from types import SimpleNamespace
import pytest
from iqpilot.selfdrive.ui.layouts.main import MainLayout, MainState
def make_layout(current_mode):
layout = object.__new__(MainLayout)
layout._current_mode = current_mode
layout._set_mode_calls = []
layout._set_mode_for_state = lambda: layout._set_mode_calls.append(current_mode)
return layout
class FakeSm:
def __init__(self, v_ego, carstate_valid):
self.valid = {"carState": carstate_valid}
self._v_ego = v_ego
def __getitem__(self, key):
return SimpleNamespace(vEgo=self._v_ego)
class TestSettingsInteractiveTimeout:
def _run(self, current_mode, started, v_ego, carstate_valid=True):
layout = make_layout(current_mode)
fake = SimpleNamespace(started=started, sm=FakeSm(v_ego, carstate_valid))
monkeypatch = pytest.MonkeyPatch()
monkeypatch.setattr("iqpilot.selfdrive.ui.layouts.main.ui_state", fake)
try:
layout._on_interactive_timeout()
finally:
monkeypatch.undo()
return layout._set_mode_calls
def test_stationary_in_settings_stays(self):
# parked/charging hybrid reads onroad; the timeout must not eject from Settings
assert self._run(MainState.SETTINGS, started=True, v_ego=0.0) == []
def test_moving_in_settings_returns_to_road(self):
assert self._run(MainState.SETTINGS, started=True, v_ego=5.0) == [MainState.SETTINGS]
def test_onroad_layout_always_handled(self):
assert self._run(MainState.ONROAD, started=True, v_ego=0.0) == [MainState.ONROAD]
def test_home_layout_always_handled(self):
assert self._run(MainState.HOME, started=True, v_ego=0.0) == [MainState.HOME]
def test_offroad_in_settings_handled(self):
# car off (offroad): existing behavior is unchanged
assert self._run(MainState.SETTINGS, started=False, v_ego=0.0) == [MainState.SETTINGS]
def test_invalid_carstate_treated_as_moving(self):
# if speed is unknown, fail safe to the road view rather than trapping in settings
assert self._run(MainState.SETTINGS, started=True, v_ego=0.0, carstate_valid=False) == [MainState.SETTINGS]

View File

@@ -0,0 +1,34 @@
from types import SimpleNamespace
from iqpilot.cereal import car
from iqpilot.selfdrive.ui import soundd
AudibleAlert = car.CarControl.HUDControl.AudibleAlert
class TestSoundd:
def test_check_selfdrive_timeout_alert(self, monkeypatch):
class FakeSubMaster:
recv_time = {'selfdriveState': 100.0}
def __init__(self, enabled):
self.state = SimpleNamespace(enabled=enabled)
def __getitem__(self, service):
assert service == 'selfdriveState'
return self.state
enabled = FakeSubMaster(True)
disabled = FakeSubMaster(False)
monkeypatch.setattr(soundd.time, "monotonic", lambda: 100.0 + soundd.SELFDRIVE_STATE_TIMEOUT)
assert not soundd.check_selfdrive_timeout_alert(enabled)
monkeypatch.setattr(soundd.time, "monotonic", lambda: 101.0 + soundd.SELFDRIVE_STATE_TIMEOUT)
assert soundd.check_selfdrive_timeout_alert(enabled)
assert not soundd.check_selfdrive_timeout_alert(disabled)
monkeypatch.setattr(soundd.time, "monotonic", lambda: 110.0 + soundd.SELFDRIVE_STATE_TIMEOUT)
assert not soundd.check_selfdrive_timeout_alert(enabled)
# TODO: add test with micd for checking that soundd actually outputs sounds

View File

@@ -0,0 +1,48 @@
import gettext
import json
import re
import pytest
from iqpilot.system.ui.lib.multilang import LANGUAGES_FILE, TRANSLATIONS_DIR
FORMAT_ARG = re.compile(r"%(?:\([^)]+\))?[#0+\-]?(?:\d+|\*)?(?:\.\d+|\.\*)?[hlL]?[diouxXeEfFgGcrsa%]")
with LANGUAGES_FILE.open(encoding="utf-8") as stream:
LANGUAGES = json.load(stream)
def load_catalog(language_code: str) -> dict[str | tuple[str, int], str]:
with TRANSLATIONS_DIR.joinpath(f"app_{language_code}.mo").open("rb") as stream:
return gettext.GNUTranslations(stream)._catalog
def message_keys(catalog: dict[str | tuple[str, int], str]) -> set[str]:
return {key for key in catalog if isinstance(key, str) and key}
def format_args(text: str) -> list[str]:
return sorted(match for match in FORMAT_ARG.findall(text) if match != "%%")
def test_language_codes_are_unique():
assert len(LANGUAGES) == len(set(LANGUAGES.values()))
@pytest.mark.parametrize("language_code", LANGUAGES.values(), ids=LANGUAGES.keys())
def test_translation_catalog_is_complete(language_code):
source = load_catalog("en")
translated = load_catalog(language_code)
assert message_keys(translated) == message_keys(source)
@pytest.mark.parametrize("language_code", LANGUAGES.values(), ids=LANGUAGES.keys())
def test_translation_catalog_entries(language_code):
catalog = load_catalog(language_code)
for source, translated in catalog.items():
if not isinstance(source, str) or not source:
continue
assert translated
assert format_args(translated) == format_args(source)

View File

@@ -0,0 +1,3 @@
# Multilanguage
[![languages](https://raw.githubusercontent.com/commaai/openpilot/badges/translation_badge.svg)](#)

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More