IQ.Pilot Prebuilt Release @ 27f668a
This commit is contained in:
1
iqpilot/selfdrive/ui/.gitignore
vendored
Normal file
1
iqpilot/selfdrive/ui/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
installer/installers/*
|
||||
1
iqpilot/selfdrive/ui/__init__.py
Normal file
1
iqpilot/selfdrive/ui/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
UI_BORDER_SIZE = 30 #
|
||||
35
iqpilot/selfdrive/ui/alert_sound_filter.py
Normal file
35
iqpilot/selfdrive/ui/alert_sound_filter.py
Normal 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
|
||||
70
iqpilot/selfdrive/ui/feedback/feedbackd.py
Executable file
70
iqpilot/selfdrive/ui/feedback/feedbackd.py
Executable 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()
|
||||
4
iqpilot/selfdrive/ui/installer/continue_openpilot.sh
Executable file
4
iqpilot/selfdrive/ui/installer/continue_openpilot.sh
Executable file
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
cd /data/openpilot
|
||||
exec ./launch_openpilot.sh
|
||||
BIN
iqpilot/selfdrive/ui/installer/inter-ascii.ttf
Normal file
BIN
iqpilot/selfdrive/ui/installer/inter-ascii.ttf
Normal file
Binary file not shown.
0
iqpilot/selfdrive/ui/layouts/__init__.py
Normal file
0
iqpilot/selfdrive/ui/layouts/__init__.py
Normal file
957
iqpilot/selfdrive/ui/layouts/home.py
Normal file
957
iqpilot/selfdrive/ui/layouts/home.py
Normal 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
|
||||
484
iqpilot/selfdrive/ui/layouts/main.py
Normal file
484
iqpilot/selfdrive/ui/layouts/main.py
Normal 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)
|
||||
562
iqpilot/selfdrive/ui/layouts/nav.py
Normal file
562
iqpilot/selfdrive/ui/layouts/nav.py
Normal 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
|
||||
113
iqpilot/selfdrive/ui/layouts/onboarding.py
Normal file
113
iqpilot/selfdrive/ui/layouts/onboarding.py
Normal 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
|
||||
194
iqpilot/selfdrive/ui/layouts/routes.py
Normal file
194
iqpilot/selfdrive/ui/layouts/routes.py
Normal 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
|
||||
9
iqpilot/selfdrive/ui/layouts/settings/common.py
Normal file
9
iqpilot/selfdrive/ui/layouts/settings/common.py
Normal 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)
|
||||
155
iqpilot/selfdrive/ui/layouts/settings/developer.py
Normal file
155
iqpilot/selfdrive/ui/layouts/settings/developer.py
Normal 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)
|
||||
201
iqpilot/selfdrive/ui/layouts/settings/device.py
Normal file
201
iqpilot/selfdrive/ui/layouts/settings/device.py
Normal 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)
|
||||
170
iqpilot/selfdrive/ui/layouts/settings/settings.py
Normal file
170
iqpilot/selfdrive/ui/layouts/settings/settings.py
Normal 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()
|
||||
262
iqpilot/selfdrive/ui/layouts/settings/software.py
Normal file
262
iqpilot/selfdrive/ui/layouts/settings/software.py
Normal 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)
|
||||
343
iqpilot/selfdrive/ui/layouts/settings/toggles.py
Normal file
343
iqpilot/selfdrive/ui/layouts/settings/toggles.py
Normal 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()
|
||||
557
iqpilot/selfdrive/ui/layouts/settings_hub.py
Normal file
557
iqpilot/selfdrive/ui/layouts/settings_hub.py
Normal 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)
|
||||
325
iqpilot/selfdrive/ui/layouts/sidebar.py
Normal file
325
iqpilot/selfdrive/ui/layouts/sidebar.py
Normal 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,
|
||||
)
|
||||
32
iqpilot/selfdrive/ui/layouts/stats.py
Normal file
32
iqpilot/selfdrive/ui/layouts/stats.py
Normal 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)
|
||||
1017
iqpilot/selfdrive/ui/layouts/video_player.py
Normal file
1017
iqpilot/selfdrive/ui/layouts/video_player.py
Normal file
File diff suppressed because it is too large
Load Diff
18
iqpilot/selfdrive/ui/lib/api_helpers.py
Normal file
18
iqpilot/selfdrive/ui/lib/api_helpers.py
Normal 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)))
|
||||
101
iqpilot/selfdrive/ui/lib/cloud_routes_shim.py
Normal file
101
iqpilot/selfdrive/ui/lib/cloud_routes_shim.py
Normal 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
|
||||
243
iqpilot/selfdrive/ui/lib/local_routes.py
Normal file
243
iqpilot/selfdrive/ui/lib/local_routes.py
Normal 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 spring–autumn, 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]
|
||||
35
iqpilot/selfdrive/ui/lib/motd.py
Normal file
35
iqpilot/selfdrive/ui/lib/motd.py
Normal 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)
|
||||
151
iqpilot/selfdrive/ui/lib/nav_helpers.py
Normal file
151
iqpilot/selfdrive/ui/lib/nav_helpers.py
Normal 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
|
||||
319
iqpilot/selfdrive/ui/lib/nav_search.py
Normal file
319
iqpilot/selfdrive/ui/lib/nav_search.py
Normal 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)
|
||||
144
iqpilot/selfdrive/ui/lib/prime_state.py
Normal file
144
iqpilot/selfdrive/ui/lib/prime_state.py
Normal 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()
|
||||
36
iqpilot/selfdrive/ui/lib/solar.py
Normal file
36
iqpilot/selfdrive/ui/lib/solar.py
Normal 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
|
||||
55
iqpilot/selfdrive/ui/lib/wifi_ssid.py
Normal file
55
iqpilot/selfdrive/ui/lib/wifi_ssid.py
Normal 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
|
||||
0
iqpilot/selfdrive/ui/mici/layouts/__init__.py
Normal file
0
iqpilot/selfdrive/ui/mici/layouts/__init__.py
Normal file
422
iqpilot/selfdrive/ui/mici/layouts/home.py
Normal file
422
iqpilot/selfdrive/ui/mici/layouts/home.py
Normal 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
|
||||
134
iqpilot/selfdrive/ui/mici/layouts/main.py
Normal file
134
iqpilot/selfdrive/ui/mici/layouts/main.py
Normal 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)
|
||||
309
iqpilot/selfdrive/ui/mici/layouts/offroad_alerts.py
Normal file
309
iqpilot/selfdrive/ui/mici/layouts/offroad_alerts.py
Normal 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)
|
||||
533
iqpilot/selfdrive/ui/mici/layouts/onboarding.py
Normal file
533
iqpilot/selfdrive/ui/mici/layouts/onboarding.py
Normal 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
|
||||
101
iqpilot/selfdrive/ui/mici/layouts/settings/cruise.py
Normal file
101
iqpilot/selfdrive/ui/mici/layouts/settings/cruise.py
Normal 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()
|
||||
38
iqpilot/selfdrive/ui/mici/layouts/settings/dashcam.py
Normal file
38
iqpilot/selfdrive/ui/mici/layouts/settings/dashcam.py
Normal 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))
|
||||
129
iqpilot/selfdrive/ui/mici/layouts/settings/developer.py
Normal file
129
iqpilot/selfdrive/ui/mici/layouts/settings/developer.py
Normal 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()
|
||||
278
iqpilot/selfdrive/ui/mici/layouts/settings/device.py
Normal file
278
iqpilot/selfdrive/ui/mici/layouts/settings/device.py
Normal 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)
|
||||
56
iqpilot/selfdrive/ui/mici/layouts/settings/display.py
Normal file
56
iqpilot/selfdrive/ui/mici/layouts/settings/display.py
Normal 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()
|
||||
89
iqpilot/selfdrive/ui/mici/layouts/settings/drive_history.py
Normal file
89
iqpilot/selfdrive/ui/mici/layouts/settings/drive_history.py
Normal 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)
|
||||
189
iqpilot/selfdrive/ui/mici/layouts/settings/iq_widgets.py
Normal file
189
iqpilot/selfdrive/ui/mici/layouts/settings/iq_widgets.py
Normal 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()
|
||||
603
iqpilot/selfdrive/ui/mici/layouts/settings/models.py
Normal file
603
iqpilot/selfdrive/ui/mici/layouts/settings/models.py
Normal 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()
|
||||
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
|
||||
"""
|
||||
189
iqpilot/selfdrive/ui/mici/layouts/settings/network/esim_ui.py
Normal file
189
iqpilot/selfdrive/ui/mici/layouts/settings/network/esim_ui.py
Normal 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)
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
443
iqpilot/selfdrive/ui/mici/layouts/settings/network/wifi_ui.py
Normal file
443
iqpilot/selfdrive/ui/mici/layouts/settings/network/wifi_ui.py
Normal 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
|
||||
131
iqpilot/selfdrive/ui/mici/layouts/settings/settings.py
Normal file
131
iqpilot/selfdrive/ui/mici/layouts/settings/settings.py
Normal 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)
|
||||
365
iqpilot/selfdrive/ui/mici/layouts/settings/software.py
Normal file
365
iqpilot/selfdrive/ui/mici/layouts/settings/software.py
Normal 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,
|
||||
])
|
||||
154
iqpilot/selfdrive/ui/mici/layouts/settings/steering.py
Normal file
154
iqpilot/selfdrive/ui/mici/layouts/settings/steering.py
Normal 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()
|
||||
48
iqpilot/selfdrive/ui/mici/layouts/settings/toggles.py
Normal file
48
iqpilot/selfdrive/ui/mici/layouts/settings/toggles.py
Normal 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))
|
||||
227
iqpilot/selfdrive/ui/mici/layouts/settings/vehicle.py
Normal file
227
iqpilot/selfdrive/ui/mici/layouts/settings/vehicle.py
Normal 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()
|
||||
26
iqpilot/selfdrive/ui/mici/layouts/settings/visuals.py
Normal file
26
iqpilot/selfdrive/ui/mici/layouts/settings/visuals.py
Normal 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()
|
||||
12
iqpilot/selfdrive/ui/mici/onroad/__init__.py
Normal file
12
iqpilot/selfdrive/ui/mici/onroad/__init__.py
Normal 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))
|
||||
369
iqpilot/selfdrive/ui/mici/onroad/alert_renderer.py
Normal file
369
iqpilot/selfdrive/ui/mici/onroad/alert_renderer.py
Normal 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)
|
||||
493
iqpilot/selfdrive/ui/mici/onroad/augmented_road_view.py
Normal file
493
iqpilot/selfdrive/ui/mici/onroad/augmented_road_view.py
Normal 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()
|
||||
418
iqpilot/selfdrive/ui/mici/onroad/cameraview.py
Normal file
418
iqpilot/selfdrive/ui/mici/onroad/cameraview.py
Normal 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))
|
||||
86
iqpilot/selfdrive/ui/mici/onroad/confidence_ball.py
Normal file
86
iqpilot/selfdrive/ui/mici/onroad/confidence_ball.py
Normal 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)
|
||||
246
iqpilot/selfdrive/ui/mici/onroad/driver_camera_dialog.py
Normal file
246
iqpilot/selfdrive/ui/mici/onroad/driver_camera_dialog.py
Normal 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()
|
||||
213
iqpilot/selfdrive/ui/mici/onroad/driver_state.py
Normal file
213
iqpilot/selfdrive/ui/mici/onroad/driver_state.py
Normal 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)
|
||||
279
iqpilot/selfdrive/ui/mici/onroad/hud_renderer.py
Normal file
279
iqpilot/selfdrive/ui/mici/onroad/hud_renderer.py
Normal 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)
|
||||
501
iqpilot/selfdrive/ui/mici/onroad/model_renderer.py
Normal file
501
iqpilot/selfdrive/ui/mici/onroad/model_renderer.py
Normal 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)]
|
||||
270
iqpilot/selfdrive/ui/mici/onroad/torque_bar.py
Normal file
270
iqpilot/selfdrive/ui/mici/onroad/torque_bar.py
Normal 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)))
|
||||
834
iqpilot/selfdrive/ui/mici/widgets/button.py
Normal file
834
iqpilot/selfdrive/ui/mici/widgets/button.py
Normal 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]))
|
||||
417
iqpilot/selfdrive/ui/mici/widgets/dialog.py
Normal file
417
iqpilot/selfdrive/ui/mici/widgets/dialog.py
Normal 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)
|
||||
265
iqpilot/selfdrive/ui/mici/widgets/drum_picker.py
Normal file
265
iqpilot/selfdrive/ui/mici/widgets/drum_picker.py
Normal 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
|
||||
196
iqpilot/selfdrive/ui/mici/widgets/pairing_dialog.py
Normal file
196
iqpilot/selfdrive/ui/mici/widgets/pairing_dialog.py
Normal 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
|
||||
31
iqpilot/selfdrive/ui/mici/widgets/side_button.py
Normal file
31
iqpilot/selfdrive/ui/mici/widgets/side_button.py
Normal 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
|
||||
538
iqpilot/selfdrive/ui/mici/widgets/stock_button.py
Normal file
538
iqpilot/selfdrive/ui/mici/widgets/stock_button.py
Normal 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())
|
||||
264
iqpilot/selfdrive/ui/mici/widgets/stock_dialog.py
Normal file
264
iqpilot/selfdrive/ui/mici/widgets/stock_dialog.py
Normal 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)
|
||||
195
iqpilot/selfdrive/ui/mici/widgets/stock_pairing_dialog.py
Normal file
195
iqpilot/selfdrive/ui/mici/widgets/stock_pairing_dialog.py
Normal 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
|
||||
0
iqpilot/selfdrive/ui/onroad/__init__.py
Normal file
0
iqpilot/selfdrive/ui/onroad/__init__.py
Normal file
186
iqpilot/selfdrive/ui/onroad/alert_renderer.py
Normal file
186
iqpilot/selfdrive/ui/onroad/alert_renderer.py
Normal 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)
|
||||
312
iqpilot/selfdrive/ui/onroad/augmented_road_view.py
Normal file
312
iqpilot/selfdrive/ui/onroad/augmented_road_view.py
Normal 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()
|
||||
367
iqpilot/selfdrive/ui/onroad/cameraview.py
Normal file
367
iqpilot/selfdrive/ui/onroad/cameraview.py
Normal 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))
|
||||
111
iqpilot/selfdrive/ui/onroad/driver_camera_dialog.py
Normal file
111
iqpilot/selfdrive/ui/onroad/driver_camera_dialog.py
Normal 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()
|
||||
231
iqpilot/selfdrive/ui/onroad/driver_state.py
Normal file
231
iqpilot/selfdrive/ui/onroad/driver_state.py
Normal 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
|
||||
141
iqpilot/selfdrive/ui/onroad/environment_renderer.py
Normal file
141
iqpilot/selfdrive/ui/onroad/environment_renderer.py
Normal 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))
|
||||
87
iqpilot/selfdrive/ui/onroad/exp_button.py
Normal file
87
iqpilot/selfdrive/ui/onroad/exp_button.py
Normal 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
|
||||
251
iqpilot/selfdrive/ui/onroad/hud_renderer.py
Normal file
251
iqpilot/selfdrive/ui/onroad/hud_renderer.py
Normal 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)
|
||||
612
iqpilot/selfdrive/ui/onroad/model_renderer.py
Normal file
612
iqpilot/selfdrive/ui/onroad/model_renderer.py
Normal 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)]
|
||||
265
iqpilot/selfdrive/ui/soundd.py
Normal file
265
iqpilot/selfdrive/ui/soundd.py
Normal 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
9
iqpilot/selfdrive/ui/tests/.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
test
|
||||
test_translations
|
||||
test_ui/report_1
|
||||
test_ui/raylib_report
|
||||
|
||||
diff/*.mp4
|
||||
diff/*.html
|
||||
diff/.coverage
|
||||
diff/htmlcov/
|
||||
0
iqpilot/selfdrive/ui/tests/__init__.py
Normal file
0
iqpilot/selfdrive/ui/tests/__init__.py
Normal file
35
iqpilot/selfdrive/ui/tests/cycle_offroad_alerts.py
Executable file
35
iqpilot/selfdrive/ui/tests/cycle_offroad_alerts.py
Executable 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)
|
||||
23
iqpilot/selfdrive/ui/tests/test_feedbackd.py
Normal file
23
iqpilot/selfdrive/ui/tests/test_feedbackd.py
Normal 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()
|
||||
38
iqpilot/selfdrive/ui/tests/test_local_routes.py
Normal file
38
iqpilot/selfdrive/ui/tests/test_local_routes.py
Normal 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) == []
|
||||
71
iqpilot/selfdrive/ui/tests/test_nav_helpers.py
Normal file
71
iqpilot/selfdrive/ui/tests/test_nav_helpers.py
Normal 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)
|
||||
72
iqpilot/selfdrive/ui/tests/test_nav_map_utils.py
Normal file
72
iqpilot/selfdrive/ui/tests/test_nav_map_utils.py
Normal 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
|
||||
95
iqpilot/selfdrive/ui/tests/test_nav_search.py
Normal file
95
iqpilot/selfdrive/ui/tests/test_nav_search.py
Normal 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
|
||||
39
iqpilot/selfdrive/ui/tests/test_offline_raster_pipeline.py
Normal file
39
iqpilot/selfdrive/ui/tests/test_offline_raster_pipeline.py
Normal 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
|
||||
128
iqpilot/selfdrive/ui/tests/test_offline_tiles.py
Normal file
128
iqpilot/selfdrive/ui/tests/test_offline_tiles.py
Normal 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
|
||||
27
iqpilot/selfdrive/ui/tests/test_raylib_ui.py
Normal file
27
iqpilot/selfdrive/ui/tests/test_raylib_ui.py
Normal 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
|
||||
@@ -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]
|
||||
34
iqpilot/selfdrive/ui/tests/test_soundd.py
Normal file
34
iqpilot/selfdrive/ui/tests/test_soundd.py
Normal 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
|
||||
48
iqpilot/selfdrive/ui/tests/test_translations.py
Normal file
48
iqpilot/selfdrive/ui/tests/test_translations.py
Normal 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)
|
||||
3
iqpilot/selfdrive/ui/translations/README.md
Normal file
3
iqpilot/selfdrive/ui/translations/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Multilanguage
|
||||
|
||||
[](#)
|
||||
4128
iqpilot/selfdrive/ui/translations/app.pot
Normal file
4128
iqpilot/selfdrive/ui/translations/app.pot
Normal file
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
Reference in New Issue
Block a user