mirror of
https://jihulab.com/mr-one/openpilot.git
synced 2026-10-26 20:43:49 +08:00
ui: remove unused mici main interface
This commit is contained in:
@@ -5,7 +5,7 @@ from openpilot.system.ui.lib.multilang import tr, tr_noop
|
||||
from openpilot.system.ui.lib.text_measure import measure_text_cached
|
||||
from openpilot.system.ui.lib.scroll_panel import GuiScrollPanel
|
||||
from openpilot.system.ui.lib.wrap_text import wrap_text
|
||||
from openpilot.selfdrive.ui.mici.layouts.settings.firehose import FirehoseLayoutBase
|
||||
from openpilot.selfdrive.ui.layouts.settings.firehose_base import FirehoseLayoutBase
|
||||
|
||||
TITLE = tr_noop("Firehose Mode")
|
||||
DESCRIPTION = tr_noop(
|
||||
|
||||
-5
@@ -15,7 +15,6 @@ from openpilot.system.ui.lib.wrap_text import wrap_text
|
||||
from openpilot.system.ui.lib.scroll_panel2 import GuiScrollPanel2
|
||||
from openpilot.system.ui.lib.multilang import tr, trn, tr_noop
|
||||
from openpilot.system.ui.widgets import Widget
|
||||
from openpilot.system.ui.widgets.scroller import NavRawScrollPanel
|
||||
|
||||
TITLE = tr_noop("Firehose Mode")
|
||||
DESCRIPTION = tr_noop(
|
||||
@@ -218,7 +217,3 @@ class FirehoseLayoutBase(Widget):
|
||||
if not ui_state.started and device._awake:
|
||||
self._fetch_firehose_stats()
|
||||
time.sleep(self.UPDATE_INTERVAL)
|
||||
|
||||
|
||||
class FirehoseLayout(NavRawScrollPanel, FirehoseLayoutBase):
|
||||
pass
|
||||
@@ -1,245 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import math
|
||||
|
||||
from openpilot.cereal import log
|
||||
import pyray as rl
|
||||
from collections.abc import Callable
|
||||
from openpilot.system.ui.widgets import Widget
|
||||
from openpilot.system.ui.widgets.layouts import HBoxLayout
|
||||
from openpilot.system.ui.widgets.icon_widget import IconWidget
|
||||
from openpilot.system.ui.widgets.label import UnifiedLabel, gui_label
|
||||
from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos, TextAlignment, TextAlignmentVertical
|
||||
from openpilot.selfdrive.ui.ui_state import ui_state, ChestnutState
|
||||
from openpilot.common.version import RELEASE_BRANCHES
|
||||
|
||||
HEAD_BUTTON_FONT_SIZE = 40
|
||||
HOME_PADDING = 8
|
||||
ALERTS_ZONE_WIDTH = 180
|
||||
|
||||
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 AlertsPill(Widget):
|
||||
ICON_OFFSET = 12
|
||||
COUNT_OFFSET = 40
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.set_rect(rl.Rectangle(0, 0, 104, 52))
|
||||
|
||||
self._pill_bg_txt = gui_app.texture("icons_mici/alerts_pill.png", 104, 52)
|
||||
self._alert_count_callback: Callable[[], int] | None = None
|
||||
self._alert_icon_callback: Callable[[], rl.Texture | None] | None = None
|
||||
|
||||
def set_alert_count_callback(self, callback: Callable[[], int] | None,
|
||||
icon_callback: Callable[[], rl.Texture | None] | None = None):
|
||||
self._alert_count_callback = callback
|
||||
self._alert_icon_callback = icon_callback
|
||||
|
||||
def _render(self, _):
|
||||
alert_count = self._alert_count_callback() if self._alert_count_callback else 0
|
||||
if alert_count > 0:
|
||||
pill_w, pill_h = self._pill_bg_txt.width, self._pill_bg_txt.height
|
||||
rl.draw_texture_ex(self._pill_bg_txt, rl.Vector2(self.rect.x, self.rect.y), 0.0, 1.0, rl.WHITE)
|
||||
|
||||
warning_txt = self._alert_icon_callback() if self._alert_icon_callback else None
|
||||
if warning_txt is not None:
|
||||
scale = 36 / max(warning_txt.width, warning_txt.height)
|
||||
warn_x = self.rect.x + self.ICON_OFFSET
|
||||
warn_y = self.rect.y + (pill_h - warning_txt.height * scale) / 2
|
||||
rl.draw_texture_ex(warning_txt, rl.Vector2(warn_x, warn_y), 0.0, scale, rl.WHITE)
|
||||
|
||||
count_rect = rl.Rectangle(self.rect.x + self.COUNT_OFFSET, self.rect.y, pill_w - self.COUNT_OFFSET, pill_h)
|
||||
gui_label(count_rect, str(alert_count), font_size=36,
|
||||
alignment=TextAlignment.CENTER,
|
||||
alignment_vertical=TextAlignmentVertical.MIDDLE)
|
||||
|
||||
|
||||
class NetworkIcon(Widget):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.set_rect(rl.Rectangle(0, 0, 60, 47)) # max size of all icons
|
||||
self._net_type = NetworkType.none
|
||||
self._net_strength = 0
|
||||
|
||||
self._wifi_slash_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_slash.png", 54, 47)
|
||||
self._wifi_none_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_none.png", 54, 40)
|
||||
self._wifi_low_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_low.png", 54, 40)
|
||||
self._wifi_medium_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_medium.png", 54, 40)
|
||||
self._wifi_full_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_full.png", 54, 40)
|
||||
|
||||
self._cell_none_txt = gui_app.texture("icons_mici/settings/network/cell_strength_none.png", 60, 40)
|
||||
self._cell_low_txt = gui_app.texture("icons_mici/settings/network/cell_strength_low.png", 60, 40)
|
||||
self._cell_medium_txt = gui_app.texture("icons_mici/settings/network/cell_strength_medium.png", 60, 40)
|
||||
self._cell_high_txt = gui_app.texture("icons_mici/settings/network/cell_strength_high.png", 60, 40)
|
||||
self._cell_full_txt = gui_app.texture("icons_mici/settings/network/cell_strength_full.png", 60, 40)
|
||||
|
||||
def _update_state(self):
|
||||
device_state = ui_state.sm['deviceState']
|
||||
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 _render(self, _):
|
||||
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)
|
||||
elif self._net_type in (NetworkType.cell2G, NetworkType.cell3G, NetworkType.cell4G, NetworkType.cell5G):
|
||||
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)
|
||||
else:
|
||||
draw_net_txt = self._wifi_slash_txt
|
||||
|
||||
draw_x = self._rect.x + (self._rect.width - draw_net_txt.width) / 2
|
||||
draw_y = self._rect.y + (self._rect.height - draw_net_txt.height) / 2
|
||||
|
||||
if draw_net_txt == self._wifi_slash_txt:
|
||||
# Offset by difference in height between slashless and slash icons to make center align match
|
||||
draw_y -= (self._wifi_slash_txt.height - self._wifi_none_txt.height) / 2
|
||||
|
||||
rl.draw_texture_ex(draw_net_txt, rl.Vector2(draw_x, draw_y), 0.0, 1.0, rl.Color(255, 255, 255, int(255 * 0.9)))
|
||||
|
||||
|
||||
class MiciHomeLayout(Widget):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._on_settings_click: Callable | None = None
|
||||
self._on_alerts_click: Callable | None = None
|
||||
self._alert_count_callback: Callable[[], int] | None = None
|
||||
|
||||
self._version_text = self._get_version_text()
|
||||
|
||||
self._usb_icon = IconWidget("icons_mici/usb.png", (62, 40))
|
||||
self._chestnut_icon = IconWidget("icons_mici/chestnut_green.png", (54, 40))
|
||||
self._chestnut_loading_icon = IconWidget("icons_mici/chestnut.png", (68, 40))
|
||||
self._chestnut_failed_icon = IconWidget("icons_mici/chestnut_orange.png", (68, 40))
|
||||
self._mic_icon = IconWidget("icons_mici/microphone.png", (32, 46))
|
||||
self._body_icon = IconWidget("icons_mici/body.png", (54, 37))
|
||||
|
||||
self._alerts_pill = AlertsPill()
|
||||
|
||||
self._status_bar_layout = HBoxLayout([
|
||||
IconWidget("icons_mici/settings.png", (48, 48), opacity=0.9),
|
||||
NetworkIcon(),
|
||||
self._usb_icon,
|
||||
self._chestnut_icon,
|
||||
self._chestnut_loading_icon,
|
||||
self._chestnut_failed_icon,
|
||||
self._body_icon,
|
||||
self._mic_icon,
|
||||
], spacing=18)
|
||||
|
||||
self._openpilot_label = UnifiedLabel("openpilot", font_size=96, font_weight=FontWeight.DISPLAY, max_width=480, wrap_text=False)
|
||||
self._version_label = UnifiedLabel("", font_size=36, font_weight=FontWeight.ROMAN, max_width=480, wrap_text=False)
|
||||
self._large_version_label = UnifiedLabel("", font_size=64, text_color=rl.GRAY, font_weight=FontWeight.ROMAN, max_width=480, wrap_text=False)
|
||||
self._date_label = UnifiedLabel("", font_size=36, text_color=rl.GRAY, font_weight=FontWeight.ROMAN, max_width=480, wrap_text=False)
|
||||
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, max_width=480, wrap_text=False)
|
||||
|
||||
def set_callbacks(self, on_settings: Callable | None = None, on_alerts: Callable | None = None,
|
||||
alert_count_callback: Callable[[], int] | None = None,
|
||||
alert_icon_callback: Callable[[], rl.Texture | None] | None = None):
|
||||
self._on_settings_click = on_settings
|
||||
self._on_alerts_click = on_alerts
|
||||
self._alert_count_callback = alert_count_callback
|
||||
self._alerts_pill.set_alert_count_callback(alert_count_callback, alert_icon_callback)
|
||||
|
||||
def _handle_mouse_release(self, mouse_pos: MousePos):
|
||||
relative_x = mouse_pos.x - self.rect.x
|
||||
has_alerts = self._alert_count_callback and self._alert_count_callback() > 0
|
||||
if has_alerts and relative_x > self.rect.width - ALERTS_ZONE_WIDTH:
|
||||
if self._on_alerts_click:
|
||||
self._on_alerts_click()
|
||||
elif self._on_settings_click:
|
||||
self._on_settings_click()
|
||||
|
||||
def _get_version_text(self) -> tuple[str, str, str, str] | None:
|
||||
version = ui_state.params.get("Version")
|
||||
branch = ui_state.params.get("GitBranch")
|
||||
commit = ui_state.params.get("GitCommit")
|
||||
|
||||
if not all((version, branch, commit)):
|
||||
return None
|
||||
|
||||
commit_date_raw = ui_state.params.get("GitCommitDate")
|
||||
try:
|
||||
# GitCommitDate format from get_commit_date(): '%ct %ci' e.g. "'1708012345 2024-02-15 ...'"
|
||||
unix_ts = int(commit_date_raw.strip("'").split()[0])
|
||||
date_str = datetime.datetime.fromtimestamp(unix_ts).strftime("%b %d")
|
||||
except (ValueError, IndexError, TypeError, AttributeError):
|
||||
date_str = ""
|
||||
|
||||
return version, branch, commit[:7], date_str
|
||||
|
||||
def _render(self, _):
|
||||
# TODO: why is there extra space here to get it to be flush?
|
||||
text_pos = rl.Vector2(self.rect.x - 2 + HOME_PADDING, self.rect.y - 16)
|
||||
self._openpilot_label.set_position(text_pos.x, text_pos.y)
|
||||
self._openpilot_label.render()
|
||||
|
||||
if self._version_text is not None:
|
||||
# release branch
|
||||
release_branch = self._version_text[1] in RELEASE_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.text_width + 10, version_pos.y)
|
||||
self._date_label.render()
|
||||
|
||||
self._branch_label.set_max_width(gui_app.width - self._version_label.text_width - self._date_label.text_width - 32)
|
||||
self._branch_label.set_text(" " + ("release" if release_branch else self._version_text[1]))
|
||||
self._branch_label.set_position(version_pos.x + self._version_label.text_width + self._date_label.text_width + 20, version_pos.y)
|
||||
self._branch_label.render()
|
||||
|
||||
if not release_branch:
|
||||
# 2nd line
|
||||
self._version_commit_label.set_text(self._version_text[2])
|
||||
self._version_commit_label.set_position(version_pos.x, version_pos.y + self._date_label.font_size + 7)
|
||||
self._version_commit_label.render()
|
||||
|
||||
# ***** Center-aligned bottom section icons *****
|
||||
usb_connected = ui_state.usb_connected
|
||||
usb_unknown = ui_state.usb_unknown
|
||||
chestnut_state = ui_state.chestnut_state
|
||||
if gui_app.sunnypilot_ui():
|
||||
self._set_chestnut_visibility()
|
||||
else:
|
||||
self._usb_icon.set_visible(usb_connected and usb_unknown)
|
||||
self._chestnut_icon.set_visible(not usb_unknown and chestnut_state not in
|
||||
(ChestnutState.LOADING, ChestnutState.UNCOMPILED, ChestnutState.FAILED) and
|
||||
(usb_connected or chestnut_state in (ChestnutState.READY, ChestnutState.ACTIVE)))
|
||||
self._chestnut_loading_icon.set_visible(not usb_unknown and chestnut_state == ChestnutState.LOADING)
|
||||
self._chestnut_loading_icon.set_opacity(0.35 + 0.65 * (0.5 - 0.5 * math.cos(rl.get_time() * 6.0)))
|
||||
self._chestnut_failed_icon.set_visible(not usb_unknown and chestnut_state in (ChestnutState.UNCOMPILED, ChestnutState.FAILED))
|
||||
self._mic_icon.set_visible(ui_state.recording_audio)
|
||||
self._body_icon.set_visible(bool(ui_state.is_body))
|
||||
|
||||
footer_rect = rl.Rectangle(self.rect.x + HOME_PADDING, self.rect.y + self.rect.height - 48, self.rect.width - HOME_PADDING, 48)
|
||||
self._status_bar_layout.render(footer_rect)
|
||||
|
||||
# TODO: add alignment to hboxlayout and add to there
|
||||
self._alerts_pill.set_position(self.rect.x + self.rect.width - self._alerts_pill.rect.width - HOME_PADDING,
|
||||
self.rect.y + self.rect.height - self._alerts_pill.rect.height)
|
||||
self._alerts_pill.render()
|
||||
@@ -1,155 +0,0 @@
|
||||
import pyray as rl
|
||||
import openpilot.cereal.messaging as messaging
|
||||
from openpilot.selfdrive.ui.mici.layouts.home import MiciHomeLayout
|
||||
from openpilot.selfdrive.ui.mici.layouts.settings.settings import SettingsLayout
|
||||
from openpilot.selfdrive.ui.mici.layouts.offroad_alerts import MiciOffroadAlerts
|
||||
from openpilot.selfdrive.ui.mici.onroad.augmented_road_view import AugmentedRoadView
|
||||
from openpilot.selfdrive.ui.ui_state import device, ui_state
|
||||
from openpilot.selfdrive.ui.mici.layouts.onboarding import OnboardingWindow
|
||||
from openpilot.selfdrive.ui.body.layouts.onroad import BodyLayout
|
||||
from openpilot.system.ui.widgets import Widget
|
||||
from openpilot.system.ui.widgets.scroller import Scroller
|
||||
from openpilot.system.ui.lib.application import gui_app
|
||||
|
||||
if gui_app.sunnypilot_ui():
|
||||
from openpilot.selfdrive.ui.sunnypilot.mici.layouts.settings import SettingsLayoutSP as SettingsLayout
|
||||
from openpilot.selfdrive.ui.sunnypilot.mici.layouts.home import MiciHomeLayoutSP as MiciHomeLayout
|
||||
|
||||
ONROAD_DELAY = 2.5 # seconds
|
||||
|
||||
|
||||
class MiciMainLayout(Scroller):
|
||||
def __init__(self):
|
||||
super().__init__(snap_items=True, spacing=0, pad=0, scroll_indicator=False, edge_shadows=False)
|
||||
|
||||
self._pm = messaging.PubMaster(['bookmarkButton', 'userBookmark'])
|
||||
|
||||
self._prev_onroad = False
|
||||
self._prev_standstill = False
|
||||
self._onroad_time_delay: float | None = None
|
||||
self._setup = False
|
||||
|
||||
# Initialize widgets
|
||||
self._home_layout = MiciHomeLayout()
|
||||
self._alerts_layout = MiciOffroadAlerts()
|
||||
self._settings_layout = SettingsLayout()
|
||||
self._car_onroad_layout = AugmentedRoadView(bookmark_callback=self._on_bookmark_clicked)
|
||||
self._body_onroad_layout = BodyLayout()
|
||||
|
||||
# Initialize widget rects
|
||||
for widget in (self._home_layout, self._alerts_layout, self._settings_layout,
|
||||
self._car_onroad_layout, self._body_onroad_layout):
|
||||
# TODO: set parent rect and use it if never passed rect from render (like in Scroller)
|
||||
widget.set_rect(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
|
||||
|
||||
self._scroller.add_widgets([
|
||||
self._alerts_layout,
|
||||
self._home_layout,
|
||||
self._car_onroad_layout,
|
||||
self._body_onroad_layout,
|
||||
])
|
||||
self._scroller.set_reset_scroll_at_show(False)
|
||||
|
||||
# Disable scrolling when onroad is interacting with bookmark
|
||||
self._scroller.set_scrolling_enabled(lambda: not self._car_onroad_layout.is_swiping_left())
|
||||
|
||||
# Set callbacks
|
||||
self._setup_callbacks()
|
||||
|
||||
gui_app.add_nav_stack_tick(self._handle_transitions)
|
||||
gui_app.push_widget(self)
|
||||
|
||||
# Start onboarding if terms or training not completed, make sure to push after self
|
||||
self._onboarding_window = OnboardingWindow(lambda: gui_app.pop_widgets_to(self))
|
||||
if not self._onboarding_window.completed:
|
||||
gui_app.push_widget(self._onboarding_window)
|
||||
|
||||
# initialize correct onroad layout
|
||||
self._on_body_changed()
|
||||
|
||||
@property
|
||||
def _onroad_layout(self) -> Widget:
|
||||
# For scroll_to
|
||||
return self._body_onroad_layout if ui_state.is_body else self._car_onroad_layout
|
||||
|
||||
def _setup_callbacks(self):
|
||||
self._home_layout.set_callbacks(
|
||||
on_settings=lambda: gui_app.push_widget(self._settings_layout),
|
||||
on_alerts=lambda: self._scroll_to(self._alerts_layout),
|
||||
alert_count_callback=self._alerts_layout.active_alerts,
|
||||
alert_icon_callback=self._alerts_layout.highest_severity_icon,
|
||||
)
|
||||
for layout in (self._car_onroad_layout, self._body_onroad_layout):
|
||||
layout.set_click_callback(lambda: self._scroll_to(self._home_layout))
|
||||
|
||||
device.add_interactive_timeout_callback(self._on_interactive_timeout)
|
||||
ui_state.add_on_body_changed_callbacks(self._on_body_changed)
|
||||
|
||||
def _scroll_to(self, layout: Widget):
|
||||
layout_x = int(layout.rect.x)
|
||||
self._scroller.scroll_to(layout_x, smooth=True)
|
||||
|
||||
def _update_state(self):
|
||||
super()._update_state()
|
||||
# TODO: Hack to run alert updates while not in view. Add a nav stack tick?
|
||||
self._alerts_layout._update_state()
|
||||
|
||||
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
|
||||
|
||||
# Render
|
||||
super()._render(self._rect)
|
||||
|
||||
def _handle_transitions(self):
|
||||
# Don't pop if onboarding
|
||||
if gui_app.widget_in_stack(self._onboarding_window):
|
||||
return
|
||||
|
||||
if ui_state.started != self._prev_onroad:
|
||||
self._prev_onroad = ui_state.started
|
||||
|
||||
# onroad: after delay, pop nav stack and scroll to onroad
|
||||
# offroad: immediately scroll to home, but don't pop nav stack (can stay in settings)
|
||||
if ui_state.started:
|
||||
self._onroad_time_delay = rl.get_time()
|
||||
else:
|
||||
self._scroll_to(self._home_layout)
|
||||
|
||||
# FIXME: these two pops can interrupt user interacting in the settings
|
||||
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
|
||||
|
||||
# When car leaves standstill, pop nav stack and scroll to onroad
|
||||
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
|
||||
|
||||
def _on_interactive_timeout(self):
|
||||
# Don't pop if onboarding
|
||||
if gui_app.widget_in_stack(self._onboarding_window):
|
||||
return
|
||||
|
||||
if ui_state.started:
|
||||
# Don't pop if at standstill
|
||||
if not ui_state.sm["carState"].standstill:
|
||||
gui_app.pop_widgets_to(self, lambda: self._scroll_to(self._onroad_layout))
|
||||
else:
|
||||
# Screen turns off on timeout offroad, so pop immediately without animation
|
||||
gui_app.pop_widgets_to(self, instant=True)
|
||||
self._scroll_to(self._home_layout)
|
||||
|
||||
def _on_bookmark_clicked(self):
|
||||
for service in ('bookmarkButton', 'userBookmark'):
|
||||
msg = messaging.new_message(service, valid=True)
|
||||
self._pm.send(service, msg)
|
||||
|
||||
def _on_body_changed(self):
|
||||
self._car_onroad_layout.set_visible(not ui_state.is_body)
|
||||
self._body_onroad_layout.set_visible(bool(ui_state.is_body))
|
||||
@@ -1,336 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pyray as rl
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from enum import IntEnum
|
||||
from openpilot.common.params import Params
|
||||
from openpilot.common.realtime import drop_realtime
|
||||
from openpilot.selfdrive.selfdrived.alertmanager import OFFROAD_ALERTS
|
||||
from openpilot.common.hardware import HARDWARE
|
||||
from openpilot.system.ui.widgets import Widget
|
||||
from openpilot.system.ui.widgets.label import UnifiedLabel
|
||||
from openpilot.system.ui.widgets.scroller import Scroller
|
||||
from openpilot.system.ui.lib.application import gui_app, FontWeight, TextAlignment, TextAlignmentVertical
|
||||
from openpilot.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
|
||||
icon: str | None = None
|
||||
|
||||
|
||||
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 alert 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._custom_icon = gui_app.texture(alert_data.icon, self.ICON_SIZE, self.ICON_SIZE) if alert_data.icon else None
|
||||
|
||||
if self._custom_icon is not None:
|
||||
self._icon = self._custom_icon
|
||||
elif alert_data.severity == -1:
|
||||
self._icon = self._icon_green
|
||||
elif alert_data.severity > 0:
|
||||
self._icon = self._icon_red
|
||||
else:
|
||||
self._icon = self._icon_orange
|
||||
|
||||
self._title_label = UnifiedLabel(text="", font_size=32, font_weight=FontWeight.SEMI_BOLD, text_color=self.TEXT_COLOR,
|
||||
alignment=TextAlignment.LEFT,
|
||||
alignment_vertical=TextAlignmentVertical.TOP, line_height=0.95)
|
||||
|
||||
self._body_label = UnifiedLabel(text="", font_size=28, font_weight=FontWeight.ROMAN, text_color=self.TEXT_COLOR,
|
||||
alignment=TextAlignment.LEFT,
|
||||
alignment_vertical=TextAlignmentVertical.BOTTOM, line_height=0.95)
|
||||
|
||||
self._title_text = ""
|
||||
self._body_text = ""
|
||||
self._alert_size = AlertSize.SMALL
|
||||
|
||||
self._update_content()
|
||||
|
||||
@property
|
||||
def icon(self) -> rl.Texture:
|
||||
return self._icon
|
||||
|
||||
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_ex(bg_texture, rl.Vector2(self._rect.x, self._rect.y), 0.0, 1.0, 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
|
||||
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_ex(self._icon, rl.Vector2(icon_x, icon_y), 0.0, 1.0, rl.WHITE)
|
||||
|
||||
|
||||
class MiciOffroadAlerts(Scroller):
|
||||
"""Offroad alerts layout with vertical scrolling."""
|
||||
|
||||
def __init__(self):
|
||||
# Create vertical scroller
|
||||
super().__init__(horizontal=False, spacing=12, pad=0)
|
||||
self.params = Params()
|
||||
self.sorted_alerts: list[AlertData] = []
|
||||
self.alert_items: list[AlertItem] = []
|
||||
|
||||
# Create empty state label
|
||||
self._empty_label = UnifiedLabel(tr("no alerts"), 65, FontWeight.DISPLAY, rl.WHITE,
|
||||
alignment=TextAlignment.CENTER,
|
||||
alignment_vertical=TextAlignmentVertical.MIDDLE)
|
||||
|
||||
# Build initial alert list
|
||||
self._build_alerts()
|
||||
|
||||
# Start param thread
|
||||
self._pending_params: dict | None = None
|
||||
self._params_thread = threading.Thread(target=self._params_worker, daemon=True)
|
||||
self._params_thread.start()
|
||||
|
||||
def active_alerts(self) -> int:
|
||||
return sum(alert.visible for alert in self.sorted_alerts)
|
||||
|
||||
def highest_severity_icon(self) -> rl.Texture | None:
|
||||
item = max((item for item in self.alert_items if item.alert_data.visible),
|
||||
key=lambda item: (item.alert_data.severity, bool(item.alert_data.icon)), default=None)
|
||||
return item.icon if item is not None else None
|
||||
|
||||
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, icon=config.get("icon"))
|
||||
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 _params_worker(self):
|
||||
drop_realtime()
|
||||
while True:
|
||||
self._pending_params = ({"UpdaterNewDescription": self.params.get("UpdaterNewDescription")} |
|
||||
{alert_data.key: self.params.get(alert_data.key) for alert_data in self.sorted_alerts})
|
||||
time.sleep(REFRESH_INTERVAL)
|
||||
|
||||
def _refresh(self, pending_params: dict) -> int:
|
||||
"""Refresh alerts from params and return active count."""
|
||||
active_count = 0
|
||||
|
||||
# Handle UpdateAvailable alert specially
|
||||
update_available = pending_params["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 = pending_params["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"\nsunnypilot {version}, {date}\n"
|
||||
|
||||
update_alert_data.text = f"Update available {version_string}. Click to update. Read the release notes at blog.comma.ai."
|
||||
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 = pending_params[alert_data.key]
|
||||
|
||||
if alert_json:
|
||||
text = alert_json.get("text", "").replace("%1", alert_json.get("extra", ""))
|
||||
|
||||
if text and not alert_data.visible:
|
||||
# Bump newly visible alerts to the top, severity sort keeps it at the top of its category
|
||||
widget = next(w for w in self._scroller.items if w.alert_data is alert_data)
|
||||
self._scroller.items.remove(widget)
|
||||
self._scroller.items.insert(0, widget)
|
||||
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)
|
||||
|
||||
self._scroller.items.sort(key=lambda w: -w.alert_data.severity)
|
||||
|
||||
return active_count
|
||||
|
||||
def _update_state(self):
|
||||
"""Periodically refresh alerts."""
|
||||
# Refresh alerts when thread updates params
|
||||
pending_params = self._pending_params
|
||||
if pending_params is not None:
|
||||
self._refresh(pending_params)
|
||||
self._pending_params = None
|
||||
|
||||
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)
|
||||
@@ -1,419 +0,0 @@
|
||||
import math
|
||||
import numpy as np
|
||||
import pyray as rl
|
||||
from collections.abc import Callable
|
||||
from openpilot.common.filter_simple import FirstOrderFilter
|
||||
from openpilot.common.qrcode import make_texture
|
||||
from openpilot.system.ui.lib.application import FontWeight, gui_app, TextAlignment
|
||||
from openpilot.system.ui.widgets import Widget
|
||||
from openpilot.system.ui.widgets.button import SmallCircleIconButton
|
||||
from openpilot.system.ui.widgets.scroller import NavScroller, Scroller
|
||||
from openpilot.system.ui.widgets.nav_widget import NavWidget
|
||||
from openpilot.system.ui.mici_setup import GreyBigButton, BigPillButton
|
||||
from openpilot.system.ui.widgets.label import gui_label
|
||||
from openpilot.system.ui.lib.multilang import tr
|
||||
from openpilot.common.version import terms_version, training_version, terms_version_sp
|
||||
from openpilot.common.version import sunnylink_consent_version, sunnylink_consent_declined
|
||||
from openpilot.selfdrive.ui.ui_state import ui_state, device
|
||||
from openpilot.selfdrive.ui.mici.widgets.dialog import BigConfirmationCircleButton
|
||||
from openpilot.selfdrive.ui.mici.onroad.driver_state import DriverStateRenderer
|
||||
from openpilot.selfdrive.ui.mici.onroad.cabin_camera_dialog import BaseCabinCameraDialog
|
||||
from openpilot.selfdrive.ui.sunnypilot.mici.layouts.onboarding import SunnylinkConsentPage
|
||||
|
||||
|
||||
class DriverCameraSetupDialog(BaseCabinCameraDialog):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
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=TextAlignment.CENTER)
|
||||
rl.end_scissor_mode()
|
||||
return
|
||||
|
||||
# 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()
|
||||
|
||||
|
||||
class TrainingGuidePreDMTutorial(NavScroller):
|
||||
def __init__(self, continue_callback: Callable[[], None]):
|
||||
super().__init__()
|
||||
|
||||
continue_button = BigPillButton("next")
|
||||
continue_button.set_click_callback(continue_callback)
|
||||
|
||||
self._scroller.add_widgets([
|
||||
GreyBigButton("driver monitoring\ncheck", "scroll to continue",
|
||||
gui_app.texture("icons_mici/setup/green_dm.png", 64, 64)),
|
||||
GreyBigButton("", "Next, we'll check if comma four can detect the driver properly."),
|
||||
GreyBigButton("", "sunnypilot uses the cabin camera to check if the driver is distracted."),
|
||||
GreyBigButton("", "If it does not have a clear view of the driver, unplug and remount before continuing."),
|
||||
continue_button,
|
||||
])
|
||||
|
||||
def show_event(self):
|
||||
super().show_event()
|
||||
# Get driver monitoring model ready for next step
|
||||
ui_state.params.put_bool("IsDriverViewEnabled", True)
|
||||
|
||||
|
||||
class DMBadFaceDetected(NavScroller):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
back_button = BigPillButton("back")
|
||||
back_button.set_click_callback(self.dismiss)
|
||||
|
||||
self._scroller.add_widgets([
|
||||
GreyBigButton("looking for driver", "make sure comma\nfour can see your face",
|
||||
gui_app.texture("icons_mici/setup/orange_dm.png", 64, 64)),
|
||||
GreyBigButton("", "Remount if your face is blocked, or driver monitoring has difficulty tracking your face."),
|
||||
back_button,
|
||||
])
|
||||
|
||||
|
||||
class TrainingGuideDMTutorial(NavWidget):
|
||||
PROGRESS_DURATION = 4
|
||||
LOOKING_THRESHOLD_DEG = 30.0
|
||||
|
||||
def __init__(self, continue_callback: Callable[[], None]):
|
||||
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(lambda: gui_app.push_widget(self._bad_face_page))
|
||||
self._back_button.set_touch_valid_callback(lambda: self.enabled and not self.is_dismissing) # for nav stack
|
||||
self._good_button = SmallCircleIconButton(gui_app.texture("icons_mici/setup/driver_monitoring/dm_check.png", 42, 42))
|
||||
self._good_button.set_touch_valid_callback(lambda: self.enabled and not self.is_dismissing) # for nav stack
|
||||
|
||||
self._good_button.set_click_callback(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()
|
||||
|
||||
# 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_event(self):
|
||||
super().show_event()
|
||||
self._dialog.show_event()
|
||||
self._progress.x = 0.0
|
||||
|
||||
def _update_state(self):
|
||||
super()._update_state()
|
||||
if device.awake and not ui_state.params.get_bool("IsDriverViewEnabled"):
|
||||
ui_state.params.put_bool("IsDriverViewEnabled", True)
|
||||
|
||||
sm = ui_state.sm
|
||||
if sm.recv_frame.get("driverMonitoringState", 0) == 0:
|
||||
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
|
||||
in_bad_face = gui_app.get_active_widget() == self._bad_face_page
|
||||
if ((dm_state.visionPolicyState.faceDetected and looking_center) or self._progress.x > 0.99) and not in_bad_face:
|
||||
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, _):
|
||||
self._dialog.render(self._rect)
|
||||
|
||||
gradient_y = int(self._rect.y + self._rect.height - 80)
|
||||
gradient_h = int(self._rect.y) + int(self._rect.height) - gradient_y
|
||||
rl.draw_rectangle_gradient_v(int(self._rect.x), gradient_y,
|
||||
int(self._rect.width), gradient_h, 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,
|
||||
)
|
||||
|
||||
if self._dialog._camera_view.frame:
|
||||
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,
|
||||
))
|
||||
|
||||
# rounded border
|
||||
rl.begin_scissor_mode(int(self._rect.x), int(self._rect.y), int(self._rect.width), int(self._rect.height))
|
||||
rl.draw_rectangle_rounded_lines_ex(self._rect, 0.2 * 1.02, 10, 50, rl.BLACK)
|
||||
rl.end_scissor_mode()
|
||||
|
||||
|
||||
class TrainingGuideRecordFront(NavScroller):
|
||||
def __init__(self, continue_callback: Callable[[], None]):
|
||||
super().__init__()
|
||||
|
||||
def on_accept():
|
||||
ui_state.params.put_bool("RecordFront", True)
|
||||
continue_callback()
|
||||
|
||||
def on_decline():
|
||||
ui_state.params.put_bool("RecordFront", False)
|
||||
continue_callback()
|
||||
|
||||
self._accept_button = BigConfirmationCircleButton("allow data uploading", gui_app.texture("icons_mici/setup/driver_monitoring/dm_check.png", 64, 64),
|
||||
on_accept, exit_on_confirm=False)
|
||||
|
||||
self._decline_button = BigConfirmationCircleButton("no, don't upload", gui_app.texture("icons_mici/setup/cancel.png", 64, 64), on_decline,
|
||||
exit_on_confirm=False)
|
||||
|
||||
self._scroller.add_widgets([
|
||||
GreyBigButton("driver camera data", "do you want to share video data for training?",
|
||||
gui_app.texture("icons_mici/setup/green_dm.png", 64, 64)),
|
||||
GreyBigButton("", "Sharing your data with comma helps improve openpilot and sunnypilot for everyone."),
|
||||
self._accept_button,
|
||||
self._decline_button,
|
||||
])
|
||||
|
||||
|
||||
class TrainingGuideAttentionNotice(Scroller):
|
||||
def __init__(self, continue_callback: Callable[[], None]):
|
||||
super().__init__()
|
||||
|
||||
continue_button = BigPillButton("next")
|
||||
continue_button.set_click_callback(continue_callback)
|
||||
|
||||
self._scroller.add_widgets([
|
||||
GreyBigButton("what is sunnypilot?", "scroll to continue",
|
||||
gui_app.texture("icons_mici/setup/green_info.png", 64, 64)),
|
||||
GreyBigButton("", "1. sunnypilot is a driver assistance system."),
|
||||
GreyBigButton("", "2. You must pay attention at all times."),
|
||||
GreyBigButton("", "3. You must be ready to take over at any time."),
|
||||
GreyBigButton("", "4. You are fully responsible for driving the car."),
|
||||
continue_button,
|
||||
])
|
||||
|
||||
|
||||
class TrainingGuide(NavWidget):
|
||||
def __init__(self, completed_callback: Callable[[], None]):
|
||||
super().__init__()
|
||||
|
||||
self._steps = [
|
||||
TrainingGuideAttentionNotice(continue_callback=lambda: gui_app.push_widget(self._steps[1])),
|
||||
TrainingGuidePreDMTutorial(continue_callback=lambda: gui_app.push_widget(self._steps[2])),
|
||||
TrainingGuideDMTutorial(continue_callback=lambda: gui_app.push_widget(self._steps[3])),
|
||||
TrainingGuideRecordFront(continue_callback=completed_callback),
|
||||
]
|
||||
|
||||
self._child(self._steps[0])
|
||||
self._steps[0].set_enabled(lambda: self.enabled and not self.is_dismissing) # for nav stack
|
||||
|
||||
def _render(self, _):
|
||||
self._steps[0].render(self._rect)
|
||||
|
||||
|
||||
class QRCodeWidget(Widget):
|
||||
def __init__(self, url: str, size: int = 170):
|
||||
super().__init__()
|
||||
self.set_rect(rl.Rectangle(0, 0, size, size))
|
||||
self._size = size
|
||||
self._qr_texture = make_texture(url, inverted=True)
|
||||
|
||||
def _render(self, _):
|
||||
if self._qr_texture:
|
||||
scale = self._size / self._qr_texture.height
|
||||
rl.draw_texture_ex(self._qr_texture, rl.Vector2(round(self._rect.x), round(self._rect.y)), 0.0, scale, rl.WHITE)
|
||||
|
||||
def __del__(self):
|
||||
if self._qr_texture and self._qr_texture.id != 0:
|
||||
rl.unload_texture(self._qr_texture)
|
||||
|
||||
|
||||
class TermsPage(Scroller):
|
||||
def __init__(self, on_accept, on_decline):
|
||||
super().__init__()
|
||||
|
||||
self._accept_button = BigConfirmationCircleButton("accept\nterms", gui_app.texture("icons_mici/setup/driver_monitoring/dm_check.png", 64, 64), on_accept)
|
||||
self._decline_button = BigConfirmationCircleButton("decline &\nuninstall", gui_app.texture("icons_mici/setup/cancel.png", 64, 64), on_decline,
|
||||
red=True, exit_on_confirm=False)
|
||||
|
||||
self._terms_header = GreyBigButton("terms of\nservice", "scroll to continue",
|
||||
gui_app.texture("icons_mici/setup/green_info.png", 64, 64))
|
||||
self._must_accept_card = GreyBigButton("", "You must accept the Terms of Service to use sunnypilot.")
|
||||
|
||||
self._scroller.add_widgets([
|
||||
self._terms_header,
|
||||
GreyBigButton("swipe for QR code", "or go to https://sunnypilot.ai/terms",
|
||||
gui_app.texture("icons_mici/setup/small_slider/slider_arrow.png", 64, 56, flip_x=True)),
|
||||
QRCodeWidget("https://sunnypilot.ai/terms"),
|
||||
self._must_accept_card,
|
||||
self._accept_button,
|
||||
self._decline_button,
|
||||
])
|
||||
|
||||
def _render(self, _):
|
||||
rl.draw_rectangle_rec(self._rect, rl.BLACK)
|
||||
super()._render(_)
|
||||
|
||||
|
||||
class OnboardingWindow(Widget):
|
||||
def __init__(self, completed_callback: Callable[[], None]):
|
||||
super().__init__()
|
||||
self._completed_callback = completed_callback
|
||||
self._accepted_terms: bool = (ui_state.params.get("HasAcceptedTerms") == terms_version and
|
||||
ui_state.params.get("HasAcceptedTermsSP") == terms_version_sp)
|
||||
self._training_done: bool = ui_state.params.get("CompletedTrainingVersion") == training_version
|
||||
self._sunnylink_consent_done: bool = ui_state.params.get("CompletedSunnylinkConsentVersion") in {
|
||||
sunnylink_consent_version, sunnylink_consent_declined
|
||||
}
|
||||
|
||||
self.set_rect(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
|
||||
|
||||
# Windows — all pushed onto nav stack, _terms is always rendered as base layer
|
||||
self._terms = TermsPage(on_accept=self._on_terms_accepted, on_decline=self._on_uninstall)
|
||||
self._terms.set_enabled(lambda: self.enabled) # for nav stack
|
||||
|
||||
self._sunnylink_consent = SunnylinkConsentPage(
|
||||
on_accept=self._on_sunnylink_accepted,
|
||||
on_decline=self._on_sunnylink_declined,
|
||||
)
|
||||
|
||||
self._training_guide = TrainingGuide(completed_callback=self._on_completed_training)
|
||||
self._training_guide.set_enabled(lambda: self.enabled) # for nav stack
|
||||
|
||||
self._needs_initial_push = False
|
||||
|
||||
def _on_uninstall(self):
|
||||
ui_state.params.put_bool("DoUninstall", True, block=True)
|
||||
|
||||
def show_event(self):
|
||||
super().show_event()
|
||||
device.set_override_interactive_timeout(300)
|
||||
device.set_offroad_brightness(100)
|
||||
self._needs_initial_push = True
|
||||
|
||||
def hide_event(self):
|
||||
super().hide_event()
|
||||
# FIXME: when nav stack sends hide event to widget 2 below on push, this needs to be moved
|
||||
device.set_override_interactive_timeout(None)
|
||||
device.set_offroad_brightness(None)
|
||||
|
||||
@property
|
||||
def completed(self) -> bool:
|
||||
return self._accepted_terms and self._sunnylink_consent_done and self._training_done
|
||||
|
||||
def close(self):
|
||||
ui_state.params.put_bool("IsDriverViewEnabled", False)
|
||||
self._completed_callback()
|
||||
|
||||
def _on_terms_accepted(self):
|
||||
ui_state.params.put("HasAcceptedTerms", terms_version, block=True)
|
||||
ui_state.params.put("HasAcceptedTermsSP", terms_version_sp, block=True)
|
||||
self._accepted_terms = True
|
||||
if not self._sunnylink_consent_done:
|
||||
gui_app.push_widget(self._sunnylink_consent)
|
||||
elif not self._training_done:
|
||||
gui_app.push_widget(self._training_guide)
|
||||
else:
|
||||
self.close()
|
||||
|
||||
def _on_sunnylink_accepted(self):
|
||||
ui_state.params.put("CompletedSunnylinkConsentVersion", sunnylink_consent_version, block=True)
|
||||
ui_state.params.put_bool("SunnylinkEnabled", True)
|
||||
self._sunnylink_consent_done = True
|
||||
if not self._training_done:
|
||||
gui_app.push_widget(self._training_guide)
|
||||
else:
|
||||
self.close()
|
||||
|
||||
def _on_sunnylink_declined(self):
|
||||
ui_state.params.put("CompletedSunnylinkConsentVersion", sunnylink_consent_declined, block=True)
|
||||
ui_state.params.put_bool("SunnylinkEnabled", False)
|
||||
self._sunnylink_consent_done = True
|
||||
if not self._training_done:
|
||||
gui_app.push_widget(self._training_guide)
|
||||
else:
|
||||
self.close()
|
||||
|
||||
def _on_completed_training(self):
|
||||
ui_state.params.put("CompletedTrainingVersion", training_version, block=True)
|
||||
self._training_done = True
|
||||
self.close()
|
||||
|
||||
def _render(self, _):
|
||||
rl.draw_rectangle_rec(self._rect, rl.BLACK)
|
||||
|
||||
# Deferred from show_event to avoid nested push_widget re-enable bug
|
||||
if self._needs_initial_push:
|
||||
self._needs_initial_push = False
|
||||
if self._accepted_terms and not self._sunnylink_consent_done:
|
||||
gui_app.push_widget(self._sunnylink_consent)
|
||||
elif self._accepted_terms and self._sunnylink_consent_done and not self._training_done:
|
||||
gui_app.push_widget(self._training_guide)
|
||||
|
||||
self._terms.render(self._rect)
|
||||
@@ -1,204 +0,0 @@
|
||||
from collections.abc import Callable
|
||||
from openpilot.common.time_helpers import system_time_valid
|
||||
from openpilot.system.ui.widgets.scroller import NavScroller
|
||||
from openpilot.selfdrive.ui.mici.widgets.button import BigButton, BigToggle, BigParamControl, BigCircleParamControl, GreyBigButton
|
||||
from openpilot.selfdrive.ui.mici.widgets.dialog import BigDialog, BigInputDialog, BigConfirmationCircleButton
|
||||
from openpilot.system.ui.lib.application import gui_app
|
||||
from openpilot.selfdrive.ui.layouts.settings.common import restart_needed_callback
|
||||
from openpilot.selfdrive.ui.ui_state import ui_state
|
||||
from openpilot.selfdrive.ui.widgets.ssh_key import SshKeyFetcher
|
||||
|
||||
|
||||
class AlphaLongConfirmPage(NavScroller):
|
||||
def __init__(self, on_confirm: Callable[[], None]):
|
||||
super().__init__()
|
||||
|
||||
accept = BigConfirmationCircleButton("enable alpha\nlongitudinal",
|
||||
gui_app.texture("icons_mici/setup/driver_monitoring/dm_check.png", 64, 64),
|
||||
lambda: self.dismiss(on_confirm))
|
||||
|
||||
self._scroller.add_widgets([
|
||||
GreyBigButton("enabling alpha longitudinal", "scroll to continue",
|
||||
gui_app.texture("icons_mici/setup/warning.png", 64, 64)),
|
||||
GreyBigButton("", "WARNING: alpha longitudinal control may disable Automatic Emergency Braking (AEB)"),
|
||||
GreyBigButton("", "On this car, openpilot defaults to the stock system's built-in ACC."),
|
||||
GreyBigButton("", "Enabling this will switch to openpilot longitudinal control."),
|
||||
accept,
|
||||
])
|
||||
|
||||
|
||||
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("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("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("Not set")
|
||||
|
||||
def ssh_keys_callback():
|
||||
github_username = ui_state.params.get("GithubUsername") or ""
|
||||
dlg = BigInputDialog("enter GitHub username...", github_username, minimum_length=0, confirm_callback=github_username_callback)
|
||||
if not system_time_valid():
|
||||
dlg = BigDialog("", "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("SSH keys", "Not set" if not github_username else github_username, icon=txt_ssh,
|
||||
description="Grant SSH access to all public keys in your GitHub settings. Only enter your own username.")
|
||||
self._ssh_keys_btn.set_click_callback(ssh_keys_callback)
|
||||
|
||||
# adb, ssh, ssh keys, debug mode, joystick debug mode, longitudinal maneuver mode, ip address
|
||||
# ******** Main Scroller ********
|
||||
self._adb_toggle = BigCircleParamControl(gui_app.texture("icons_mici/adb_short.png", 82, 82), "AdbEnabled", icon_offset=(0, 12),
|
||||
description="Use Android Debug Bridge (ADB) over USB or the network.", title="enable ADB")
|
||||
self._ssh_toggle = BigCircleParamControl(gui_app.texture("icons_mici/ssh_short.png", 82, 82), "SshEnabled", icon_offset=(0, 12),
|
||||
description="Access the device remotely using your SSH keys.", title="enable SSH")
|
||||
self._joystick_toggle = BigToggle("joystick debug\nmode", initial_state=ui_state.params.get_bool("JoystickDebugMode"),
|
||||
toggle_callback=self._on_joystick_debug_mode, description="Control the car with a joystick for debugging.")
|
||||
self._long_maneuver_toggle = BigToggle("longitudinal maneuver mode", initial_state=ui_state.params.get_bool("LongitudinalManeuverMode"),
|
||||
toggle_callback=self._on_long_maneuver_mode,
|
||||
description="Run longitudinal maneuvers for testing gas and brake control.")
|
||||
self._lat_maneuver_toggle = BigToggle("lateral maneuver mode", initial_state=ui_state.params.get_bool("LateralManeuverMode"),
|
||||
toggle_callback=self._on_lat_maneuver_mode,
|
||||
description="Run lateral maneuvers for testing steering control.")
|
||||
self._alpha_long_toggle = BigToggle("alpha longitudinal", initial_state=ui_state.params.get_bool("AlphaLongitudinalEnabled"),
|
||||
toggle_callback=self._on_alpha_long_enabled,
|
||||
description="Use alpha openpilot longitudinal control instead of stock ACC. This may disable Automatic Emergency " +
|
||||
"Braking (AEB).")
|
||||
self._navigation_toggle = BigParamControl("navigation (alpha)", "NavigationEnabled",
|
||||
description="Turn-by-turn navigation with a route map. Set a destination from comma connect.")
|
||||
self._debug_mode_toggle = BigParamControl("ui debug mode", "ShowDebugInfo",
|
||||
toggle_callback=lambda checked: (gui_app.set_show_touches(checked), gui_app.set_show_fps(checked)),
|
||||
description="Show touch locations and the UI frame rate.")
|
||||
|
||||
self._scroller.add_widgets([
|
||||
self._adb_toggle,
|
||||
self._ssh_toggle,
|
||||
self._ssh_keys_btn,
|
||||
self._joystick_toggle,
|
||||
self._long_maneuver_toggle,
|
||||
self._lat_maneuver_toggle,
|
||||
self._alpha_long_toggle,
|
||||
self._debug_mode_toggle,
|
||||
self._navigation_toggle,
|
||||
])
|
||||
|
||||
# Toggle lists
|
||||
self._refresh_toggles = (
|
||||
("AdbEnabled", self._adb_toggle),
|
||||
("SshEnabled", self._ssh_toggle),
|
||||
("JoystickDebugMode", self._joystick_toggle),
|
||||
("LongitudinalManeuverMode", self._long_maneuver_toggle),
|
||||
("LateralManeuverMode", self._lat_maneuver_toggle),
|
||||
("AlphaLongitudinalEnabled", self._alpha_long_toggle),
|
||||
("ShowDebugInfo", self._debug_mode_toggle),
|
||||
("NavigationEnabled", self._navigation_toggle),
|
||||
)
|
||||
onroad_blocked_toggles = (self._adb_toggle, self._joystick_toggle)
|
||||
release_blocked_toggles = (self._joystick_toggle, self._long_maneuver_toggle, self._lat_maneuver_toggle, self._alpha_long_toggle, self._navigation_toggle)
|
||||
engaged_blocked_toggles = (self._long_maneuver_toggle, self._lat_maneuver_toggle, self._alpha_long_toggle)
|
||||
|
||||
# Hide non-release toggles on release builds
|
||||
for item in release_blocked_toggles:
|
||||
item.set_visible(not ui_state.is_release)
|
||||
|
||||
# Disable toggles that require offroad
|
||||
for item in onroad_blocked_toggles:
|
||||
item.set_enabled(lambda: ui_state.is_offroad())
|
||||
|
||||
# Disable toggles that require not engaged
|
||||
for item in engaged_blocked_toggles:
|
||||
item.set_enabled(lambda: not ui_state.engaged)
|
||||
|
||||
# Set initial state
|
||||
if ui_state.params.get_bool("ShowDebugInfo"):
|
||||
gui_app.set_show_touches(True)
|
||||
gui_app.set_show_fps(True)
|
||||
|
||||
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()
|
||||
|
||||
# CP gating
|
||||
if ui_state.CP is not None:
|
||||
alpha_avail = ui_state.CP.alphaLongitudinalAvailable
|
||||
if not alpha_avail or ui_state.is_release:
|
||||
self._alpha_long_toggle.set_visible(False)
|
||||
ui_state.params.remove("AlphaLongitudinalEnabled")
|
||||
else:
|
||||
self._alpha_long_toggle.set_visible(True)
|
||||
|
||||
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)
|
||||
self._alpha_long_toggle.set_visible(False)
|
||||
|
||||
# Refresh toggles from params to mirror external changes
|
||||
for key, item in self._refresh_toggles:
|
||||
item.set_checked(ui_state.params.get_bool(key))
|
||||
|
||||
def _on_joystick_debug_mode(self, state: bool):
|
||||
ui_state.params.put_bool("JoystickDebugMode", state, block=True)
|
||||
ui_state.params.put_bool("LongitudinalManeuverMode", False, block=True)
|
||||
self._long_maneuver_toggle.set_checked(False)
|
||||
ui_state.params.put_bool("LateralManeuverMode", False, block=True)
|
||||
self._lat_maneuver_toggle.set_checked(False)
|
||||
|
||||
def _on_long_maneuver_mode(self, state: bool):
|
||||
ui_state.params.put_bool("LongitudinalManeuverMode", state, block=True)
|
||||
ui_state.params.put_bool("JoystickDebugMode", False, block=True)
|
||||
self._joystick_toggle.set_checked(False)
|
||||
ui_state.params.put_bool("LateralManeuverMode", False, block=True)
|
||||
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, block=True)
|
||||
ui_state.params.put_bool("JoystickDebugMode", False, block=True)
|
||||
self._joystick_toggle.set_checked(False)
|
||||
ui_state.params.put_bool("LongitudinalManeuverMode", False, block=True)
|
||||
self._long_maneuver_toggle.set_checked(False)
|
||||
restart_needed_callback()
|
||||
|
||||
def _on_alpha_long_enabled(self, state: bool):
|
||||
def do_toggle(_state: bool):
|
||||
ui_state.params.put_bool("AlphaLongitudinalEnabled", _state, block=True)
|
||||
restart_needed_callback()
|
||||
self._update_toggles()
|
||||
|
||||
if state:
|
||||
# Don't show enabled state until confirm
|
||||
self._alpha_long_toggle.set_checked(False)
|
||||
gui_app.push_widget(AlphaLongConfirmPage(lambda: do_toggle(True)))
|
||||
else:
|
||||
do_toggle(False)
|
||||
@@ -1,223 +0,0 @@
|
||||
import os
|
||||
import pyray as rl
|
||||
from collections.abc import Callable
|
||||
from typing import Union
|
||||
|
||||
from openpilot.common.basedir import BASEDIR
|
||||
from openpilot.common.params import Params
|
||||
from openpilot.common.time_helpers import system_time_valid
|
||||
from openpilot.system.ui.widgets.scroller import NavRawScrollPanel, NavScroller
|
||||
from openpilot.selfdrive.ui.mici.widgets.button import BigButton, BigCircleButton
|
||||
from openpilot.selfdrive.ui.mici.widgets.dialog import BigDialog, BigConfirmationDialog
|
||||
from openpilot.selfdrive.ui.mici.widgets.pairing_dialog import PairingDialog
|
||||
from openpilot.selfdrive.ui.mici.onroad.cabin_camera_dialog import CabinCameraDialog
|
||||
from openpilot.selfdrive.ui.mici.layouts.onboarding import TrainingGuide, TermsPage
|
||||
from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos
|
||||
from openpilot.system.ui.lib.multilang import tr
|
||||
from openpilot.system.ui.widgets import Widget
|
||||
from openpilot.selfdrive.ui.ui_state import device, ui_state
|
||||
from openpilot.system.ui.widgets.label import UnifiedLabel
|
||||
from openpilot.system.ui.widgets.html_render import HtmlRenderer
|
||||
from openpilot.system.athena.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._terms_header.set_visible(False)
|
||||
self._must_accept_card.set_visible(False)
|
||||
self._accept_button.set_visible(False)
|
||||
self._decline_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(f"slide to\n{action_text.lower()}", icon, confirm_callback, exit_on_confirm=exit_on_confirm, red=red))
|
||||
else:
|
||||
gui_app.push_widget(BigDialog("", f"Disengage to {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), *, description: str = ""):
|
||||
super().__init__(icon, red, icon_offset, description=description, title=title)
|
||||
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, *, description: str = "",
|
||||
description_icon: Union[rl.Texture, None] = None):
|
||||
super().__init__(text, "", icon, description=description, description_icon=description_icon)
|
||||
self.set_click_callback(lambda: _engaged_confirmation_click(callback, action_text, icon, exit_on_confirm=exit_on_confirm, red=red))
|
||||
|
||||
|
||||
class DeviceInfoLayoutMici(Widget):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
self.set_rect(rl.Rectangle(0, 0, 360, 180))
|
||||
|
||||
params = Params()
|
||||
subheader_color = rl.Color(255, 255, 255, int(255 * 0.9 * 0.65))
|
||||
max_width = int(self._rect.width - 20)
|
||||
self._dongle_id_label = UnifiedLabel("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("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):
|
||||
def __init__(self):
|
||||
super().__init__("pair", "connect.comma.ai", gui_app.texture("icons_mici/settings/comma_icon.png", 33, 60))
|
||||
|
||||
def _get_label_font_size(self):
|
||||
return 64
|
||||
|
||||
def _update_state(self):
|
||||
super()._update_state()
|
||||
|
||||
if ui_state.prime_state.is_paired():
|
||||
self.set_text("paired")
|
||||
if ui_state.prime_state.is_prime():
|
||||
self.set_value("subscribed")
|
||||
else:
|
||||
self.set_value("upgrade to prime")
|
||||
else:
|
||||
self.set_text("pair")
|
||||
self.set_value("connect.comma.ai")
|
||||
|
||||
def _handle_mouse_release(self, mouse_pos: MousePos):
|
||||
super()._handle_mouse_release(mouse_pos)
|
||||
|
||||
# TODO: show ad dialog when clicked if not prime
|
||||
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 the comma.ai backend to pair."))
|
||||
else:
|
||||
dlg = PairingDialog()
|
||||
gui_app.push_widget(dlg)
|
||||
|
||||
|
||||
class DeviceLayoutMici(NavScroller):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
self._fcc_dialog: MiciFccModal | None = None
|
||||
|
||||
def power_off_callback():
|
||||
ui_state.params.put_bool("DoShutdown", True, block=True)
|
||||
|
||||
def reboot_callback():
|
||||
ui_state.params.put_bool("DoReboot", True, block=True)
|
||||
|
||||
def reset_calibration_callback():
|
||||
params = ui_state.params
|
||||
params.remove("CalibrationParams")
|
||||
params.remove("LiveTorqueParameters")
|
||||
params.remove("LiveParametersV2")
|
||||
params.remove("LiveDelay")
|
||||
params.put_bool("OnroadCycleRequested", True, block=True)
|
||||
|
||||
reset_calibration_btn = EngagedConfirmationButton("reset calibration", "reset", gui_app.texture("icons_mici/settings/device/lkas.png", 122, 64),
|
||||
reset_calibration_callback,
|
||||
description="Mount the device within 4° left or right and 5° up or 9° down. openpilot calibrates " +
|
||||
"continuously; resetting is rarely needed. Resetting clears learned calibration.")
|
||||
|
||||
reboot_btn = EngagedConfirmationCircleButton("reboot", gui_app.texture("icons_mici/settings/device/reboot.png", 64, 70),
|
||||
reboot_callback, exit_on_confirm=False)
|
||||
|
||||
self._power_off_btn = EngagedConfirmationCircleButton("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("regulatory info", "", gui_app.texture("icons_mici/settings/device/info.png", 64, 64))
|
||||
regulatory_btn.set_click_callback(self._on_regulatory)
|
||||
|
||||
cabin_cam_btn = BigButton("driver\ncamera preview", "", gui_app.texture("icons_mici/settings/device/cameras.png", 64, 64),
|
||||
description="Preview the cabin camera to check driver monitoring visibility. The vehicle must be off.")
|
||||
cabin_cam_btn.set_click_callback(lambda: gui_app.push_widget(CabinCameraDialog()))
|
||||
cabin_cam_btn.set_enabled(lambda: ui_state.is_offroad())
|
||||
|
||||
review_training_guide_btn = BigButton("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("terms &\nconditions", "", gui_app.texture("icons_mici/settings/device/info.png", 64, 64))
|
||||
terms_btn.set_click_callback(lambda: gui_app.push_widget(ReviewTermsPage()))
|
||||
|
||||
self._scroller.add_widgets([
|
||||
DeviceInfoLayoutMici(),
|
||||
PairBigButton(),
|
||||
review_training_guide_btn,
|
||||
cabin_cam_btn,
|
||||
terms_btn,
|
||||
regulatory_btn,
|
||||
reset_calibration_btn,
|
||||
reboot_btn,
|
||||
self._power_off_btn,
|
||||
])
|
||||
|
||||
def _on_regulatory(self):
|
||||
if not self._fcc_dialog:
|
||||
self._fcc_dialog = MiciFccModal(os.path.join(BASEDIR, "openpilot/selfdrive/assets/offroad/mici_fcc.html"))
|
||||
gui_app.push_widget(self._fcc_dialog)
|
||||
@@ -1,171 +0,0 @@
|
||||
import pyray as rl
|
||||
import time
|
||||
|
||||
from openpilot.common.api import Api
|
||||
from openpilot.common.swaglog import cloudlog
|
||||
from openpilot.common.params import Params
|
||||
from openpilot.selfdrive.ui.mici.widgets.button import BigButton, GreyBigButton
|
||||
from openpilot.selfdrive.ui.mici.widgets.info import InfoLayoutMici
|
||||
from openpilot.selfdrive.ui.mici.widgets.qr import QR
|
||||
from openpilot.selfdrive.ui.ui_state import ui_state
|
||||
from openpilot.system.ui.lib.application import gui_app
|
||||
from openpilot.system.ui.widgets.scroller import NavScroller
|
||||
|
||||
|
||||
class PairingInfoLayout(InfoLayoutMici):
|
||||
def __init__(self):
|
||||
super().__init__("account", "", "status", "")
|
||||
self._commacare_badge = gui_app.texture("icons_mici/settings/device/commacare.png", 27, 32)
|
||||
self._provider_icons = {provider: gui_app.texture(f"icons_mici/settings/device/{provider}.png", 32, 32)
|
||||
for provider in ("github", "google", "apple")}
|
||||
|
||||
def _update_state(self):
|
||||
super()._update_state()
|
||||
self.subtext1.set_text(ui_state.prime_state.get_pairing_account())
|
||||
self.subtext2.set_text(self._get_prime_status())
|
||||
self._provider_icon = self._provider_icons.get(ui_state.prime_state.get_pairing_provider())
|
||||
self._show_commacare = ui_state.prime_state.has_commacare()
|
||||
|
||||
@staticmethod
|
||||
def _get_prime_status() -> str:
|
||||
if ui_state.prime_state.is_prime():
|
||||
return "prime" if ui_state.prime_state.is_full_prime() else "prime lite"
|
||||
return "not subscribed"
|
||||
|
||||
def _layout(self):
|
||||
super()._layout()
|
||||
provider_offset = self._provider_icon.width + 14 if self._provider_icon else 0
|
||||
commacare_offset = self._commacare_badge.width + 14 if self._show_commacare else 0
|
||||
self.subtext1.set_position(self.subtext1.rect.x + provider_offset, self.subtext1.rect.y)
|
||||
self.subtext1.set_max_width(int(self._rect.width - provider_offset - (40 if self._provider_icon else 20)))
|
||||
self.subtext2.set_position(self.subtext2.rect.x + commacare_offset, self.subtext2.rect.y)
|
||||
self.subtext2.set_max_width(int(self._rect.width - commacare_offset - (40 if self._show_commacare else 20)))
|
||||
|
||||
def _render(self, rect: rl.Rectangle):
|
||||
super()._render(rect)
|
||||
|
||||
if self._provider_icon:
|
||||
icon_pos = rl.Vector2(self._rect.x + 20, self.subtext1.rect.y + (self.subtext1.rect.height - self._provider_icon.height) / 2)
|
||||
rl.draw_texture_v(self._provider_icon, icon_pos, self._subheader_color)
|
||||
if self._show_commacare:
|
||||
icon_pos = rl.Vector2(self._rect.x + 20, self.subtext2.rect.y + (self.subtext2.rect.height - self._commacare_badge.height) / 2 + 4)
|
||||
rl.draw_texture_v(self._commacare_badge, icon_pos, rl.WHITE)
|
||||
|
||||
|
||||
class PrimeManagementScroller(NavScroller):
|
||||
"""Prime subscription management and upgrade information."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._params = Params()
|
||||
|
||||
can_claim_trial = ui_state.prime_state.can_claim_prime_trial()
|
||||
|
||||
self._prime_icon = gui_app.texture("icons_mici/settings/device/green_cell.png", 64, 64)
|
||||
self._phone_icon = gui_app.texture("icons_mici/settings/device/phone.png", 85, 64)
|
||||
self._prime_adverts = [
|
||||
QR(self._get_prime_url()),
|
||||
GreyBigButton(
|
||||
"try prime for\n30 days" if can_claim_trial else "upgrade to prime",
|
||||
"scan to claim trial" if can_claim_trial else "scan to manage\nprime status",
|
||||
self._prime_icon
|
||||
),
|
||||
GreyBigButton("", "prime adds 24/7 LTE to device and 1 year of cloud storage in connect.",),
|
||||
GreyBigButton("", "prime lets you view live video and GPS location remotely in connect.",),
|
||||
GreyBigButton("", "prime also includes commacare extended device warranty.",),
|
||||
]
|
||||
self._prime_management = [
|
||||
QR(self._get_prime_url()),
|
||||
GreyBigButton(
|
||||
"manage prime",
|
||||
"scan to open\ndevice prime settings",
|
||||
self._phone_icon
|
||||
),
|
||||
]
|
||||
self._scroller.add_widgets(self._prime_management if ui_state.prime_state.is_prime() else self._prime_adverts)
|
||||
|
||||
def _get_prime_url(self) -> str:
|
||||
if dongle_id := self._params.get("DongleId"):
|
||||
return f"https://connect.comma.ai/{dongle_id}/prime"
|
||||
return "https://connect.comma.ai"
|
||||
|
||||
def _update_state(self):
|
||||
super()._update_state()
|
||||
if not ui_state.prime_state.is_paired():
|
||||
self.dismiss()
|
||||
|
||||
|
||||
class PrimeScroller(NavScroller):
|
||||
"""Pairing flow or paired account information with access to prime management."""
|
||||
|
||||
QR_REFRESH_INTERVAL = 300 # 5 minutes in seconds
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._params = Params()
|
||||
self.initial_is_paired = ui_state.prime_state.is_paired()
|
||||
self._last_pairing_qr_generation = float("-inf")
|
||||
|
||||
self._manage_icon = gui_app.texture("icons_mici/settings/comma_icon.png", 33, 60)
|
||||
if not ui_state.prime_state.is_prime():
|
||||
title = "claim\nprime trial" if ui_state.prime_state.can_claim_prime_trial() else "upgrade\nto prime"
|
||||
self._manage_prime = BigButton(title, icon=self._manage_icon)
|
||||
else:
|
||||
self._manage_prime = BigButton("manage prime", icon=self._manage_icon)
|
||||
self._manage_prime.set_click_callback(lambda: gui_app.push_widget(PrimeManagementScroller()))
|
||||
|
||||
if self.initial_is_paired:
|
||||
self._scroller.add_widgets([
|
||||
PairingInfoLayout(),
|
||||
self._manage_prime,
|
||||
])
|
||||
else:
|
||||
self._qr = QR(self._get_pairing_url())
|
||||
self._scroller.scroll_indicator_start_after = self._qr
|
||||
self._scroller.add_widgets([
|
||||
self._qr,
|
||||
GreyBigButton("finish setup", "scan QR code or visit connect.comma.ai",
|
||||
gui_app.texture("icons_mici/settings/device/green_settings.png", 64, 64)),
|
||||
GreyBigButton("", "connect lets you review recent driving footage and bookmark events."),
|
||||
])
|
||||
|
||||
def _get_pairing_url(self) -> str:
|
||||
try:
|
||||
dongle_id = self._params.get("DongleId") or ""
|
||||
token = Api(dongle_id).get_token({'pair': True})
|
||||
except Exception as e:
|
||||
cloudlog.warning(f"Failed to get pairing token: {e}")
|
||||
token = ""
|
||||
return f"https://connect.comma.ai/?pair={token}"
|
||||
|
||||
def _update_layout_rects(self):
|
||||
super()._update_layout_rects()
|
||||
if not self.initial_is_paired:
|
||||
self._qr.set_rect(rl.Rectangle(self._qr.rect.x, self._qr.rect.y, self._rect.height, self._rect.height))
|
||||
|
||||
def _render(self, rect: rl.Rectangle):
|
||||
if not self.initial_is_paired:
|
||||
current_time = time.monotonic()
|
||||
if current_time - self._last_pairing_qr_generation >= self.QR_REFRESH_INTERVAL:
|
||||
self._qr._url = self._get_pairing_url()
|
||||
if self._qr._texture and self._qr._texture.id != 0:
|
||||
rl.unload_texture(self._qr._texture)
|
||||
self._qr._texture = self._qr._generate_qr_code()
|
||||
self._last_pairing_qr_generation = current_time
|
||||
super()._render(rect)
|
||||
|
||||
def _update_state(self):
|
||||
super()._update_state()
|
||||
if self.initial_is_paired != ui_state.prime_state.is_paired() and gui_app.get_active_widget() is self:
|
||||
self.dismiss()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
gui_app.init_window("pairing device")
|
||||
pairing = PrimeScroller()
|
||||
gui_app.push_widget(pairing)
|
||||
try:
|
||||
for _ in gui_app.render():
|
||||
pass
|
||||
finally:
|
||||
del pairing
|
||||
@@ -1,160 +0,0 @@
|
||||
import math
|
||||
import pyray as rl
|
||||
from collections.abc import Callable
|
||||
|
||||
from openpilot.selfdrive.ui.layouts.settings.initial_cruise_speed import get_speed_options, set_initial_cruise_speed
|
||||
from openpilot.selfdrive.ui.mici.widgets.button import BigButton, LABEL_COLOR
|
||||
from openpilot.selfdrive.ui.mici.widgets.dialog import BigDialogBase
|
||||
from openpilot.selfdrive.ui.ui_state import ui_state
|
||||
from openpilot.system.ui.lib.application import gui_app, FontWeight, TextAlignment, FONT_SCALE
|
||||
from openpilot.system.ui.lib.multilang import tr
|
||||
from openpilot.system.ui.lib.scroll_panel2 import AUTO_SCROLL_TC, GuiScrollPanel2, ScrollState
|
||||
from openpilot.system.ui.lib.text_measure import measure_text_cached
|
||||
from openpilot.system.ui.widgets import Widget
|
||||
from openpilot.system.ui.widgets.label import UnifiedLabel
|
||||
|
||||
ITEM_DRAG_DISTANCE = 120 # px of drag per value
|
||||
ITEM_GAP = 20 # px between adjacent values
|
||||
VALUE_CENTER_Y = 102
|
||||
VISIBLE_VALUES = 3 # on each side of center value
|
||||
|
||||
# value styles
|
||||
CENTER_FONT_SIZE = 120
|
||||
CENTER_LETTER_SPACING = -0.03
|
||||
SIDE_FONT_SIZE = 96
|
||||
SIDE_LETTER_SPACING = -0.01
|
||||
|
||||
|
||||
def lerp(start: float, end: float, t: float) -> float:
|
||||
return start + (end - start) * t
|
||||
|
||||
|
||||
class SpeedScroller(Widget):
|
||||
def __init__(self, change_callback: Callable[[], None]):
|
||||
super().__init__()
|
||||
self._change_callback = change_callback
|
||||
|
||||
self._center_font = gui_app.font(FontWeight.BOLD)
|
||||
self._side_font = gui_app.font(FontWeight.ROMAN)
|
||||
|
||||
self._scroll_panel = GuiScrollPanel2(horizontal=True)
|
||||
self._scroll_panel.set_enabled(lambda: self.enabled)
|
||||
self.set_touch_valid_callback(self._scroll_panel.is_touch_valid)
|
||||
self.set_click_callback(self._on_tap)
|
||||
|
||||
self._options: list[int] = []
|
||||
self._center_widths: list[float] = []
|
||||
self._side_widths: list[float] = []
|
||||
self._locked_idx: int | None = None # value the scroller is easing onto, None while the user drags
|
||||
|
||||
def show_event(self):
|
||||
super().show_event()
|
||||
self._options, self._locked_idx = get_speed_options()
|
||||
self._center_widths = [measure_text_cached(self._center_font, str(option), CENTER_FONT_SIZE,
|
||||
CENTER_LETTER_SPACING * CENTER_FONT_SIZE * FONT_SCALE).x for option in self._options]
|
||||
self._side_widths = [measure_text_cached(self._side_font, str(option), SIDE_FONT_SIZE,
|
||||
SIDE_LETTER_SPACING * SIDE_FONT_SIZE * FONT_SCALE).x for option in self._options]
|
||||
|
||||
self._scroll_panel.set_offset(-self._locked_idx * ITEM_DRAG_DISTANCE)
|
||||
|
||||
def _clamp_index(self, idx: int) -> int:
|
||||
return max(0, min(len(self._options) - 1, idx))
|
||||
|
||||
def _lock(self, idx: int):
|
||||
self._locked_idx = self._clamp_index(idx)
|
||||
set_initial_cruise_speed(self._options[self._locked_idx])
|
||||
self._change_callback()
|
||||
|
||||
def _update_state(self):
|
||||
super()._update_state()
|
||||
|
||||
# when the finger releases, lock the value where the scroll will stop
|
||||
if self._scroll_panel.state == ScrollState.MANUAL_SCROLL:
|
||||
self._locked_idx = None
|
||||
elif self._locked_idx is None and self._scroll_panel.state != ScrollState.PRESSED:
|
||||
projected_offset = self._scroll_panel.get_offset() + self._scroll_panel.velocity * AUTO_SCROLL_TC
|
||||
self._lock(round(-projected_offset / ITEM_DRAG_DISTANCE))
|
||||
|
||||
snap_target = -self._locked_idx * ITEM_DRAG_DISTANCE if self._locked_idx is not None else None
|
||||
content_size = (len(self._options) - 1) * ITEM_DRAG_DISTANCE + self._rect.width
|
||||
self._scroll_panel.update(self._rect, content_size, snap_target=snap_target)
|
||||
|
||||
def _on_tap(self):
|
||||
if self._locked_idx is None:
|
||||
return
|
||||
|
||||
# a tap on the left or the right moves one value
|
||||
tap_x = gui_app.last_mouse_event.pos.x
|
||||
center_x = self._rect.x + self._rect.width / 2
|
||||
tap_zone = self._center_widths[self._locked_idx] / 2 + ITEM_GAP / 2
|
||||
if tap_x > center_x + tap_zone:
|
||||
self._lock(self._locked_idx + 1)
|
||||
elif tap_x < center_x - tap_zone:
|
||||
self._lock(self._locked_idx - 1)
|
||||
|
||||
def _render(self, _):
|
||||
center_x = self._rect.x + self._rect.width / 2
|
||||
current = -self._scroll_panel.get_offset() / ITEM_DRAG_DISTANCE
|
||||
|
||||
# the style of each value changes from the center style to the side style over a distance of one value
|
||||
blends = [min(abs(idx - current), 1.0) for idx in range(len(self._options))]
|
||||
widths = [lerp(center, side, t) for center, side, t in zip(self._center_widths, self._side_widths, blends, strict=True)]
|
||||
|
||||
# lay values out with a constant gap, then shift so the scroll position sits at the center
|
||||
centers = [0.0]
|
||||
for idx in range(1, len(widths)):
|
||||
centers.append(centers[-1] + widths[idx - 1] / 2 + ITEM_GAP + widths[idx] / 2)
|
||||
left_idx = max(0, min(len(widths) - 2, math.floor(current)))
|
||||
shift = center_x - lerp(centers[left_idx], centers[left_idx + 1], current - left_idx)
|
||||
|
||||
# only the values near the center can be on the screen. the nearest value uses the bold font
|
||||
closest_idx = self._clamp_index(round(current))
|
||||
for idx in range(max(0, closest_idx - VISIBLE_VALUES), min(len(self._options), closest_idx + VISIBLE_VALUES + 1)):
|
||||
t = blends[idx]
|
||||
font_size = lerp(CENTER_FONT_SIZE, SIDE_FONT_SIZE, t)
|
||||
spacing = lerp(CENTER_LETTER_SPACING, SIDE_LETTER_SPACING, t) * font_size * FONT_SCALE
|
||||
color = rl.Color(255, 255, 255, int(255 * lerp(0.9, 0.45, t)))
|
||||
# use the width of the font that draws the value. this keeps the value at its center when the font changes
|
||||
if idx == closest_idx:
|
||||
font, width = self._center_font, self._center_widths[idx] * font_size / CENTER_FONT_SIZE
|
||||
else:
|
||||
font, width = self._side_font, self._side_widths[idx] * font_size / SIDE_FONT_SIZE
|
||||
position = rl.Vector2(centers[idx] + shift - width / 2, self._rect.y + VALUE_CENTER_Y - font_size * FONT_SCALE / 2)
|
||||
rl.draw_text_ex(font, str(self._options[idx]), position, font_size, spacing, color)
|
||||
|
||||
# gradient on left and right
|
||||
rl.draw_rectangle_gradient_h(int(self._rect.x), int(self._rect.y), 100, int(self._rect.height), rl.BLACK, rl.BLANK)
|
||||
rl.draw_rectangle_gradient_h(int(self._rect.x + self._rect.width - 100), int(self._rect.y), 100, int(self._rect.height), rl.BLANK, rl.BLACK)
|
||||
|
||||
|
||||
class InitialCruiseSpeedPicker(BigDialogBase):
|
||||
def __init__(self, change_callback: Callable[[], None]):
|
||||
super().__init__()
|
||||
self._unit_label = self._child(UnifiedLabel("", 36, FontWeight.BOLD, LABEL_COLOR, alignment=TextAlignment.CENTER, letter_spacing=-0.03))
|
||||
self._speed_scroller = self._child(SpeedScroller(change_callback))
|
||||
self._speed_scroller.set_enabled(lambda: self.enabled and not self.is_dismissing)
|
||||
|
||||
def show_event(self):
|
||||
super().show_event()
|
||||
self._unit_label.set_text(tr("KPH") if ui_state.is_metric else tr("MPH"))
|
||||
|
||||
def _render(self, _):
|
||||
self._speed_scroller.render(self._rect)
|
||||
self._unit_label.render(rl.Rectangle(self._rect.x, self._rect.y + 166, self._rect.width, self._rect.height - 166))
|
||||
|
||||
|
||||
class InitialCruiseSpeedButton(BigButton):
|
||||
def __init__(self):
|
||||
super().__init__("initial max speed", " ", description="Max speed when engaging openpilot.\n" +
|
||||
"If you are already driving faster, your current speed is used.\n" +
|
||||
"Resume restores the previous max speed.")
|
||||
self._picker = InitialCruiseSpeedPicker(self.refresh)
|
||||
self.set_click_callback(lambda: gui_app.push_widget(self._picker))
|
||||
self.refresh()
|
||||
|
||||
def _get_label_font_size(self):
|
||||
return 42
|
||||
|
||||
def refresh(self):
|
||||
options, idx = get_speed_options()
|
||||
self.set_value(f"{options[idx]} " + (tr("kph") if ui_state.is_metric else tr("mph")))
|
||||
@@ -1,56 +0,0 @@
|
||||
from openpilot.common.params import Params
|
||||
from openpilot.system.ui.widgets.scroller import NavScroller
|
||||
from openpilot.selfdrive.ui.mici.widgets.button import BigButton
|
||||
from openpilot.selfdrive.ui.mici.layouts.settings.toggles import TogglesLayoutMici
|
||||
from openpilot.selfdrive.ui.mici.layouts.settings.network.network_layout import NetworkLayoutMici
|
||||
from openpilot.selfdrive.ui.mici.layouts.settings.device import DeviceLayoutMici
|
||||
from openpilot.selfdrive.ui.mici.layouts.settings.developer import DeveloperLayoutMici
|
||||
from openpilot.selfdrive.ui.mici.layouts.settings.software import SoftwareLayoutMici
|
||||
from openpilot.selfdrive.ui.mici.layouts.settings.firehose import FirehoseLayout
|
||||
from openpilot.system.ui.lib.application import gui_app, FontWeight
|
||||
|
||||
|
||||
class SettingsBigButton(BigButton):
|
||||
def _get_label_font_size(self):
|
||||
return 64
|
||||
|
||||
|
||||
class SettingsLayout(NavScroller):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._params = Params()
|
||||
|
||||
toggles_panel = TogglesLayoutMici()
|
||||
toggles_btn = SettingsBigButton("toggles", "", gui_app.texture("icons_mici/settings.png", 64, 64))
|
||||
toggles_btn.set_click_callback(lambda: gui_app.push_widget(toggles_panel))
|
||||
|
||||
network_panel = NetworkLayoutMici()
|
||||
network_btn = SettingsBigButton("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("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("software", "", gui_app.texture("icons_mici/settings/software.png", 64, 75))
|
||||
software_btn.set_click_callback(lambda: gui_app.push_widget(software_panel))
|
||||
|
||||
developer_panel = DeveloperLayoutMici()
|
||||
developer_btn = SettingsBigButton("developer", "", gui_app.texture("icons_mici/settings/developer_icon.png", 64, 60))
|
||||
developer_btn.set_click_callback(lambda: gui_app.push_widget(developer_panel))
|
||||
|
||||
firehose_panel = FirehoseLayout()
|
||||
firehose_btn = SettingsBigButton("firehose", "", gui_app.texture("icons_mici/settings/firehose.png", 52, 62))
|
||||
firehose_btn.set_click_callback(lambda: gui_app.push_widget(firehose_panel))
|
||||
|
||||
self._scroller.add_widgets([
|
||||
toggles_btn,
|
||||
network_btn,
|
||||
device_btn,
|
||||
software_btn,
|
||||
firehose_btn,
|
||||
developer_btn,
|
||||
])
|
||||
|
||||
self._font_medium = gui_app.font(FontWeight.MEDIUM)
|
||||
@@ -1,291 +0,0 @@
|
||||
import subprocess
|
||||
import threading
|
||||
import pyray as rl
|
||||
from enum import IntEnum
|
||||
from collections.abc import Callable
|
||||
|
||||
from openpilot.common.time_helpers import system_time_valid
|
||||
from openpilot.selfdrive.ui.mici.layouts.settings.device import EngagedConfirmationButton
|
||||
from openpilot.selfdrive.ui.mici.widgets.button import BigButton
|
||||
from openpilot.selfdrive.ui.mici.widgets.dialog import BigDialog
|
||||
from openpilot.selfdrive.ui.ui_state import ui_state
|
||||
from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos
|
||||
from openpilot.system.ui.lib.multilang import tr
|
||||
from openpilot.system.ui.widgets import Widget
|
||||
from openpilot.system.ui.widgets.label import UnifiedLabel
|
||||
from openpilot.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("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)
|
||||
|
||||
self._branch_label = UnifiedLabel("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):
|
||||
UPDATER_PROC = "openpilot.system.updated.updated"
|
||||
CHECK_FOR_UPDATE = "SIGUSR1"
|
||||
DOWNLOAD_UPDATE = "SIGHUP"
|
||||
|
||||
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__("check for update", "", self._txt_update_icon)
|
||||
|
||||
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._signal_updater(self.DOWNLOAD_UPDATE if self.get_value() == "download update" else self.CHECK_FOR_UPDATE)
|
||||
|
||||
def check_for_update(self):
|
||||
self._signal_updater(self.CHECK_FOR_UPDATE)
|
||||
|
||||
def _signal_updater(self, sig: str):
|
||||
self.set_enabled(False)
|
||||
self._state = UpdaterState.WAITING_FOR_UPDATER
|
||||
self._hide_value_t = None
|
||||
self.set_value("")
|
||||
self.set_icon(self._txt_update_icon)
|
||||
|
||||
def run():
|
||||
subprocess.run(f"pkill -{sig} -f {self.UPDATER_PROC}", shell=True)
|
||||
|
||||
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("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
|
||||
|
||||
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("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() != updater_state:
|
||||
self.set_value(updater_state)
|
||||
|
||||
elif self._state == UpdaterState.IDLE:
|
||||
self.set_rotate_icon(False)
|
||||
if failed:
|
||||
self.set_enabled(True) # allow retry when failure came from updater param
|
||||
if self.get_value() != "failed to update":
|
||||
self.set_value("failed to update")
|
||||
|
||||
elif ui_state.params.get_bool("UpdaterFetchAvailable"):
|
||||
self.set_enabled(True)
|
||||
if self.get_value() != "download update":
|
||||
self.set_value("download update")
|
||||
|
||||
elif self._hide_value_t is not None:
|
||||
self.set_enabled(True)
|
||||
if self.get_value() == "checking...":
|
||||
self.set_value("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:
|
||||
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__("install now", "", gui_app.texture("icons_mici/settings/device/reboot.png", 64, 70))
|
||||
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, block=True)
|
||||
|
||||
threading.Thread(target=run, daemon=True).start()
|
||||
|
||||
|
||||
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, check_update_btn: CheckUpdateButton):
|
||||
super().__init__("target branch", ui_state.params.get("UpdaterTargetBranch") or "",
|
||||
description="Select the software branch to download on the next update check.")
|
||||
self._check_update_btn = check_update_btn
|
||||
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):
|
||||
if not ui_state.params.get("UpdaterAvailableBranches"):
|
||||
gui_app.push_widget(BigDialog("", tr("Failed to get available branches. Ensure you're connected to the internet and try again.")))
|
||||
return
|
||||
gui_app.push_widget(BranchSelectPage(self._on_select))
|
||||
|
||||
def _on_select(self, branch: str):
|
||||
ui_state.params.put("UpdaterTargetBranch", branch, block=True)
|
||||
self.set_value(branch)
|
||||
self._check_update_btn.check_for_update()
|
||||
|
||||
|
||||
class SoftwareLayoutMici(NavScroller):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
def uninstall_openpilot_callback():
|
||||
ui_state.params.put_bool("DoUninstall", True, block=True)
|
||||
|
||||
uninstall_openpilot_btn = EngagedConfirmationButton("uninstall sunnypilot", "uninstall",
|
||||
gui_app.texture("icons_mici/settings/device/uninstall.png", 64, 64),
|
||||
uninstall_openpilot_callback, exit_on_confirm=False,
|
||||
description="Remove openpilot from this device.",
|
||||
description_icon=gui_app.texture("icons_mici/setup/factory_reset.png", 64, 64))
|
||||
|
||||
check_update_btn = CheckUpdateButton()
|
||||
self._scroller.add_widgets([
|
||||
SoftwareInfoLayoutMici(),
|
||||
check_update_btn,
|
||||
InstallUpdateButton(),
|
||||
TargetBranchButton(check_update_btn),
|
||||
uninstall_openpilot_btn,
|
||||
])
|
||||
@@ -1,98 +0,0 @@
|
||||
from openpilot.cereal import log
|
||||
|
||||
from openpilot.system.ui.widgets.scroller import NavScroller
|
||||
from openpilot.selfdrive.ui.mici.widgets.button import BigToggle, BigParamControl, BigMultiParamToggle
|
||||
from openpilot.system.ui.lib.application import gui_app
|
||||
from openpilot.selfdrive.ui.layouts.settings.common import restart_needed_callback
|
||||
from openpilot.selfdrive.ui.ui_state import ui_state
|
||||
from openpilot.selfdrive.ui.mici.layouts.settings.initial_cruise_speed import InitialCruiseSpeedButton
|
||||
|
||||
PERSONALITY_TO_INT = log.LongitudinalPersonality.schema.enumerants
|
||||
|
||||
|
||||
class TogglesLayoutMici(NavScroller):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
self._personality_toggle = BigMultiParamToggle("following distance", "LongitudinalPersonality", ["aggressive", "standard", "relaxed"],
|
||||
description="Standard is recommended.\n" +
|
||||
"Aggressive follows closer, with firmer gas and braking.\n" +
|
||||
"Relaxed leaves more space.\n" +
|
||||
"Use the steering wheel distance button on supported cars.")
|
||||
self._initial_cruise_speed = InitialCruiseSpeedButton()
|
||||
self._initial_cruise_speed.set_visible(lambda: ui_state.CP is not None and ui_state.has_longitudinal_control and not ui_state.CP.pcmCruise)
|
||||
is_metric_toggle = BigToggle("use metric units", initial_state=ui_state.params.get_bool("IsMetric"), toggle_callback=self._on_metric_toggle)
|
||||
ldw_toggle = BigParamControl("lane departure warnings", "IsLdwEnabled",
|
||||
description="Warn when you drift across a detected lane line.\n" +
|
||||
"Only above 31 mph (50 km/h), with no turn signal.")
|
||||
always_on_dm_toggle = BigParamControl("always-on driver monitor", "AlwaysOnDM", description="Monitor the driver even when sunnypilot is not engaged.")
|
||||
record_front = BigParamControl("record & upload cabin camera", "RecordFront",
|
||||
description_icon=gui_app.texture("icons_mici/settings/device/cameras.png", 64, 64),
|
||||
toggle_callback=restart_needed_callback, description="Upload cabin camera data to help improve driver monitoring.")
|
||||
record_mic = BigParamControl("record & upload mic audio", "RecordAudio", description_icon=gui_app.texture("icons_mici/microphone.png", 64, 64),
|
||||
toggle_callback=restart_needed_callback,
|
||||
description="Record microphone audio while driving.\n" +
|
||||
"Audio is included in dashcam videos in sunnylink.")
|
||||
enable_openpilot = BigParamControl("enable sunnypilot", "OpenpilotEnabledToggle", toggle_callback=restart_needed_callback,
|
||||
description="Enable to use sunnypilot driver assistance.\n" +
|
||||
"Disable to use your car's stock driver assistance.")
|
||||
|
||||
|
||||
self._scroller.add_widgets([
|
||||
self._personality_toggle,
|
||||
self._initial_cruise_speed,
|
||||
is_metric_toggle,
|
||||
ldw_toggle,
|
||||
always_on_dm_toggle,
|
||||
record_front,
|
||||
record_mic,
|
||||
enable_openpilot,
|
||||
])
|
||||
|
||||
# Toggle lists
|
||||
self._refresh_toggles = (
|
||||
("IsMetric", is_metric_toggle),
|
||||
("IsLdwEnabled", ldw_toggle),
|
||||
("AlwaysOnDM", always_on_dm_toggle),
|
||||
("RecordFront", record_front),
|
||||
("RecordAudio", record_mic),
|
||||
("OpenpilotEnabledToggle", enable_openpilot),
|
||||
)
|
||||
|
||||
enable_openpilot.set_enabled(lambda: not ui_state.engaged)
|
||||
record_front.set_enabled(False if ui_state.params.get_bool("RecordFrontLock") else (lambda: not ui_state.engaged))
|
||||
record_mic.set_enabled(lambda: not ui_state.engaged)
|
||||
|
||||
if ui_state.params.get_bool("ShowDebugInfo"):
|
||||
gui_app.set_show_touches(True)
|
||||
gui_app.set_show_fps(True)
|
||||
|
||||
ui_state.add_engaged_transition_callback(self._update_toggles)
|
||||
|
||||
def _update_state(self):
|
||||
super()._update_state()
|
||||
|
||||
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._personality_toggle.set_value(self._personality_toggle._options[personality])
|
||||
ui_state.personality = personality
|
||||
|
||||
def show_event(self):
|
||||
super().show_event()
|
||||
self._update_toggles()
|
||||
|
||||
def _on_metric_toggle(self, state: bool):
|
||||
ui_state.params.put_bool("IsMetric", state, block=True)
|
||||
self._update_toggles()
|
||||
|
||||
def _update_toggles(self):
|
||||
ui_state.update_params()
|
||||
self._initial_cruise_speed.refresh()
|
||||
|
||||
if ui_state.CP is not None:
|
||||
self._personality_toggle.set_visible(ui_state.has_longitudinal_control)
|
||||
|
||||
# Refresh toggles from params to mirror external changes
|
||||
for key, item in self._refresh_toggles:
|
||||
item.set_checked(ui_state.params.get_bool(key))
|
||||
@@ -1,386 +0,0 @@
|
||||
import time
|
||||
from enum import StrEnum
|
||||
from typing import NamedTuple
|
||||
import pyray as rl
|
||||
import random
|
||||
import string
|
||||
from dataclasses import dataclass
|
||||
from openpilot.cereal import messaging, log
|
||||
from opendbc.car.structs import car
|
||||
from openpilot.selfdrive.ui.ui_state import ui_state
|
||||
from openpilot.common.filter_simple import BounceFilter, FirstOrderFilter
|
||||
from openpilot.common.hardware import COMMA_HARDWARE
|
||||
from openpilot.system.ui.lib.application import gui_app, FontWeight, TextAlignment
|
||||
from openpilot.system.ui.widgets import Widget
|
||||
from openpilot.system.ui.widgets.label import UnifiedLabel
|
||||
|
||||
from openpilot.selfdrive.ui.sunnypilot.onroad.speed_limit import SpeedLimitAlertRenderer
|
||||
|
||||
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
|
||||
alpha: float = 255.0
|
||||
|
||||
|
||||
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="sunnypilot 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, SpeedLimitAlertRenderer):
|
||||
def __init__(self):
|
||||
Widget.__init__(self)
|
||||
SpeedLimitAlertRenderer.__init__(self)
|
||||
|
||||
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_left.png', 104, 96, flip_x=True)
|
||||
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_left.png', 134, 150, flip_x=True)
|
||||
|
||||
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 > 10:
|
||||
return ALERT_STARTUP_PENDING
|
||||
|
||||
# 2. Lost communication with selfdriveState after receiving it
|
||||
if COMMA_HARDWARE 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
|
||||
|
||||
# Return current alert
|
||||
ret = Alert(text1=self._tr_alert_text(ss.alertText1), text2=self._tr_alert_text(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_alpha = 255.0
|
||||
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
|
||||
|
||||
elif event_name == 'speedLimitPreActive':
|
||||
icon_side, txt_icon, icon_alpha, icon_margin_x, icon_margin_y = SpeedLimitAlertRenderer.speed_limit_pre_active_icon_helper(self)
|
||||
|
||||
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, icon_alpha) if txt_icon is not None and icon_side is not None else None
|
||||
return AlertLayout(text_rect, icon_layout)
|
||||
|
||||
def _tr_alert_text(self, text: str) -> str:
|
||||
t = tr(text)
|
||||
if t != text:
|
||||
return t
|
||||
if ': ' in text:
|
||||
head, _, tail = text.partition(': ')
|
||||
ht = tr(head)
|
||||
if ht != head:
|
||||
return f"{ht}: {tail}"
|
||||
parts = text.split(' ', 2)
|
||||
if len(parts) >= 3:
|
||||
head = parts[0] + ' ' + parts[1]
|
||||
ht = tr(head)
|
||||
if ht != head:
|
||||
return f"{ht} {' '.join(parts[2:])}"
|
||||
return text
|
||||
|
||||
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.sunnypilot_ui():
|
||||
ui_state.onroad_brightness_handle_alerts(ui_state, 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)
|
||||
|
||||
# update speed limit UI states
|
||||
SpeedLimitAlertRenderer.update(self)
|
||||
|
||||
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
|
||||
|
||||
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 = alert_layout.icon.alpha
|
||||
else:
|
||||
icon_alpha = int(min(self._turn_signal_alpha_filter.x, 255))
|
||||
|
||||
rl.draw_texture_ex(alert_layout.icon.texture, rl.Vector2(pos_x, self._rect.y + alert_layout.icon.margin_y), 0.0, 1.0,
|
||||
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')
|
||||
# TODO: there should be a common way to determine font size based on text length to maximize rect
|
||||
if len(alert_text1) <= 12:
|
||||
font_size = 92 - 10
|
||||
elif len(alert_text1) <= 16:
|
||||
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(TextAlignment.LEFT if icon_side != 'left' else TextAlignment.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 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) > 24:
|
||||
small_font_size = 32
|
||||
elif len(alert_text2) > 18:
|
||||
small_font_size = 36
|
||||
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(TextAlignment.LEFT if icon_side != 'left' else TextAlignment.RIGHT)
|
||||
self._alert_text2_label.render(text_rect2)
|
||||
@@ -1,414 +0,0 @@
|
||||
import numpy as np
|
||||
import pyray as rl
|
||||
from openpilot.cereal import log
|
||||
from opendbc.car.structs import car
|
||||
from openpilot.cereal.visionipc import VisionStreamType
|
||||
from openpilot.selfdrive.ui.ui_state import ui_state, UIStatus
|
||||
from openpilot.selfdrive.ui.mici.onroad import SIDE_PANEL_WIDTH
|
||||
from openpilot.selfdrive.ui.mici.onroad.alert_renderer import AlertRenderer
|
||||
from openpilot.selfdrive.ui.mici.onroad.driver_state import DriverStateRenderer
|
||||
from openpilot.selfdrive.ui.mici.onroad.hud_renderer import HudRenderer
|
||||
from openpilot.selfdrive.ui.mici.onroad.long_indicator import LongIndicator
|
||||
from openpilot.selfdrive.ui.mici.onroad.model_renderer import ModelRenderer
|
||||
from openpilot.selfdrive.ui.onroad.navigation import NavigationPanel, PANEL_WIDTH
|
||||
from openpilot.selfdrive.ui.mici.onroad.confidence_ball import ConfidenceBall
|
||||
from openpilot.selfdrive.ui.mici.onroad.cameraview import CameraView
|
||||
from openpilot.system.ui.lib.application import FontWeight, gui_app, MousePos, MouseEvent, TextAlignment, TextAlignmentVertical
|
||||
from openpilot.system.ui.widgets.label import UnifiedLabel
|
||||
from openpilot.system.ui.widgets import Widget
|
||||
from openpilot.common.filter_simple import BounceFilter, FirstOrderFilter
|
||||
from openpilot.common.transformations.camera import DEVICE_CAMERAS, DeviceCameraConfig, view_frame_from_device_frame
|
||||
from openpilot.common.transformations.orientation import rot_from_euler
|
||||
from enum import IntEnum
|
||||
|
||||
if gui_app.sunnypilot_ui():
|
||||
from openpilot.selfdrive.ui.sunnypilot.mici.onroad.hud_renderer import HudRendererSP as HudRenderer
|
||||
from openpilot.selfdrive.ui.sunnypilot.ui_state import OnroadTimerStatus
|
||||
|
||||
OpState = log.SelfdriveState.OpenpilotState
|
||||
CALIBRATED = log.ExtrinsicsCalibration.Status.calibrated
|
||||
NARROW_ROAD_CAM = VisionStreamType.VISION_STREAM_NARROW_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 (11 mph)
|
||||
ROAD_CAM_MIN_SPEED = 10.0 # m/s (22 mph)
|
||||
|
||||
CAM_Y_OFFSET = 20
|
||||
|
||||
|
||||
class BookmarkIcon(Widget):
|
||||
PEEK_THRESHOLD = 150 # 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._filled_icon = gui_app.texture("icons_mici/onroad/bookmark_fill.png", 180, 180)
|
||||
self._offset_filter = BounceFilter(0.0, 0.1, 1 / gui_app.target_fps)
|
||||
self._active_alpha = FirstOrderFilter(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
|
||||
self._triggered_duration = 1.5
|
||||
self._swipe_expired = False
|
||||
|
||||
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.TRIGGERED and rl.get_time() - self._triggered_time >= self._triggered_duration:
|
||||
self._state = BookmarkState.HIDDEN
|
||||
self._swipe_expired = self._is_swiping
|
||||
|
||||
swipe_offset = self._swipe_start_x - self._swipe_current_x
|
||||
armed = self._state == BookmarkState.DRAGGING and swipe_offset > self.PEEK_THRESHOLD
|
||||
if self._state == BookmarkState.DRAGGING:
|
||||
# Snap to the released position when armed, while allowing further dragging or cancellation.
|
||||
if armed:
|
||||
swipe_offset += self.FULL_VISIBLE_OFFSET - self.PEEK_THRESHOLD
|
||||
swipe_offset = min(swipe_offset, self.FULL_VISIBLE_OFFSET + 50)
|
||||
self._offset_filter.update(swipe_offset)
|
||||
|
||||
elif self._state == BookmarkState.TRIGGERED:
|
||||
# Let another left swipe move the same bookmark.
|
||||
offset = self.FULL_VISIBLE_OFFSET
|
||||
if self._is_swiping and self._is_swiping_left:
|
||||
offset += min(swipe_offset, 50)
|
||||
self._offset_filter.update(offset)
|
||||
|
||||
elif self._state == BookmarkState.HIDDEN:
|
||||
self._offset_filter.update(self.HIDDEN_OFFSET)
|
||||
|
||||
if self._offset_filter.x < 1e-3:
|
||||
self._interacting = False
|
||||
|
||||
self._active_alpha.update(float(armed or self._state == BookmarkState.TRIGGERED))
|
||||
|
||||
def _handle_mouse_event(self, mouse_event: MouseEvent):
|
||||
if not ui_state.started:
|
||||
return
|
||||
|
||||
if mouse_event.left_pressed:
|
||||
if self._state == BookmarkState.TRIGGERED:
|
||||
self._triggered_time = rl.get_time()
|
||||
self._triggered_duration = 0.5
|
||||
|
||||
# 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._swipe_expired = False
|
||||
|
||||
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
|
||||
if self._state == BookmarkState.HIDDEN and not self._swipe_expired:
|
||||
self._state = BookmarkState.DRAGGING
|
||||
|
||||
elif mouse_event.left_released:
|
||||
if self._is_swiping and self._state == BookmarkState.DRAGGING:
|
||||
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._triggered_duration = 1.5
|
||||
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._icon.height) / 2 # Vertically centered
|
||||
for icon, alpha in ((self._icon, 1.0 - self._active_alpha.x), (self._filled_icon, self._active_alpha.x)):
|
||||
rl.draw_texture_ex(icon, rl.Vector2(icon_x, icon_y), 0.0, 1.0, rl.Color(255, 255, 255, round(255 * alpha)))
|
||||
|
||||
|
||||
class AugmentedRoadView(CameraView):
|
||||
def __init__(self, bookmark_callback=None, stream_type: VisionStreamType = VisionStreamType.VISION_STREAM_NARROW_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._navigation = NavigationPanel()
|
||||
self._model_renderer = ModelRenderer()
|
||||
self._hud_renderer = HudRenderer()
|
||||
self._long_indicator = LongIndicator()
|
||||
self._alert_renderer = AlertRenderer()
|
||||
self._driver_state_renderer = DriverStateRenderer()
|
||||
self._confidence_ball = ConfidenceBall()
|
||||
self._offroad_label = UnifiedLabel("start the car to\nuse sunnypilot", 54, FontWeight.DISPLAY,
|
||||
text_color=rl.Color(255, 255, 255, int(255 * 0.9)),
|
||||
alignment=TextAlignment.CENTER,
|
||||
alignment_vertical=TextAlignmentVertical.MIDDLE)
|
||||
|
||||
self._fade_texture = gui_app.texture("icons_mici/onroad/onroad_fade.png")
|
||||
|
||||
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()
|
||||
|
||||
# update offroad label
|
||||
if ui_state.panda_type == log.PandaState.PandaType.unknown:
|
||||
self._offroad_label.set_text("system booting")
|
||||
elif ui_state.ignition and not ui_state.started:
|
||||
self._offroad_label.set_text("openpilot can't start\ncheck alerts")
|
||||
else:
|
||||
self._offroad_label.set_text("start the car to\nuse sunnypilot")
|
||||
|
||||
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, _):
|
||||
# Draw text if not onroad
|
||||
if not ui_state.started:
|
||||
rl.draw_rectangle_rec(self.rect, rl.BLACK)
|
||||
self._offroad_label.render(self._rect)
|
||||
return
|
||||
|
||||
self._switch_stream_if_needed(ui_state.sm)
|
||||
|
||||
# Update calibration before rendering
|
||||
self._update_calibration()
|
||||
|
||||
alert_to_render, not_animating_out = self._alert_renderer.will_render()
|
||||
show_navigation = ui_state.navigation_enabled and ui_state.nav_destination_set and alert_to_render is None
|
||||
|
||||
# Create inner content area with border padding
|
||||
self._content_rect = rl.Rectangle(
|
||||
self.rect.x,
|
||||
self.rect.y,
|
||||
self.rect.width - (PANEL_WIDTH if show_navigation else 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)
|
||||
|
||||
# Fade out bottom of overlays for looks
|
||||
rl.draw_texture_ex(self._fade_texture, rl.Vector2(self._content_rect.x, self._content_rect.y), 0.0, 1.0, rl.WHITE)
|
||||
|
||||
# Hide DMoji when disengaged unless AlwaysOnDM is enabled
|
||||
should_draw_dmoji = (not self._hud_renderer.drawing_top_icons() 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._long_indicator.set_should_draw(not self._hud_renderer.drawing_top_icons())
|
||||
self._long_indicator.render(self._content_rect)
|
||||
|
||||
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)
|
||||
self._alert_renderer.render(self._content_rect)
|
||||
self._hud_renderer.render(self._content_rect)
|
||||
|
||||
# Draw fake rounded border
|
||||
rl.draw_rectangle_rounded_lines_ex(self._content_rect, 0.2 * 1.02, 10, 50, rl.BLACK)
|
||||
|
||||
# End clipping region
|
||||
rl.end_scissor_mode()
|
||||
|
||||
# Custom UI extension point - add custom overlays here
|
||||
# Use self._content_rect for positioning within camera bounds
|
||||
if show_navigation:
|
||||
self._navigation.render(rl.Rectangle(self._content_rect.x + self._content_rect.width, self.rect.y, PANEL_WIDTH, self.rect.height))
|
||||
else:
|
||||
self._confidence_ball.render(self.rect)
|
||||
|
||||
self._bookmark_icon.render(self.rect)
|
||||
|
||||
def close(self):
|
||||
self._navigation.close()
|
||||
super().close()
|
||||
|
||||
def _switch_stream_if_needed(self, sm):
|
||||
if sm['carParams'].openpilotLongitudinalControl 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 = NARROW_ROAD_CAM
|
||||
else:
|
||||
# Hysteresis zone - keep current stream
|
||||
target = self.stream_type
|
||||
else:
|
||||
target = NARROW_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['narrowRoadCameraState'] and sm.seen['deviceState']:
|
||||
self.device_camera = DEVICE_CAMERAS[(str(sm['deviceState'].deviceType), str(sm['narrowRoadCameraState'].sensor))]
|
||||
|
||||
# Check if camera calibration data is available and valid
|
||||
if not (sm.updated["extrinsicsCalibration"] and sm.valid['extrinsicsCalibration']):
|
||||
return
|
||||
|
||||
calib = sm['extrinsicsCalibration']
|
||||
if len(calib.rpyCalib) != 3 or calib.calStatus != CALIBRATED:
|
||||
return
|
||||
|
||||
# Update view_from_calib matrix
|
||||
device_from_calib = rot_from_euler(calib.rpyCalib)
|
||||
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
|
||||
|
||||
def _calc_frame_matrix(self, rect: rl.Rectangle) -> np.ndarray:
|
||||
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.wide_road.intrinsics if is_wide_camera else device_camera.narrow_road.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
|
||||
w, h = self._content_rect.width, self._content_rect.height
|
||||
cx, cy = intrinsic[0, 2], intrinsic[1, 2]
|
||||
|
||||
# Ensure zoom views the whole area
|
||||
zoom = max(zoom, w / (2 * cx), h / (2 * cy))
|
||||
|
||||
# Calculate max allowed offsets with margins
|
||||
margin = 5
|
||||
max_x_offset = max(0.0, cx * zoom - w / 2 - margin)
|
||||
max_y_offset = max(0.0, 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 cache stays hot during scroll. ModelRenderer adds offset at draw time
|
||||
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.sunnypilot_ui():
|
||||
ui_state.reset_onroad_sleep_timer(OnroadTimerStatus.RESUME)
|
||||
|
||||
def hide_event(self):
|
||||
if gui_app.sunnypilot_ui():
|
||||
ui_state.reset_onroad_sleep_timer(OnroadTimerStatus.PAUSE)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
gui_app.init_window("OnRoad Camera View")
|
||||
road_camera_view = AugmentedRoadView(lambda: None, stream_type=NARROW_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 = NARROW_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()
|
||||
@@ -1,86 +0,0 @@
|
||||
import math
|
||||
import pyray as rl
|
||||
from openpilot.selfdrive.ui.mici.onroad import SIDE_PANEL_WIDTH
|
||||
from openpilot.selfdrive.ui.ui_state import ui_state, UIStatus
|
||||
from openpilot.system.ui.widgets import Widget
|
||||
from openpilot.system.ui.lib.application import gui_app
|
||||
from openpilot.common.filter_simple import FirstOrderFilter
|
||||
|
||||
from openpilot.selfdrive.ui.sunnypilot.mici.onroad.confidence_ball import ConfidenceBallSP
|
||||
|
||||
|
||||
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, ConfidenceBallSP):
|
||||
def __init__(self, demo: bool = False):
|
||||
Widget.__init__(self)
|
||||
ConfidenceBallSP.__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_color()
|
||||
|
||||
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)
|
||||
@@ -1,317 +0,0 @@
|
||||
import math
|
||||
import pyray as rl
|
||||
from dataclasses import dataclass
|
||||
from openpilot.common.constants import CV
|
||||
from openpilot.selfdrive.ui.mici.onroad.torque_bar import TorqueBar
|
||||
from openpilot.selfdrive.ui.ui_state import ui_state, UIStatus, ChestnutState
|
||||
from openpilot.system.ui.lib.application import gui_app, FontWeight
|
||||
from openpilot.system.ui.lib.multilang import tr
|
||||
from openpilot.system.ui.lib.text_measure import measure_text_cached
|
||||
from openpilot.system.ui.widgets import Widget
|
||||
from openpilot.common.filter_simple import FirstOrderFilter
|
||||
from openpilot.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_left.png', 50, 20, flip_x=True)
|
||||
|
||||
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._chestnut_fade_time: float = 0
|
||||
|
||||
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', 9, 44)
|
||||
self._txt_chestnut: rl.Texture = gui_app.texture('icons_mici/chestnut.png', 60, 44)
|
||||
self._txt_chestnut_green: rl.Texture = gui_app.texture('icons_mici/chestnut_green.png', 60, 44)
|
||||
self._txt_chestnut_orange: rl.Texture = gui_app.texture('icons_mici/chestnut_orange.png', 75, 44)
|
||||
self._chestnut_icon: rl.Texture | None = None
|
||||
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)
|
||||
self._chestnut_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.deprecated.vCruise 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()
|
||||
if engaged != self._engaged:
|
||||
self._chestnut_fade_time = rl.get_time() if engaged else 0
|
||||
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_model_source(rect)
|
||||
|
||||
self._draw_steering_wheel(rect)
|
||||
|
||||
def _draw_model_source(self, rect: rl.Rectangle) -> None:
|
||||
if ui_state.sm.recv_frame['selfdriveState'] < ui_state.started_frame:
|
||||
return
|
||||
|
||||
loading = ui_state.chestnut_state == ChestnutState.LOADING
|
||||
if loading:
|
||||
icon = self._txt_chestnut
|
||||
opacity = 0.35 + 0.65 * (0.5 - 0.5 * math.cos(rl.get_time() * 6.0))
|
||||
elif ui_state.chestnut_state in (ChestnutState.UNCOMPILED, ChestnutState.FAILED):
|
||||
icon = self._txt_chestnut_orange
|
||||
opacity = 1.0
|
||||
elif ui_state.chestnut_state == ChestnutState.ACTIVE:
|
||||
icon = self._txt_chestnut_green
|
||||
opacity = 1.0
|
||||
else:
|
||||
return
|
||||
|
||||
if icon is not self._chestnut_icon:
|
||||
self._chestnut_fade_time = rl.get_time()
|
||||
self._chestnut_icon = icon
|
||||
visible = loading or rl.get_time() - self._chestnut_fade_time < SET_SPEED_PERSISTENCE
|
||||
alpha = self._chestnut_alpha_filter.update(visible)
|
||||
if alpha < 1e-2:
|
||||
return
|
||||
|
||||
pos = rl.Vector2(rect.x + rect.width - 10 - icon.width,
|
||||
rect.y + rect.height - 14 - (self._txt_wheel.height + icon.height) / 2)
|
||||
rl.draw_texture_ex(icon, pos, 0.0, 1.0, rl.Color(255, 255, 255, int(255 * opacity * alpha)))
|
||||
|
||||
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.sunnypilot_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_ex(self._txt_exclamation_point, rl.Vector2(exclamation_pos_x, exclamation_pos_y), 0.0, 1.0, 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
|
||||
rl.draw_circle_gradient(rl.Vector2(x + circle_radius, 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)
|
||||
@@ -1,89 +0,0 @@
|
||||
import pyray as rl
|
||||
from openpilot.cereal import log
|
||||
from openpilot.common.filter_simple import FirstOrderFilter
|
||||
from openpilot.selfdrive.ui.ui_state import ui_state
|
||||
from openpilot.system.ui.lib.application import gui_app
|
||||
from openpilot.system.ui.widgets import Widget
|
||||
|
||||
HIGHLIGHT_TIME = 2.5 # seconds
|
||||
|
||||
|
||||
class LongIndicator(Widget):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._txt_lead_car = (self._texture('car', 35, 27), self._texture('car_green', 50, 42))
|
||||
self._txt_distance = [(self._texture('distance_1', 32, 7), self._texture('distance_1_green', 60, 35)),
|
||||
(self._texture('distance_2', 40, 9), self._texture('distance_2_green', 68, 37)),
|
||||
(self._texture('distance_3', 48, 11), self._texture('distance_3_green', 76, 39))]
|
||||
self._alpha_filter = FirstOrderFilter(0.0, 0.05, 1 / gui_app.target_fps)
|
||||
# crossfade between states, a white and a green filter per icon
|
||||
self._lead_car_filters = (FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps), FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps))
|
||||
self._distance_filters = [(FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps), FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps))
|
||||
for _ in range(3)]
|
||||
self._personality: int | None = None
|
||||
self._personality_changed_time = -HIGHLIGHT_TIME
|
||||
self._should_draw = False
|
||||
|
||||
@staticmethod
|
||||
def _texture(name: str, width: int, height: int) -> rl.Texture:
|
||||
return gui_app.texture(f'icons_mici/longitudinal/{name}.png', width, height, keep_aspect_ratio=False)
|
||||
|
||||
def set_should_draw(self, should_draw: bool):
|
||||
self._should_draw = should_draw
|
||||
|
||||
def _render(self, rect: rl.Rectangle) -> None:
|
||||
sm = ui_state.sm
|
||||
if sm.recv_frame['selfdriveState'] < ui_state.started_frame or not sm['selfdriveState'].enabled or not ui_state.has_longitudinal_control:
|
||||
self._personality = None
|
||||
self._personality_changed_time = -HIGHLIGHT_TIME
|
||||
self._alpha_filter.x = 0.0
|
||||
return
|
||||
|
||||
# hidden under alerts and set speed
|
||||
visible = self._should_draw and sm['selfdriveState'].alertSize == log.SelfdriveState.AlertSize.none
|
||||
alpha = self._alpha_filter.update(visible)
|
||||
self._draw_lead_car(rect, alpha)
|
||||
self._draw_distance_bars(rect, alpha, visible)
|
||||
|
||||
def _draw_lead_car(self, rect: rl.Rectangle, alpha: float) -> None:
|
||||
sm = ui_state.sm
|
||||
plan = sm['longitudinalPlan']
|
||||
has_lead = sm.alive['longitudinalPlan'] and plan.hasLead
|
||||
|
||||
e2e = has_lead and plan.longitudinalPlanSource == log.LongitudinalPlan.LongitudinalPlanSource.e2e
|
||||
white_f, green_f = self._lead_car_filters
|
||||
white_alpha = white_f.update(0.0 if e2e else 0.9 if has_lead else 0.35)
|
||||
green_alpha = green_f.update(float(e2e))
|
||||
|
||||
white, green = self._txt_lead_car
|
||||
self._draw_centered(white, rect, 100, white_alpha * alpha)
|
||||
self._draw_centered(green, rect, 100, green_alpha * alpha)
|
||||
|
||||
def _draw_distance_bars(self, rect: rl.Rectangle, alpha: float, visible: bool) -> None:
|
||||
sm = ui_state.sm
|
||||
now = rl.get_time()
|
||||
personality = sm['selfdriveState'].personality.raw
|
||||
if self._personality is not None and personality != self._personality:
|
||||
self._personality_changed_time = now
|
||||
self._personality = personality
|
||||
# the personality alert covers the bars, hold the highlight until they show
|
||||
if not visible and now - self._personality_changed_time < HIGHLIGHT_TIME:
|
||||
self._personality_changed_time = now
|
||||
highlight = now - self._personality_changed_time < HIGHLIGHT_TIME
|
||||
|
||||
# blink at double the turn signal rate (2.67 Hz) while overriding the gas
|
||||
overriding = any(e.name == log.OnroadEvent.EventName.gasPressedOverride for e in sm['onroadEvents'])
|
||||
blink = 0.35 / 0.9 if overriding and now % 0.375 > 0.1875 else 1.0
|
||||
|
||||
count = personality + 1
|
||||
for i, ((white, green), y, (active_f, green_f)) in enumerate(zip(self._txt_distance, (122, 136, 152), self._distance_filters, strict=True)):
|
||||
active = active_f.update(float(i < count))
|
||||
green_alpha = green_f.update(float(highlight and i == count - 1))
|
||||
# only lit bars blink on override
|
||||
self._draw_centered(white, rect, y, (0.35 * (1 - active) + 0.9 * (active - green_alpha) * blink) * alpha)
|
||||
self._draw_centered(green, rect, y, green_alpha * blink * alpha)
|
||||
|
||||
@staticmethod
|
||||
def _draw_centered(texture: rl.Texture, rect: rl.Rectangle, y: float, alpha: float) -> None:
|
||||
pos = rl.Vector2(rect.x + 46 - texture.width / 2, rect.y + y - texture.height / 2)
|
||||
rl.draw_texture_ex(texture, pos, 0.0, 1.0, rl.Color(255, 255, 255, round(255 * alpha)))
|
||||
@@ -1,424 +0,0 @@
|
||||
import colorsys
|
||||
import numpy as np
|
||||
import pyray as rl
|
||||
from openpilot.cereal import log, messaging
|
||||
from opendbc.car.structs import car
|
||||
from dataclasses import dataclass, field
|
||||
from openpilot.common.params import Params
|
||||
from openpilot.common.filter_simple import FirstOrderFilter
|
||||
from openpilot.selfdrive.controls.radard import RADAR_TO_CAMERA
|
||||
from openpilot.selfdrive.locationd.calibrationd import HEIGHT_INIT
|
||||
from openpilot.selfdrive.ui.ui_state import ui_state, UIStatus
|
||||
from openpilot.selfdrive.ui.mici.onroad import blend_colors
|
||||
from openpilot.system.ui.lib.application import gui_app
|
||||
from openpilot.system.ui.lib.shader_polygon import draw_polygon, Gradient
|
||||
from openpilot.system.ui.widgets import Widget
|
||||
|
||||
from openpilot.selfdrive.ui.sunnypilot.mici.onroad.model_renderer import LANE_LINE_COLORS_SP, ModelRendererSP
|
||||
|
||||
CLIP_MARGIN = 500
|
||||
MIN_DRAW_DISTANCE = 10.0
|
||||
MAX_DRAW_DISTANCE = 100.0
|
||||
LEAD_BAR_LENGTH = 12.0 # px
|
||||
LEAD_BAR_WIDTH = 1.8 # m
|
||||
|
||||
STOCK_PATH_GRADIENT = Gradient(
|
||||
start=(0.0, 1.0),
|
||||
end=(0.0, 0.0),
|
||||
colors=[rl.Color(13, 248, 122, 102), rl.Color(114, 255, 92, 89), rl.Color(114, 255, 92, 0)],
|
||||
stops=[0.0, 0.5, 1.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),
|
||||
**LANE_LINE_COLORS_SP,
|
||||
}
|
||||
|
||||
|
||||
@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))
|
||||
|
||||
|
||||
class LeadVehicle:
|
||||
def __init__(self):
|
||||
self.bar = np.empty((0, 2), dtype=np.float32)
|
||||
self.d_filter = FirstOrderFilter(0.0, 0.2, 1 / gui_app.target_fps, initialized=False)
|
||||
self.y_filter = FirstOrderFilter(0.0, 0.2, 1 / gui_app.target_fps, initialized=False)
|
||||
self.fade_filter = FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps)
|
||||
|
||||
|
||||
class ModelRenderer(Widget, ModelRendererSP):
|
||||
def __init__(self):
|
||||
Widget.__init__(self)
|
||||
ModelRendererSP.__init__(self)
|
||||
self._longitudinal_control = False
|
||||
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._torque_filter = FirstOrderFilter(0, 0.1, 1 / gui_app.target_fps)
|
||||
|
||||
# 3x3 car space -> rect-origin space (draw methods add rect.x/y)
|
||||
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._path_gradient = Gradient(
|
||||
start=(0.0, 1.0), # Bottom of path
|
||||
end=(0.0, 0.0), # Top of path
|
||||
colors=[],
|
||||
stops=[],
|
||||
)
|
||||
|
||||
# Get longitudinal control setting from car parameters
|
||||
if car_params := Params().get("CarParams"):
|
||||
cp = messaging.log_from_bytes(car_params, car.CarParams)
|
||||
self._longitudinal_control = cp.openpilotLongitudinalControl
|
||||
|
||||
def set_transform(self, transform: np.ndarray):
|
||||
self._car_space_transform = transform.astype(np.float32)
|
||||
self._transform_dirty = True
|
||||
|
||||
def _render(self, rect: rl.Rectangle):
|
||||
sm = ui_state.sm
|
||||
|
||||
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
|
||||
extrinsics_calibration = sm['extrinsicsCalibration']
|
||||
self._path_offset_z = extrinsics_calibration.height[0] if extrinsics_calibration.height else HEIGHT_INIT[0]
|
||||
|
||||
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)
|
||||
self._transform_dirty = False
|
||||
|
||||
# Draw elements (hide when disengaged)
|
||||
if ui_state.status != UIStatus.DISENGAGED:
|
||||
self._draw_lane_lines()
|
||||
self._draw_path()
|
||||
|
||||
if render_lead_indicator:
|
||||
self._update_leads(sm)
|
||||
self._draw_lead_indicator()
|
||||
else:
|
||||
self._lead_vehicles = [LeadVehicle(), LeadVehicle()]
|
||||
|
||||
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, sm):
|
||||
plan = sm['longitudinalPlan']
|
||||
if plan.longitudinalPlanSource == log.LongitudinalPlan.LongitudinalPlanSource.e2e and len(sm['modelV2'].leadsV3) > 1:
|
||||
leads = [(lead.prob > 0.5, lead.x[0], -lead.y[0]) for lead in list(sm['modelV2'].leadsV3)[:2]]
|
||||
else:
|
||||
radar = sm['radarState']
|
||||
leads = [(lead.present, lead.dRel + RADAR_TO_CAMERA, lead.yRel) for lead in (radar.leadOne, radar.leadTwo)]
|
||||
|
||||
# both leads can be the same vehicle
|
||||
if leads[0][0] and abs(leads[1][1] - leads[0][1]) < 3.0:
|
||||
leads[1] = (False, 0.0, 0.0)
|
||||
|
||||
ss, cs = sm['selfdriveState'], sm['carState']
|
||||
# braking disengages without making openpilot unavailable
|
||||
available = ss.enabled or ss.engageable or cs.brakePressed
|
||||
lane = self._path.raw_points
|
||||
opacity = 0.4 if ui_state.status == UIStatus.DISENGAGED else 0.8
|
||||
for lead, (present, d_rel, y_rel) in zip(self._lead_vehicles, leads, strict=True):
|
||||
visible = available and present and d_rel < MAX_DRAW_DISTANCE and len(lane) > 0
|
||||
# snap to a new vehicle instead of sliding over
|
||||
if not visible or abs(y_rel - lead.y_filter.x) > 3.0:
|
||||
lead.d_filter.initialized = lead.y_filter.initialized = False
|
||||
lead.fade_filter.update(opacity if visible else 0.0)
|
||||
if visible:
|
||||
lead.bar = self._get_lead_bar(lane, lead.d_filter.update(d_rel), lead.y_filter.update(y_rel))
|
||||
|
||||
def _get_lead_bar(self, lane, d_rel, y_rel):
|
||||
# bar on the road behind the lead, following the lane
|
||||
x = np.array([d_rel, d_rel - min(6.0, 0.25 * d_rel)])
|
||||
y = np.interp(x, lane[:, 0], lane[:, 1]) - np.interp(d_rel, lane[:, 0], lane[:, 1]) - y_rel + self._camera_offset
|
||||
z = np.interp(x, self._path.raw_points[:, 0], self._path.raw_points[:, 2]) + self._path_offset_z
|
||||
corners = np.vstack((np.column_stack((x, y + LEAD_BAR_WIDTH / 2, z)), np.column_stack((x, y - LEAD_BAR_WIDTH / 2, z))[::-1]))
|
||||
pts = self._car_space_transform @ corners.T
|
||||
bar = (pts[:2] / pts[2]).T
|
||||
|
||||
far, near = bar[[0, 3]], bar[[1, 2]]
|
||||
length = np.linalg.norm(near.mean(axis=0) - far.mean(axis=0))
|
||||
bar[[1, 2]] = far + (near - far) * np.clip(length, 3.0, LEAD_BAR_LENGTH) / length
|
||||
return bar.astype(np.float32)
|
||||
|
||||
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.present:
|
||||
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, allow_invert=False
|
||||
)
|
||||
|
||||
self._update_path_gradient()
|
||||
|
||||
def _update_path_gradient(self):
|
||||
if not self._longitudinal_control:
|
||||
return
|
||||
|
||||
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._path_gradient.colors = segment_colors
|
||||
self._path_gradient.stops = gradient_stops
|
||||
|
||||
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)."""
|
||||
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):
|
||||
if not self._path.projected_points.size:
|
||||
return
|
||||
|
||||
if ui_state.rainbow_path:
|
||||
self.rainbow_path.draw_rainbow_path(self._rect, self._path)
|
||||
return
|
||||
|
||||
path_pts = self._path.projected_points + np.array([self._rect.x, self._rect.y], dtype=np.float32)
|
||||
|
||||
if ui_state.status == UIStatus.DISENGAGED:
|
||||
draw_polygon(self._rect, path_pts, rl.Color(0, 0, 0, 90))
|
||||
elif not self._longitudinal_control:
|
||||
draw_polygon(self._rect, path_pts, gradient=STOCK_PATH_GRADIENT)
|
||||
elif len(self._path_gradient.colors) > 1:
|
||||
draw_polygon(self._rect, path_pts, gradient=self._path_gradient)
|
||||
else:
|
||||
draw_polygon(self._rect, path_pts, rl.Color(255, 255, 255, 30))
|
||||
|
||||
def _draw_lead_indicator(self):
|
||||
offset = np.array([self._rect.x, self._rect.y], dtype=np.float32)
|
||||
for lead in self._lead_vehicles:
|
||||
draw_polygon(self._rect, lead.bar + offset, rl.Color(255, 255, 255, int(255 * lead.fade_filter.x)))
|
||||
|
||||
@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_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)
|
||||
)
|
||||
@@ -1,122 +0,0 @@
|
||||
import gc
|
||||
import weakref
|
||||
import unittest
|
||||
|
||||
# FIXME: known small leaks not worth worrying about at the moment
|
||||
KNOWN_LEAKS = {
|
||||
"openpilot.selfdrive.ui.mici.onroad.cabin_camera_dialog.CabinCameraView",
|
||||
"openpilot.selfdrive.ui.mici.layouts.onboarding.TermsPage",
|
||||
"openpilot.selfdrive.ui.mici.layouts.onboarding.TrainingGuide",
|
||||
"openpilot.selfdrive.ui.mici.layouts.onboarding.DeclinePage",
|
||||
"openpilot.selfdrive.ui.mici.layouts.onboarding.OnboardingWindow",
|
||||
"openpilot.selfdrive.ui.onroad.driver_state.DriverStateRenderer",
|
||||
"openpilot.selfdrive.ui.onroad.cabin_camera_dialog.CabinCameraDialog",
|
||||
"openpilot.selfdrive.ui.layouts.onboarding.TermsPage",
|
||||
"openpilot.selfdrive.ui.layouts.onboarding.DeclinePage",
|
||||
"openpilot.selfdrive.ui.layouts.onboarding.OnboardingWindow",
|
||||
"openpilot.system.ui.widgets.confirm_dialog.ConfirmDialog",
|
||||
"openpilot.system.ui.widgets.label.Label",
|
||||
"openpilot.system.ui.widgets.button.Button",
|
||||
"openpilot.system.ui.widgets.html_render.HtmlRenderer",
|
||||
"openpilot.system.ui.widgets.nav_widget.NavBar",
|
||||
"openpilot.selfdrive.ui.mici.layouts.settings.device.MiciFccModal",
|
||||
"openpilot.system.ui.widgets.inputbox.InputBox",
|
||||
"openpilot.system.ui.widgets.scroller_tici.Scroller",
|
||||
"openpilot.system.ui.widgets.label.UnifiedLabel",
|
||||
"openpilot.system.ui.widgets.mici_keyboard.MiciKeyboard",
|
||||
"openpilot.selfdrive.ui.mici.widgets.dialog.BigConfirmationDialog",
|
||||
"openpilot.system.ui.widgets.keyboard.Keyboard",
|
||||
"openpilot.system.ui.widgets.slider.BigSlider",
|
||||
"openpilot.selfdrive.ui.mici.widgets.dialog.BigInputDialog",
|
||||
"openpilot.system.ui.widgets.option_dialog.MultiOptionDialog",
|
||||
}
|
||||
|
||||
|
||||
def get_child_widgets(widget) -> list:
|
||||
from openpilot.system.ui.widgets import Widget
|
||||
children = []
|
||||
for val in widget.__dict__.values():
|
||||
items = val if isinstance(val, (list, tuple)) else (val,)
|
||||
children.extend(w for w in items if isinstance(w, Widget))
|
||||
return children
|
||||
|
||||
|
||||
from openpilot.common.test import OpenpilotTestCase
|
||||
class TestWidgetLeaks(OpenpilotTestCase):
|
||||
@unittest.skip("segfaults")
|
||||
def test_dialogs_do_not_leak(self):
|
||||
import pyray as rl
|
||||
rl.set_config_flags(rl.ConfigFlags.FLAG_WINDOW_HIDDEN)
|
||||
from openpilot.system.ui.lib.application import gui_app
|
||||
|
||||
# mici dialogs
|
||||
from openpilot.selfdrive.ui.mici.layouts.onboarding import TrainingGuide as MiciTrainingGuide, OnboardingWindow as MiciOnboardingWindow
|
||||
from openpilot.selfdrive.ui.mici.onroad.cabin_camera_dialog import CabinCameraDialog as MiciCabinCameraDialog
|
||||
from openpilot.selfdrive.ui.mici.widgets.pairing_dialog import PairingDialog as MiciPairingDialog
|
||||
from openpilot.selfdrive.ui.mici.widgets.dialog import BigDialog, BigConfirmationDialog, BigInputDialog
|
||||
from openpilot.selfdrive.ui.mici.layouts.settings.device import MiciFccModal
|
||||
|
||||
# tici dialogs
|
||||
from openpilot.selfdrive.ui.onroad.cabin_camera_dialog import CabinCameraDialog as TiciCabinCameraDialog
|
||||
from openpilot.selfdrive.ui.layouts.onboarding import OnboardingWindow as TiciOnboardingWindow
|
||||
from openpilot.selfdrive.ui.widgets.pairing_dialog import PairingDialog as TiciPairingDialog
|
||||
from openpilot.system.ui.widgets.confirm_dialog import ConfirmDialog
|
||||
from openpilot.system.ui.widgets.option_dialog import MultiOptionDialog
|
||||
from openpilot.system.ui.widgets.html_render import HtmlModal
|
||||
from openpilot.system.ui.widgets.keyboard import Keyboard
|
||||
|
||||
gui_app.init_window("ref-test")
|
||||
|
||||
leaked_widgets = set()
|
||||
|
||||
for ctor in (
|
||||
# mici
|
||||
MiciCabinCameraDialog, MiciPairingDialog,
|
||||
lambda: MiciTrainingGuide(lambda: None),
|
||||
lambda: MiciOnboardingWindow(lambda: None),
|
||||
lambda: BigDialog("test", "test"),
|
||||
lambda: BigConfirmationDialog("test", gui_app.texture("icons_mici/settings/network/new/trash.png", 54, 64), lambda: None),
|
||||
lambda: BigInputDialog("test"),
|
||||
lambda: MiciFccModal(text="test"),
|
||||
# tici
|
||||
TiciCabinCameraDialog, TiciOnboardingWindow, TiciPairingDialog, Keyboard,
|
||||
lambda: ConfirmDialog("test", "ok"),
|
||||
lambda: MultiOptionDialog("test", ["a", "b"]),
|
||||
lambda: HtmlModal(text="test"),
|
||||
):
|
||||
widget = ctor()
|
||||
all_refs = [weakref.ref(w) for w in get_child_widgets(widget) + [widget]]
|
||||
|
||||
del widget
|
||||
|
||||
for ref in all_refs:
|
||||
if ref() is not None:
|
||||
obj = ref()
|
||||
name = f"{type(obj).__module__}.{type(obj).__qualname__}"
|
||||
leaked_widgets.add(name)
|
||||
|
||||
print(f"\n=== Widget {name} alive after del")
|
||||
print(" Referrers:")
|
||||
for r in gc.get_referrers(obj):
|
||||
if r is obj:
|
||||
continue
|
||||
|
||||
if hasattr(r, '__self__') and r.__self__ is not obj:
|
||||
print(f" bound method: {type(r.__self__).__qualname__}.{r.__name__}")
|
||||
elif hasattr(r, '__func__'):
|
||||
print(f" method: {r.__name__}")
|
||||
else:
|
||||
print(f" {type(r).__module__}.{type(r).__qualname__}")
|
||||
del obj
|
||||
|
||||
gui_app.close()
|
||||
|
||||
unexpected = leaked_widgets - KNOWN_LEAKS
|
||||
assert not unexpected, f"New leaked widgets: {unexpected}"
|
||||
|
||||
fixed = KNOWN_LEAKS - leaked_widgets
|
||||
assert not fixed, f"These leaks are fixed, remove from KNOWN_LEAKS: {fixed}"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
TestWidgetLeaks().test_dialogs_do_not_leak()
|
||||
@@ -1,31 +0,0 @@
|
||||
import pyray as rl
|
||||
|
||||
from openpilot.system.ui.lib.application import FontWeight
|
||||
from openpilot.system.ui.widgets import Widget
|
||||
from openpilot.system.ui.widgets.label import UnifiedLabel
|
||||
|
||||
|
||||
class InfoLayoutMici(Widget):
|
||||
def __init__(self, title1: str, subtext1: str, title2: str, subtext2: str, *, width: int = 360):
|
||||
super().__init__()
|
||||
self.set_rect(rl.Rectangle(0, 0, width, 180))
|
||||
|
||||
max_width = int(self._rect.width - 20)
|
||||
self._subheader_color = rl.Color(255, 255, 255, int(255 * 0.9 * 0.65))
|
||||
|
||||
self.title1 = UnifiedLabel(title1, 48, max_width=max_width, font_weight=FontWeight.DISPLAY, wrap_text=False)
|
||||
self.subtext1 = UnifiedLabel(subtext1, 36, max_width=max_width, text_color=self._subheader_color,
|
||||
font_weight=FontWeight.ROMAN, wrap_text=False, scroll=True)
|
||||
self.title2 = UnifiedLabel(title2, 48, max_width=max_width, font_weight=FontWeight.DISPLAY, wrap_text=False)
|
||||
self.subtext2 = UnifiedLabel(subtext2, 36, max_width=max_width, text_color=self._subheader_color,
|
||||
font_weight=FontWeight.ROMAN, wrap_text=False, scroll=True)
|
||||
|
||||
def _layout(self):
|
||||
self.title1.set_position(self._rect.x + 20, self._rect.y - 10)
|
||||
self.subtext1.set_position(self._rect.x + 20, self._rect.y + 68 - 25)
|
||||
self.title2.set_position(self._rect.x + 20, self._rect.y + 114 - 30)
|
||||
self.subtext2.set_position(self._rect.x + 20, self._rect.y + 161 - 25)
|
||||
|
||||
def _render(self, rect: rl.Rectangle):
|
||||
for widget in [self.title1, self.subtext1, self.title2, self.subtext2]:
|
||||
widget.render()
|
||||
@@ -1,95 +0,0 @@
|
||||
import pyray as rl
|
||||
import time
|
||||
|
||||
from openpilot.common.api import Api
|
||||
from openpilot.common.qrcode import make_texture
|
||||
from openpilot.common.swaglog import cloudlog
|
||||
from openpilot.common.params import Params
|
||||
from openpilot.selfdrive.ui.ui_state import ui_state
|
||||
from openpilot.system.ui.widgets.nav_widget import NavWidget
|
||||
from openpilot.system.ui.lib.application import FontWeight, gui_app
|
||||
from openpilot.system.ui.widgets.label import UnifiedLabel
|
||||
|
||||
|
||||
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("pair with comma connect", font_size=48, font_weight=FontWeight.BOLD, line_height=0.8)
|
||||
|
||||
def _get_pairing_url(self) -> str:
|
||||
try:
|
||||
dongle_id = self._params.get("DongleId") or ""
|
||||
token = Api(dongle_id).get_token({'pair': True})
|
||||
except Exception as e:
|
||||
cloudlog.warning(f"Failed to get pairing token: {e}")
|
||||
token = ""
|
||||
return f"https://connect.comma.ai/?pair={token}"
|
||||
|
||||
def _generate_qr_code(self) -> None:
|
||||
try:
|
||||
if self._qr_texture and self._qr_texture.id != 0:
|
||||
rl.unload_texture(self._qr_texture)
|
||||
self._qr_texture = make_texture(self._get_pairing_url(), inverted=True)
|
||||
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
|
||||
@@ -1,37 +0,0 @@
|
||||
import pyray as rl
|
||||
|
||||
from openpilot.common.swaglog import cloudlog
|
||||
from openpilot.system.ui.lib.application import FontWeight
|
||||
from openpilot.system.ui.widgets import Widget
|
||||
from openpilot.system.ui.widgets.label import Label
|
||||
from openpilot.common.qrcode import make_texture
|
||||
|
||||
|
||||
class QR(Widget):
|
||||
def __init__(self, url: str, width: int = 170):
|
||||
super().__init__()
|
||||
self._url = url
|
||||
|
||||
self._texture = self._generate_qr_code()
|
||||
|
||||
self.set_rect(rl.Rectangle(0, 0, width, width))
|
||||
self._error = Label("QR Code Error", font_size=30, font_weight=FontWeight.BOLD, text_color=rl.RED)
|
||||
|
||||
def _generate_qr_code(self):
|
||||
try:
|
||||
return make_texture(self._url, inverted=True)
|
||||
except Exception as e:
|
||||
cloudlog.warning(f"QR code generation failed: {e}")
|
||||
return None
|
||||
|
||||
def _render(self, rect: rl.Rectangle):
|
||||
if not self._texture:
|
||||
self._error.render(rect)
|
||||
return
|
||||
scale = rect.height / self._texture.height
|
||||
pos = rl.Vector2(round(rect.x), round(rect.y))
|
||||
rl.draw_texture_ex(self._texture, pos, 0.0, scale, rl.WHITE)
|
||||
|
||||
def __del__(self):
|
||||
if self._texture and self._texture.id != 0:
|
||||
rl.unload_texture(self._texture)
|
||||
+7
-1
@@ -6,7 +6,6 @@ from collections import OrderedDict
|
||||
import numpy as np
|
||||
import pyray as rl
|
||||
from opendbc.car import ACCELERATION_DUE_TO_GRAVITY
|
||||
from openpilot.selfdrive.ui.mici.onroad import blend_colors
|
||||
from openpilot.selfdrive.ui.ui_state import ui_state, UIStatus
|
||||
from openpilot.system.ui.lib.application import gui_app
|
||||
from openpilot.system.ui.lib.shader_polygon import draw_polygon, Gradient
|
||||
@@ -19,6 +18,13 @@ TORQUE_ANGLE_SPAN = 12.7
|
||||
DEBUG = False
|
||||
|
||||
|
||||
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
|
||||
return rl.color_from_hsv((h0 + f * dh) % 360, s0 + f * (s1 - s0), v0 + f * (v1 - v0))
|
||||
|
||||
|
||||
def quantized_lru_cache(maxsize=128):
|
||||
def decorator(func):
|
||||
cache = OrderedDict()
|
||||
@@ -1,33 +0,0 @@
|
||||
"""
|
||||
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
|
||||
|
||||
This file is part of sunnypilot and is licensed under the MIT License.
|
||||
See the LICENSE.md file in the root directory for more details.
|
||||
"""
|
||||
import math
|
||||
|
||||
import pyray as rl
|
||||
|
||||
from openpilot.selfdrive.ui.mici.layouts.home import MiciHomeLayout
|
||||
from openpilot.selfdrive.ui.ui_state import ui_state, ChestnutState
|
||||
from openpilot.system.ui.lib.application import FontWeight
|
||||
from openpilot.system.ui.widgets.label import UnifiedLabel
|
||||
|
||||
|
||||
class MiciHomeLayoutSP(MiciHomeLayout):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._openpilot_label = UnifiedLabel("", font_size=88, font_weight=FontWeight.AUDIOWIDE, max_width=480, wrap_text=False)
|
||||
|
||||
def _set_chestnut_visibility(self):
|
||||
usb_connected = ui_state.usb_connected
|
||||
usb_unknown = ui_state.usb_unknown
|
||||
chestnut_state = ui_state.chestnut_state
|
||||
loading = chestnut_state == ChestnutState.LOADING
|
||||
|
||||
self._usb_icon.set_visible(usb_connected and usb_unknown)
|
||||
self._chestnut_loading_icon.set_opacity(0.35 + 0.65 * (0.5 - 0.5 * math.cos(rl.get_time() * 6.0)))
|
||||
self._chestnut_loading_icon.set_visible(not usb_unknown and loading)
|
||||
self._chestnut_icon.set_visible(not usb_unknown and not loading and
|
||||
chestnut_state in (ChestnutState.READY, ChestnutState.ACTIVE))
|
||||
self._chestnut_failed_icon.set_visible(not usb_unknown and chestnut_state in (ChestnutState.UNCOMPILED, ChestnutState.FAILED))
|
||||
@@ -1,283 +0,0 @@
|
||||
"""
|
||||
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
|
||||
|
||||
This file is part of sunnypilot and is licensed under the MIT License.
|
||||
See the LICENSE.md file in the root directory for more details.
|
||||
"""
|
||||
import time
|
||||
|
||||
import pyray as rl
|
||||
|
||||
from openpilot.cereal import custom
|
||||
from openpilot.selfdrive.ui.mici.widgets.dialog import BigConfirmationDialog, BigDialog
|
||||
from openpilot.sunnypilot.models.helpers import ACTIVE_BUNDLE_KEYS, get_selected_bundle
|
||||
from openpilot.selfdrive.ui.mici.widgets.button import BigButton
|
||||
from openpilot.selfdrive.ui.ui_state import ui_state, device
|
||||
from openpilot.selfdrive.ui.sunnypilot.model_info import (active_source, big_model_state, bundles_for_source, carrying_model,
|
||||
default_model_name, model_cache_size_mb, model_info, queued_name,
|
||||
refresh_in_progress, refresh_model_list)
|
||||
from openpilot.system.ui.lib.application import FontWeight, gui_app
|
||||
from openpilot.system.ui.lib.multilang import tr
|
||||
from openpilot.system.ui.widgets import Widget
|
||||
from openpilot.system.ui.widgets.label import UnifiedLabel
|
||||
from openpilot.system.ui.widgets.scroller import NavScroller
|
||||
|
||||
def _model_info() -> tuple[str, str, str]:
|
||||
"""(active model, info header, info text) for the panel. Runner-matched: the
|
||||
active line names what actually drives, and a notable big-model state takes
|
||||
the info pair."""
|
||||
source, active_name, other_name = model_info()
|
||||
state = big_model_state()
|
||||
_, _, carry_display = carrying_model()
|
||||
if carry_display is None:
|
||||
big = get_selected_bundle(ui_state.params, "chestnut")
|
||||
carry_display = big.displayName if big else default_model_name("chestnut")
|
||||
active_text = (carry_display or active_name).lower()
|
||||
if state == 'failed':
|
||||
return active_text, tr("big model"), tr("unavailable")
|
||||
if state == 'loading':
|
||||
return active_text, tr("big model"), tr("getting ready")
|
||||
header = tr("small model") if source == "chestnut" else tr("big model")
|
||||
return active_text, header, other_name.lower()
|
||||
|
||||
|
||||
class CurrentModelInfo(Widget):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
self.set_rect(rl.Rectangle(0, 0, 360, 180))
|
||||
|
||||
header_color = rl.Color(255, 255, 255, int(255 * 0.9))
|
||||
subheader_color = rl.Color(255, 255, 255, int(255 * 0.9 * 0.65))
|
||||
max_width = int(self._rect.width - 20)
|
||||
active_text, info_header, info_text = _model_info()
|
||||
self.current_model_header = UnifiedLabel(tr("active model"), 48, max_width=max_width, text_color=header_color, font_weight=FontWeight.DISPLAY)
|
||||
self.current_model_text = UnifiedLabel(active_text, 32, max_width=max_width, text_color=subheader_color, font_weight=FontWeight.ROMAN, scroll=True)
|
||||
|
||||
self.info_header = UnifiedLabel(info_header, 48, max_width=max_width, text_color=header_color, font_weight=FontWeight.DISPLAY)
|
||||
self.info_text = UnifiedLabel(info_text, 32, max_width=max_width, text_color=subheader_color, font_weight=FontWeight.ROMAN, scroll=True)
|
||||
|
||||
def _render(self, _):
|
||||
self.current_model_header.set_position(self._rect.x + 20, self._rect.y - 10)
|
||||
self.current_model_header.render()
|
||||
|
||||
self.current_model_text.set_position(self._rect.x + 20, self._rect.y + 68 - 25)
|
||||
self.current_model_text.render()
|
||||
|
||||
self.info_header.set_position(self._rect.x + 20, self._rect.y + 114 - 30)
|
||||
self.info_header.render()
|
||||
|
||||
self.info_text.set_position(self._rect.x + 20, self._rect.y + 161 - 25)
|
||||
self.info_text.render()
|
||||
|
||||
class ModelsLayoutMici(NavScroller):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.focused_widget = None
|
||||
|
||||
self.current_model_info = CurrentModelInfo()
|
||||
self._download_progress = "."
|
||||
self._download_frame = 0
|
||||
self._was_downloading = False
|
||||
self._selection_source: str | None = None
|
||||
|
||||
self.select_model_btn = BigButton(tr("select model"))
|
||||
self.select_model_btn.set_click_callback(self._show_folders)
|
||||
|
||||
self.refresh_btn = BigButton(tr("refresh models"))
|
||||
self.refresh_btn.set_click_callback(self._refresh_models)
|
||||
self._refresh_start: float | None = None
|
||||
|
||||
self.cancel_download_btn = BigButton(tr("cancel download"))
|
||||
self.cancel_download_btn.set_click_callback(lambda: ui_state.params.remove("ModelManager_DownloadRef"))
|
||||
|
||||
self.clear_cache_btn = BigButton(tr("clear cache"), value=f"{model_cache_size_mb():.1f} MB")
|
||||
self.clear_cache_btn.set_click_callback(self._confirm_clear_cache)
|
||||
self._cache_size_time = 0.0
|
||||
|
||||
self.main_items = [self.current_model_info, self.select_model_btn, self.cancel_download_btn, self.refresh_btn, self.clear_cache_btn]
|
||||
self._scroller.add_widgets(self.main_items)
|
||||
|
||||
@property
|
||||
def model_manager(self):
|
||||
return ui_state.sm["modelManagerSP"]
|
||||
|
||||
def _get_grouped_bundles(self, bundles, favorites = None):
|
||||
folders = {}
|
||||
for bundle in bundles:
|
||||
folder = next((override.value for override in bundle.overrides if override.key == "folder"), "")
|
||||
folders.setdefault(folder, []).append(bundle)
|
||||
|
||||
if favorites:
|
||||
for fav_bundle in [bundle for bundle in bundles if bundle.ref in favorites]:
|
||||
folders.setdefault("favorites", []).append(fav_bundle)
|
||||
|
||||
return folders
|
||||
|
||||
def _push_selection_view(self, items):
|
||||
scroller = NavScroller()
|
||||
scroller._scroller.add_widgets(items)
|
||||
gui_app.push_widget(scroller)
|
||||
|
||||
def _show_folders(self):
|
||||
self.focused_widget = self.select_model_btn
|
||||
|
||||
hardware_btns = []
|
||||
active = active_source()
|
||||
for source, label in (("qcom", tr("small models")), ("chestnut", tr("big models"))):
|
||||
bundle = get_selected_bundle(ui_state.params, source)
|
||||
value = (bundle.internalName if bundle else default_model_name(source)).lower()
|
||||
if source == active:
|
||||
value += f" ({tr('active')})"
|
||||
btn = BigButton(label.lower(), value=value)
|
||||
btn.set_click_callback(lambda s=source: self._select_hardware(s))
|
||||
hardware_btns.append(btn)
|
||||
self._push_selection_view(hardware_btns)
|
||||
|
||||
def _select_hardware(self, source):
|
||||
self._selection_source = source
|
||||
|
||||
favs = ui_state.params.get("ModelManager_Favs")
|
||||
favorites = set(favs.split(';')) if favs else set()
|
||||
|
||||
bundles = bundles_for_source(source)
|
||||
if not bundles:
|
||||
gui_app.push_widget(BigDialog(title=tr("No models available"),
|
||||
description=tr("No models are available for this hardware yet. Connect to the internet and refresh the model list.")))
|
||||
return
|
||||
folders = self._get_grouped_bundles(bundles, favorites)
|
||||
|
||||
folder_buttons = []
|
||||
default_btn = BigButton(default_model_name(source).lower())
|
||||
default_btn.set_click_callback(lambda s=source: self._select_default(s))
|
||||
folder_buttons.append(default_btn)
|
||||
|
||||
for folder in sorted(folders.keys(), key=lambda f: max((bundle.index for bundle in folders[f]), default=-1), reverse=True):
|
||||
btn = BigButton(folder.lower())
|
||||
btn.set_click_callback(lambda f=folder: self._select_folder(f))
|
||||
if folder.lower() == "favorites":
|
||||
folder_buttons.insert(0, btn)
|
||||
else:
|
||||
folder_buttons.append(btn)
|
||||
self._push_selection_view(folder_buttons)
|
||||
|
||||
def _pop_to_main(self):
|
||||
gui_app.pop_widgets_to(self)
|
||||
self._scroller.scroll_panel.set_offset(0.0)
|
||||
|
||||
def _select_model(self, bundle):
|
||||
ui_state.params.put("ModelManager_DownloadRef", bundle.ref)
|
||||
self._pop_to_main()
|
||||
|
||||
def _select_default(self, source):
|
||||
ui_state.params.remove(ACTIVE_BUNDLE_KEYS[source])
|
||||
self._pop_to_main()
|
||||
|
||||
def _confirm_clear_cache(self):
|
||||
icon = gui_app.texture("icons_mici/settings/network/new/trash.png", 54, 64)
|
||||
gui_app.push_widget(BigConfirmationDialog(f"{tr('slide to')}\n{tr('clear cache')}", icon,
|
||||
lambda: ui_state.params.put_bool("ModelManager_ClearCache", True), red=True))
|
||||
|
||||
def _refresh_models(self):
|
||||
refresh_model_list()
|
||||
self._refresh_start = time.monotonic()
|
||||
|
||||
def _select_folder(self, folder_name):
|
||||
source = self._selection_source
|
||||
if source is None: # folders are only reachable after picking a hardware
|
||||
return
|
||||
favs = ui_state.params.get("ModelManager_Favs")
|
||||
favorites = set(favs.split(';')) if favs else set()
|
||||
|
||||
folders = self._get_grouped_bundles(bundles_for_source(source), favorites)
|
||||
bundles = sorted(folders.get(folder_name, []), key=lambda b: b.index, reverse=True)
|
||||
|
||||
btns = []
|
||||
for bundle in bundles:
|
||||
btn = BigButton(bundle.displayName.lower())
|
||||
btn.set_click_callback(lambda b=bundle: self._select_model(b))
|
||||
btns.append(btn)
|
||||
self._push_selection_view(btns)
|
||||
|
||||
def hide_event(self):
|
||||
super().hide_event()
|
||||
if self._was_downloading:
|
||||
device.set_override_interactive_timeout(None)
|
||||
self._was_downloading = False
|
||||
|
||||
def _update_state(self):
|
||||
super()._update_state()
|
||||
|
||||
self.select_model_btn.set_enabled(ui_state.is_offroad())
|
||||
self.cancel_download_btn.set_visible(False)
|
||||
self.current_model_info.current_model_header._shimmer = False
|
||||
self.current_model_info.info_header._shimmer = False
|
||||
|
||||
manager = self.model_manager
|
||||
self._download_frame += 1
|
||||
should_update = self._download_frame % (gui_app.target_fps / 2) == 0
|
||||
if should_update:
|
||||
self._download_progress = self._download_progress + "." if len(self._download_progress) < 3 else ""
|
||||
|
||||
is_downloading = (manager.selectedBundle
|
||||
and manager.selectedBundle.status == custom.ModelManagerSP.DownloadStatus.downloading)
|
||||
if self._was_downloading and not is_downloading:
|
||||
device.set_override_interactive_timeout(None)
|
||||
self._was_downloading = is_downloading
|
||||
|
||||
# manager is offroad-only, so an onroad clear would never be serviced
|
||||
clearing = ui_state.params.get_bool("ModelManager_ClearCache")
|
||||
self.clear_cache_btn.set_enabled(ui_state.is_offroad() and not is_downloading and not clearing)
|
||||
if clearing:
|
||||
self.clear_cache_btn.set_value(tr("clearing..."))
|
||||
self._cache_size_time = 0.0 # refresh the size as soon as clearing finishes
|
||||
elif (now := time.monotonic()) - self._cache_size_time > 0.5:
|
||||
self._cache_size_time = now
|
||||
self.clear_cache_btn.set_value(f"{model_cache_size_mb():.1f} MB")
|
||||
|
||||
# manager is offroad-only, so a refresh queued onroad would never be serviced
|
||||
refreshing = refresh_in_progress(self._refresh_start)
|
||||
self.refresh_btn.set_enabled(ui_state.is_offroad() and not is_downloading and not refreshing)
|
||||
self.refresh_btn.set_value(tr("fetching...") if refreshing else "")
|
||||
|
||||
self.current_model_info.current_model_header.set_text(tr("active model"))
|
||||
active_text, info_header, info_text = _model_info()
|
||||
self.current_model_info.current_model_text.set_text(active_text)
|
||||
self.current_model_info.info_header.set_text(info_header)
|
||||
self.current_model_info.info_text.set_text(info_text)
|
||||
|
||||
if manager.selectedBundle and manager.selectedBundle.status == custom.ModelManagerSP.DownloadStatus.failed:
|
||||
self.current_model_info.info_header.set_text(tr("error") + self._download_progress)
|
||||
self.current_model_info.info_text.set_text(tr("download failed"))
|
||||
|
||||
elif manager.selectedBundle and manager.selectedBundle.status == custom.ModelManagerSP.DownloadStatus.downloading:
|
||||
self.cancel_download_btn.set_visible(True)
|
||||
device.set_override_interactive_timeout(5)
|
||||
progress = 0.0
|
||||
count = 0
|
||||
verifying = False
|
||||
for model in manager.selectedBundle.models:
|
||||
count += 1
|
||||
p = model.artifact.downloadProgress
|
||||
if p.status in (custom.ModelManagerSP.DownloadStatus.downloading,
|
||||
custom.ModelManagerSP.DownloadStatus.verifying):
|
||||
progress += p.progress
|
||||
verifying = verifying or p.status == custom.ModelManagerSP.DownloadStatus.verifying
|
||||
elif p.status in (custom.ModelManagerSP.DownloadStatus.downloaded,
|
||||
custom.ModelManagerSP.DownloadStatus.cached):
|
||||
progress += 100.0
|
||||
|
||||
self.current_model_info.current_model_header.set_text(tr("verifying") if verifying else tr("downloading"))
|
||||
self.cancel_download_btn.set_text(tr("cancel verification") if verifying else tr("cancel download"))
|
||||
self.current_model_info.current_model_header._shimmer = True
|
||||
name_text = manager.selectedBundle.internalName.lower()
|
||||
if queued := queued_name(manager.selectedBundle.ref):
|
||||
name_text += f" | {queued.lower()} {tr('queued')}"
|
||||
self.current_model_info.current_model_text.set_text(name_text)
|
||||
self.current_model_info.info_header.set_text(tr("progress") + self._download_progress)
|
||||
self.current_model_info.info_header._shimmer = True
|
||||
self.current_model_info.info_text.set_text(f"{progress/count:.2f}%")
|
||||
|
||||
elif manager.selectedBundle and manager.selectedBundle.status == custom.ModelManagerSP.DownloadStatus.downloaded:
|
||||
self.current_model_info.info_header.set_text(tr("downloaded"))
|
||||
self.current_model_info.info_text.set_text(tr("downloaded"))
|
||||
@@ -1,34 +0,0 @@
|
||||
"""
|
||||
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
|
||||
|
||||
This file is part of sunnypilot and is licensed under the MIT License.
|
||||
See the LICENSE.md file in the root directory for more details.
|
||||
"""
|
||||
from collections.abc import Callable
|
||||
|
||||
from openpilot.selfdrive.ui.mici.widgets.dialog import BigConfirmationCircleButton
|
||||
from openpilot.system.ui.lib.application import gui_app
|
||||
from openpilot.system.ui.mici_setup import GreyBigButton
|
||||
from openpilot.system.ui.widgets.scroller import NavScroller
|
||||
|
||||
|
||||
class SunnylinkConsentPage(NavScroller):
|
||||
def __init__(self, on_accept: Callable | None = None, on_decline: Callable | None = None):
|
||||
super().__init__()
|
||||
|
||||
assert on_accept is not None and callable(on_accept)
|
||||
assert on_decline is not None and callable(on_decline)
|
||||
|
||||
self._accept_button = BigConfirmationCircleButton("enable\nsunnylink", gui_app.texture("icons_mici/setup/driver_monitoring/dm_check.png", 64, 64),
|
||||
on_accept, exit_on_confirm=False)
|
||||
|
||||
self._decline_button = BigConfirmationCircleButton("disable\nsunnylink", gui_app.texture("icons_mici/setup/cancel.png", 64, 64),
|
||||
on_decline, red=True, exit_on_confirm=False)
|
||||
|
||||
self._scroller.add_widgets([
|
||||
GreyBigButton("sunnylink", "scroll to continue",
|
||||
gui_app.texture("../../sunnypilot/selfdrive/assets/logo.png", 64, 64)),
|
||||
GreyBigButton("", "sunnylink enables secured remote access to your comma device from anywhere."),
|
||||
self._accept_button,
|
||||
self._decline_button,
|
||||
])
|
||||
@@ -1,92 +0,0 @@
|
||||
"""
|
||||
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
|
||||
|
||||
This file is part of sunnypilot and is licensed under the MIT License.
|
||||
See the LICENSE.md file in the root directory for more details.
|
||||
"""
|
||||
from openpilot.selfdrive.ui.mici.layouts.settings import settings as OP
|
||||
from openpilot.selfdrive.ui.mici.layouts.settings.settings import SettingsBigButton
|
||||
from openpilot.selfdrive.ui.mici.layouts.settings.device import DeviceLayoutMici
|
||||
from openpilot.selfdrive.ui.mici.widgets.button import BigCircleButton
|
||||
from openpilot.selfdrive.ui.mici.widgets.dialog import BigConfirmationDialog, BigDialog
|
||||
from openpilot.selfdrive.ui.sunnypilot.mici.layouts.sunnylink import SunnylinkLayoutMici
|
||||
from openpilot.selfdrive.ui.sunnypilot.mici.layouts.models import ModelsLayoutMici
|
||||
from openpilot.selfdrive.ui.ui_state import ui_state
|
||||
from openpilot.system.ui.lib.application import gui_app
|
||||
from openpilot.system.ui.lib.multilang import tr
|
||||
|
||||
ICON_SIZE = 70
|
||||
BIG_ICON_SIZE = 110
|
||||
|
||||
|
||||
class SettingsLayoutSP(OP.SettingsLayout):
|
||||
def __init__(self):
|
||||
OP.SettingsLayout.__init__(self)
|
||||
|
||||
device_panel = DeviceLayoutMici()
|
||||
self._scroller._items[2].set_click_callback(lambda: gui_app.push_widget(device_panel))
|
||||
|
||||
self.icon_offroad_enable = gui_app.texture("../../sunnypilot/selfdrive/assets/icons_mici/always_offroad.png", BIG_ICON_SIZE,
|
||||
BIG_ICON_SIZE)
|
||||
self.icon_offroad_disable = gui_app.texture("../../sunnypilot/selfdrive/assets/icons_mici/disable_offroad.png", BIG_ICON_SIZE,
|
||||
BIG_ICON_SIZE)
|
||||
self.icon_offroad_slider = gui_app.texture("icons_mici/settings/device/lkas.png", BIG_ICON_SIZE, BIG_ICON_SIZE)
|
||||
|
||||
sunnylink_panel = SunnylinkLayoutMici()
|
||||
sunnylink_btn = SettingsBigButton(tr("sunnylink"), "", gui_app.texture("icons_mici/settings/developer/ssh.png", 55, 55))
|
||||
sunnylink_btn.set_click_callback(lambda: gui_app.push_widget(sunnylink_panel))
|
||||
|
||||
models_panel = ModelsLayoutMici()
|
||||
models_btn = SettingsBigButton(tr("models"), "", gui_app.texture("../../sunnypilot/selfdrive/assets/offroad/icon_models.png", ICON_SIZE, ICON_SIZE))
|
||||
models_btn.set_click_callback(lambda: gui_app.push_widget(models_panel))
|
||||
|
||||
# onroad: enable button sits at the front (left of toggles)
|
||||
self._enable_offroad_btn_onroad = BigCircleButton(self.icon_offroad_enable, red=True)
|
||||
self._enable_offroad_btn_onroad.set_click_callback(lambda: self._handle_always_offroad(True))
|
||||
self._enable_offroad_btn_onroad.set_visible(lambda: ui_state.started and not ui_state.always_offroad)
|
||||
|
||||
# offroad: enable button sits at the end (right of developer)
|
||||
self._enable_offroad_btn_offroad = BigCircleButton(self.icon_offroad_enable, red=True)
|
||||
self._enable_offroad_btn_offroad.set_click_callback(lambda: self._handle_always_offroad(True))
|
||||
self._enable_offroad_btn_offroad.set_visible(lambda: not ui_state.started and not ui_state.always_offroad)
|
||||
|
||||
self._disable_offroad_btn = BigCircleButton(self.icon_offroad_disable, red=False)
|
||||
self._disable_offroad_btn.set_click_callback(lambda: self._handle_always_offroad(False))
|
||||
self._disable_offroad_btn.set_visible(lambda: ui_state.always_offroad)
|
||||
|
||||
items = self._scroller._items.copy()
|
||||
|
||||
items.insert(1, sunnylink_btn)
|
||||
items.insert(2, models_btn)
|
||||
|
||||
# front slots (only one ever visible at a time): exit-always-offroad, then enable-onroad
|
||||
items.insert(0, self._enable_offroad_btn_onroad)
|
||||
items.insert(0, self._disable_offroad_btn)
|
||||
# end slot: enable-offroad (right of developer)
|
||||
items.append(self._enable_offroad_btn_offroad)
|
||||
|
||||
self._scroller._items.clear()
|
||||
for item in items:
|
||||
self._scroller.add_widget(item)
|
||||
|
||||
def _update_state(self):
|
||||
super()._update_state()
|
||||
|
||||
def _handle_always_offroad(self, enable: bool):
|
||||
|
||||
def _set_offroad_status(status: bool):
|
||||
if not ui_state.engaged:
|
||||
ui_state.params.put_bool("OffroadMode", status)
|
||||
ui_state.always_offroad = status
|
||||
|
||||
if not enable:
|
||||
dlg = BigConfirmationDialog(tr("slide to exit always offroad"), self.icon_offroad_slider, red=False,
|
||||
confirm_callback=lambda: _set_offroad_status(False))
|
||||
else:
|
||||
if ui_state.engaged:
|
||||
gui_app.push_widget(BigDialog(tr("disengage to enable always offroad"), "", ))
|
||||
return
|
||||
|
||||
dlg = BigConfirmationDialog(tr("slide to force offroad"), self.icon_offroad_slider, red=True,
|
||||
confirm_callback=lambda: _set_offroad_status(True))
|
||||
gui_app.push_widget(dlg)
|
||||
@@ -1,254 +0,0 @@
|
||||
"""
|
||||
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
|
||||
|
||||
This file is part of sunnypilot and is licensed under the MIT License.
|
||||
See the LICENSE.md file in the root directory for more details.
|
||||
"""
|
||||
import pyray as rl
|
||||
|
||||
|
||||
from openpilot.cereal import custom
|
||||
from openpilot.selfdrive.ui.mici.widgets.button import BigButton, BigToggle
|
||||
from openpilot.selfdrive.ui.mici.widgets.dialog import BigDialog, BigConfirmationDialog
|
||||
from openpilot.selfdrive.ui.sunnypilot.mici.layouts.onboarding import SunnylinkConsentPage
|
||||
from openpilot.selfdrive.ui.sunnypilot.mici.widgets.sunnylink_pairing_dialog import SunnylinkPairingDialog
|
||||
from openpilot.selfdrive.ui.ui_state import ui_state
|
||||
from openpilot.sunnypilot.sunnylink.api import UNREGISTERED_SUNNYLINK_DONGLE_ID
|
||||
from openpilot.system.ui.lib.application import gui_app, MousePos, FontWeight
|
||||
from openpilot.system.ui.lib.multilang import tr
|
||||
from openpilot.system.ui.widgets import Widget
|
||||
from openpilot.system.ui.widgets.label import UnifiedLabel
|
||||
from openpilot.system.ui.widgets.scroller import NavScroller
|
||||
from openpilot.common.version import sunnylink_consent_version, sunnylink_consent_declined
|
||||
|
||||
class SunnylinkInfo(Widget):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
self.set_rect(rl.Rectangle(0, 0, 360, 180))
|
||||
|
||||
header_color = rl.Color(255, 255, 255, int(255 * 0.9))
|
||||
subheader_color = rl.Color(255, 255, 255, int(255 * 0.9 * 0.65))
|
||||
max_width = int(self._rect.width - 20)
|
||||
self.device_id_header = UnifiedLabel(tr("device id"), 48, max_width=max_width, text_color=header_color,
|
||||
font_weight=FontWeight.DISPLAY, shimmer=True)
|
||||
self.device_id_text = UnifiedLabel(UNREGISTERED_SUNNYLINK_DONGLE_ID, 32, max_width=max_width, text_color=subheader_color,
|
||||
font_weight=FontWeight.ROMAN, scroll=True)
|
||||
|
||||
self.sponsor_header = UnifiedLabel(tr("sponsor tier"), 48, max_width=max_width, text_color=header_color,
|
||||
font_weight=FontWeight.DISPLAY, shimmer=True)
|
||||
self.sponsor_text = UnifiedLabel("N/A", 32, max_width=max_width, text_color=subheader_color, font_weight=FontWeight.ROMAN)
|
||||
|
||||
def _render(self, _):
|
||||
self.device_id_header.set_position(self._rect.x + 20, self._rect.y - 10)
|
||||
self.device_id_header.render()
|
||||
|
||||
self.device_id_text.set_position(self._rect.x + 20, self._rect.y + 68 - 25)
|
||||
self.device_id_text.render()
|
||||
|
||||
self.sponsor_header.set_position(self._rect.x + 20, self._rect.y + 114 - 30)
|
||||
self.sponsor_header.render()
|
||||
|
||||
self.sponsor_text.set_position(self._rect.x + 20, self._rect.y + 161 - 25)
|
||||
self.sponsor_text.render()
|
||||
|
||||
class SunnylinkLayoutMici(NavScroller):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._restore_in_progress = False
|
||||
self._backup_in_progress = False
|
||||
self._sunnylink_enabled = ui_state.params.get("SunnylinkEnabled")
|
||||
|
||||
self._sunnylink_info = SunnylinkInfo()
|
||||
|
||||
self._sunnylink_toggle = BigToggle(text=tr("enable sunnylink"),
|
||||
initial_state=self._sunnylink_enabled,
|
||||
toggle_callback=self._sunnylink_toggle_callback)
|
||||
self._sunnylink_sponsor_button = SunnylinkPairBigButton(sponsor_pairing=False)
|
||||
self._sunnylink_pair_button = SunnylinkPairBigButton(sponsor_pairing=True)
|
||||
self._backup_btn = BigButton(tr("backup settings"), "")
|
||||
self._backup_btn.set_click_callback(lambda: self._handle_backup_restore_btn(restore=False))
|
||||
self._restore_btn = BigButton(tr("restore settings"), "")
|
||||
self._restore_btn.set_click_callback(lambda: self._handle_backup_restore_btn(restore=True))
|
||||
self._sunnylink_uploader_toggle = BigToggle(text=tr("sunnylink uploader"), initial_state=False,
|
||||
toggle_callback=self._sunnylink_uploader_callback)
|
||||
|
||||
self._scroller.add_widgets([
|
||||
self._sunnylink_info,
|
||||
self._sunnylink_toggle,
|
||||
self._sunnylink_sponsor_button,
|
||||
self._sunnylink_pair_button,
|
||||
self._backup_btn,
|
||||
self._restore_btn,
|
||||
self._sunnylink_uploader_toggle
|
||||
])
|
||||
|
||||
def _update_state(self):
|
||||
super()._update_state()
|
||||
self._sunnylink_enabled = ui_state.params.get("SunnylinkEnabled")
|
||||
self._sunnylink_toggle.set_checked(self._sunnylink_enabled)
|
||||
self._sunnylink_pair_button.set_visible(self._sunnylink_enabled)
|
||||
self._sunnylink_sponsor_button.set_visible(self._sunnylink_enabled)
|
||||
self._backup_btn.set_visible(self._sunnylink_enabled)
|
||||
self._restore_btn.set_visible(self._sunnylink_enabled)
|
||||
self._sunnylink_uploader_toggle.set_visible(self._sunnylink_enabled)
|
||||
self.handle_backup_restore_progress()
|
||||
|
||||
self._sunnylink_info.device_id_text.set_text(ui_state.params.get("SunnylinkDongleId") or UNREGISTERED_SUNNYLINK_DONGLE_ID)
|
||||
self._sunnylink_info.sponsor_text.set_text(ui_state.sunnylink_state.get_sponsor_tier().name.lower() or "N/A")
|
||||
self._sunnylink_info.set_visible(self._sunnylink_enabled)
|
||||
|
||||
if ui_state.sunnylink_state.is_sponsor():
|
||||
self._sunnylink_sponsor_button.set_text(tr("thanks"))
|
||||
self._sunnylink_sponsor_button.set_value(ui_state.sunnylink_state.get_sponsor_tier().name.lower())
|
||||
self._sunnylink_sponsor_button.set_enabled(False)
|
||||
else:
|
||||
self._sunnylink_sponsor_button.set_text(tr("sponsor"))
|
||||
self._sunnylink_sponsor_button.set_value("")
|
||||
|
||||
if ui_state.sunnylink_state.is_paired():
|
||||
self._sunnylink_pair_button.set_text(tr("paired"))
|
||||
else:
|
||||
self._sunnylink_pair_button.set_text(tr("pair"))
|
||||
|
||||
def show_event(self):
|
||||
super().show_event()
|
||||
ui_state.update_params()
|
||||
ui_state.sunnylink_state.set_settings_open(True)
|
||||
|
||||
def hide_event(self):
|
||||
super().hide_event()
|
||||
ui_state.sunnylink_state.set_settings_open(False)
|
||||
|
||||
@staticmethod
|
||||
def _sunnylink_toggle_callback(state: bool):
|
||||
sl_consent: bool = ui_state.params.get("CompletedSunnylinkConsentVersion") == sunnylink_consent_version
|
||||
sl_enabled: bool = ui_state.params.get("SunnylinkEnabled")
|
||||
|
||||
def sl_terms_accepted():
|
||||
ui_state.params.put("CompletedSunnylinkConsentVersion", sunnylink_consent_version)
|
||||
ui_state.params.put_bool("SunnylinkEnabled", True)
|
||||
gui_app.pop_widget()
|
||||
|
||||
def sl_terms_declined():
|
||||
ui_state.params.put("CompletedSunnylinkConsentVersion", sunnylink_consent_declined)
|
||||
ui_state.params.put_bool("SunnylinkEnabled", False)
|
||||
gui_app.pop_widget()
|
||||
|
||||
if state and not sl_consent and not sl_enabled:
|
||||
sl_terms_dlg = SunnylinkConsentPage(on_accept=sl_terms_accepted, on_decline=sl_terms_declined)
|
||||
gui_app.push_widget(sl_terms_dlg)
|
||||
else:
|
||||
ui_state.params.put_bool("SunnylinkEnabled", state)
|
||||
|
||||
ui_state.update_params()
|
||||
|
||||
@staticmethod
|
||||
def _sunnylink_uploader_callback(state: bool):
|
||||
ui_state.params.put_bool("EnableSunnylinkUploader", state)
|
||||
|
||||
def _handle_backup_restore_btn(self, restore: bool = False):
|
||||
lbl = tr("slide to restore") if restore else tr("slide to backup")
|
||||
icon = gui_app.texture("icons_mici/settings/device/update.png", 64, 64)
|
||||
dlg = BigConfirmationDialog(lbl, icon, confirm_callback=self._restore_handler if restore else self._backup_handler)
|
||||
gui_app.push_widget(dlg)
|
||||
|
||||
def _backup_handler(self):
|
||||
self._backup_in_progress = True
|
||||
self._backup_btn.set_enabled(False)
|
||||
ui_state.params.put_bool("BackupManager_CreateBackup", True)
|
||||
|
||||
def _restore_handler(self):
|
||||
self._restore_in_progress = True
|
||||
self._restore_btn.set_enabled(False)
|
||||
ui_state.params.put("BackupManager_RestoreVersion", "latest")
|
||||
|
||||
def handle_backup_restore_progress(self):
|
||||
sunnylink_backup_manager = ui_state.sm["backupManagerSP"]
|
||||
|
||||
backup_status = sunnylink_backup_manager.backupStatus
|
||||
restore_status = sunnylink_backup_manager.restoreStatus
|
||||
backup_progress = sunnylink_backup_manager.backupProgress
|
||||
restore_progress = sunnylink_backup_manager.restoreProgress
|
||||
|
||||
if self._backup_in_progress:
|
||||
self._restore_btn.set_enabled(False)
|
||||
self._backup_btn.set_enabled(False)
|
||||
|
||||
if backup_status == custom.BackupManagerSP.Status.inProgress:
|
||||
self._backup_in_progress = True
|
||||
self._backup_btn.set_text(tr("backing up"))
|
||||
text = tr(f"{backup_progress}%")
|
||||
self._backup_btn.set_value(text)
|
||||
|
||||
elif backup_status == custom.BackupManagerSP.Status.failed:
|
||||
self._backup_in_progress = False
|
||||
self._backup_btn.set_enabled(not ui_state.is_onroad())
|
||||
self._backup_btn.set_text(tr("backup"))
|
||||
self._backup_btn.set_value(tr("failed"))
|
||||
|
||||
elif (backup_status == custom.BackupManagerSP.Status.completed or
|
||||
(backup_status == custom.BackupManagerSP.Status.idle and backup_progress == 100.0)):
|
||||
self._backup_in_progress = False
|
||||
gui_app.push_widget(BigDialog(title=tr("settings backed up"), description=""))
|
||||
self._backup_btn.set_enabled(not ui_state.is_onroad())
|
||||
|
||||
elif self._restore_in_progress:
|
||||
self._restore_btn.set_enabled(False)
|
||||
self._backup_btn.set_enabled(False)
|
||||
|
||||
if restore_status == custom.BackupManagerSP.Status.inProgress:
|
||||
self._restore_in_progress = True
|
||||
self._restore_btn.set_text(tr("restoring"))
|
||||
text = tr(f"{restore_progress}%")
|
||||
self._restore_btn.set_value(text)
|
||||
|
||||
elif restore_status == custom.BackupManagerSP.Status.failed:
|
||||
self._restore_in_progress = False
|
||||
self._restore_btn.set_enabled(not ui_state.is_onroad())
|
||||
self._restore_btn.set_text(tr("restore"))
|
||||
self._restore_btn.set_value(tr("failed"))
|
||||
gui_app.push_widget(BigDialog(title=tr("unable to restore"), description="try again later."))
|
||||
|
||||
elif (restore_status == custom.BackupManagerSP.Status.completed or
|
||||
(restore_status == custom.BackupManagerSP.Status.idle and restore_progress == 100.0)):
|
||||
self._restore_in_progress = False
|
||||
gui_app.push_widget(BigConfirmationDialog(
|
||||
title="slide to restart", icon=gui_app.texture("icons_mici/settings/device/reboot.png", 64, 64),
|
||||
confirm_callback=lambda: gui_app.request_close()))
|
||||
|
||||
else:
|
||||
can_enable = self._sunnylink_enabled and not ui_state.is_onroad()
|
||||
self._backup_btn.set_enabled(can_enable)
|
||||
self._backup_btn.set_text(tr("backup settings"))
|
||||
self._backup_btn.set_value("")
|
||||
self._restore_btn.set_enabled(can_enable)
|
||||
self._restore_btn.set_text(tr("restore settings"))
|
||||
self._restore_btn.set_value("")
|
||||
|
||||
|
||||
class SunnylinkPairBigButton(BigButton):
|
||||
def __init__(self, sponsor_pairing: bool = False):
|
||||
self.sponsor_pairing = sponsor_pairing
|
||||
super().__init__("")
|
||||
|
||||
def _update_state(self):
|
||||
super()._update_state()
|
||||
|
||||
def _handle_mouse_release(self, mouse_pos: MousePos):
|
||||
super()._handle_mouse_release(mouse_pos)
|
||||
|
||||
network_type = ui_state.sm["deviceState"].networkType
|
||||
|
||||
dlg: BigDialog | SunnylinkPairingDialog | None = None
|
||||
|
||||
if network_type == 0:
|
||||
dlg = BigDialog(tr("no internet"), tr("please connect to WiFi & try again"))
|
||||
elif UNREGISTERED_SUNNYLINK_DONGLE_ID == (ui_state.params.get("SunnylinkDongleId") or UNREGISTERED_SUNNYLINK_DONGLE_ID):
|
||||
dlg = BigDialog(tr("sunnylink dongle id not found"), tr("please reboot & try again"))
|
||||
elif self.sponsor_pairing:
|
||||
dlg = SunnylinkPairingDialog(sponsor_pairing=True)
|
||||
elif not self.sponsor_pairing:
|
||||
dlg = SunnylinkPairingDialog(sponsor_pairing=False)
|
||||
if dlg:
|
||||
gui_app.push_widget(dlg)
|
||||
@@ -1,26 +0,0 @@
|
||||
"""
|
||||
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
|
||||
|
||||
This file is part of sunnypilot and is licensed under the MIT License.
|
||||
See the LICENSE.md file in the root directory for more details.
|
||||
"""
|
||||
from openpilot.selfdrive.ui.onroad.augmented_road_view import BORDER_COLORS
|
||||
from openpilot.selfdrive.ui.ui_state import ui_state, UIStatus
|
||||
|
||||
|
||||
class ConfidenceBallSP:
|
||||
@staticmethod
|
||||
def get_animate_status_probs():
|
||||
if ui_state.status == UIStatus.LAT_ONLY:
|
||||
return ui_state.sm['modelV2'].meta.disengagePredictions.steerOverrideProbs
|
||||
|
||||
# UIStatus.LONG_ONLY
|
||||
return ui_state.sm['modelV2'].meta.disengagePredictions.brakeDisengageProbs
|
||||
|
||||
@staticmethod
|
||||
def get_lat_long_dot_color():
|
||||
if ui_state.status == UIStatus.LAT_ONLY:
|
||||
return BORDER_COLORS[UIStatus.LAT_ONLY]
|
||||
|
||||
# UIStatus.LONG_ONLY
|
||||
return BORDER_COLORS[UIStatus.LONG_ONLY]
|
||||
@@ -1,35 +0,0 @@
|
||||
"""
|
||||
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
|
||||
|
||||
This file is part of sunnypilot and is licensed under the MIT License.
|
||||
See the LICENSE.md file in the root directory for more details.
|
||||
"""
|
||||
import pyray as rl
|
||||
|
||||
from openpilot.selfdrive.ui.mici.onroad.hud_renderer import HudRenderer
|
||||
from openpilot.selfdrive.ui.sunnypilot.onroad.blind_spot_indicators import BlindSpotIndicators
|
||||
from openpilot.selfdrive.ui.ui_state import ui_state
|
||||
|
||||
|
||||
class HudRendererSP(HudRenderer):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.blind_spot_indicators = BlindSpotIndicators()
|
||||
self._sp_engaged: bool = False
|
||||
|
||||
def _update_state(self) -> None:
|
||||
super()._update_state()
|
||||
self.blind_spot_indicators.update()
|
||||
|
||||
sp_engaged = ui_state.engaged
|
||||
if sp_engaged != self._sp_engaged:
|
||||
self._chestnut_fade_time = rl.get_time() if sp_engaged else 0
|
||||
self._sp_engaged = sp_engaged
|
||||
|
||||
def _render(self, rect: rl.Rectangle) -> None:
|
||||
super()._render(rect)
|
||||
self.blind_spot_indicators.render(rect)
|
||||
|
||||
def _has_blind_spot_detected(self) -> bool:
|
||||
|
||||
return self.blind_spot_indicators.detected
|
||||
@@ -1,19 +0,0 @@
|
||||
"""
|
||||
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
|
||||
|
||||
This file is part of sunnypilot and is licensed under the MIT License.
|
||||
See the LICENSE.md file in the root directory for more details.
|
||||
"""
|
||||
import pyray as rl
|
||||
from openpilot.selfdrive.ui.ui_state import UIStatus
|
||||
from openpilot.selfdrive.ui.sunnypilot.onroad.rainbow_path import RainbowPath
|
||||
|
||||
LANE_LINE_COLORS_SP = {
|
||||
UIStatus.LAT_ONLY: rl.Color(0, 255, 64, 255),
|
||||
UIStatus.LONG_ONLY: rl.Color(0, 255, 64, 255),
|
||||
}
|
||||
|
||||
|
||||
class ModelRendererSP:
|
||||
def __init__(self):
|
||||
self.rainbow_path = RainbowPath()
|
||||
@@ -1,57 +0,0 @@
|
||||
"""
|
||||
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
|
||||
|
||||
This file is part of sunnypilot and is licensed under the MIT License.
|
||||
See the LICENSE.md file in the root directory for more details.
|
||||
"""
|
||||
import base64
|
||||
|
||||
import pyray as rl
|
||||
from openpilot.common.swaglog import cloudlog
|
||||
from openpilot.selfdrive.ui.mici.widgets.pairing_dialog import PairingDialog
|
||||
from openpilot.sunnypilot.sunnylink.api import SunnylinkApi, UNREGISTERED_SUNNYLINK_DONGLE_ID, API_HOST
|
||||
from openpilot.system.ui.lib.application import FontWeight, gui_app
|
||||
from openpilot.system.ui.lib.multilang import tr
|
||||
from openpilot.system.ui.widgets.nav_widget import NavWidget
|
||||
from openpilot.system.ui.widgets.label import UnifiedLabel
|
||||
|
||||
|
||||
class SunnylinkPairingDialog(PairingDialog):
|
||||
"""Dialog for device pairing with QR code."""
|
||||
|
||||
def __init__(self, sponsor_pairing: bool = False):
|
||||
PairingDialog.__init__(self)
|
||||
self._sponsor_pairing = sponsor_pairing
|
||||
label_text = tr("pair with sunnylink") if sponsor_pairing else tr("become a sunnypilot sponsor")
|
||||
self._pair_label = UnifiedLabel(label_text, font_size=48, font_weight=FontWeight.BOLD,
|
||||
text_color=rl.Color(255, 255, 255, int(255 * 0.9)), line_height=0.8)
|
||||
|
||||
def _get_pairing_url(self) -> str:
|
||||
qr_string = "https://github.com/sponsors/sunnyhaibin"
|
||||
|
||||
if self._sponsor_pairing:
|
||||
try:
|
||||
sl_dongle_id = self._params.get("SunnylinkDongleId") or UNREGISTERED_SUNNYLINK_DONGLE_ID
|
||||
token = SunnylinkApi(sl_dongle_id).get_token()
|
||||
inner_string = f"1|{sl_dongle_id}|{token}"
|
||||
payload_bytes = base64.b64encode(inner_string.encode('utf-8')).decode('utf-8')
|
||||
qr_string = f"{API_HOST}/sso?state={payload_bytes}"
|
||||
except Exception:
|
||||
cloudlog.exception("Failed to get pairing token")
|
||||
|
||||
return qr_string
|
||||
|
||||
def _update_state(self):
|
||||
NavWidget._update_state(self)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
gui_app.init_window("pairing device")
|
||||
pairing = SunnylinkPairingDialog(sponsor_pairing=True)
|
||||
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
|
||||
@@ -7,7 +7,7 @@ See the LICENSE.md file in the root directory for more details.
|
||||
import pyray as rl
|
||||
|
||||
from openpilot.common.constants import CV
|
||||
from openpilot.selfdrive.ui.mici.onroad.torque_bar import TorqueBar
|
||||
from openpilot.selfdrive.ui.onroad.torque_bar import TorqueBar
|
||||
from openpilot.selfdrive.ui.sunnypilot.onroad.developer_ui import DeveloperUiRenderer, DeveloperUiState, get_bottom_dev_ui_offset
|
||||
from openpilot.selfdrive.ui.sunnypilot.onroad.road_name import RoadNameRenderer
|
||||
from openpilot.selfdrive.ui.sunnypilot.onroad.rocket_fuel import RocketFuel
|
||||
|
||||
@@ -7,14 +7,22 @@ See the LICENSE.md file in the root directory for more details.
|
||||
import pyray as rl
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
|
||||
from openpilot.selfdrive.ui.ui_state import ui_state
|
||||
from openpilot.selfdrive.ui.mici.onroad.alert_renderer import IconSide, TURN_SIGNAL_BLINK_PERIOD
|
||||
from openpilot.system.ui.lib.application import gui_app
|
||||
from openpilot.system.ui.widgets import Widget
|
||||
from openpilot.common.filter_simple import FirstOrderFilter
|
||||
|
||||
|
||||
TURN_SIGNAL_BLINK_PERIOD = 1 / (80 / 60) # Mazda heartbeat turn signal BPM
|
||||
|
||||
|
||||
class IconSide(StrEnum):
|
||||
left = 'left'
|
||||
right = 'right'
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TurnSignalConfig:
|
||||
left_x: int = 80
|
||||
|
||||
@@ -16,7 +16,7 @@ from openpilot.selfdrive.ui.tests.diff.diff import DIFF_OUT_DIR
|
||||
from openpilot.system.updated.updated import parse_release_notes
|
||||
from openpilot.common.version import terms_version, training_version, terms_version_sp, sunnylink_consent_version
|
||||
|
||||
LayoutVariant = Literal["mici", "tizi"]
|
||||
LayoutVariant = Literal["tizi"]
|
||||
|
||||
FPS = 60
|
||||
HEADLESS = os.getenv("WINDOWED", "0") != "1"
|
||||
@@ -56,11 +56,7 @@ def run_replay(variant: LayoutVariant) -> None:
|
||||
|
||||
gui_app.init_window("ui diff test", fps=FPS)
|
||||
|
||||
# Dynamically import main layout based on variant
|
||||
if variant == "mici":
|
||||
from openpilot.selfdrive.ui.mici.layouts.main import MiciMainLayout as MainLayout
|
||||
else:
|
||||
from openpilot.selfdrive.ui.layouts.main import MainLayout
|
||||
from openpilot.selfdrive.ui.layouts.main import MainLayout
|
||||
main_layout = MainLayout()
|
||||
|
||||
# Disable interactive timeout — replay clicks use left_down=False so they never reset the timer,
|
||||
@@ -115,27 +111,23 @@ def run_replay(variant: LayoutVariant) -> None:
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('--big', action='store_true', help='Use big UI layout (tizi/tici) instead of mici layout')
|
||||
args = parser.parse_args()
|
||||
argparse.ArgumentParser().parse_args()
|
||||
|
||||
variant: LayoutVariant = 'tizi' if args.big else 'mici'
|
||||
|
||||
if args.big:
|
||||
os.environ["BIG"] = "1"
|
||||
variant: LayoutVariant = 'tizi'
|
||||
os.environ["BIG"] = "1"
|
||||
os.environ["RECORD"] = "1"
|
||||
os.environ["RECORD_QUALITY"] = "0" # Use CRF 0 ("lossless" encode) for deterministic output across different machines
|
||||
os.environ["RECORD_OUTPUT"] = os.path.join(DIFF_OUT_DIR, os.environ.get("RECORD_OUTPUT", f"{variant}_ui_replay.mp4"))
|
||||
|
||||
print(f"Running {variant} UI replay...")
|
||||
with OpenpilotPrefix():
|
||||
sources = ["openpilot.system.ui"]
|
||||
if variant == "mici":
|
||||
sources.append("openpilot.selfdrive.ui.mici")
|
||||
omit = ["**/*tizi*", "**/*tici*"] # exclude files containing "tizi" or "tici"
|
||||
else:
|
||||
sources.extend(["openpilot.selfdrive.ui.layouts", "openpilot.selfdrive.ui.onroad", "openpilot.selfdrive.ui.widgets"])
|
||||
omit = ["**/*mici*"] # exclude files containing "mici"
|
||||
sources = [
|
||||
"openpilot.system.ui",
|
||||
"openpilot.selfdrive.ui.layouts",
|
||||
"openpilot.selfdrive.ui.onroad",
|
||||
"openpilot.selfdrive.ui.widgets",
|
||||
]
|
||||
omit = ["**/*mici*"]
|
||||
cov = coverage.Coverage(source=sources, omit=omit)
|
||||
with cov.collect():
|
||||
run_replay(variant)
|
||||
|
||||
@@ -218,173 +218,6 @@ def test_onroad_alerts(script: Script, pm: PubMaster) -> None:
|
||||
|
||||
# --- Script builders ---
|
||||
|
||||
def build_mici_script(pm: PubMaster, main_layout, script: Script) -> None:
|
||||
"""Build the replay script for the mici layout."""
|
||||
from openpilot.system.ui.lib.application import gui_app
|
||||
|
||||
width, height = gui_app.width, gui_app.height
|
||||
center = (width // 2, height // 2)
|
||||
right = (width * 4 // 5, height // 2)
|
||||
left = (width // 5, height // 2)
|
||||
top = (width // 2, height // 10)
|
||||
bottom = (width // 2, height * 9 // 10)
|
||||
|
||||
DURATION = 5
|
||||
SWIPE_WAIT = FPS * 3 // 4
|
||||
|
||||
def click(times: int = 1, wait_after: int = WAIT_SHORT) -> None:
|
||||
"""Click at the center of the screen the given number of times with optional delay after."""
|
||||
for _ in range(times):
|
||||
script.click(*center, wait_after=wait_after)
|
||||
|
||||
def press(x: int, y: int, duration_frames: int = DURATION, wait_after: int = WAIT_SHORT) -> None:
|
||||
"""Perform a drag with no movement to simulate a left_down mouse event at the given position for the specified duration and delay after."""
|
||||
script.drag(x, y, (0, 0), 0, duration_frames, wait_after=wait_after)
|
||||
|
||||
def swipe_left(distance: int = right[0] - left[0], duration_frames: int = DURATION, wait_after: int = SWIPE_WAIT) -> None:
|
||||
"""Drag from right edge to left (scroll right / slide confirmation)."""
|
||||
script.drag(*right, DIR_LEFT, distance, duration_frames, wait_after)
|
||||
|
||||
def swipe_right(distance: int = right[0] - left[0], duration_frames: int = DURATION, wait_after: int = SWIPE_WAIT) -> None:
|
||||
"""Drag from left edge to right (scroll left)."""
|
||||
script.drag(*left, DIR_RIGHT, distance, duration_frames, wait_after)
|
||||
|
||||
def swipe_down(distance: int = bottom[1] - top[1], duration_frames: int = DURATION, wait_after: int = SWIPE_WAIT) -> None:
|
||||
"""Drag from top edge to bottom (scroll up / go back)."""
|
||||
script.drag(*top, DIR_DOWN, distance, duration_frames, wait_after)
|
||||
|
||||
def swipe_up(distance: int = bottom[1] - top[1], duration_frames: int = DURATION, wait_after: int = SWIPE_WAIT) -> None:
|
||||
"""Drag from bottom edge to top (scroll down)."""
|
||||
script.drag(*bottom, DIR_UP, distance, duration_frames, wait_after)
|
||||
|
||||
ActionFn = Callable[[], None] | None
|
||||
Cases = list[ActionFn]
|
||||
|
||||
def run_actions(*actions: ActionFn, after_each: ActionFn = None) -> None:
|
||||
"""Helper function to run a sequence of actions in order for interaction tests, calling after_each callback after each action if provided."""
|
||||
for action in actions:
|
||||
if action is not None:
|
||||
action()
|
||||
if after_each is not None:
|
||||
after_each()
|
||||
|
||||
def explore_setting(*actions: ActionFn) -> None:
|
||||
"""Helper function to open a settings item, run the given actions, and go back."""
|
||||
run_actions(click, *actions, swipe_down) # open, interact, go back
|
||||
|
||||
def scroll_through_cases(cases: Cases) -> None:
|
||||
"""Helper function to explore a panel by calling the interaction callbacks for each item/page before swiping to the next one."""
|
||||
run_actions(*cases, after_each=lambda: swipe_left(210, 10)) # swipe to roughly the center of the next toggle after each case
|
||||
|
||||
def interact_keyboard() -> None:
|
||||
"""Interact with the keyboard in various ways to test different actions and states.
|
||||
Assumes it's a password keyboard with 8 characters required. Closes by pressing confirm at the end."""
|
||||
KEY = (250, 160) # key in the middle of the keyboard ('G')
|
||||
SHIFT = (50, 210)
|
||||
NUMBERS = (480, 210)
|
||||
SPACE = (500, 160)
|
||||
BACKSPACE = (490, 30)
|
||||
CONFIRM = (50, 30)
|
||||
# Begin interactions
|
||||
press(*CONFIRM, wait_after=FAST_CLICK) # confirm while disabled should do nothing
|
||||
swipe_left(duration_frames=FPS // 2) # swipe to type
|
||||
swipe_up(duration_frames=FPS // 2) # swipe out of keyboard (nothing typed)
|
||||
# press various keys to test different states:
|
||||
for key in [
|
||||
SHIFT, KEY, KEY, SHIFT, SHIFT, KEY, KEY, # test casing (upper, lower, caps lock)
|
||||
SPACE, SPACE, BACKSPACE, BACKSPACE, # test multiple space and backspace
|
||||
NUMBERS, KEY, center, SHIFT, KEY # test numbers and symbols
|
||||
]:
|
||||
press(*key, wait_after=FAST_CLICK)
|
||||
# press confirm to close
|
||||
script.wait(WAIT_SHORT) # wait for confirm to enable
|
||||
press(*CONFIRM)
|
||||
|
||||
toggle_cases: Cases = [
|
||||
lambda: click(times=3, wait_after=FAST_CLICK), # first toggle is personality, which has 3 states
|
||||
None, None, None, None, None, None, # skip other toggles to save time
|
||||
lambda: click(times=2, wait_after=FAST_CLICK), # test final toggle (enable openpilot)
|
||||
]
|
||||
|
||||
network_cases: Cases = [
|
||||
explore_setting, # select wifi (just open and close)
|
||||
None, None,
|
||||
lambda: run_actions(click, interact_keyboard), # tether password keyboard
|
||||
]
|
||||
|
||||
device_cases: Cases = [
|
||||
None,
|
||||
click, # update
|
||||
explore_setting, # pairing (just open and close)
|
||||
lambda: explore_setting(
|
||||
# training guide
|
||||
lambda: swipe_left(width * 2), click, # first page, click next
|
||||
lambda: swipe_left(width * 2), swipe_down # second page, go back (TODO: make driver cam preview work)
|
||||
),
|
||||
None, # TODO: preview cabin camera; enabling this causes MultiplePublishersError later in onroad alert tests
|
||||
lambda: explore_setting(swipe_left), # terms & conditions (swipe to view QR code)
|
||||
lambda: explore_setting(lambda: swipe_up(height * 3), lambda: swipe_down(height * 3)), # regulatory info
|
||||
lambda: run_actions(click, lambda: swipe_left(width)), # reset calibration confirm (goes back automatically)
|
||||
lambda: explore_setting(lambda: swipe_left(width)), # uninstall
|
||||
lambda: run_actions(
|
||||
lambda: explore_setting(lambda: swipe_left(width)), # reboot
|
||||
lambda: script.click(430, 120), lambda: swipe_left(width), swipe_down, # shutdown
|
||||
),
|
||||
]
|
||||
|
||||
developer_cases: Cases = [
|
||||
lambda: click(times=2, wait_after=FAST_CLICK), # toggle ssh mode
|
||||
explore_setting, # SSH keys keyboard (just open and close)
|
||||
None, # joystick mode
|
||||
lambda: click(wait_after=FAST_CLICK), # longitudinal maneuver mode (disabled; should do nothing)
|
||||
lambda: click(times=2, wait_after=FAST_CLICK), # toggle UI debug mode
|
||||
]
|
||||
|
||||
settings_cases: Cases = [
|
||||
lambda: scroll_through_cases(toggle_cases),
|
||||
None, # sunnylink (just open and close)
|
||||
None, # models (just open and close)
|
||||
lambda: scroll_through_cases(network_cases),
|
||||
lambda: scroll_through_cases(device_cases),
|
||||
lambda: script.wait(WAIT_SHORT), # software
|
||||
lambda: script.wait(WAIT_SHORT), # pairing
|
||||
lambda: run_actions(lambda: swipe_up(height * 3), lambda: swipe_down(height * 3)), # firehose (scroll down and back up)
|
||||
lambda: scroll_through_cases(developer_cases),
|
||||
]
|
||||
|
||||
# === Homescreen === #
|
||||
script.wait(WAIT_SHORT)
|
||||
swipe_left(width, wait_after=WAIT_SHORT) # onroad screen
|
||||
swipe_right(width, wait_after=WAIT_SHORT) # back to home
|
||||
|
||||
# === Offroad Alerts ===
|
||||
def setup_offroad_alerts_and_refresh() -> None:
|
||||
"""Setup function to trigger offroad alerts and force a refresh on the alerts layout."""
|
||||
setup_offroad_alerts()
|
||||
params = Params()
|
||||
main_layout._alerts_layout._pending_params = ({"UpdaterNewDescription": params.get("UpdaterNewDescription")} |
|
||||
{alert_data.key: params.get(alert_data.key) for alert_data in main_layout._alerts_layout.sorted_alerts})
|
||||
main_layout._alerts_layout._update_state()
|
||||
|
||||
swipe_right(width, wait_after=WAIT_SHORT) # open alerts
|
||||
script.setup(setup_offroad_alerts_and_refresh) # show alerts
|
||||
swipe_up(height) # scroll alerts
|
||||
swipe_left(width, wait_after=WAIT_SHORT) # close alerts
|
||||
|
||||
# === Settings === #
|
||||
click() # open settings
|
||||
scroll_through_cases([lambda case=case: explore_setting(case) for case in settings_cases]) # explore settings
|
||||
swipe_down() # back to home
|
||||
|
||||
# === Onroad ===
|
||||
script.set_send(lambda: send_onroad(pm))
|
||||
swipe_left(width, wait_after=WAIT_SHORT) # onroad screen
|
||||
test_onroad_alerts(script, pm)
|
||||
swipe_right(width) # back to home
|
||||
|
||||
script.end()
|
||||
|
||||
|
||||
def build_tizi_script(pm: PubMaster, main_layout, script: Script) -> None:
|
||||
"""Build the replay script for the tizi layout."""
|
||||
|
||||
@@ -528,8 +361,7 @@ def build_script(pm: PubMaster, main_layout, variant: LayoutVariant) -> list[Scr
|
||||
print(f"Building {variant} replay script...")
|
||||
|
||||
script = Script(FPS)
|
||||
builder = build_tizi_script if variant == 'tizi' else build_mici_script
|
||||
builder(pm, main_layout, script)
|
||||
build_tizi_script(pm, main_layout, script)
|
||||
|
||||
print(f"Built replay script with {len(script.entries)} events and {script.frame} frames ({script.get_frame_time():.2f} seconds)")
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import numpy as np
|
||||
from openpilot.cereal.visionipc import VisionStreamType
|
||||
from msgq.visionipc import VisionIpcServer
|
||||
from openpilot.selfdrive.ui.ui_state import ui_state
|
||||
from openpilot.selfdrive.ui.mici.layouts.main import MiciMainLayout
|
||||
from openpilot.selfdrive.ui.layouts.main import MainLayout
|
||||
from openpilot.system.ui.lib.application import gui_app
|
||||
from openpilot.tools.lib.logreader import LogReader
|
||||
|
||||
@@ -83,7 +83,7 @@ if __name__ == "__main__":
|
||||
os.environ['SDL_VIDEODRIVER'] = 'dummy'
|
||||
|
||||
gui_app.init_window("UI Profiling", fps=600)
|
||||
main_layout = MiciMainLayout()
|
||||
main_layout = MainLayout()
|
||||
|
||||
print("Running...")
|
||||
patch_submaster(message_chunks)
|
||||
|
||||
@@ -16,7 +16,6 @@ def update_translations():
|
||||
os.walk(os.path.join(str(UI_DIR), "layouts")),
|
||||
os.walk(os.path.join(str(UI_DIR), "onroad")),
|
||||
os.walk(os.path.join(str(UI_DIR), "sunnypilot")),
|
||||
os.walk(os.path.join(str(UI_DIR), "mici")),
|
||||
os.walk(os.path.join(str(UI_DIR), "..", "selfdrived"))):
|
||||
for filename in filenames:
|
||||
if filename.endswith(".py"):
|
||||
|
||||
@@ -9,20 +9,13 @@ from openpilot.system.ui.lib.application import gui_app
|
||||
from openpilot.selfdrive.ui.layouts.main import MainLayout
|
||||
from openpilot.selfdrive.ui.ui_state import ui_state
|
||||
|
||||
BIG_UI = gui_app.big_ui()
|
||||
|
||||
|
||||
def main():
|
||||
cores = {5, }
|
||||
# above plannerd and radard
|
||||
config_realtime_process(0, Priority.CTRL_HIGH)
|
||||
|
||||
gui_app.init_window("UI")
|
||||
if BIG_UI:
|
||||
MainLayout()
|
||||
else:
|
||||
from openpilot.selfdrive.ui.mici.layouts.main import MiciMainLayout
|
||||
MiciMainLayout()
|
||||
MainLayout()
|
||||
|
||||
pm = messaging.PubMaster(['uiDebug'])
|
||||
for should_render, frame_time, cpu_time in gui_app.render():
|
||||
|
||||
@@ -287,10 +287,7 @@ def clip(route: Route, output: str, start: int, end: int, headless: bool = True,
|
||||
timer, duration = Timer(), end - start
|
||||
|
||||
import pyray as rl
|
||||
if big:
|
||||
from openpilot.selfdrive.ui.onroad.augmented_road_view import AugmentedRoadView
|
||||
else:
|
||||
from openpilot.selfdrive.ui.mici.onroad.augmented_road_view import AugmentedRoadView
|
||||
from openpilot.selfdrive.ui.onroad.augmented_road_view import AugmentedRoadView
|
||||
from openpilot.selfdrive.ui.ui_state import ui_state
|
||||
from openpilot.system.ui.lib.application import gui_app, FontWeight
|
||||
timer.lap("import")
|
||||
|
||||
@@ -22,7 +22,7 @@ from openpilot.tools.replay.lib.ui_helpers import (
|
||||
plot_model,
|
||||
)
|
||||
from openpilot.cereal.visionipc import VisionStreamType
|
||||
from openpilot.selfdrive.ui.mici.onroad.cameraview import CameraView
|
||||
from openpilot.selfdrive.ui.onroad.cameraview import CameraView
|
||||
|
||||
os.environ['BASEDIR'] = BASEDIR
|
||||
|
||||
|
||||
Reference in New Issue
Block a user