sunnypilot v2026.002.000
version: sunnypilot v2026.002.000 (staging) date: 2026-07-21T07:44:02 master commit: 2c334ede443d7391d27575af8c854a095ba702a8
This commit is contained in:
commit
de77c89cf6
3995 files changed
+1260688
No files matched your search
Whitespace-only changes.
@@ -0,0 +1,262 @@
|
||||
import datetime
|
||||
import time
|
||||
|
||||
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
|
||||
from openpilot.selfdrive.ui.ui_state import ui_state
|
||||
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._icon_red = gui_app.texture("icons_mici/offroad_alerts/red_warning.png", 36, 36)
|
||||
self._icon_orange = gui_app.texture("icons_mici/offroad_alerts/orange_warning.png", 36, 36)
|
||||
self._icon_green = gui_app.texture("icons_mici/offroad_alerts/green_wheel.png", 36, 36)
|
||||
self._alert_count_callback: Callable[[], int] | None = None
|
||||
self._max_severity_callback: Callable[[], int | None] | None = None
|
||||
|
||||
def set_alert_count_callback(self, callback: Callable[[], int] | None,
|
||||
severity_callback: Callable[[], int | None] | None = None):
|
||||
self._alert_count_callback = callback
|
||||
self._max_severity_callback = severity_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)
|
||||
|
||||
severity = self._max_severity_callback() if self._max_severity_callback else None
|
||||
if severity == -1:
|
||||
warning_txt = self._icon_green
|
||||
elif severity is not None and severity > 0:
|
||||
warning_txt = self._icon_red
|
||||
else:
|
||||
warning_txt = self._icon_orange
|
||||
|
||||
warn_x = self.rect.x + self.ICON_OFFSET
|
||||
warn_y = self.rect.y + (pill_h - warning_txt.height) / 2
|
||||
rl.draw_texture_ex(warning_txt, rl.Vector2(warn_x, warn_y), 0.0, 1.0, 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=rl.GuiTextAlignment.TEXT_ALIGN_CENTER,
|
||||
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE)
|
||||
|
||||
|
||||
class NetworkIcon(Widget):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.set_rect(rl.Rectangle(0, 0, 54, 44)) # 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", 50, 44)
|
||||
self._wifi_none_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_none.png", 50, 37)
|
||||
self._wifi_low_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_low.png", 50, 37)
|
||||
self._wifi_medium_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_medium.png", 50, 37)
|
||||
self._wifi_full_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_full.png", 50, 37)
|
||||
|
||||
self._cell_none_txt = gui_app.texture("icons_mici/settings/network/cell_strength_none.png", 54, 36)
|
||||
self._cell_low_txt = gui_app.texture("icons_mici/settings/network/cell_strength_low.png", 54, 36)
|
||||
self._cell_medium_txt = gui_app.texture("icons_mici/settings/network/cell_strength_medium.png", 54, 36)
|
||||
self._cell_high_txt = gui_app.texture("icons_mici/settings/network/cell_strength_high.png", 54, 36)
|
||||
self._cell_full_txt = gui_app.texture("icons_mici/settings/network/cell_strength_full.png", 54, 36)
|
||||
|
||||
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._mouse_down_t: None | float = None
|
||||
self._did_long_press = False
|
||||
self._is_pressed_prev = False
|
||||
|
||||
self._version_text = self._get_version_text()
|
||||
|
||||
self._experimental_icon = IconWidget("icons_mici/experimental_mode.png", (48, 48))
|
||||
self._egpu_icon = IconWidget("icons_mici/egpu.png", (50, 37))
|
||||
self._egpu_icon_gray = IconWidget("icons_mici/egpu_gray.png", (50, 37))
|
||||
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._experimental_icon,
|
||||
self._egpu_icon,
|
||||
self._egpu_icon_gray,
|
||||
self._body_icon,
|
||||
self._mic_icon,
|
||||
], spacing=18)
|
||||
|
||||
self._openpilot_label = UnifiedLabel("sunnypilot", 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 _update_state(self):
|
||||
if self.is_pressed and not self._is_pressed_prev:
|
||||
self._mouse_down_t = time.monotonic()
|
||||
elif not self.is_pressed and self._is_pressed_prev:
|
||||
self._mouse_down_t = None
|
||||
self._did_long_press = False
|
||||
self._is_pressed_prev = self.is_pressed
|
||||
|
||||
if self._mouse_down_t is not None:
|
||||
if time.monotonic() - self._mouse_down_t > 0.5:
|
||||
# long gating for experimental mode - only allow toggle if longitudinal control is available
|
||||
if ui_state.has_longitudinal_control:
|
||||
ui_state.experimental_mode = not ui_state.experimental_mode
|
||||
ui_state.params.put("ExperimentalMode", ui_state.experimental_mode, block=True)
|
||||
self._mouse_down_t = None
|
||||
self._did_long_press = True
|
||||
|
||||
def set_callbacks(self, on_settings: Callable | None = None, on_alerts: Callable | None = None,
|
||||
alert_count_callback: Callable[[], int] | None = None,
|
||||
max_severity_callback: Callable[[], int | 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, max_severity_callback)
|
||||
|
||||
def _handle_mouse_release(self, mouse_pos: MousePos):
|
||||
if not self._did_long_press:
|
||||
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()
|
||||
self._did_long_press = False
|
||||
|
||||
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 *****
|
||||
self._experimental_icon.set_visible(ui_state.experimental_mode)
|
||||
self._egpu_icon.set_visible(ui_state.usbgpu and ui_state.usbgpu_compiled)
|
||||
self._egpu_icon_gray.set_visible(ui_state.usbgpu and not ui_state.usbgpu_compiled)
|
||||
self._mic_icon.set_visible(ui_state.recording_audio)
|
||||
self._body_icon.set_visible(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()
|
||||
@@ -0,0 +1,151 @@
|
||||
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
|
||||
|
||||
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'])
|
||||
|
||||
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)
|
||||
|
||||
@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,
|
||||
max_severity_callback=self._alerts_layout.max_severity,
|
||||
)
|
||||
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):
|
||||
user_bookmark = messaging.new_message('bookmarkButton')
|
||||
user_bookmark.valid = True
|
||||
self._pm.send('bookmarkButton', user_bookmark)
|
||||
|
||||
def _on_body_changed(self):
|
||||
self._car_onroad_layout.set_visible(not ui_state.is_body)
|
||||
self._body_onroad_layout.set_visible(ui_state.is_body)
|
||||
@@ -0,0 +1,323 @@
|
||||
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
|
||||
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
|
||||
|
||||
|
||||
class AlertItem(Widget):
|
||||
# TODO: click should always go somewhere: home or specific settings pane
|
||||
"""Individual alert item widget with background image and text."""
|
||||
ALERT_WIDTH = 520
|
||||
ALERT_HEIGHT_SMALL = 212
|
||||
ALERT_HEIGHT_MED = 240
|
||||
ALERT_HEIGHT_BIG = 324
|
||||
ALERT_PADDING = 28
|
||||
ICON_SIZE = 64
|
||||
ICON_MARGIN = 12
|
||||
TEXT_COLOR = rl.Color(255, 255, 255, int(255 * 0.9))
|
||||
TITLE_BODY_SPACING = 24
|
||||
|
||||
def __init__(self, alert_data: AlertData):
|
||||
super().__init__()
|
||||
self.alert_data = alert_data
|
||||
|
||||
# Load background textures
|
||||
self._bg_small = gui_app.texture("icons_mici/offroad_alerts/small_alert.png", self.ALERT_WIDTH, self.ALERT_HEIGHT_SMALL)
|
||||
self._bg_small_pressed = gui_app.texture("icons_mici/offroad_alerts/small_alert_pressed.png", self.ALERT_WIDTH, self.ALERT_HEIGHT_SMALL)
|
||||
self._bg_medium = gui_app.texture("icons_mici/offroad_alerts/medium_alert.png", self.ALERT_WIDTH, self.ALERT_HEIGHT_MED)
|
||||
self._bg_medium_pressed = gui_app.texture("icons_mici/offroad_alerts/medium_alert_pressed.png", self.ALERT_WIDTH, self.ALERT_HEIGHT_MED)
|
||||
self._bg_big = gui_app.texture("icons_mici/offroad_alerts/big_alert.png", self.ALERT_WIDTH, self.ALERT_HEIGHT_BIG)
|
||||
self._bg_big_pressed = gui_app.texture("icons_mici/offroad_alerts/big_alert_pressed.png", self.ALERT_WIDTH, self.ALERT_HEIGHT_BIG)
|
||||
|
||||
# Load warning icons
|
||||
self._icon_orange = gui_app.texture("icons_mici/offroad_alerts/orange_warning.png", self.ICON_SIZE, self.ICON_SIZE)
|
||||
self._icon_red = gui_app.texture("icons_mici/offroad_alerts/red_warning.png", self.ICON_SIZE, self.ICON_SIZE)
|
||||
self._icon_green = gui_app.texture("icons_mici/offroad_alerts/green_wheel.png", self.ICON_SIZE, self.ICON_SIZE)
|
||||
|
||||
self._title_label = UnifiedLabel(text="", font_size=32, font_weight=FontWeight.SEMI_BOLD, text_color=self.TEXT_COLOR,
|
||||
alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT,
|
||||
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP, line_height=0.95)
|
||||
|
||||
self._body_label = UnifiedLabel(text="", font_size=28, font_weight=FontWeight.ROMAN, text_color=self.TEXT_COLOR,
|
||||
alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT,
|
||||
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM, line_height=0.95)
|
||||
|
||||
self._title_text = ""
|
||||
self._body_text = ""
|
||||
self._alert_size = AlertSize.SMALL
|
||||
|
||||
self._update_content()
|
||||
|
||||
def _split_text(self, text: str) -> tuple[str, str]:
|
||||
"""Split text into title (first sentence) and body (remaining text)."""
|
||||
# Find the end of the first sentence (period, exclamation, or question mark followed by space or end)
|
||||
match = re.search(r'[.!?](?:\s+|$)', text)
|
||||
if match:
|
||||
# Found a sentence boundary - split at the end of the sentence
|
||||
title = text[:match.start()].strip()
|
||||
body = text[match.end():].strip()
|
||||
return title, body
|
||||
else:
|
||||
# No sentence boundary found, return full text as title
|
||||
return "", text
|
||||
|
||||
def _update_content(self):
|
||||
"""Update text and calculate height."""
|
||||
if not self.alert_data.visible or not self.alert_data.text:
|
||||
self.set_visible(False)
|
||||
return
|
||||
|
||||
self.set_visible(True)
|
||||
|
||||
# Split text into title and body
|
||||
self._title_text, self._body_text = self._split_text(self.alert_data.text)
|
||||
|
||||
# Calculate text width (alert width minus padding and icon space on right)
|
||||
title_width = self.ALERT_WIDTH - (self.ALERT_PADDING * 2) - self.ICON_SIZE - self.ICON_MARGIN
|
||||
body_width = self.ALERT_WIDTH - (self.ALERT_PADDING * 2)
|
||||
|
||||
# Update labels
|
||||
self._title_label.set_text(self._title_text)
|
||||
self._body_label.set_text(self._body_text)
|
||||
|
||||
# Calculate content height
|
||||
title_height = self._title_label.get_content_height(title_width) if self._title_text else 0
|
||||
body_height = self._body_label.get_content_height(body_width) if self._body_text else 0
|
||||
spacing = self.TITLE_BODY_SPACING if (self._title_text and self._body_text) else 0
|
||||
total_text_height = title_height + spacing + body_height
|
||||
|
||||
# Determine which background size to use based on content height
|
||||
min_height_with_padding = total_text_height + (self.ALERT_PADDING * 2)
|
||||
if min_height_with_padding > self.ALERT_HEIGHT_MED:
|
||||
self._alert_size = AlertSize.BIG
|
||||
height = self.ALERT_HEIGHT_BIG
|
||||
elif min_height_with_padding > self.ALERT_HEIGHT_SMALL:
|
||||
self._alert_size = AlertSize.MEDIUM
|
||||
height = self.ALERT_HEIGHT_MED
|
||||
else:
|
||||
self._alert_size = AlertSize.SMALL
|
||||
height = self.ALERT_HEIGHT_SMALL
|
||||
|
||||
# Set rect size
|
||||
self.set_rect(rl.Rectangle(0, 0, self.ALERT_WIDTH, height))
|
||||
|
||||
def update_alert_data(self, alert_data: AlertData):
|
||||
"""Update alert data and refresh display."""
|
||||
self.alert_data = alert_data
|
||||
self._update_content()
|
||||
|
||||
def _render(self, _):
|
||||
if not self.alert_data.visible or not self.alert_data.text:
|
||||
return
|
||||
|
||||
# Choose background based on size
|
||||
if self._alert_size == AlertSize.BIG:
|
||||
bg_texture = self._bg_big_pressed if self.is_pressed else self._bg_big
|
||||
elif self._alert_size == AlertSize.MEDIUM:
|
||||
bg_texture = self._bg_medium_pressed if self.is_pressed else self._bg_medium
|
||||
else: # AlertSize.SMALL
|
||||
bg_texture = self._bg_small_pressed if self.is_pressed else self._bg_small
|
||||
|
||||
# Draw background
|
||||
rl.draw_texture_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
|
||||
# Use green icon for update alerts (severity = -1), red for high severity, orange for low severity
|
||||
if self.alert_data.severity == -1:
|
||||
icon_texture = self._icon_green
|
||||
elif self.alert_data.severity > 0:
|
||||
icon_texture = self._icon_red
|
||||
else:
|
||||
icon_texture = self._icon_orange
|
||||
icon_x = self._rect.x + self.ALERT_WIDTH - self.ALERT_PADDING - self.ICON_SIZE
|
||||
icon_y = self._rect.y + self.ALERT_PADDING
|
||||
rl.draw_texture_ex(icon_texture, 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=rl.GuiTextAlignment.TEXT_ALIGN_CENTER,
|
||||
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_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 max_severity(self) -> int | None:
|
||||
return max((alert.severity for alert in self.sorted_alerts if alert.visible), default=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)
|
||||
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) -> int:
|
||||
"""Refresh alerts from params and return active count."""
|
||||
active_count = 0
|
||||
|
||||
# Handle UpdateAvailable alert specially
|
||||
update_available = self._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 = self._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 = self._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
|
||||
if self._pending_params is not None:
|
||||
self._refresh()
|
||||
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)
|
||||
@@ -0,0 +1,437 @@
|
||||
import math
|
||||
import numpy as np
|
||||
import qrcode
|
||||
import pyray as rl
|
||||
from collections.abc import Callable
|
||||
from openpilot.common.filter_simple import FirstOrderFilter
|
||||
from openpilot.system.ui.lib.application import FontWeight, gui_app
|
||||
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.driver_camera_dialog import BaseDriverCameraDialog
|
||||
from openpilot.selfdrive.ui.sunnypilot.mici.layouts.onboarding import SunnylinkConsentPage
|
||||
|
||||
|
||||
class DriverCameraSetupDialog(BaseDriverCameraDialog):
|
||||
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=rl.GuiTextAlignment.TEXT_ALIGN_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: rl.Texture | None = None
|
||||
self._generate_qr(url)
|
||||
|
||||
def _generate_qr(self, url: str):
|
||||
qr = qrcode.QRCode(version=1, error_correction=qrcode.constants.ERROR_CORRECT_L, box_size=10, border=0)
|
||||
qr.add_data(url)
|
||||
qr.make(fit=True)
|
||||
|
||||
pil_img = qr.make_image(fill_color="white", back_color="black").convert('RGBA')
|
||||
img_array = np.array(pil_img, dtype=np.uint8)
|
||||
|
||||
rl_image = rl.Image()
|
||||
rl_image.data = rl.ffi.cast("void *", img_array.ctypes.data)
|
||||
rl_image.width = pil_img.width
|
||||
rl_image.height = pil_img.height
|
||||
rl_image.mipmaps = 1
|
||||
rl_image.format = rl.PixelFormat.PIXELFORMAT_UNCOMPRESSED_R8G8B8A8
|
||||
|
||||
self._qr_texture = rl.load_texture_from_image(rl_image)
|
||||
|
||||
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)
|
||||
@@ -0,0 +1,200 @@
|
||||
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."),
|
||||
GreyBigButton("", "Using Experimental mode is recommended with openpilot longitudinal control alpha."),
|
||||
GreyBigButton("", "Changing this setting will restart openpilot if the car is powered on."),
|
||||
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)
|
||||
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))
|
||||
self._ssh_toggle = BigCircleParamControl(gui_app.texture("icons_mici/ssh_short.png", 82, 82), "SshEnabled", icon_offset=(0, 12))
|
||||
self._joystick_toggle = BigToggle("joystick debug mode",
|
||||
initial_state=ui_state.params.get_bool("JoystickDebugMode"),
|
||||
toggle_callback=self._on_joystick_debug_mode)
|
||||
self._long_maneuver_toggle = BigToggle("longitudinal maneuver mode",
|
||||
initial_state=ui_state.params.get_bool("LongitudinalManeuverMode"),
|
||||
toggle_callback=self._on_long_maneuver_mode)
|
||||
self._lat_maneuver_toggle = BigToggle("lateral maneuver mode",
|
||||
initial_state=ui_state.params.get_bool("LateralManeuverMode"),
|
||||
toggle_callback=self._on_lat_maneuver_mode)
|
||||
self._alpha_long_toggle = BigToggle("alpha longitudinal",
|
||||
initial_state=ui_state.params.get_bool("AlphaLongitudinalEnabled"),
|
||||
toggle_callback=self._on_alpha_long_enabled)
|
||||
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)))
|
||||
|
||||
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,
|
||||
])
|
||||
|
||||
# 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),
|
||||
)
|
||||
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)
|
||||
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("ExperimentalMode", False, 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)
|
||||
@@ -0,0 +1,219 @@
|
||||
import os
|
||||
import pyray as rl
|
||||
from collections.abc import Callable
|
||||
|
||||
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.driver_camera_dialog import DriverCameraDialog
|
||||
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 HtmlModal, 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)):
|
||||
super().__init__(icon, red, icon_offset)
|
||||
self.set_click_callback(lambda: _engaged_confirmation_click(callback, title, icon, exit_on_confirm=exit_on_confirm, red=red))
|
||||
|
||||
|
||||
class EngagedConfirmationButton(BigButton):
|
||||
def __init__(self, text: str, action_text: str, icon: rl.Texture, callback: Callable[[], None],
|
||||
exit_on_confirm: bool = True, red: bool = False):
|
||||
super().__init__(text, "", icon)
|
||||
self.set_click_callback(lambda: _engaged_confirmation_click(callback, action_text, icon, exit_on_confirm=exit_on_confirm, red=red))
|
||||
|
||||
|
||||
class DeviceInfoLayoutMici(Widget):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
self.set_rect(rl.Rectangle(0, 0, 360, 180))
|
||||
|
||||
params = Params()
|
||||
subheader_color = rl.Color(255, 255, 255, int(255 * 0.9 * 0.65))
|
||||
max_width = int(self._rect.width - 20)
|
||||
self._dongle_id_label = UnifiedLabel("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: HtmlModal | 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("LiveParameters")
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
driver_cam_btn = BigButton("driver\ncamera preview", "", gui_app.texture("icons_mici/settings/device/cameras.png", 64, 64))
|
||||
driver_cam_btn.set_click_callback(lambda: gui_app.push_widget(DriverCameraDialog()))
|
||||
driver_cam_btn.set_enabled(lambda: ui_state.is_offroad())
|
||||
|
||||
review_training_guide_btn = BigButton("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,
|
||||
driver_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)
|
||||
@@ -0,0 +1,224 @@
|
||||
import requests
|
||||
import threading
|
||||
import time
|
||||
import pyray as rl
|
||||
|
||||
from openpilot.common.api import api_get
|
||||
from openpilot.common.params import Params
|
||||
from openpilot.common.realtime import drop_realtime
|
||||
from openpilot.common.swaglog import cloudlog
|
||||
from openpilot.selfdrive.ui.lib.api_helpers import get_token
|
||||
from openpilot.selfdrive.ui.ui_state import ui_state, device
|
||||
from openpilot.system.athena.registration import UNREGISTERED_DONGLE_ID
|
||||
from openpilot.system.ui.lib.application import gui_app, FontWeight, FONT_SCALE
|
||||
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(
|
||||
"sunnypilot learns to drive by watching humans, like you, drive.\n\n"
|
||||
+ "Firehose Mode allows you to maximize your training data uploads to improve "
|
||||
+ "openpilot's driving models. More data means bigger models, which means better Experimental Mode."
|
||||
)
|
||||
INSTRUCTIONS_INTRO = tr_noop(
|
||||
"For maximum effectiveness, bring your device inside and connect to a good USB-C adapter and Wi-Fi weekly.\n\n"
|
||||
+ "Firehose Mode can also work while you're driving if connected to a hotspot or unlimited SIM card."
|
||||
)
|
||||
FAQ_HEADER = tr_noop("Frequently Asked Questions")
|
||||
FAQ_ITEMS = [
|
||||
(tr_noop("Does it matter how or where I drive?"), tr_noop("Nope, just drive as you normally would.")),
|
||||
(tr_noop("Do all of my segments get pulled in Firehose Mode?"), tr_noop("No, we selectively pull a subset of your segments.")),
|
||||
(tr_noop("What's a good USB-C adapter?"), tr_noop("Any fast phone or laptop charger should be fine.")),
|
||||
(tr_noop("Does it matter which software I run?"), tr_noop("Yes, only upstream openpilot (and particular forks) are able to be used for training.")),
|
||||
]
|
||||
|
||||
|
||||
class FirehoseLayoutBase(Widget):
|
||||
PARAM_KEY = "ApiCache_FirehoseStats"
|
||||
GREEN = rl.Color(46, 204, 113, 255)
|
||||
RED = rl.Color(231, 76, 60, 255)
|
||||
GRAY = rl.Color(68, 68, 68, 255)
|
||||
LIGHT_GRAY = rl.Color(228, 228, 228, 255)
|
||||
UPDATE_INTERVAL = 30 # seconds
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._params = Params()
|
||||
self._session = requests.Session() # reuse session to reduce SSL handshake overhead
|
||||
self._segment_count = self._get_segment_count()
|
||||
|
||||
self._scroll_panel = GuiScrollPanel2(horizontal=False)
|
||||
self._content_height = 0
|
||||
|
||||
self._running = True
|
||||
self._update_thread = threading.Thread(target=self._update_loop, daemon=True)
|
||||
self._update_thread.start()
|
||||
|
||||
def __del__(self):
|
||||
self._running = False
|
||||
try:
|
||||
if self._update_thread and self._update_thread.is_alive():
|
||||
self._update_thread.join(timeout=1.0)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def show_event(self):
|
||||
super().show_event()
|
||||
self._scroll_panel.set_offset(0)
|
||||
|
||||
def _get_segment_count(self) -> int:
|
||||
stats = self._params.get(self.PARAM_KEY)
|
||||
if not stats:
|
||||
return 0
|
||||
try:
|
||||
return int(stats.get("firehose", 0))
|
||||
except Exception:
|
||||
cloudlog.exception(f"Failed to decode firehose stats: {stats}")
|
||||
return 0
|
||||
|
||||
def _render(self, rect: rl.Rectangle):
|
||||
# compute total content height for scrolling
|
||||
content_height = self._measure_content_height(rect)
|
||||
scroll_offset = self._scroll_panel.update(rect, content_height)
|
||||
|
||||
# start drawing with offset
|
||||
x = rect.x + 40
|
||||
y = rect.y + 40 + scroll_offset
|
||||
w = rect.width - 80
|
||||
|
||||
# Title
|
||||
title_text = tr(TITLE)
|
||||
title_font = gui_app.font(FontWeight.BOLD)
|
||||
title_size = 64
|
||||
rl.draw_text_ex(title_font, title_text, rl.Vector2(x, y), title_size, 0, rl.WHITE)
|
||||
y += int(title_size * FONT_SCALE) + 20
|
||||
|
||||
# Description
|
||||
y = self._draw_wrapped_text(x, y, w, tr(DESCRIPTION), gui_app.font(FontWeight.ROMAN), 36, rl.WHITE)
|
||||
y += 20
|
||||
|
||||
# Separator
|
||||
rl.draw_rectangle_rec(rl.Rectangle(x, y, w, 2), self.GRAY)
|
||||
y += 20
|
||||
|
||||
# Status
|
||||
status_text, status_color = self._get_status()
|
||||
y = self._draw_wrapped_text(x, y, w, status_text, gui_app.font(FontWeight.BOLD), 48, status_color)
|
||||
y += 20
|
||||
|
||||
# Contribution count (if available)
|
||||
if self._segment_count > 0:
|
||||
contrib_text = trn("{} segment of your driving is in the training dataset so far.",
|
||||
"{} segments of your driving is in the training dataset so far.", self._segment_count).format(self._segment_count)
|
||||
y = self._draw_wrapped_text(x, y, w, contrib_text, gui_app.font(FontWeight.BOLD), 42, rl.WHITE)
|
||||
y += 20
|
||||
|
||||
# Separator
|
||||
rl.draw_rectangle_rec(rl.Rectangle(x, y, w, 2), self.GRAY)
|
||||
y += 20
|
||||
|
||||
# Instructions intro
|
||||
y = self._draw_wrapped_text(x, y, w, tr(INSTRUCTIONS_INTRO), gui_app.font(FontWeight.ROMAN), 32, self.LIGHT_GRAY)
|
||||
y += 20
|
||||
|
||||
# FAQ Header
|
||||
y = self._draw_wrapped_text(x, y, w, tr(FAQ_HEADER), gui_app.font(FontWeight.BOLD), 44, rl.WHITE)
|
||||
y += 20
|
||||
|
||||
# FAQ Items
|
||||
for question, answer in FAQ_ITEMS:
|
||||
y = self._draw_wrapped_text(x, y, w, tr(question), gui_app.font(FontWeight.BOLD), 32, self.LIGHT_GRAY)
|
||||
y = self._draw_wrapped_text(x, y, w, tr(answer), gui_app.font(FontWeight.ROMAN), 32, self.LIGHT_GRAY)
|
||||
y += 20
|
||||
|
||||
def _draw_wrapped_text(self, x, y, width, text, font, font_size, color):
|
||||
wrapped = wrap_text(font, text, font_size, width)
|
||||
for line in wrapped:
|
||||
rl.draw_text_ex(font, line, rl.Vector2(x, y), font_size, 0, color)
|
||||
y += int(font_size * FONT_SCALE)
|
||||
return y
|
||||
|
||||
def _measure_content_height(self, rect: rl.Rectangle) -> int:
|
||||
# Rough measurement using the same wrapping as rendering
|
||||
w = int(rect.width - 80)
|
||||
y = 40
|
||||
|
||||
# Title
|
||||
title_size = 72
|
||||
y += int(title_size * FONT_SCALE) + 20
|
||||
|
||||
# Description
|
||||
desc_lines = wrap_text(gui_app.font(FontWeight.ROMAN), tr(DESCRIPTION), 36, w)
|
||||
y += int(len(desc_lines) * 36 * FONT_SCALE) + 20
|
||||
|
||||
# Separator + Status
|
||||
y += 2 + 20
|
||||
status_text, _ = self._get_status()
|
||||
status_lines = wrap_text(gui_app.font(FontWeight.BOLD), status_text, 48, w)
|
||||
y += int(len(status_lines) * 48 * FONT_SCALE) + 20
|
||||
|
||||
# Contribution count
|
||||
if self._segment_count > 0:
|
||||
contrib_text = trn("{} segment of your driving is in the training dataset so far.",
|
||||
"{} segments of your driving is in the training dataset so far.", self._segment_count).format(self._segment_count)
|
||||
contrib_lines = wrap_text(gui_app.font(FontWeight.BOLD), contrib_text, 42, w)
|
||||
y += int(len(contrib_lines) * 42 * FONT_SCALE) + 20
|
||||
|
||||
# Separator + Instructions
|
||||
y += 2 + 20
|
||||
|
||||
# Instructions intro
|
||||
intro_lines = wrap_text(gui_app.font(FontWeight.ROMAN), tr(INSTRUCTIONS_INTRO), 32, w)
|
||||
y += int(len(intro_lines) * 32 * FONT_SCALE) + 20
|
||||
|
||||
# FAQ Header
|
||||
faq_header_lines = wrap_text(gui_app.font(FontWeight.BOLD), tr(FAQ_HEADER), 44, w)
|
||||
y += int(len(faq_header_lines) * 44 * FONT_SCALE) + 20
|
||||
|
||||
# FAQ Items
|
||||
for question, answer in FAQ_ITEMS:
|
||||
q_lines = wrap_text(gui_app.font(FontWeight.BOLD), tr(question), 32, w)
|
||||
y += int(len(q_lines) * 32 * FONT_SCALE)
|
||||
a_lines = wrap_text(gui_app.font(FontWeight.ROMAN), tr(answer), 32, w)
|
||||
y += int(len(a_lines) * 32 * FONT_SCALE) + 20
|
||||
|
||||
# bottom padding
|
||||
y += 40
|
||||
return y
|
||||
|
||||
def _get_status(self) -> tuple[str, rl.Color]:
|
||||
network_type = ui_state.sm["deviceState"].networkType
|
||||
network_metered = ui_state.sm["deviceState"].networkMetered
|
||||
|
||||
if not network_metered and network_type != 0: # Not metered and connected
|
||||
return tr("ACTIVE"), self.GREEN
|
||||
else:
|
||||
return tr("INACTIVE: connect to an unmetered network"), self.RED
|
||||
|
||||
def _fetch_firehose_stats(self):
|
||||
try:
|
||||
dongle_id = self._params.get("DongleId")
|
||||
if not dongle_id or dongle_id == UNREGISTERED_DONGLE_ID:
|
||||
return
|
||||
identity_token = get_token(dongle_id)
|
||||
response = api_get(f"v1/devices/{dongle_id}/firehose_stats", access_token=identity_token, session=self._session)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
self._segment_count = data.get("firehose", 0)
|
||||
self._params.put(self.PARAM_KEY, data)
|
||||
except Exception as e:
|
||||
cloudlog.error(f"Failed to fetch firehose stats: {e}")
|
||||
|
||||
def _update_loop(self):
|
||||
drop_realtime()
|
||||
while self._running:
|
||||
if not ui_state.started and device._awake:
|
||||
self._fetch_firehose_stats()
|
||||
time.sleep(self.UPDATE_INTERVAL)
|
||||
|
||||
|
||||
class FirehoseLayout(NavRawScrollPanel, FirehoseLayoutBase):
|
||||
pass
|
||||
@@ -0,0 +1,60 @@
|
||||
import pyray as rl
|
||||
|
||||
from openpilot.selfdrive.ui.mici.layouts.settings.network.wifi_ui import WifiIcon
|
||||
from openpilot.selfdrive.ui.mici.widgets.button import BigButton
|
||||
from openpilot.system.ui.lib.application import gui_app
|
||||
from openpilot.system.ui.lib.wifi_manager import WifiManager, ConnectStatus, SecurityType, normalize_ssid
|
||||
|
||||
|
||||
class WifiNetworkButton(BigButton):
|
||||
def __init__(self, wifi_manager: WifiManager):
|
||||
self._wifi_manager = wifi_manager
|
||||
self._lock_txt = gui_app.texture("icons_mici/settings/network/new/lock.png", 28, 36)
|
||||
self._draw_lock = False
|
||||
|
||||
self._wifi_slash_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_slash.png", 64, 56)
|
||||
self._wifi_low_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_low.png", 64, 47)
|
||||
self._wifi_medium_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_medium.png", 64, 47)
|
||||
self._wifi_full_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_full.png", 64, 47)
|
||||
|
||||
super().__init__("wi-fi", "not connected", self._wifi_slash_txt, scroll=True)
|
||||
|
||||
def _update_state(self):
|
||||
super()._update_state()
|
||||
|
||||
# Update wi-fi button with ssid and ip address
|
||||
# TODO: make sure we handle hidden ssids
|
||||
wifi_state = self._wifi_manager.wifi_state
|
||||
display_network = next((n for n in self._wifi_manager.networks if n.ssid == wifi_state.ssid), None)
|
||||
if wifi_state.status == ConnectStatus.CONNECTING:
|
||||
self.set_text(normalize_ssid(wifi_state.ssid or "wi-fi"))
|
||||
self.set_value("starting" if self._wifi_manager.is_tethering_active() else "connecting...")
|
||||
elif wifi_state.status == ConnectStatus.CONNECTED:
|
||||
self.set_text(normalize_ssid(wifi_state.ssid or "wi-fi"))
|
||||
self.set_value(self._wifi_manager.ipv4_address or "obtaining IP...")
|
||||
else:
|
||||
display_network = None
|
||||
self.set_text("wi-fi")
|
||||
self.set_value("not connected")
|
||||
|
||||
if display_network is not None:
|
||||
strength = WifiIcon.get_strength_icon_idx(display_network.strength)
|
||||
self.set_icon(self._wifi_full_txt if strength == 2 else self._wifi_medium_txt if strength == 1 else self._wifi_low_txt)
|
||||
self._draw_lock = display_network.security_type not in (SecurityType.OPEN, SecurityType.UNSUPPORTED)
|
||||
elif self._wifi_manager.is_tethering_active():
|
||||
# takes a while to get Network
|
||||
self.set_icon(self._wifi_full_txt)
|
||||
self._draw_lock = True
|
||||
else:
|
||||
self.set_icon(self._wifi_slash_txt)
|
||||
self._draw_lock = False
|
||||
|
||||
def _draw_content(self, btn_y: float):
|
||||
super()._draw_content(btn_y)
|
||||
# Render lock icon at lower right of wifi icon if secured
|
||||
if self._draw_lock:
|
||||
icon_x = self._rect.x + self._rect.width - 30 - self._txt_icon.width
|
||||
icon_y = btn_y + 30
|
||||
lock_x = icon_x + self._txt_icon.width - self._lock_txt.width + 7
|
||||
lock_y = icon_y + self._txt_icon.height - self._lock_txt.height + 8
|
||||
rl.draw_texture_ex(self._lock_txt, (lock_x, lock_y), 0.0, 1.0, rl.WHITE)
|
||||
@@ -0,0 +1,141 @@
|
||||
from openpilot.system.ui.widgets.scroller import NavScroller
|
||||
from openpilot.selfdrive.ui.mici.layouts.settings.network import WifiNetworkButton
|
||||
from openpilot.selfdrive.ui.mici.layouts.settings.network.wifi_ui import WifiUIMici
|
||||
from openpilot.selfdrive.ui.mici.widgets.button import BigButton, BigMultiToggle, BigParamControl, BigToggle
|
||||
from openpilot.selfdrive.ui.mici.widgets.dialog import BigInputDialog
|
||||
from openpilot.selfdrive.ui.ui_state import ui_state
|
||||
from openpilot.selfdrive.ui.lib.prime_state import PrimeType
|
||||
from openpilot.system.ui.lib.application import gui_app
|
||||
from openpilot.system.ui.lib.wifi_manager import WifiManager, Network, MeteredType
|
||||
|
||||
|
||||
class NetworkLayoutMici(NavScroller):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
self._wifi_manager = WifiManager()
|
||||
self._wifi_manager.set_active(False)
|
||||
self._wifi_ui = WifiUIMici(self._wifi_manager)
|
||||
|
||||
self._wifi_manager.add_callbacks(
|
||||
networks_updated=self._on_network_updated,
|
||||
)
|
||||
|
||||
# ******** Tethering ********
|
||||
def tethering_toggle_callback(checked: bool):
|
||||
self._tethering_toggle_btn.set_enabled(False)
|
||||
self._tethering_password_btn.set_enabled(False)
|
||||
self._network_metered_btn.set_enabled(False)
|
||||
self._wifi_manager.set_tethering_active(checked)
|
||||
|
||||
self._tethering_toggle_btn = BigToggle("enable tethering", "", toggle_callback=tethering_toggle_callback)
|
||||
|
||||
def tethering_password_callback(password: str):
|
||||
if password:
|
||||
self._tethering_toggle_btn.set_enabled(False)
|
||||
self._tethering_password_btn.set_enabled(False)
|
||||
self._wifi_manager.set_tethering_password(password)
|
||||
|
||||
def tethering_password_clicked():
|
||||
tethering_password = self._wifi_manager.tethering_password
|
||||
dlg = BigInputDialog("enter password...", tethering_password, minimum_length=8,
|
||||
confirm_callback=tethering_password_callback)
|
||||
gui_app.push_widget(dlg)
|
||||
|
||||
txt_tethering = gui_app.texture("icons_mici/settings/network/tethering.png", 64, 54)
|
||||
self._tethering_password_btn = BigButton("tethering password", "", txt_tethering)
|
||||
self._tethering_password_btn.set_click_callback(tethering_password_clicked)
|
||||
|
||||
# ******** Network Metered ********
|
||||
def network_metered_callback(value: str):
|
||||
self._network_metered_btn.set_enabled(False)
|
||||
metered = {
|
||||
'default': MeteredType.UNKNOWN,
|
||||
'metered': MeteredType.YES,
|
||||
'unmetered': MeteredType.NO
|
||||
}.get(value, MeteredType.UNKNOWN)
|
||||
self._wifi_manager.set_current_network_metered(metered)
|
||||
|
||||
# TODO: signal for current network metered type when changing networks, this is wrong until you press it once
|
||||
# TODO: disable when not connected
|
||||
self._network_metered_btn = BigMultiToggle("network usage", ["default", "metered", "unmetered"], select_callback=network_metered_callback)
|
||||
self._network_metered_btn.set_enabled(False)
|
||||
|
||||
self._wifi_button = WifiNetworkButton(self._wifi_manager)
|
||||
self._wifi_button.set_click_callback(lambda: gui_app.push_widget(self._wifi_ui))
|
||||
|
||||
# ******** Advanced settings ********
|
||||
# ******** Roaming toggle ********
|
||||
self._roaming_btn = BigParamControl("enable roaming", "GsmRoaming")
|
||||
|
||||
# ******** APN settings ********
|
||||
self._apn_btn = BigButton("apn settings", "edit")
|
||||
self._apn_btn.set_click_callback(self._edit_apn)
|
||||
|
||||
# ******** Cellular metered toggle ********
|
||||
self._cellular_metered_btn = BigParamControl("cellular metered", "GsmMetered")
|
||||
|
||||
# Main scroller ----------------------------------
|
||||
self._scroller.add_widgets([
|
||||
self._wifi_button,
|
||||
self._network_metered_btn,
|
||||
self._tethering_toggle_btn,
|
||||
self._tethering_password_btn,
|
||||
# /* Advanced settings
|
||||
self._roaming_btn,
|
||||
self._apn_btn,
|
||||
self._cellular_metered_btn,
|
||||
# */
|
||||
])
|
||||
|
||||
def _update_state(self):
|
||||
super()._update_state()
|
||||
|
||||
# If not using prime SIM, show GSM settings and enable IPv4 forwarding
|
||||
show_cell_settings = ui_state.prime_state.get_type() in (PrimeType.NONE, PrimeType.LITE)
|
||||
self._wifi_manager.set_ipv4_forward(show_cell_settings)
|
||||
self._roaming_btn.set_visible(show_cell_settings)
|
||||
self._apn_btn.set_visible(show_cell_settings)
|
||||
self._cellular_metered_btn.set_visible(show_cell_settings)
|
||||
|
||||
def show_event(self):
|
||||
super().show_event()
|
||||
self._wifi_manager.set_active(True)
|
||||
|
||||
# Process wifi callbacks while at any point in the nav stack
|
||||
gui_app.add_nav_stack_tick(self._wifi_manager.process_callbacks)
|
||||
|
||||
def hide_event(self):
|
||||
super().hide_event()
|
||||
self._wifi_manager.set_active(False)
|
||||
|
||||
gui_app.remove_nav_stack_tick(self._wifi_manager.process_callbacks)
|
||||
|
||||
def _edit_apn(self):
|
||||
def update_apn(apn: str):
|
||||
apn = apn.strip()
|
||||
if apn == "":
|
||||
ui_state.params.remove("GsmApn")
|
||||
else:
|
||||
ui_state.params.put("GsmApn", apn)
|
||||
|
||||
current_apn = ui_state.params.get("GsmApn") or ""
|
||||
dlg = BigInputDialog("enter APN...", current_apn, minimum_length=0, confirm_callback=update_apn)
|
||||
gui_app.push_widget(dlg)
|
||||
|
||||
def _on_network_updated(self, networks: list[Network]):
|
||||
# Update tethering state
|
||||
tethering_active = self._wifi_manager.is_tethering_active()
|
||||
# TODO: use real signals (like activated/settings changed, etc.) to speed up re-enabling buttons
|
||||
self._tethering_toggle_btn.set_enabled(True)
|
||||
self._tethering_password_btn.set_enabled(True)
|
||||
self._network_metered_btn.set_enabled(lambda: not tethering_active and bool(self._wifi_manager.ipv4_address))
|
||||
self._tethering_toggle_btn.set_checked(tethering_active)
|
||||
|
||||
# Update network metered
|
||||
self._network_metered_btn.set_value(
|
||||
{
|
||||
MeteredType.UNKNOWN: 'default',
|
||||
MeteredType.YES: 'metered',
|
||||
MeteredType.NO: 'unmetered'
|
||||
}.get(self._wifi_manager.current_network_metered, 'default'))
|
||||
@@ -0,0 +1,391 @@
|
||||
import math
|
||||
import numpy as np
|
||||
import pyray as rl
|
||||
from collections.abc import Callable
|
||||
|
||||
from openpilot.common.swaglog import cloudlog
|
||||
from openpilot.selfdrive.ui.mici.widgets.dialog import BigInputDialog, BigConfirmationDialog
|
||||
from openpilot.selfdrive.ui.mici.widgets.button import BigButton, LABEL_COLOR
|
||||
from openpilot.system.ui.lib.application import gui_app, MousePos, FontWeight
|
||||
from openpilot.system.ui.widgets import Widget
|
||||
from openpilot.system.ui.widgets.scroller import NavScroller
|
||||
from openpilot.system.ui.lib.wifi_manager import WifiManager, Network, SecurityType, normalize_ssid
|
||||
|
||||
|
||||
class LoadingAnimation(Widget):
|
||||
RADIUS = 8
|
||||
SPACING = 24 # center-to-center: diameter (16) + gap (8)
|
||||
Y_MAG = 11.2
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
w = self.SPACING * 2 + self.RADIUS * 2
|
||||
h = self.RADIUS * 2 + int(self.Y_MAG)
|
||||
self.set_rect(rl.Rectangle(0, 0, w, h))
|
||||
|
||||
def _render(self, _):
|
||||
# Balls rest at bottom center; bounce upward
|
||||
base_x = int(self._rect.x + self._rect.width / 2)
|
||||
base_y = int(self._rect.y + self._rect.height - self.RADIUS)
|
||||
|
||||
for i in range(3):
|
||||
x = base_x + (i - 1) * self.SPACING
|
||||
y = int(base_y + min(math.sin((rl.get_time() - i * 0.2) * 4) * self.Y_MAG, 0))
|
||||
alpha = int(np.interp(base_y - y, [0, self.Y_MAG], [255 * 0.45, 255 * 0.9]))
|
||||
rl.draw_circle(x, y, self.RADIUS, rl.Color(255, 255, 255, alpha))
|
||||
|
||||
|
||||
class WifiIcon(Widget):
|
||||
def __init__(self, network: Network):
|
||||
super().__init__()
|
||||
self.set_rect(rl.Rectangle(0, 0, 48 + 5, 36 + 5))
|
||||
|
||||
self._wifi_slash_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_slash.png", 48, 42)
|
||||
self._wifi_low_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_low.png", 48, 36)
|
||||
self._wifi_medium_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_medium.png", 48, 36)
|
||||
self._wifi_full_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_full.png", 48, 36)
|
||||
self._lock_txt = gui_app.texture("icons_mici/settings/network/new/lock.png", 21, 27)
|
||||
|
||||
self._network: Network = network
|
||||
self._network_missing = False # if network disappeared from scan results
|
||||
|
||||
def update_network(self, network: Network):
|
||||
self._network = network
|
||||
|
||||
def set_network_missing(self, missing: bool):
|
||||
self._network_missing = missing
|
||||
|
||||
@staticmethod
|
||||
def get_strength_icon_idx(strength: int) -> int:
|
||||
return round(strength / 100 * 2)
|
||||
|
||||
def _render(self, _):
|
||||
# Determine which wifi strength icon to use
|
||||
strength = self.get_strength_icon_idx(self._network.strength)
|
||||
if self._network_missing:
|
||||
strength_icon = self._wifi_slash_txt
|
||||
elif strength == 2:
|
||||
strength_icon = self._wifi_full_txt
|
||||
elif strength == 1:
|
||||
strength_icon = self._wifi_medium_txt
|
||||
else:
|
||||
strength_icon = self._wifi_low_txt
|
||||
|
||||
rl.draw_texture_ex(strength_icon, (self._rect.x, self._rect.y + self._rect.height - strength_icon.height), 0.0, 1.0, rl.WHITE)
|
||||
|
||||
# Render lock icon at lower right of wifi icon if secured
|
||||
if self._network.security_type not in (SecurityType.OPEN, SecurityType.UNSUPPORTED):
|
||||
lock_x = self._rect.x + self._rect.width - self._lock_txt.width
|
||||
lock_y = self._rect.y + self._rect.height - self._lock_txt.height + 6
|
||||
rl.draw_texture_ex(self._lock_txt, (lock_x, lock_y), 0.0, 1.0, rl.WHITE)
|
||||
|
||||
|
||||
class WifiButton(BigButton):
|
||||
LABEL_PADDING = 98
|
||||
LABEL_WIDTH = 402 - 98 - 28 # button width - left padding - right padding
|
||||
SUB_LABEL_WIDTH = 402 - BigButton.LABEL_HORIZONTAL_PADDING * 2
|
||||
|
||||
def __init__(self, network: Network, wifi_manager: WifiManager):
|
||||
super().__init__(normalize_ssid(network.ssid), scroll=True)
|
||||
|
||||
self._network = network
|
||||
self._wifi_manager = wifi_manager
|
||||
|
||||
self._wifi_icon = WifiIcon(network)
|
||||
self._forget_btn = ForgetButton(self._forget_network)
|
||||
self._check_txt = gui_app.texture("icons_mici/setup/driver_monitoring/dm_check.png", 32, 32)
|
||||
|
||||
# Eager state (not sourced from Network)
|
||||
self._network_missing = False
|
||||
self._network_forgetting = False
|
||||
self._wrong_password = False
|
||||
|
||||
def update_network(self, network: Network):
|
||||
self._network = network
|
||||
self._wifi_icon.update_network(network)
|
||||
|
||||
# We can assume network is not missing if got new Network
|
||||
self._network_missing = False
|
||||
self._wifi_icon.set_network_missing(False)
|
||||
if self._is_connected or self._is_connecting:
|
||||
self._wrong_password = False
|
||||
|
||||
@property
|
||||
def network_forgetting(self) -> bool:
|
||||
return self._network_forgetting
|
||||
|
||||
def _forget_network(self):
|
||||
if self._network_forgetting:
|
||||
return
|
||||
|
||||
self._network_forgetting = True
|
||||
self._wifi_manager.forget_connection(self._network.ssid)
|
||||
|
||||
def on_forgotten(self):
|
||||
self._network_forgetting = False
|
||||
|
||||
def set_network_missing(self, missing: bool):
|
||||
self._network_missing = missing
|
||||
self._wifi_icon.set_network_missing(missing)
|
||||
|
||||
def set_wrong_password(self):
|
||||
self._wrong_password = True
|
||||
self.trigger_shake()
|
||||
|
||||
@property
|
||||
def network(self) -> Network:
|
||||
return self._network
|
||||
|
||||
@property
|
||||
def _show_forget_btn(self):
|
||||
if self._network.is_tethering or self._network_forgetting:
|
||||
return False
|
||||
|
||||
return (self._is_saved and not self._wrong_password) or self._is_connecting
|
||||
|
||||
def _handle_mouse_release(self, mouse_pos: MousePos):
|
||||
if self._show_forget_btn and rl.check_collision_point_rec(mouse_pos, self._forget_btn.rect):
|
||||
return
|
||||
super()._handle_mouse_release(mouse_pos)
|
||||
|
||||
def _get_label_font_size(self):
|
||||
return 48
|
||||
|
||||
def _draw_content(self, btn_y: float):
|
||||
self._label.set_color(LABEL_COLOR)
|
||||
label_rect = rl.Rectangle(self._rect.x + self.LABEL_PADDING, btn_y + self.LABEL_VERTICAL_PADDING,
|
||||
self.LABEL_WIDTH, self._rect.height - self.LABEL_VERTICAL_PADDING * 2)
|
||||
self._label.render(label_rect)
|
||||
|
||||
if self.value:
|
||||
sub_label_x = self._rect.x + self.LABEL_HORIZONTAL_PADDING
|
||||
label_y = btn_y + self._rect.height - self.LABEL_VERTICAL_PADDING
|
||||
sub_label_w = self.SUB_LABEL_WIDTH - (self._forget_btn.rect.width if self._show_forget_btn else 0)
|
||||
sub_label_height = self._sub_label.get_content_height(sub_label_w)
|
||||
|
||||
if self._is_connected and not self._network_forgetting:
|
||||
check_y = int(label_y - sub_label_height + (sub_label_height - self._check_txt.height) / 2)
|
||||
rl.draw_texture_ex(self._check_txt, rl.Vector2(sub_label_x, check_y), 0.0, 1.0, rl.Color(255, 255, 255, int(255 * 0.9 * 0.65)))
|
||||
sub_label_x += self._check_txt.width + 14
|
||||
|
||||
sub_label_rect = rl.Rectangle(sub_label_x, label_y - sub_label_height, sub_label_w, sub_label_height)
|
||||
self._sub_label.render(sub_label_rect)
|
||||
|
||||
# Wifi icon
|
||||
self._wifi_icon.render(rl.Rectangle(
|
||||
self._rect.x + 30,
|
||||
btn_y + 30,
|
||||
self._wifi_icon.rect.width,
|
||||
self._wifi_icon.rect.height,
|
||||
))
|
||||
|
||||
# Forget button
|
||||
if self._show_forget_btn:
|
||||
self._forget_btn.render(rl.Rectangle(
|
||||
self._rect.x + self._rect.width - self._forget_btn.rect.width,
|
||||
btn_y + self._rect.height - self._forget_btn.rect.height,
|
||||
self._forget_btn.rect.width,
|
||||
self._forget_btn.rect.height,
|
||||
))
|
||||
|
||||
def set_touch_valid_callback(self, touch_callback: Callable[[], bool]) -> None:
|
||||
super().set_touch_valid_callback(lambda: touch_callback() and not self._forget_btn.is_pressed)
|
||||
self._forget_btn.set_touch_valid_callback(touch_callback)
|
||||
|
||||
@property
|
||||
def _is_saved(self):
|
||||
return self._wifi_manager.is_connection_saved(self._network.ssid)
|
||||
|
||||
@property
|
||||
def _is_connecting(self):
|
||||
return self._wifi_manager.connecting_to_ssid == self._network.ssid
|
||||
|
||||
@property
|
||||
def _is_connected(self):
|
||||
return self._wifi_manager.connected_ssid == self._network.ssid
|
||||
|
||||
def _update_state(self):
|
||||
super()._update_state()
|
||||
|
||||
if any((self._network_missing, self._is_connecting, self._is_connected, self._network_forgetting,
|
||||
self._network.security_type == SecurityType.UNSUPPORTED)):
|
||||
self.set_enabled(False)
|
||||
self._sub_label.set_color(rl.Color(255, 255, 255, int(255 * 0.585)))
|
||||
self._sub_label.set_font_weight(FontWeight.ROMAN)
|
||||
|
||||
if self._network_forgetting:
|
||||
self.set_value("forgetting...")
|
||||
elif self._is_connecting:
|
||||
self.set_value("starting..." if self._network.is_tethering else "connecting...")
|
||||
elif self._is_connected:
|
||||
self.set_value("tethering" if self._network.is_tethering else "connected")
|
||||
elif self._network_missing:
|
||||
# after connecting/connected since NM will still attempt to connect/stay connected for a while
|
||||
self.set_value("not in range")
|
||||
else:
|
||||
self.set_value("unsupported")
|
||||
|
||||
else: # saved, wrong password, or unknown
|
||||
self.set_value("wrong password" if self._wrong_password else "connect")
|
||||
self.set_enabled(True)
|
||||
self._sub_label.set_color(rl.Color(255, 255, 255, int(255 * 0.9)))
|
||||
self._sub_label.set_font_weight(FontWeight.SEMI_BOLD)
|
||||
|
||||
|
||||
class ForgetButton(Widget):
|
||||
MARGIN = 12 # bottom and right
|
||||
|
||||
def __init__(self, forget_network: Callable):
|
||||
super().__init__()
|
||||
self._forget_network = forget_network
|
||||
|
||||
self._bg_txt = gui_app.texture("icons_mici/settings/network/new/forget_button.png", 84, 84)
|
||||
self._bg_pressed_txt = gui_app.texture("icons_mici/settings/network/new/forget_button_pressed.png", 84, 84)
|
||||
self._trash_txt = gui_app.texture("icons_mici/settings/network/new/trash.png", 29, 35)
|
||||
self.set_rect(rl.Rectangle(0, 0, 84 + self.MARGIN * 2, 84 + self.MARGIN * 2))
|
||||
|
||||
def _handle_mouse_release(self, mouse_pos: MousePos):
|
||||
super()._handle_mouse_release(mouse_pos)
|
||||
dlg = BigConfirmationDialog("slide to forget", gui_app.texture("icons_mici/settings/network/new/trash.png", 54, 64), self._forget_network, red=True)
|
||||
gui_app.push_widget(dlg)
|
||||
|
||||
def _render(self, _):
|
||||
bg_txt = self._bg_pressed_txt if self.is_pressed else self._bg_txt
|
||||
rl.draw_texture_ex(bg_txt, (self._rect.x + (self._rect.width - self._bg_txt.width) / 2,
|
||||
self._rect.y + (self._rect.height - self._bg_txt.height) / 2), 0, 1.0, rl.WHITE)
|
||||
|
||||
trash_x = self._rect.x + (self._rect.width - self._trash_txt.width) / 2
|
||||
trash_y = self._rect.y + (self._rect.height - self._trash_txt.height) / 2
|
||||
rl.draw_texture_ex(self._trash_txt, (trash_x, trash_y), 0, 1.0, rl.WHITE)
|
||||
|
||||
|
||||
class ScanningButton(BigButton):
|
||||
def __init__(self):
|
||||
super().__init__("", "searching for networks")
|
||||
self.set_enabled(False)
|
||||
self._loading_animation = LoadingAnimation()
|
||||
|
||||
def _draw_content(self, btn_y: float):
|
||||
super()._draw_content(btn_y)
|
||||
anim = self._loading_animation
|
||||
x = self._rect.x + self._rect.width - anim.rect.width - 40
|
||||
y = btn_y + self._rect.height - anim.rect.height - 30
|
||||
anim.set_position(x, y)
|
||||
anim.render()
|
||||
|
||||
|
||||
class WifiUIMici(NavScroller):
|
||||
def __init__(self, wifi_manager: WifiManager):
|
||||
super().__init__()
|
||||
|
||||
self._scanning_btn = ScanningButton()
|
||||
|
||||
self._wifi_manager = wifi_manager
|
||||
self._networks: dict[str, Network] = {}
|
||||
|
||||
self._wifi_manager.add_callbacks(
|
||||
need_auth=self._on_need_auth,
|
||||
forgotten=self._on_forgotten,
|
||||
networks_updated=self._on_network_updated,
|
||||
)
|
||||
|
||||
@property
|
||||
def any_network_forgetting(self) -> bool:
|
||||
# TODO: deactivate before forget and add DISCONNECTING state
|
||||
return any(btn.network_forgetting for btn in self._scroller.items if isinstance(btn, WifiButton))
|
||||
|
||||
def show_event(self):
|
||||
# Re-sort scroller items and update from latest scan results
|
||||
super().show_event()
|
||||
self._wifi_manager.set_active(True)
|
||||
self._networks = {n.ssid: n for n in self._wifi_manager.networks}
|
||||
self._update_buttons(re_sort=True)
|
||||
|
||||
def _on_network_updated(self, networks: list[Network]):
|
||||
self._networks = {network.ssid: network for network in networks}
|
||||
self._update_buttons()
|
||||
|
||||
def _update_buttons(self, re_sort: bool = False):
|
||||
# Update existing buttons, add new ones to the end
|
||||
existing = {btn.network.ssid: btn for btn in self._scroller.items if isinstance(btn, WifiButton)}
|
||||
|
||||
for network in self._networks.values():
|
||||
if network.ssid in existing:
|
||||
existing[network.ssid].update_network(network)
|
||||
else:
|
||||
btn = WifiButton(network, self._wifi_manager)
|
||||
btn.set_click_callback(lambda ssid=network.ssid: self._connect_to_network(ssid))
|
||||
self._scroller.add_widget(btn)
|
||||
|
||||
if re_sort:
|
||||
# Remove stale buttons and sort to match scan order, preserving eager state
|
||||
btn_map = {btn.network.ssid: btn for btn in self._scroller.items if isinstance(btn, WifiButton)}
|
||||
self._scroller.items[:] = [btn_map[ssid] for ssid in self._networks if ssid in btn_map]
|
||||
else:
|
||||
# Mark networks no longer in scan results (display handled by _update_state)
|
||||
for btn in self._scroller.items:
|
||||
if isinstance(btn, WifiButton) and btn.network.ssid not in self._networks:
|
||||
btn.set_network_missing(True)
|
||||
|
||||
# Keep scanning button at the end
|
||||
items = self._scroller.items
|
||||
if self._scanning_btn in items:
|
||||
items.append(items.pop(items.index(self._scanning_btn)))
|
||||
else:
|
||||
self._scroller.add_widget(self._scanning_btn)
|
||||
|
||||
def _connect_with_password(self, ssid: str, password: str):
|
||||
self._wifi_manager.connect_to_network(ssid, password)
|
||||
self._move_network_to_front(ssid, scroll=True)
|
||||
|
||||
def _connect_to_network(self, ssid: str):
|
||||
network = self._networks.get(ssid)
|
||||
if network is None:
|
||||
cloudlog.warning(f"Trying to connect to unknown network: {ssid}")
|
||||
return
|
||||
|
||||
if self._wifi_manager.is_connection_saved(network.ssid):
|
||||
self._wifi_manager.activate_connection(network.ssid)
|
||||
elif network.security_type == SecurityType.OPEN:
|
||||
self._wifi_manager.connect_to_network(network.ssid, "")
|
||||
else:
|
||||
self._on_need_auth(network.ssid, False)
|
||||
return
|
||||
|
||||
self._move_network_to_front(ssid, scroll=True)
|
||||
|
||||
def _on_need_auth(self, ssid, incorrect_password=True):
|
||||
if incorrect_password:
|
||||
for btn in self._scroller.items:
|
||||
if isinstance(btn, WifiButton) and btn.network.ssid == ssid:
|
||||
btn.set_wrong_password()
|
||||
break
|
||||
return
|
||||
|
||||
dlg = BigInputDialog("enter password...", "", minimum_length=8,
|
||||
confirm_callback=lambda _password: self._connect_with_password(ssid, _password))
|
||||
gui_app.push_widget(dlg)
|
||||
|
||||
def _on_forgotten(self, ssid):
|
||||
# For eager UI forget
|
||||
for btn in self._scroller.items:
|
||||
if isinstance(btn, WifiButton) and btn.network.ssid == ssid:
|
||||
btn.on_forgotten()
|
||||
|
||||
def _move_network_to_front(self, ssid: str | None, scroll: bool = False):
|
||||
# Move connecting/connected network to the front with animation
|
||||
front_btn_idx = next((i for i, btn in enumerate(self._scroller.items)
|
||||
if isinstance(btn, WifiButton) and
|
||||
btn.network.ssid == ssid), None) if ssid else None
|
||||
|
||||
if front_btn_idx is not None and front_btn_idx > 0:
|
||||
self._scroller.move_item(front_btn_idx, 0)
|
||||
|
||||
if scroll:
|
||||
# Scroll to the new position of the network
|
||||
self._scroller.scroll_to(self._scroller.scroll_panel.get_offset(), smooth=True, block_widget_interaction=True)
|
||||
|
||||
def _update_state(self):
|
||||
super()._update_state()
|
||||
|
||||
self._move_network_to_front(self._wifi_manager.wifi_state.ssid)
|
||||
@@ -0,0 +1,58 @@
|
||||
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, PairBigButton
|
||||
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,
|
||||
PairBigButton(),
|
||||
#BigDialogButton("manual", "", "icons_mici/settings/manual_icon.png", "Check out the mici user\nmanual at comma.ai/setup"),
|
||||
firehose_btn,
|
||||
developer_btn,
|
||||
])
|
||||
|
||||
self._font_medium = gui_app.font(FontWeight.MEDIUM)
|
||||
@@ -0,0 +1,274 @@
|
||||
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)
|
||||
|
||||
def _update_state(self):
|
||||
desc = _split_description(ui_state.params.get("UpdaterCurrentDescription") or "")
|
||||
if desc is not None:
|
||||
version, branch, commit, date = desc
|
||||
self._version_text_label.set_text(f"{version} ({date})")
|
||||
self._branch_text_label.set_text(f"{branch} ({commit})")
|
||||
else:
|
||||
self._version_text_label.set_text(ui_state.params.get("Version") or "N/A")
|
||||
self._branch_text_label.set_text(ui_state.params.get("GitBranch") or "N/A")
|
||||
|
||||
def _render(self, _):
|
||||
self._version_label.set_position(self._rect.x + 20, self._rect.y - 10)
|
||||
self._version_label.render()
|
||||
|
||||
self._version_text_label.set_position(self._rect.x + 20, self._rect.y + 68 - 25)
|
||||
self._version_text_label.render()
|
||||
|
||||
self._branch_label.set_position(self._rect.x + 20, self._rect.y + 114 - 30)
|
||||
self._branch_label.render()
|
||||
|
||||
self._branch_text_label.set_position(self._rect.x + 20, self._rect.y + 161 - 25)
|
||||
self._branch_text_label.render()
|
||||
|
||||
|
||||
class CheckUpdateButton(BigButton):
|
||||
def __init__(self):
|
||||
self._txt_update_icon = gui_app.texture("icons_mici/settings/device/update.png", 64, 75)
|
||||
self._txt_up_to_date_icon = gui_app.texture("icons_mici/settings/device/up_to_date.png", 64, 64)
|
||||
super().__init__("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.set_enabled(False)
|
||||
self._state = UpdaterState.WAITING_FOR_UPDATER
|
||||
self.set_icon(self._txt_update_icon)
|
||||
|
||||
def run():
|
||||
if self.get_value() == "download update":
|
||||
subprocess.run("pkill -SIGHUP -f openpilot.system.updated.updated", shell=True)
|
||||
else:
|
||||
subprocess.run("pkill -SIGUSR1 -f openpilot.system.updated.updated", 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 update", "", 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):
|
||||
super().__init__("target branch", ui_state.params.get("UpdaterTargetBranch") or "")
|
||||
self.set_click_callback(self._on_click)
|
||||
self.set_visible(not ui_state.params.get_bool("IsTestedBranch"))
|
||||
self.set_enabled(lambda: ui_state.is_offroad())
|
||||
|
||||
def _update_state(self):
|
||||
super()._update_state()
|
||||
|
||||
target = ui_state.params.get("UpdaterTargetBranch") or ""
|
||||
if self.get_value() != target:
|
||||
self.set_value(target)
|
||||
|
||||
def _on_click(self):
|
||||
gui_app.push_widget(BranchSelectPage(self._on_select))
|
||||
|
||||
def _on_select(self, branch: str):
|
||||
ui_state.params.put("UpdaterTargetBranch", branch, block=True)
|
||||
self.set_value(branch)
|
||||
subprocess.run("pkill -SIGUSR1 -f openpilot.system.updated.updated", shell=True)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
self._scroller.add_widgets([
|
||||
SoftwareInfoLayoutMici(),
|
||||
CheckUpdateButton(),
|
||||
InstallUpdateButton(),
|
||||
TargetBranchButton(),
|
||||
uninstall_openpilot_btn,
|
||||
])
|
||||
@@ -0,0 +1,87 @@
|
||||
from openpilot.cereal import log
|
||||
|
||||
from openpilot.system.ui.widgets.scroller import NavScroller
|
||||
from openpilot.selfdrive.ui.mici.widgets.button import 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
|
||||
|
||||
PERSONALITY_TO_INT = log.LongitudinalPersonality.schema.enumerants
|
||||
|
||||
|
||||
class TogglesLayoutMici(NavScroller):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
self._personality_toggle = BigMultiParamToggle("driving personality", "LongitudinalPersonality", ["aggressive", "standard", "relaxed"])
|
||||
self._experimental_btn = BigParamControl("experimental mode", "ExperimentalMode")
|
||||
is_metric_toggle = BigParamControl("use metric units", "IsMetric")
|
||||
ldw_toggle = BigParamControl("lane departure warnings", "IsLdwEnabled")
|
||||
always_on_dm_toggle = BigParamControl("always-on driver monitor", "AlwaysOnDM")
|
||||
record_front = BigParamControl("record & upload driver camera", "RecordFront", toggle_callback=restart_needed_callback)
|
||||
record_mic = BigParamControl("record & upload mic audio", "RecordAudio", toggle_callback=restart_needed_callback)
|
||||
enable_openpilot = BigParamControl("enable sunnypilot", "OpenpilotEnabledToggle", toggle_callback=restart_needed_callback)
|
||||
|
||||
self._scroller.add_widgets([
|
||||
self._personality_toggle,
|
||||
self._experimental_btn,
|
||||
is_metric_toggle,
|
||||
ldw_toggle,
|
||||
always_on_dm_toggle,
|
||||
record_front,
|
||||
record_mic,
|
||||
enable_openpilot,
|
||||
])
|
||||
|
||||
# Toggle lists
|
||||
self._refresh_toggles = (
|
||||
("ExperimentalMode", self._experimental_btn),
|
||||
("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 _update_toggles(self):
|
||||
ui_state.update_params()
|
||||
|
||||
# CP gating for experimental mode
|
||||
if ui_state.CP is not None:
|
||||
if ui_state.has_longitudinal_control:
|
||||
self._experimental_btn.set_visible(True)
|
||||
self._personality_toggle.set_visible(True)
|
||||
else:
|
||||
# no long for now
|
||||
self._experimental_btn.set_visible(False)
|
||||
self._experimental_btn.set_checked(False)
|
||||
self._personality_toggle.set_visible(False)
|
||||
ui_state.params.remove("ExperimentalMode")
|
||||
|
||||
# Refresh toggles from params to mirror external changes
|
||||
for key, item in self._refresh_toggles:
|
||||
item.set_checked(ui_state.params.get_bool(key))
|
||||
@@ -0,0 +1,12 @@
|
||||
import pyray as rl
|
||||
|
||||
SIDE_PANEL_WIDTH = 60
|
||||
|
||||
|
||||
def blend_colors(a: rl.Color, b: rl.Color, f: float) -> rl.Color:
|
||||
h0, s0, v0 = (hsv0 := rl.color_to_hsv(a)).x, hsv0.y, hsv0.z
|
||||
h1, s1, v1 = (hsv1 := rl.color_to_hsv(b)).x, hsv1.y, hsv1.z
|
||||
dh = ((h1 - h0 + 180) % 360) - 180 # shortest hue delta
|
||||
return rl.color_from_hsv((h0 + f * dh) % 360,
|
||||
s0 + f * (s1 - s0),
|
||||
v0 + f * (v1 - v0))
|
||||
@@ -0,0 +1,372 @@
|
||||
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 TICI
|
||||
from openpilot.system.ui.lib.application import gui_app, FontWeight
|
||||
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 > 5:
|
||||
return ALERT_STARTUP_PENDING
|
||||
|
||||
# 2. Lost communication with selfdriveState after receiving it
|
||||
if TICI and not waiting_for_startup:
|
||||
ss_missing = time.monotonic() - sm.recv_time['selfdriveState']
|
||||
if ss_missing > SELFDRIVE_STATE_TIMEOUT:
|
||||
if ss.enabled and (ss_missing - SELFDRIVE_STATE_TIMEOUT) < SELFDRIVE_UNRESPONSIVE_TIMEOUT:
|
||||
return ALERT_CRITICAL_TIMEOUT
|
||||
return ALERT_CRITICAL_REBOOT
|
||||
|
||||
# No alert if size is none
|
||||
if ss.alertSize == 0:
|
||||
return None
|
||||
|
||||
# Return current alert
|
||||
ret = Alert(text1=ss.alertText1, text2=ss.alertText2, size=ss.alertSize.raw, status=ss.alertStatus.raw,
|
||||
visual_alert=ss.alertHudVisual, alert_type=ss.alertType)
|
||||
self._prev_alert = ret
|
||||
return ret
|
||||
|
||||
def will_render(self) -> tuple[Alert | None, bool]:
|
||||
alert = self.get_alert(ui_state.sm)
|
||||
return alert or self._prev_alert, alert is None
|
||||
|
||||
def _icon_helper(self, alert: Alert) -> AlertLayout:
|
||||
icon_side = None
|
||||
txt_icon = None
|
||||
icon_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 _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')
|
||||
can_draw_second_line = False
|
||||
# TODO: there should be a common way to determine font size based on text length to maximize rect
|
||||
if len(alert_text1) <= 12:
|
||||
can_draw_second_line = True
|
||||
font_size = 92 - 10
|
||||
elif len(alert_text1) <= 16:
|
||||
can_draw_second_line = True
|
||||
font_size = 70
|
||||
else:
|
||||
font_size = 64 - 10
|
||||
|
||||
if icon_side is not None:
|
||||
font_size -= 10
|
||||
|
||||
color = rl.Color(255, 255, 255, int(255 * 0.9 * self._alpha_filter.x))
|
||||
|
||||
text1_y_offset = 11 if font_size >= 70 else 4
|
||||
text_rect1 = rl.Rectangle(
|
||||
alert_layout.text_rect.x,
|
||||
alert_layout.text_rect.y - text1_y_offset,
|
||||
alert_layout.text_rect.width,
|
||||
alert_layout.text_rect.height,
|
||||
)
|
||||
self._alert_text1_label.set_text(alert_text1)
|
||||
self._alert_text1_label.set_text_color(color)
|
||||
self._alert_text1_label.set_font_size(font_size)
|
||||
self._alert_text1_label.set_alignment(rl.GuiTextAlignment.TEXT_ALIGN_LEFT if icon_side != 'left' else rl.GuiTextAlignment.TEXT_ALIGN_RIGHT)
|
||||
self._alert_text1_label.render(text_rect1)
|
||||
|
||||
alert_text2 = alert.text2.lower()
|
||||
|
||||
# randomize chars and length for testing
|
||||
if DEBUG:
|
||||
if time.monotonic() - self._text_gen_time > 0.5:
|
||||
self._alert_text2_gen = ''.join(random.choices(string.ascii_lowercase + ' ', k=random.randint(0, 40)))
|
||||
self._text_gen_time = time.monotonic()
|
||||
alert_text2 = self._alert_text2_gen or alert_text2
|
||||
|
||||
if can_draw_second_line and alert_text2:
|
||||
last_line_h = self._alert_text1_label.rect.y + self._alert_text1_label.get_content_height(int(alert_layout.text_rect.width))
|
||||
last_line_h -= 4
|
||||
if len(alert_text2) > 18:
|
||||
small_font_size = 36
|
||||
elif len(alert_text2) > 24:
|
||||
small_font_size = 32
|
||||
else:
|
||||
small_font_size = 40
|
||||
text_rect2 = rl.Rectangle(
|
||||
alert_layout.text_rect.x,
|
||||
last_line_h,
|
||||
alert_layout.text_rect.width,
|
||||
alert_layout.text_rect.height - last_line_h
|
||||
)
|
||||
color = rl.Color(255, 255, 255, int(255 * 0.65 * self._alpha_filter.x))
|
||||
|
||||
self._alert_text2_label.set_text(alert_text2)
|
||||
self._alert_text2_label.set_text_color(color)
|
||||
self._alert_text2_label.set_font_size(small_font_size)
|
||||
self._alert_text2_label.set_alignment(rl.GuiTextAlignment.TEXT_ALIGN_LEFT if icon_side != 'left' else rl.GuiTextAlignment.TEXT_ALIGN_RIGHT)
|
||||
self._alert_text2_label.render(text_rect2)
|
||||
@@ -0,0 +1,379 @@
|
||||
import numpy as np
|
||||
import pyray as rl
|
||||
from openpilot.cereal import log
|
||||
from opendbc.car.structs import car
|
||||
from msgq.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.model_renderer import ModelRenderer
|
||||
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
|
||||
from openpilot.system.ui.widgets.label import UnifiedLabel
|
||||
from openpilot.system.ui.widgets import Widget
|
||||
from openpilot.common.filter_simple import BounceFilter
|
||||
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.LiveCalibrationData.Status.calibrated
|
||||
ROAD_CAM = VisionStreamType.VISION_STREAM_ROAD
|
||||
WIDE_CAM = VisionStreamType.VISION_STREAM_WIDE_ROAD
|
||||
DEFAULT_DEVICE_CAMERA = DEVICE_CAMERAS["tici", "ar0231"]
|
||||
|
||||
|
||||
class BookmarkState(IntEnum):
|
||||
HIDDEN = 0
|
||||
DRAGGING = 1
|
||||
TRIGGERED = 2
|
||||
|
||||
WIDE_CAM_MAX_SPEED = 5.0 # m/s (10 mph)
|
||||
ROAD_CAM_MIN_SPEED = 10 # m/s (25 mph)
|
||||
|
||||
CAM_Y_OFFSET = 20
|
||||
|
||||
|
||||
class BookmarkIcon(Widget):
|
||||
PEEK_THRESHOLD = 50 # If icon peeks out this much, snap it fully visible
|
||||
FULL_VISIBLE_OFFSET = 200 # How far onscreen when fully visible
|
||||
HIDDEN_OFFSET = -50 # How far offscreen when hidden
|
||||
|
||||
def __init__(self, bookmark_callback):
|
||||
super().__init__()
|
||||
self._bookmark_callback = bookmark_callback
|
||||
self._icon = gui_app.texture("icons_mici/onroad/bookmark.png", 180, 180)
|
||||
self._offset_filter = BounceFilter(0.0, 0.1, 1 / gui_app.target_fps)
|
||||
|
||||
# State
|
||||
self._interacting = False
|
||||
self._state = BookmarkState.HIDDEN
|
||||
self._swipe_start_x = 0.0
|
||||
self._swipe_current_x = 0.0
|
||||
self._is_swiping = False
|
||||
self._is_swiping_left: bool = False
|
||||
self._triggered_time: float = 0.0
|
||||
|
||||
def is_swiping_left(self) -> bool:
|
||||
"""Check if currently swiping left (for scroller to disable)."""
|
||||
return self._is_swiping_left
|
||||
|
||||
def interacting(self):
|
||||
interacting, self._interacting = self._interacting, False
|
||||
return interacting
|
||||
|
||||
def _update_state(self):
|
||||
if self._state == BookmarkState.DRAGGING:
|
||||
# Allow pulling past activated position with rubber band effect
|
||||
swipe_offset = self._swipe_start_x - self._swipe_current_x
|
||||
swipe_offset = min(swipe_offset, self.FULL_VISIBLE_OFFSET + 50)
|
||||
self._offset_filter.update(swipe_offset)
|
||||
|
||||
elif self._state == BookmarkState.TRIGGERED:
|
||||
# Continue animating to fully visible
|
||||
self._offset_filter.update(self.FULL_VISIBLE_OFFSET)
|
||||
# Stay in TRIGGERED state for 1 second
|
||||
if rl.get_time() - self._triggered_time >= 1.5:
|
||||
self._state = BookmarkState.HIDDEN
|
||||
|
||||
elif self._state == BookmarkState.HIDDEN:
|
||||
self._offset_filter.update(self.HIDDEN_OFFSET)
|
||||
|
||||
if self._offset_filter.x < 1e-3:
|
||||
self._interacting = False
|
||||
|
||||
def _handle_mouse_event(self, mouse_event: MouseEvent):
|
||||
if not ui_state.started:
|
||||
return
|
||||
|
||||
if mouse_event.left_pressed:
|
||||
# Store relative position within widget
|
||||
self._swipe_start_x = mouse_event.pos.x
|
||||
self._swipe_current_x = mouse_event.pos.x
|
||||
self._is_swiping = True
|
||||
self._is_swiping_left = False
|
||||
self._state = BookmarkState.DRAGGING
|
||||
|
||||
elif mouse_event.left_down and self._is_swiping:
|
||||
self._swipe_current_x = mouse_event.pos.x
|
||||
swipe_offset = self._swipe_start_x - self._swipe_current_x
|
||||
self._is_swiping_left = swipe_offset > 0
|
||||
if self._is_swiping_left:
|
||||
self._interacting = True
|
||||
|
||||
elif mouse_event.left_released:
|
||||
if self._is_swiping:
|
||||
swipe_distance = self._swipe_start_x - self._swipe_current_x
|
||||
|
||||
# If peeking past threshold, transition to animating to fully visible and bookmark
|
||||
if swipe_distance > self.PEEK_THRESHOLD:
|
||||
self._state = BookmarkState.TRIGGERED
|
||||
self._triggered_time = rl.get_time()
|
||||
self._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
|
||||
rl.draw_texture_ex(self._icon, rl.Vector2(icon_x, icon_y), 0.0, 1.0, rl.WHITE)
|
||||
|
||||
|
||||
class AugmentedRoadView(CameraView):
|
||||
def __init__(self, bookmark_callback=None, stream_type: VisionStreamType = VisionStreamType.VISION_STREAM_ROAD):
|
||||
super().__init__("camerad", stream_type)
|
||||
self._bookmark_callback = bookmark_callback
|
||||
self._set_placeholder_color(rl.BLACK)
|
||||
|
||||
self.device_camera: DeviceCameraConfig | None = None
|
||||
self.view_from_calib = view_frame_from_device_frame.copy()
|
||||
self.view_from_wide_calib = view_frame_from_device_frame.copy()
|
||||
|
||||
self._matrix_cache_key: tuple | None = None
|
||||
self._cached_matrix: np.ndarray | None = None
|
||||
self._content_rect = rl.Rectangle()
|
||||
self._last_click_time = 0.0
|
||||
|
||||
# Bookmark icon with swipe gesture
|
||||
self._bookmark_icon = BookmarkIcon(bookmark_callback)
|
||||
|
||||
self._model_renderer = ModelRenderer()
|
||||
self._hud_renderer = HudRenderer()
|
||||
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=rl.GuiTextAlignment.TEXT_ALIGN_CENTER,
|
||||
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_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()
|
||||
|
||||
# Create inner content area with border padding
|
||||
self._content_rect = rl.Rectangle(
|
||||
self.rect.x,
|
||||
self.rect.y,
|
||||
self.rect.width - SIDE_PANEL_WIDTH,
|
||||
self.rect.height,
|
||||
)
|
||||
|
||||
# Enable scissor mode to clip all rendering within content rectangle boundaries
|
||||
# This creates a rendering viewport that prevents graphics from drawing outside the border
|
||||
rl.begin_scissor_mode(
|
||||
int(self._content_rect.x),
|
||||
int(self._content_rect.y),
|
||||
int(self._content_rect.width),
|
||||
int(self._content_rect.height)
|
||||
)
|
||||
|
||||
# Render the base camera view
|
||||
super()._render(self._content_rect)
|
||||
|
||||
# Draw all UI overlays
|
||||
self._model_renderer.render(self._content_rect)
|
||||
|
||||
# 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)
|
||||
|
||||
alert_to_render, not_animating_out = self._alert_renderer.will_render()
|
||||
|
||||
# Hide DMoji when disengaged unless AlwaysOnDM is enabled
|
||||
should_draw_dmoji = (not self._hud_renderer.drawing_top_icons() and
|
||||
(ui_state.status != UIStatus.DISENGAGED or ui_state.always_on_dm))
|
||||
self._driver_state_renderer.set_should_draw(should_draw_dmoji)
|
||||
self._driver_state_renderer.set_position(self._rect.x + 16, self._rect.y + 10)
|
||||
self._driver_state_renderer.render()
|
||||
|
||||
self._hud_renderer.set_can_draw_top_icons(alert_to_render is None)
|
||||
self._hud_renderer.set_wheel_critical_icon(alert_to_render is not None and not not_animating_out and
|
||||
alert_to_render.visual_alert == car.CarControl.HUDControl.VisualAlert.steerRequired)
|
||||
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
|
||||
self._confidence_ball.render(self.rect)
|
||||
|
||||
self._bookmark_icon.render(self.rect)
|
||||
|
||||
def _switch_stream_if_needed(self, sm):
|
||||
if sm['selfdriveState'].experimentalMode and WIDE_CAM in self.available_streams:
|
||||
v_ego = sm['carState'].vEgo
|
||||
if v_ego < WIDE_CAM_MAX_SPEED:
|
||||
target = WIDE_CAM
|
||||
elif v_ego > ROAD_CAM_MIN_SPEED:
|
||||
target = ROAD_CAM
|
||||
else:
|
||||
# Hysteresis zone - keep current stream
|
||||
target = self.stream_type
|
||||
else:
|
||||
target = ROAD_CAM
|
||||
|
||||
if self.stream_type != target:
|
||||
self.switch_stream(target)
|
||||
|
||||
def _update_calibration(self):
|
||||
# Update device camera if not already set
|
||||
sm = ui_state.sm
|
||||
if not self.device_camera and sm.seen['roadCameraState'] and sm.seen['deviceState']:
|
||||
self.device_camera = DEVICE_CAMERAS[(str(sm['deviceState'].deviceType), str(sm['roadCameraState'].sensor))]
|
||||
|
||||
# Check if live calibration data is available and valid
|
||||
if not (sm.updated["liveCalibration"] and sm.valid['liveCalibration']):
|
||||
return
|
||||
|
||||
calib = sm['liveCalibration']
|
||||
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['liveCalibration'],
|
||||
int(self._content_rect.width),
|
||||
int(self._content_rect.height),
|
||||
self.stream_type,
|
||||
round(ui_state.sm['carState'].vEgo, 1),
|
||||
)
|
||||
|
||||
if cache_key == self._matrix_cache_key and self._cached_matrix is not None:
|
||||
return self._cached_matrix
|
||||
|
||||
# Get camera configuration
|
||||
device_camera = self.device_camera or DEFAULT_DEVICE_CAMERA
|
||||
is_wide_camera = self.stream_type == WIDE_CAM
|
||||
intrinsic = device_camera.ecam.intrinsics if is_wide_camera else device_camera.fcam.intrinsics
|
||||
calibration = self.view_from_wide_calib if is_wide_camera else self.view_from_calib
|
||||
if is_wide_camera:
|
||||
zoom = 0.7 * 1.5
|
||||
else:
|
||||
zoom = np.interp(ui_state.sm['carState'].vEgo, [10, 30], [0.8, 1.0])
|
||||
|
||||
# Calculate transforms for vanishing point
|
||||
inf_point = np.array([1000.0, 0.0, 0.0])
|
||||
calib_transform = intrinsic @ calibration
|
||||
kep = calib_transform @ inf_point
|
||||
|
||||
# Calculate center points and dimensions
|
||||
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=ROAD_CAM)
|
||||
print("***press space to switch camera view***")
|
||||
try:
|
||||
for _ in gui_app.render():
|
||||
ui_state.update()
|
||||
if rl.is_key_released(rl.KeyboardKey.KEY_SPACE):
|
||||
if WIDE_CAM in road_camera_view.available_streams:
|
||||
stream = ROAD_CAM if road_camera_view.stream_type == WIDE_CAM else WIDE_CAM
|
||||
road_camera_view.switch_stream(stream)
|
||||
road_camera_view.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
|
||||
finally:
|
||||
road_camera_view.close()
|
||||
@@ -0,0 +1,413 @@
|
||||
import platform
|
||||
import numpy as np
|
||||
import pyray as rl
|
||||
|
||||
from msgq.visionipc import VisionIpcClient, VisionStreamType, VisionBuf
|
||||
from openpilot.common.swaglog import cloudlog
|
||||
from openpilot.common.hardware import TICI
|
||||
from openpilot.system.ui.lib.application import gui_app
|
||||
from openpilot.system.ui.lib.egl import init_egl, create_egl_image, destroy_egl_image, bind_egl_image_to_texture, EGLImage
|
||||
from openpilot.system.ui.widgets import Widget
|
||||
from openpilot.selfdrive.ui.ui_state import ui_state, UIStatus
|
||||
|
||||
CONNECTION_RETRY_INTERVAL = 0.2 # seconds between connection attempts
|
||||
|
||||
VERSION = """
|
||||
#version 300 es
|
||||
precision mediump float;
|
||||
"""
|
||||
if platform.system() == "Darwin":
|
||||
VERSION = """
|
||||
#version 330 core
|
||||
"""
|
||||
|
||||
|
||||
VERTEX_SHADER = VERSION + """
|
||||
in vec3 vertexPosition;
|
||||
in vec2 vertexTexCoord;
|
||||
in vec3 vertexNormal;
|
||||
in vec4 vertexColor;
|
||||
uniform mat4 mvp;
|
||||
out vec2 fragTexCoord;
|
||||
out vec4 fragColor;
|
||||
void main() {
|
||||
fragTexCoord = vertexTexCoord;
|
||||
fragColor = vertexColor;
|
||||
gl_Position = mvp * vec4(vertexPosition, 1.0);
|
||||
}
|
||||
"""
|
||||
|
||||
# Choose fragment shader based on platform capabilities
|
||||
if TICI:
|
||||
FRAME_FRAGMENT_SHADER = """
|
||||
#version 300 es
|
||||
#extension GL_OES_EGL_image_external_essl3 : enable
|
||||
precision mediump float;
|
||||
in vec2 fragTexCoord;
|
||||
uniform samplerExternalOES texture0;
|
||||
out vec4 fragColor;
|
||||
uniform int engaged;
|
||||
uniform int enhance_driver;
|
||||
|
||||
void main() {
|
||||
vec4 color = texture(texture0, fragTexCoord);
|
||||
if (engaged == 1) {
|
||||
float gray = dot(color.rgb, vec3(0.299, 0.587, 0.114)); // Luma
|
||||
color.rgb = mix(vec3(gray), color.rgb, 0.2); // 20% saturation
|
||||
color.rgb = clamp((color.rgb - 0.5) * 1.2 + 0.5, 0.0, 1.0); // +20% contrast
|
||||
color.rgb = pow(color.rgb, vec3(1.0/1.28));
|
||||
fragColor = vec4(color.rgb, color.a);
|
||||
} else {
|
||||
color.rgb *= 0.85; // 85% opacity
|
||||
}
|
||||
if (enhance_driver == 1) {
|
||||
float brightness = 1.1;
|
||||
color.rgb = color.rgb + 0.15;
|
||||
color.rgb = clamp((color.rgb - 0.5) * (brightness * 0.8) + 0.5, 0.0, 1.0);
|
||||
color.rgb = color.rgb * color.rgb * (3.0 - 2.0 * color.rgb);
|
||||
color.rgb = pow(color.rgb, vec3(0.8));
|
||||
}
|
||||
fragColor = vec4(color.rgb, color.a);
|
||||
}
|
||||
"""
|
||||
else:
|
||||
FRAME_FRAGMENT_SHADER = VERSION + """
|
||||
in vec2 fragTexCoord;
|
||||
uniform sampler2D texture0;
|
||||
uniform sampler2D texture1;
|
||||
out vec4 fragColor;
|
||||
uniform int engaged;
|
||||
uniform int enhance_driver;
|
||||
|
||||
void main() {
|
||||
float y = texture(texture0, fragTexCoord).r;
|
||||
vec2 uv = texture(texture1, fragTexCoord).ra - 0.5;
|
||||
vec3 rgb = vec3(y + 1.402*uv.y, y - 0.344*uv.x - 0.714*uv.y, y + 1.772*uv.x);
|
||||
if (engaged == 1) {
|
||||
float gray = dot(rgb, vec3(0.299, 0.587, 0.114));
|
||||
rgb = mix(vec3(gray), rgb, 0.2); // 20% saturation
|
||||
rgb = clamp((rgb - 0.5) * 1.2 + 0.5, 0.0, 1.0); // +20% contrast
|
||||
} else {
|
||||
rgb *= 0.85; // 85% opacity
|
||||
}
|
||||
// TODO: the images out of camerad need some more correction and
|
||||
// the ui should apply a gamma curve for the device display
|
||||
if (enhance_driver == 1) {
|
||||
float brightness = 1.1;
|
||||
rgb = rgb + 0.15;
|
||||
rgb = clamp((rgb - 0.5) * (brightness * 0.8) + 0.5, 0.0, 1.0);
|
||||
rgb = rgb * rgb * (3.0 - 2.0 * rgb);
|
||||
rgb = pow(rgb, vec3(0.8));
|
||||
}
|
||||
fragColor = vec4(rgb, 1.0);
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
class CameraView(Widget):
|
||||
def __init__(self, name: str, stream_type: VisionStreamType):
|
||||
super().__init__()
|
||||
self._name = name
|
||||
# Primary stream
|
||||
self.client = VisionIpcClient(name, stream_type, conflate=True)
|
||||
self._stream_type = stream_type
|
||||
self.available_streams: list[VisionStreamType] = []
|
||||
|
||||
# Target stream for switching
|
||||
self._target_client: VisionIpcClient | None = None
|
||||
self._target_stream_type: VisionStreamType | None = None
|
||||
self._switching: bool = False
|
||||
|
||||
self._texture_needs_update = True
|
||||
self.last_connection_attempt: float = 0.0
|
||||
self.shader = rl.load_shader_from_memory(VERTEX_SHADER, FRAME_FRAGMENT_SHADER)
|
||||
self._texture1_loc: int = rl.get_shader_location(self.shader, "texture1") if not TICI else -1
|
||||
self._engaged_loc = rl.get_shader_location(self.shader, "engaged")
|
||||
self._engaged_val = rl.ffi.new("int[1]", [1])
|
||||
self._enhance_driver_loc = rl.get_shader_location(self.shader, "enhance_driver")
|
||||
self._enhance_driver_val = rl.ffi.new("int[1]", [1 if stream_type == VisionStreamType.VISION_STREAM_DRIVER else 0])
|
||||
|
||||
self.frame: VisionBuf | None = None
|
||||
self.texture_y: rl.Texture | None = None
|
||||
self.texture_uv: rl.Texture | None = None
|
||||
|
||||
# EGL resources
|
||||
self.egl_images: dict[int, EGLImage] = {}
|
||||
self.egl_texture: rl.Texture | None = None
|
||||
|
||||
self._placeholder_color: rl.Color | None = None
|
||||
|
||||
# Initialize EGL for zero-copy rendering on TICI
|
||||
if TICI:
|
||||
if not init_egl():
|
||||
raise RuntimeError("Failed to initialize EGL")
|
||||
|
||||
# Create a 1x1 pixel placeholder texture for EGL image binding
|
||||
temp_image = rl.gen_image_color(1, 1, rl.BLACK)
|
||||
self.egl_texture = rl.load_texture_from_image(temp_image)
|
||||
rl.unload_image(temp_image)
|
||||
|
||||
ui_state.add_offroad_transition_callback(self._offroad_transition)
|
||||
|
||||
def _offroad_transition(self):
|
||||
# Drain queued SubSocket messages to prevent old frames from showing when going
|
||||
# onroad. Qt had a separate thread which drains the VisionIpcClient SubSocket for us.
|
||||
if self.client and self.client.is_connected():
|
||||
while self.client.recv(timeout_ms=0) is not None:
|
||||
pass
|
||||
self.frame = None
|
||||
|
||||
def _set_placeholder_color(self, color: rl.Color):
|
||||
"""Set a placeholder color to be drawn when no frame is available."""
|
||||
self._placeholder_color = color
|
||||
|
||||
def switch_stream(self, stream_type: VisionStreamType) -> None:
|
||||
if self._stream_type == stream_type:
|
||||
return
|
||||
|
||||
if self._switching and self._target_stream_type == stream_type:
|
||||
return
|
||||
|
||||
cloudlog.debug(f'Preparing switch from {self._stream_type} to {stream_type}')
|
||||
|
||||
if self._target_client:
|
||||
del self._target_client
|
||||
|
||||
self._target_stream_type = stream_type
|
||||
self._target_client = VisionIpcClient(self._name, stream_type, conflate=True)
|
||||
self._switching = True
|
||||
|
||||
@property
|
||||
def stream_type(self) -> VisionStreamType:
|
||||
return self._stream_type
|
||||
|
||||
def close(self) -> None:
|
||||
self._clear_textures()
|
||||
|
||||
# Clean up EGL texture
|
||||
if TICI and self.egl_texture:
|
||||
rl.unload_texture(self.egl_texture)
|
||||
self.egl_texture = None
|
||||
|
||||
# Clean up shader
|
||||
if self.shader and self.shader.id:
|
||||
rl.unload_shader(self.shader)
|
||||
self.shader.id = 0
|
||||
|
||||
self.frame = None
|
||||
self.available_streams.clear()
|
||||
self.client = None
|
||||
|
||||
def __del__(self):
|
||||
self.close()
|
||||
|
||||
def _calc_frame_matrix(self, rect: rl.Rectangle) -> np.ndarray:
|
||||
if not self.frame:
|
||||
return np.eye(3)
|
||||
|
||||
# Calculate aspect ratios
|
||||
widget_aspect_ratio = rect.width / rect.height
|
||||
frame_aspect_ratio = self.frame.width / self.frame.height
|
||||
|
||||
# Calculate scaling factors to maintain aspect ratio
|
||||
zx = min(frame_aspect_ratio / widget_aspect_ratio, 1.0)
|
||||
zy = min(widget_aspect_ratio / frame_aspect_ratio, 1.0)
|
||||
|
||||
return np.array([
|
||||
[zx, 0.0, 0.0],
|
||||
[0.0, zy, 0.0],
|
||||
[0.0, 0.0, 1.0]
|
||||
])
|
||||
|
||||
def _render(self, rect: rl.Rectangle):
|
||||
if self._switching:
|
||||
self._handle_switch()
|
||||
|
||||
if not self._ensure_connection():
|
||||
self._draw_placeholder(rect)
|
||||
return
|
||||
|
||||
# Try to get a new buffer without blocking
|
||||
buffer = self.client.recv(timeout_ms=0)
|
||||
if buffer:
|
||||
self._texture_needs_update = True
|
||||
self.frame = buffer
|
||||
elif not self.client.is_connected():
|
||||
# ensure we clear the displayed frame when the connection is lost
|
||||
self.frame = None
|
||||
|
||||
if not self.frame:
|
||||
self._draw_placeholder(rect)
|
||||
return
|
||||
|
||||
transform = self._calc_frame_matrix(rect)
|
||||
src_rect = rl.Rectangle(0, 0, float(self.frame.width), float(self.frame.height))
|
||||
# Flip driver camera horizontally
|
||||
if self._stream_type == VisionStreamType.VISION_STREAM_DRIVER:
|
||||
src_rect.width = -src_rect.width
|
||||
|
||||
# Calculate scale
|
||||
scale_x = rect.width * transform[0, 0] # zx
|
||||
scale_y = rect.height * transform[1, 1] # zy
|
||||
|
||||
# Calculate base position (centered)
|
||||
x_offset = rect.x + (rect.width - scale_x) / 2
|
||||
y_offset = rect.y + (rect.height - scale_y) / 2
|
||||
|
||||
x_offset += transform[0, 2] * rect.width / 2
|
||||
y_offset += transform[1, 2] * rect.height / 2
|
||||
|
||||
dst_rect = rl.Rectangle(x_offset, y_offset, scale_x, scale_y)
|
||||
|
||||
# Render with appropriate method
|
||||
if TICI:
|
||||
self._render_egl(src_rect, dst_rect)
|
||||
else:
|
||||
self._render_textures(src_rect, dst_rect)
|
||||
|
||||
def _draw_placeholder(self, rect: rl.Rectangle):
|
||||
if self._placeholder_color:
|
||||
rl.draw_rectangle_rec(rect, self._placeholder_color)
|
||||
|
||||
def _render_egl(self, src_rect: rl.Rectangle, dst_rect: rl.Rectangle) -> None:
|
||||
"""Render using EGL for direct buffer access"""
|
||||
if self.frame is None or self.egl_texture is None:
|
||||
return
|
||||
|
||||
idx = self.frame.idx
|
||||
egl_image = self.egl_images.get(idx)
|
||||
|
||||
# Create EGL image if needed
|
||||
if egl_image is None:
|
||||
egl_image = create_egl_image(self.frame.width, self.frame.height, self.frame.stride, self.frame.fd, self.frame.uv_offset)
|
||||
if egl_image:
|
||||
self.egl_images[idx] = egl_image
|
||||
else:
|
||||
return
|
||||
|
||||
# Update texture dimensions to match current frame
|
||||
self.egl_texture.width = self.frame.width
|
||||
self.egl_texture.height = self.frame.height
|
||||
|
||||
# Bind the EGL image to our texture
|
||||
bind_egl_image_to_texture(self.egl_texture.id, egl_image)
|
||||
|
||||
# Render with shader
|
||||
rl.begin_shader_mode(self.shader)
|
||||
self._update_texture_color_filtering()
|
||||
rl.draw_texture_pro(self.egl_texture, src_rect, dst_rect, rl.Vector2(0, 0), 0.0, rl.WHITE)
|
||||
rl.end_shader_mode()
|
||||
|
||||
def _render_textures(self, src_rect: rl.Rectangle, dst_rect: rl.Rectangle) -> None:
|
||||
"""Render using texture copies"""
|
||||
if not self.texture_y or not self.texture_uv or self.frame is None:
|
||||
return
|
||||
|
||||
# Update textures with new frame data
|
||||
if self._texture_needs_update:
|
||||
y_data = self.frame.data[: self.frame.uv_offset]
|
||||
uv_data = self.frame.data[self.frame.uv_offset:]
|
||||
|
||||
rl.update_texture(self.texture_y, rl.ffi.cast("void *", rl.ffi.from_buffer(y_data)))
|
||||
rl.update_texture(self.texture_uv, rl.ffi.cast("void *", rl.ffi.from_buffer(uv_data)))
|
||||
self._texture_needs_update = False
|
||||
|
||||
# Render with shader
|
||||
rl.begin_shader_mode(self.shader)
|
||||
self._update_texture_color_filtering()
|
||||
rl.set_shader_value_texture(self.shader, self._texture1_loc, self.texture_uv)
|
||||
rl.draw_texture_pro(self.texture_y, src_rect, dst_rect, rl.Vector2(0, 0), 0.0, rl.WHITE)
|
||||
rl.end_shader_mode()
|
||||
|
||||
def _update_texture_color_filtering(self):
|
||||
self._engaged_val[0] = 1 if ui_state.status != UIStatus.DISENGAGED else 0
|
||||
rl.set_shader_value(self.shader, self._engaged_loc, self._engaged_val, rl.ShaderUniformDataType.SHADER_UNIFORM_INT)
|
||||
rl.set_shader_value(self.shader, self._enhance_driver_loc, self._enhance_driver_val, rl.ShaderUniformDataType.SHADER_UNIFORM_INT)
|
||||
|
||||
def _ensure_connection(self) -> bool:
|
||||
if not self.client.is_connected():
|
||||
self.frame = None
|
||||
self.available_streams.clear()
|
||||
|
||||
# Throttle connection attempts
|
||||
current_time = rl.get_time()
|
||||
if current_time - self.last_connection_attempt < CONNECTION_RETRY_INTERVAL:
|
||||
return False
|
||||
self.last_connection_attempt = current_time
|
||||
|
||||
if not self.client.connect(False) or not self.client.num_buffers:
|
||||
return False
|
||||
|
||||
cloudlog.debug(f"Connected to {self._name} stream: {self._stream_type}, buffers: {self.client.num_buffers}")
|
||||
self._initialize_textures()
|
||||
self.available_streams = self.client.available_streams(self._name, block=False)
|
||||
|
||||
return True
|
||||
|
||||
def _handle_switch(self) -> None:
|
||||
"""Check if target stream is ready and switch immediately."""
|
||||
if not self._target_client or not self._switching:
|
||||
return
|
||||
|
||||
# Try to connect target if needed
|
||||
if not self._target_client.is_connected():
|
||||
if not self._target_client.connect(False) or not self._target_client.num_buffers:
|
||||
return
|
||||
|
||||
cloudlog.debug(f"Target stream connected: {self._target_stream_type}")
|
||||
|
||||
# Check if target has frames ready
|
||||
target_frame = self._target_client.recv(timeout_ms=0)
|
||||
if target_frame:
|
||||
self.frame = target_frame # Update current frame to target frame
|
||||
self._complete_switch()
|
||||
|
||||
def _complete_switch(self) -> None:
|
||||
"""Instantly switch to target stream."""
|
||||
cloudlog.debug(f"Switching to {self._target_stream_type}")
|
||||
# Clean up current resources
|
||||
if self.client:
|
||||
del self.client
|
||||
|
||||
# Switch to target
|
||||
self.client = self._target_client
|
||||
self._stream_type = self._target_stream_type
|
||||
self._texture_needs_update = True
|
||||
|
||||
# Reset state
|
||||
self._target_client = None
|
||||
self._target_stream_type = None
|
||||
self._switching = False
|
||||
|
||||
# Initialize textures for new stream
|
||||
self._initialize_textures()
|
||||
|
||||
def _initialize_textures(self):
|
||||
self._clear_textures()
|
||||
if not TICI:
|
||||
self.texture_y = rl.load_texture_from_image(rl.Image(None, int(self.client.stride),
|
||||
int(self.client.height), 1, rl.PixelFormat.PIXELFORMAT_UNCOMPRESSED_GRAYSCALE))
|
||||
self.texture_uv = rl.load_texture_from_image(rl.Image(None, int(self.client.stride // 2),
|
||||
int(self.client.height // 2), 1, rl.PixelFormat.PIXELFORMAT_UNCOMPRESSED_GRAY_ALPHA))
|
||||
|
||||
def _clear_textures(self):
|
||||
if self.texture_y and self.texture_y.id:
|
||||
rl.unload_texture(self.texture_y)
|
||||
self.texture_y = None
|
||||
|
||||
if self.texture_uv and self.texture_uv.id:
|
||||
rl.unload_texture(self.texture_uv)
|
||||
self.texture_uv = None
|
||||
|
||||
# Clean up EGL resources
|
||||
if TICI:
|
||||
for data in self.egl_images.values():
|
||||
destroy_egl_image(data)
|
||||
self.egl_images = {}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
gui_app.init_window("camera view")
|
||||
road = CameraView("camerad", VisionStreamType.VISION_STREAM_ROAD)
|
||||
for _ in gui_app.render():
|
||||
road.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
|
||||
@@ -0,0 +1,86 @@
|
||||
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)
|
||||
@@ -0,0 +1,248 @@
|
||||
import pyray as rl
|
||||
from openpilot.cereal import log, messaging
|
||||
from msgq.visionipc import VisionStreamType
|
||||
from openpilot.selfdrive.ui.mici.onroad.cameraview import CameraView
|
||||
from openpilot.selfdrive.ui.mici.onroad.driver_state import DriverStateRenderer
|
||||
from openpilot.selfdrive.ui.ui_state import ui_state, device
|
||||
from openpilot.system.ui.lib.application import gui_app, FontWeight
|
||||
from openpilot.system.ui.lib.multilang import tr
|
||||
from openpilot.system.ui.widgets import Widget
|
||||
from openpilot.system.ui.widgets.nav_widget import NavWidget
|
||||
from openpilot.system.ui.widgets.label import gui_label
|
||||
|
||||
|
||||
class DriverCameraView(CameraView):
|
||||
def _calc_frame_matrix(self, rect: rl.Rectangle):
|
||||
base = super()._calc_frame_matrix(rect)
|
||||
driver_view_ratio = 1.5
|
||||
base[0, 0] *= driver_view_ratio
|
||||
base[1, 1] *= driver_view_ratio
|
||||
return base
|
||||
|
||||
|
||||
class BaseDriverCameraDialog(Widget):
|
||||
# Not a NavWidget so training guide can use this without back navigation
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._camera_view = DriverCameraView("camerad", VisionStreamType.VISION_STREAM_DRIVER)
|
||||
self.driver_state_renderer = DriverStateRenderer(lines=True)
|
||||
self.driver_state_renderer.set_rect(rl.Rectangle(0, 0, 200, 200))
|
||||
self.driver_state_renderer.load_icons()
|
||||
self._pm: messaging.PubMaster | None = None
|
||||
|
||||
# Load eye icons
|
||||
self._eye_fill_texture = None
|
||||
self._eye_orange_texture = None
|
||||
self._eye_size = 74
|
||||
self._glasses_texture = None
|
||||
self._glasses_size = 171
|
||||
|
||||
self._load_eye_textures()
|
||||
|
||||
def show_event(self):
|
||||
super().show_event()
|
||||
ui_state.params.put_bool("IsDriverViewEnabled", True)
|
||||
self._publish_alert_sound(None)
|
||||
device.set_override_interactive_timeout(300)
|
||||
ui_state.params.remove("DriverTooDistracted")
|
||||
self._pm = messaging.PubMaster(['selfdriveState'])
|
||||
|
||||
def hide_event(self):
|
||||
super().hide_event()
|
||||
ui_state.params.put_bool("IsDriverViewEnabled", False)
|
||||
device.set_override_interactive_timeout(None)
|
||||
|
||||
def _handle_mouse_release(self, _):
|
||||
ui_state.params.remove("DriverTooDistracted")
|
||||
|
||||
def __del__(self):
|
||||
self.close()
|
||||
|
||||
def close(self):
|
||||
if self._camera_view:
|
||||
self._camera_view.close()
|
||||
|
||||
def _update_state(self):
|
||||
if self._camera_view:
|
||||
self._camera_view._update_state()
|
||||
# Enable driver state renderer to show Dmoji in preview
|
||||
self.driver_state_renderer.set_should_draw(True)
|
||||
self.driver_state_renderer.set_force_active(True)
|
||||
super()._update_state()
|
||||
|
||||
def _render(self, rect):
|
||||
rl.begin_scissor_mode(int(rect.x), int(rect.y), int(rect.width), int(rect.height))
|
||||
self._camera_view._render(rect)
|
||||
|
||||
if not self._camera_view.frame:
|
||||
gui_label(rect, tr("camera starting"), font_size=54, font_weight=FontWeight.BOLD,
|
||||
alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER)
|
||||
rl.end_scissor_mode()
|
||||
self._publish_alert_sound(None)
|
||||
return
|
||||
|
||||
driver_data = self._draw_face_detection(rect)
|
||||
if driver_data is not None:
|
||||
self._draw_eyes(rect, driver_data)
|
||||
|
||||
# Position dmoji on opposite side from driver
|
||||
driver_state_rect = (
|
||||
rect.x if self.driver_state_renderer.is_rhd else rect.x + rect.width - self.driver_state_renderer.rect.width,
|
||||
rect.y + (rect.height - self.driver_state_renderer.rect.height) / 2,
|
||||
)
|
||||
self.driver_state_renderer.set_position(*driver_state_rect)
|
||||
self.driver_state_renderer.render()
|
||||
|
||||
# Render driver monitoring alerts
|
||||
self._render_dm_alerts(rect)
|
||||
|
||||
rl.end_scissor_mode()
|
||||
return
|
||||
|
||||
def _publish_alert_sound(self, dm_state):
|
||||
"""Publish selfdriveState with only alertSound field set"""
|
||||
if self._pm is None:
|
||||
return
|
||||
|
||||
AudibleAlert = log.SelfdriveState.AudibleAlert
|
||||
ALERT_SOUNDS = {
|
||||
'one': AudibleAlert.preAlert,
|
||||
'two': AudibleAlert.promptDistracted,
|
||||
'three': AudibleAlert.warningImmediate,
|
||||
}
|
||||
msg = messaging.new_message('selfdriveState')
|
||||
if dm_state is not None:
|
||||
msg.selfdriveState.alertSound = ALERT_SOUNDS.get(str(dm_state.alertLevel), AudibleAlert.none)
|
||||
self._pm.send('selfdriveState', msg)
|
||||
|
||||
def _render_dm_alerts(self, rect: rl.Rectangle):
|
||||
"""Render driver monitoring event names"""
|
||||
dm_state = ui_state.sm["driverMonitoringState"]
|
||||
self._publish_alert_sound(dm_state)
|
||||
|
||||
is_vision = dm_state.activePolicy == log.DriverMonitoringState.MonitoringPolicy.vision
|
||||
awareness_pct = dm_state.visionPolicyState.awarenessPercent if is_vision else dm_state.wheeltouchPolicyState.awarenessPercent
|
||||
gui_label(rl.Rectangle(rect.x + 2, rect.y + 2, rect.width, rect.height),
|
||||
f"Awareness: {awareness_pct:.0f}%", font_size=44, font_weight=FontWeight.MEDIUM,
|
||||
alignment=rl.GuiTextAlignment.TEXT_ALIGN_RIGHT,
|
||||
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP,
|
||||
color=rl.Color(0, 0, 0, 180))
|
||||
gui_label(rect, f"Awareness: {awareness_pct:.0f}%", font_size=44, font_weight=FontWeight.MEDIUM,
|
||||
alignment=rl.GuiTextAlignment.TEXT_ALIGN_RIGHT,
|
||||
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP,
|
||||
color=rl.Color(255, 255, 255, int(255 * 0.9)))
|
||||
|
||||
if dm_state.alertLevel == log.DriverMonitoringState.AlertLevel.none:
|
||||
return
|
||||
|
||||
# Show alert level
|
||||
alert_level_str = f"{'Pay Attention' if is_vision else 'Touch Wheel'} - level {dm_state.alertLevel}"
|
||||
alignment = rl.GuiTextAlignment.TEXT_ALIGN_RIGHT if self.driver_state_renderer.is_rhd else rl.GuiTextAlignment.TEXT_ALIGN_LEFT
|
||||
|
||||
shadow_rect = rl.Rectangle(rect.x + 2, rect.y + 2, rect.width, rect.height)
|
||||
gui_label(shadow_rect, alert_level_str, font_size=40, font_weight=FontWeight.BOLD,
|
||||
alignment=alignment,
|
||||
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM,
|
||||
color=rl.Color(0, 0, 0, 180))
|
||||
gui_label(rect, alert_level_str, font_size=40, font_weight=FontWeight.BOLD,
|
||||
alignment=alignment,
|
||||
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM,
|
||||
color=rl.Color(255, 255, 255, int(255 * 0.9)))
|
||||
|
||||
def _load_eye_textures(self):
|
||||
"""Lazy load eye textures"""
|
||||
if self._eye_fill_texture is None:
|
||||
self._eye_fill_texture = gui_app.texture("icons_mici/onroad/eye_fill.png", self._eye_size, self._eye_size)
|
||||
if self._eye_orange_texture is None:
|
||||
self._eye_orange_texture = gui_app.texture("icons_mici/onroad/eye_orange.png", self._eye_size, self._eye_size)
|
||||
if self._glasses_texture is None:
|
||||
self._glasses_texture = gui_app.texture("icons_mici/onroad/glasses.png", self._glasses_size, self._glasses_size)
|
||||
|
||||
def _draw_face_detection(self, rect: rl.Rectangle):
|
||||
dm_state = ui_state.sm["driverMonitoringState"]
|
||||
driver_data = self.driver_state_renderer.get_driver_data()
|
||||
if not dm_state.visionPolicyState.faceDetected:
|
||||
return
|
||||
|
||||
# Get face position and orientation
|
||||
face_x, face_y = driver_data.facePosition
|
||||
face_std = max(driver_data.faceOrientationStd[0], driver_data.faceOrientationStd[1])
|
||||
alpha = 0.7
|
||||
if face_std > 0.15:
|
||||
alpha = max(0.7 - (face_std - 0.15) * 3.5, 0.0)
|
||||
|
||||
# use approx instead of distort_points
|
||||
# TODO: replace with distort_points
|
||||
tici_x = 1080.0 - 1714.0 * face_x
|
||||
tici_y = -135.0 + (504.0 + abs(face_x) * 112.0) + (1205.0 - abs(face_x) * 724.0) * face_y
|
||||
|
||||
# Tici coords are relative to center, scale offset
|
||||
offset_x = (tici_x - 1080.0) * 1.25
|
||||
offset_y = (tici_y - 540.0) * 1.25
|
||||
|
||||
# Map to mici screen (scale from 2160x1080 to rect dimensions)
|
||||
scale_x = rect.width / 2160.0
|
||||
scale_y = rect.height / 1080.0
|
||||
fbox_x = rect.x + rect.width / 2 + offset_x * scale_x
|
||||
fbox_y = rect.y + rect.height / 2 + offset_y * scale_y
|
||||
box_size = 75
|
||||
line_thickness = 3
|
||||
|
||||
line_color = rl.Color(255, 255, 255, int(alpha * 255))
|
||||
rl.draw_rectangle_rounded_lines_ex(
|
||||
rl.Rectangle(fbox_x - box_size / 2, fbox_y - box_size / 2, box_size, box_size),
|
||||
35.0 / box_size / 2,
|
||||
line_thickness,
|
||||
line_thickness,
|
||||
line_color,
|
||||
)
|
||||
return driver_data
|
||||
|
||||
def _draw_eyes(self, rect: rl.Rectangle, driver_data):
|
||||
# Draw eye indicators based on eye probabilities
|
||||
eye_offset_x = 10
|
||||
eye_offset_y = 10
|
||||
eye_spacing = self._eye_size + 15
|
||||
|
||||
left_eye_x = rect.x + eye_offset_x
|
||||
left_eye_y = rect.y + eye_offset_y
|
||||
left_eye_prob = driver_data.leftEyeProb
|
||||
|
||||
right_eye_x = rect.x + eye_offset_x + eye_spacing
|
||||
right_eye_y = rect.y + eye_offset_y
|
||||
right_eye_prob = driver_data.rightEyeProb
|
||||
|
||||
# Draw eyes with opacity based on probability
|
||||
for eye_x, eye_y, eye_prob in [(left_eye_x, left_eye_y, left_eye_prob), (right_eye_x, right_eye_y, right_eye_prob)]:
|
||||
fill_opacity = eye_prob
|
||||
orange_opacity = 1.0 - eye_prob
|
||||
|
||||
rl.draw_texture_v(self._eye_orange_texture, (eye_x, eye_y), rl.Color(255, 255, 255, int(255 * orange_opacity)))
|
||||
rl.draw_texture_v(self._eye_fill_texture, (eye_x, eye_y), rl.Color(255, 255, 255, int(255 * fill_opacity)))
|
||||
|
||||
# Draw sunglasses indicator based on sunglasses probability
|
||||
# Position glasses centered between the two eyes at top left
|
||||
glasses_x = rect.x + eye_offset_x - 4
|
||||
glasses_y = rect.y
|
||||
glasses_pos = rl.Vector2(glasses_x, glasses_y)
|
||||
glasses_prob = driver_data.sunglassesProb
|
||||
rl.draw_texture_v(self._glasses_texture, glasses_pos, rl.Color(70, 80, 161, int(255 * glasses_prob)))
|
||||
|
||||
|
||||
class DriverCameraDialog(NavWidget, BaseDriverCameraDialog):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
# TODO: this can grow unbounded, should be given some thought
|
||||
device.add_interactive_timeout_callback(gui_app.pop_widget)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
gui_app.init_window("Driver Camera View (mici)")
|
||||
|
||||
driver_camera_view = DriverCameraDialog()
|
||||
gui_app.push_widget(driver_camera_view)
|
||||
try:
|
||||
for _ in gui_app.render():
|
||||
ui_state.update()
|
||||
finally:
|
||||
driver_camera_view.close()
|
||||
@@ -0,0 +1,216 @@
|
||||
import pyray as rl
|
||||
import numpy as np
|
||||
import math
|
||||
from openpilot.cereal import log
|
||||
from openpilot.common.filter_simple import FirstOrderFilter
|
||||
from openpilot.system.ui.lib.application import gui_app
|
||||
from openpilot.system.ui.widgets import Widget
|
||||
from openpilot.selfdrive.ui.ui_state import ui_state
|
||||
|
||||
|
||||
AlertSize = log.SelfdriveState.AlertSize
|
||||
|
||||
DEBUG = False
|
||||
|
||||
# TODO: Only left for DM preview, remove
|
||||
LOOKING_CENTER_THRESHOLD_UPPER = math.radians(6)
|
||||
LOOKING_CENTER_THRESHOLD_LOWER = math.radians(3)
|
||||
|
||||
CONE_COLOR_GREEN = (0, 255, 64)
|
||||
CONE_COLOR_ORANGE = (255, 115, 0)
|
||||
|
||||
|
||||
class DriverStateRenderer(Widget):
|
||||
BASE_SIZE = 60
|
||||
LINES_ANGLE_INCREMENT = 5
|
||||
LINES_STALE_ANGLES = 3.0 # seconds
|
||||
AWARENESS_UNFULL_PERCENT = 95 # ~0.5s
|
||||
|
||||
def __init__(self, lines: bool = False, inset: bool = False):
|
||||
super().__init__()
|
||||
self.set_rect(rl.Rectangle(0, 0, self.BASE_SIZE, self.BASE_SIZE))
|
||||
self._lines = lines
|
||||
self._inset = inset
|
||||
|
||||
# In line mode, track smoothed angles
|
||||
assert 360 % self.LINES_ANGLE_INCREMENT == 0
|
||||
self._head_angles = {i * self.LINES_ANGLE_INCREMENT: FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps) for i in range(360 // self.LINES_ANGLE_INCREMENT)}
|
||||
|
||||
self._is_active = False
|
||||
self._is_rhd = False
|
||||
self._face_detected = False
|
||||
self._face_pitch = 0.
|
||||
self._face_yaw = 0.
|
||||
self._should_draw = False
|
||||
self._force_active = False
|
||||
self._looking_center = False
|
||||
self._awareness_unfull = False
|
||||
|
||||
self._fade_filter = FirstOrderFilter(0.0, 0.05, 1 / gui_app.target_fps)
|
||||
self._color_fade_filter = FirstOrderFilter(1.0, 0.05, 1 / gui_app.target_fps) # 1.0 = full green, 0.0 = full orange
|
||||
self._pitch_filter = FirstOrderFilter(0.0, 0.05, 1 / gui_app.target_fps, initialized=False)
|
||||
self._yaw_filter = FirstOrderFilter(0.0, 0.05, 1 / gui_app.target_fps, initialized=False)
|
||||
self._rotation_filter = FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps, initialized=False)
|
||||
self._looking_center_filter = FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps)
|
||||
|
||||
# Load the driver face icons
|
||||
self.load_icons()
|
||||
|
||||
def load_icons(self):
|
||||
"""Load or reload the driver face icon texture"""
|
||||
cone_and_person_size = round(52 / self.BASE_SIZE * self._rect.width)
|
||||
|
||||
# If inset is enabled, push cone and person smaller by 2x the current inset space
|
||||
if self._inset:
|
||||
# Current inset space = (rect.width - cone_and_person_size) / 2
|
||||
current_inset = (self._rect.width - cone_and_person_size) / 2
|
||||
# Reduce size by 2x the current inset (1x on each side)
|
||||
cone_and_person_size = round(cone_and_person_size - current_inset * 2)
|
||||
|
||||
self._dm_person = gui_app.texture("icons_mici/onroad/driver_monitoring/dm_person.png", cone_and_person_size, cone_and_person_size)
|
||||
self._dm_cone = gui_app.texture("icons_mici/onroad/driver_monitoring/dm_cone.png", cone_and_person_size, cone_and_person_size)
|
||||
self._dm_background = gui_app.texture("icons_mici/onroad/driver_monitoring/dm_background.png", int(self._rect.width), int(self._rect.height))
|
||||
|
||||
def set_should_draw(self, should_draw: bool):
|
||||
self._should_draw = should_draw
|
||||
|
||||
@property
|
||||
def should_draw(self):
|
||||
return (self._should_draw and ui_state.sm["selfdriveState"].alertSize == AlertSize.none and
|
||||
ui_state.sm.recv_frame["driverStateV2"] > ui_state.started_frame)
|
||||
|
||||
def set_force_active(self, force_active: bool):
|
||||
"""Force the dmoji to always appear active (green) regardless of actual state"""
|
||||
self._force_active = force_active
|
||||
|
||||
@property
|
||||
def effective_active(self) -> bool:
|
||||
"""Returns True if dmoji should appear active (either actually active or forced)"""
|
||||
return bool(self._force_active or self._is_active)
|
||||
|
||||
@property
|
||||
def is_rhd(self) -> bool:
|
||||
return self._is_rhd
|
||||
|
||||
def _render(self, _):
|
||||
if DEBUG:
|
||||
rl.draw_rectangle_lines_ex(self._rect, 1, rl.RED)
|
||||
|
||||
rl.draw_texture_ex(self._dm_background,
|
||||
rl.Vector2(self._rect.x, self._rect.y), 0.0, 1.0,
|
||||
rl.Color(255, 255, 255, int(255 * self._fade_filter.x)))
|
||||
|
||||
rl.draw_texture_ex(self._dm_person,
|
||||
rl.Vector2(self._rect.x + (self._rect.width - self._dm_person.width) / 2,
|
||||
self._rect.y + (self._rect.height - self._dm_person.height) / 2), 0.0, 1.0,
|
||||
rl.Color(255, 255, 255, int(255 * 0.9 * self._fade_filter.x)))
|
||||
|
||||
green_amount = self._color_fade_filter.update(0.0 if self._awareness_unfull else 1.0)
|
||||
if self.effective_active:
|
||||
source_rect = rl.Rectangle(0, 0, self._dm_cone.width, self._dm_cone.height)
|
||||
dest_rect = rl.Rectangle(
|
||||
self._rect.x + self._rect.width / 2,
|
||||
self._rect.y + self._rect.height / 2,
|
||||
self._dm_cone.width,
|
||||
self._dm_cone.height,
|
||||
)
|
||||
|
||||
if not self._lines:
|
||||
r = int(round(CONE_COLOR_GREEN[0] * green_amount + CONE_COLOR_ORANGE[0] * (1 - green_amount)))
|
||||
g = int(round(CONE_COLOR_GREEN[1] * green_amount + CONE_COLOR_ORANGE[1] * (1 - green_amount)))
|
||||
b = int(round(CONE_COLOR_GREEN[2] * green_amount + CONE_COLOR_ORANGE[2] * (1 - green_amount)))
|
||||
rl.draw_texture_pro(
|
||||
self._dm_cone,
|
||||
source_rect,
|
||||
dest_rect,
|
||||
rl.Vector2(dest_rect.width / 2, dest_rect.height / 2),
|
||||
self._rotation_filter.x - 90,
|
||||
rl.Color(r, g, b, int(255 * self._fade_filter.x)),
|
||||
)
|
||||
|
||||
else:
|
||||
# remove old angles
|
||||
for angle, f in self._head_angles.items():
|
||||
dst_from_current = ((angle - self._rotation_filter.x) % 360) - 180
|
||||
target = 1.0 if abs(dst_from_current) <= self.LINES_ANGLE_INCREMENT * 5 else 0.0
|
||||
if not self._face_detected:
|
||||
target = 0.0
|
||||
|
||||
# Reduce all line lengths when looking center
|
||||
if self._looking_center:
|
||||
target = np.interp(self._looking_center_filter.x, [0.0, 1.0], [target, 0.45])
|
||||
|
||||
f.update(target)
|
||||
self._draw_line(angle, f, self._looking_center)
|
||||
|
||||
def _draw_line(self, angle: int, f: FirstOrderFilter, grey: bool):
|
||||
line_length = self._rect.width / 6
|
||||
line_length = round(np.interp(f.x, [0.0, 1.0], [0, line_length]))
|
||||
line_offset = self._rect.width / 2 - line_length * 2 # ensure line ends within rect
|
||||
center_x = self._rect.x + self._rect.width / 2
|
||||
center_y = self._rect.y + self._rect.height / 2
|
||||
start_x = center_x + (line_offset + line_length) * math.cos(math.radians(angle))
|
||||
start_y = center_y + (line_offset + line_length) * math.sin(math.radians(angle))
|
||||
end_x = start_x + line_length * math.cos(math.radians(angle))
|
||||
end_y = start_y + line_length * math.sin(math.radians(angle))
|
||||
color = rl.Color(0, 255, 64, 255)
|
||||
|
||||
if grey:
|
||||
color = rl.Color(166, 166, 166, 255)
|
||||
|
||||
if f.x > 0.01:
|
||||
rl.draw_line_ex((start_x, start_y), (end_x, end_y), 12, color)
|
||||
|
||||
def get_driver_data(self):
|
||||
sm = ui_state.sm
|
||||
|
||||
dm_state = sm["driverMonitoringState"]
|
||||
self._is_active = dm_state.activePolicy == log.DriverMonitoringState.MonitoringPolicy.vision
|
||||
self._is_rhd = dm_state.isRHD
|
||||
self._face_detected = dm_state.visionPolicyState.faceDetected
|
||||
self._awareness_unfull = self.effective_active and dm_state.visionPolicyState.awarenessPercent < self.AWARENESS_UNFULL_PERCENT
|
||||
self._face_pitch = dm_state.visionPolicyState.pose.pitch + math.radians(6) # calib or DM pose is not accurate, add a fake upward pitch to bias forward
|
||||
self._face_yaw = -dm_state.visionPolicyState.pose.yaw # undo sign flip in face_orientation_from_model to match UI convention
|
||||
|
||||
driverstate = sm["driverStateV2"]
|
||||
driver_data = driverstate.rightDriverData if self._is_rhd else driverstate.leftDriverData
|
||||
return driver_data
|
||||
|
||||
def _update_state(self):
|
||||
# Get monitoring state
|
||||
_ = self.get_driver_data()
|
||||
pitch = self._pitch_filter.update(self._face_pitch)
|
||||
yaw = self._yaw_filter.update(self._face_yaw)
|
||||
|
||||
# hysteresis on looking center
|
||||
if abs(pitch) < LOOKING_CENTER_THRESHOLD_LOWER and abs(yaw) < LOOKING_CENTER_THRESHOLD_LOWER:
|
||||
self._looking_center = True
|
||||
elif abs(pitch) > LOOKING_CENTER_THRESHOLD_UPPER or abs(yaw) > LOOKING_CENTER_THRESHOLD_UPPER:
|
||||
self._looking_center = False
|
||||
self._looking_center_filter.update(1 if self._looking_center else 0)
|
||||
|
||||
if DEBUG:
|
||||
pitchd = math.degrees(pitch)
|
||||
yawd = math.degrees(yaw)
|
||||
|
||||
rl.draw_line_ex((0, 100), (200, 100), 3, rl.RED)
|
||||
rl.draw_line_ex((0, 120), (200, 120), 3, rl.RED)
|
||||
rl.draw_line_ex((0, 140), (200, 140), 3, rl.RED)
|
||||
|
||||
pitch_x = 100 + pitchd
|
||||
yaw_x = 100 + yawd
|
||||
rl.draw_circle(int(pitch_x), 100, 5, rl.GREEN)
|
||||
rl.draw_circle(int(yaw_x), 120, 5, rl.GREEN)
|
||||
|
||||
# filter head rotation, handling wrap-around
|
||||
rotation = math.degrees(math.atan2(pitch * 2, yaw)) # reduce yaw sensitivity
|
||||
angle_diff = rotation - self._rotation_filter.x
|
||||
angle_diff = ((angle_diff + 180) % 360) - 180
|
||||
self._rotation_filter.update(self._rotation_filter.x + angle_diff)
|
||||
|
||||
if not self.should_draw:
|
||||
self._fade_filter.update(0.0)
|
||||
elif not self.effective_active:
|
||||
self._fade_filter.update(0.35)
|
||||
else:
|
||||
self._fade_filter.update(1.0)
|
||||
@@ -0,0 +1,278 @@
|
||||
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
|
||||
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._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._wheel_alpha_filter = FirstOrderFilter(0, 0.05, 1 / gui_app.target_fps)
|
||||
self._wheel_y_filter = FirstOrderFilter(0, 0.1, 1 / gui_app.target_fps)
|
||||
|
||||
self._set_speed_alpha_filter = FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps)
|
||||
|
||||
def set_wheel_critical_icon(self, critical: bool):
|
||||
"""Set the wheel icon to critical or normal state."""
|
||||
self._show_wheel_critical = critical
|
||||
|
||||
def set_can_draw_top_icons(self, can_draw_top_icons: bool):
|
||||
"""Set whether to draw the top part of the HUD."""
|
||||
self._can_draw_top_icons = can_draw_top_icons
|
||||
|
||||
def drawing_top_icons(self) -> bool:
|
||||
# whether we're drawing any top icons currently
|
||||
return bool(self._set_speed_alpha_filter.x > 1e-2)
|
||||
|
||||
def _update_state(self) -> None:
|
||||
"""Update HUD state based on car state and controls state."""
|
||||
sm = ui_state.sm
|
||||
if sm.recv_frame["carState"] < ui_state.started_frame:
|
||||
self.is_cruise_set = False
|
||||
self.set_speed = SET_SPEED_NA
|
||||
self.speed = 0.0
|
||||
return
|
||||
|
||||
controls_state = sm['controlsState']
|
||||
car_state = sm['carState']
|
||||
|
||||
v_cruise_cluster = car_state.vCruiseCluster
|
||||
set_speed = (
|
||||
controls_state.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()
|
||||
self._engaged = engaged
|
||||
self.set_speed = set_speed
|
||||
self.is_cruise_set = 0 < self.set_speed < SET_SPEED_NA
|
||||
self.is_cruise_available = self.set_speed != -1
|
||||
|
||||
v_ego_cluster = car_state.vEgoCluster
|
||||
self.v_ego_cluster_seen = self.v_ego_cluster_seen or v_ego_cluster != 0.0
|
||||
v_ego = v_ego_cluster if self.v_ego_cluster_seen else car_state.vEgo
|
||||
speed_conversion = CV.MS_TO_KPH if ui_state.is_metric else CV.MS_TO_MPH
|
||||
self.speed = max(0.0, v_ego * speed_conversion)
|
||||
|
||||
def _render(self, rect: rl.Rectangle) -> None:
|
||||
"""Render HUD elements to the screen."""
|
||||
|
||||
self._torque_bar.render(rect)
|
||||
|
||||
if self.is_cruise_set:
|
||||
self._draw_set_speed(rect)
|
||||
|
||||
self._draw_steering_wheel(rect)
|
||||
|
||||
def _draw_steering_wheel(self, rect: rl.Rectangle) -> None:
|
||||
wheel_txt = self._txt_wheel_critical if self._show_wheel_critical else self._txt_wheel
|
||||
|
||||
bsm_detected = self._has_blind_spot_detected() if gui_app.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)
|
||||
@@ -0,0 +1,499 @@
|
||||
import colorsys
|
||||
import numpy as np
|
||||
import pyray as rl
|
||||
from openpilot.cereal import 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.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
|
||||
|
||||
THROTTLE_COLORS = [
|
||||
rl.Color(13, 248, 122, 102), # HSLF(148/360, 0.94, 0.51, 0.4)
|
||||
rl.Color(114, 255, 92, 89), # HSLF(112/360, 1.0, 0.68, 0.35)
|
||||
rl.Color(114, 255, 92, 0), # HSLF(112/360, 1.0, 0.68, 0.0)
|
||||
]
|
||||
|
||||
NO_THROTTLE_COLORS = [
|
||||
rl.Color(242, 242, 242, 102), # HSLF(148/360, 0.0, 0.95, 0.4)
|
||||
rl.Color(242, 242, 242, 89), # HSLF(112/360, 0.0, 0.95, 0.35)
|
||||
rl.Color(242, 242, 242, 0), # HSLF(112/360, 0.0, 0.95, 0.0)
|
||||
]
|
||||
|
||||
LANE_LINE_COLORS = {
|
||||
UIStatus.DISENGAGED: rl.Color(200, 200, 200, 255),
|
||||
UIStatus.OVERRIDE: rl.Color(255, 255, 255, 255),
|
||||
UIStatus.ENGAGED: rl.Color(0, 255, 64, 255),
|
||||
**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))
|
||||
|
||||
|
||||
@dataclass
|
||||
class LeadVehicle:
|
||||
glow: list[float] = field(default_factory=list)
|
||||
chevron: list[float] = field(default_factory=list)
|
||||
fill_alpha: int = 0
|
||||
|
||||
|
||||
class ModelRenderer(Widget, ModelRendererSP):
|
||||
def __init__(self):
|
||||
Widget.__init__(self)
|
||||
ModelRendererSP.__init__(self)
|
||||
self._longitudinal_control = False
|
||||
self._experimental_mode = False
|
||||
self._blend_filter = FirstOrderFilter(1.0, 0.25, 1 / gui_app.target_fps)
|
||||
self._prev_allow_throttle = True
|
||||
self._lane_line_probs = np.zeros(4, dtype=np.float32)
|
||||
self._road_edge_stds = np.zeros(2, dtype=np.float32)
|
||||
self._lead_vehicles = [LeadVehicle(), LeadVehicle()]
|
||||
self._path_offset_z = HEIGHT_INIT[0]
|
||||
|
||||
# Initialize ModelPoints objects
|
||||
self._path = ModelPoints()
|
||||
self._lane_lines = [ModelPoints() for _ in range(4)]
|
||||
self._road_edges = [ModelPoints() for _ in range(2)]
|
||||
self._acceleration_x = np.empty((0,), dtype=np.float32)
|
||||
|
||||
self._acceleration_x_filter = FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps)
|
||||
self._acceleration_x_filter2 = FirstOrderFilter(0.0, 1, 1 / gui_app.target_fps)
|
||||
|
||||
self._torque_filter = FirstOrderFilter(0, 0.1, 1 / gui_app.target_fps)
|
||||
self._ll_color_filter = FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps)
|
||||
|
||||
# 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._exp_gradient = Gradient(
|
||||
start=(0.0, 1.0), # Bottom of path
|
||||
end=(0.0, 0.0), # Top of path
|
||||
colors=[],
|
||||
stops=[],
|
||||
)
|
||||
|
||||
# Get longitudinal control setting from car parameters
|
||||
if car_params := Params().get("CarParams"):
|
||||
cp = messaging.log_from_bytes(car_params, car.CarParams)
|
||||
self._longitudinal_control = cp.openpilotLongitudinalControl
|
||||
|
||||
def set_transform(self, transform: np.ndarray):
|
||||
self._car_space_transform = transform.astype(np.float32)
|
||||
self._transform_dirty = True
|
||||
|
||||
def _render(self, rect: rl.Rectangle):
|
||||
sm = ui_state.sm
|
||||
|
||||
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["liveCalibration"] < ui_state.started_frame or
|
||||
sm.recv_frame["modelV2"] < ui_state.started_frame):
|
||||
return
|
||||
|
||||
# Set up clipping region
|
||||
self._clip_region = rl.Rectangle(
|
||||
rect.x - CLIP_MARGIN, rect.y - CLIP_MARGIN, rect.width + 2 * CLIP_MARGIN, rect.height + 2 * CLIP_MARGIN
|
||||
)
|
||||
|
||||
# Update state
|
||||
self._experimental_mode = sm['selfdriveState'].experimentalMode
|
||||
|
||||
live_calib = sm['liveCalibration']
|
||||
self._path_offset_z = live_calib.height[0] if live_calib.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)
|
||||
if render_lead_indicator:
|
||||
self._update_leads(radar_state, path_x_array)
|
||||
self._transform_dirty = False
|
||||
|
||||
# Draw elements (hide when disengaged)
|
||||
if ui_state.status != UIStatus.DISENGAGED:
|
||||
self._draw_lane_lines()
|
||||
self._draw_path(sm)
|
||||
|
||||
# if render_lead_indicator and radar_state:
|
||||
# self._draw_lead_indicator()
|
||||
|
||||
def _update_raw_points(self, model):
|
||||
"""Update raw 3D points from model data"""
|
||||
self._path.raw_points = np.array([model.position.x, np.array(model.position.y) + self._camera_offset, model.position.z], dtype=np.float32).T
|
||||
|
||||
for i, lane_line in enumerate(model.laneLines):
|
||||
self._lane_lines[i].raw_points = np.array([lane_line.x, np.array(lane_line.y) + self._camera_offset, lane_line.z], dtype=np.float32).T
|
||||
|
||||
for i, road_edge in enumerate(model.roadEdges):
|
||||
self._road_edges[i].raw_points = np.array([road_edge.x, np.array(road_edge.y) + self._camera_offset, road_edge.z], dtype=np.float32).T
|
||||
|
||||
self._lane_line_probs = np.array(model.laneLineProbs, dtype=np.float32)
|
||||
self._road_edge_stds = np.array(model.roadEdgeStds, dtype=np.float32)
|
||||
self._acceleration_x = np.array(model.acceleration.x, dtype=np.float32)
|
||||
|
||||
def _update_leads(self, radar_state, path_x_array):
|
||||
"""Update positions of lead vehicles"""
|
||||
self._lead_vehicles = [LeadVehicle(), LeadVehicle()]
|
||||
leads = [radar_state.leadOne, radar_state.leadTwo]
|
||||
|
||||
for i, lead_data in enumerate(leads):
|
||||
if lead_data and lead_data.present:
|
||||
d_rel, y_rel, v_rel = lead_data.dRel, lead_data.yRel, lead_data.vRel
|
||||
idx = self._get_path_length_idx(path_x_array, d_rel)
|
||||
|
||||
# Get z-coordinate from path at the lead vehicle position
|
||||
z = self._path.raw_points[idx, 2] if idx < len(self._path.raw_points) else 0.0
|
||||
point = self._map_to_screen(d_rel, -y_rel + self._camera_offset, z + self._path_offset_z)
|
||||
if point:
|
||||
self._lead_vehicles[i] = self._update_lead_vehicle(d_rel, v_rel, point, self._rect)
|
||||
|
||||
def _update_model(self, lead, path_x_array):
|
||||
"""Update model visualization data based on model message"""
|
||||
max_distance = np.clip(path_x_array[-1], MIN_DRAW_DISTANCE, MAX_DRAW_DISTANCE)
|
||||
max_idx = self._get_path_length_idx(self._lane_lines[0].raw_points[:, 0], max_distance)
|
||||
|
||||
# Update lane lines using raw points
|
||||
line_width_factor = 0.12
|
||||
for i, lane_line in enumerate(self._lane_lines):
|
||||
if i in (1, 2):
|
||||
line_width_factor = 0.16
|
||||
lane_line.projected_points = self._map_line_to_polygon(
|
||||
lane_line.raw_points, line_width_factor * self._lane_line_probs[i], 0.0, max_idx
|
||||
)
|
||||
|
||||
# Update road edges using raw points
|
||||
for road_edge in self._road_edges:
|
||||
road_edge.projected_points = self._map_line_to_polygon(road_edge.raw_points, line_width_factor, 0.0, max_idx)
|
||||
|
||||
# Update path using raw points
|
||||
if lead and lead.present:
|
||||
lead_d = lead.dRel * 2.0
|
||||
max_distance = np.clip(lead_d - min(lead_d * 0.35, 10.0), 0.0, max_distance)
|
||||
|
||||
soon_acceleration = self._acceleration_x[len(self._acceleration_x) // 4] if len(self._acceleration_x) > 0 else 0
|
||||
self._acceleration_x_filter.update(soon_acceleration)
|
||||
self._acceleration_x_filter2.update(soon_acceleration)
|
||||
|
||||
# make path width wider/thinner when initially braking/accelerating
|
||||
if self._experimental_mode and False:
|
||||
high_pass_acceleration = self._acceleration_x_filter.x - self._acceleration_x_filter2.x
|
||||
y_off = np.interp(high_pass_acceleration, [-1, 0, 1], [0.9 * 2, 0.9, 0.9 / 2])
|
||||
else:
|
||||
y_off = 0.9
|
||||
|
||||
max_idx = self._get_path_length_idx(path_x_array, max_distance)
|
||||
self._path.projected_points = self._map_line_to_polygon(
|
||||
self._path.raw_points, y_off, self._path_offset_z, max_idx, allow_invert=False
|
||||
)
|
||||
|
||||
self._update_experimental_gradient()
|
||||
|
||||
def _update_experimental_gradient(self):
|
||||
"""Pre-calculate experimental mode gradient colors"""
|
||||
if not self._experimental_mode:
|
||||
return
|
||||
|
||||
path_pts = self._path.projected_points + np.array([self._rect.x, self._rect.y], dtype=np.float32)
|
||||
max_len = min(len(path_pts) // 2, len(self._acceleration_x))
|
||||
|
||||
segment_colors = []
|
||||
gradient_stops = []
|
||||
|
||||
i = 0
|
||||
while i < max_len:
|
||||
# Some points (screen space) are out of frame (rect space)
|
||||
track_y = path_pts[i][1]
|
||||
if track_y < self._rect.y or track_y > (self._rect.y + self._rect.height):
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Calculate color based on acceleration (0 is bottom, 1 is top)
|
||||
lin_grad_point = 1 - (track_y - self._rect.y) / self._rect.height
|
||||
|
||||
# speed up: 120, slow down: 0
|
||||
path_hue = np.clip(60 + self._acceleration_x[i] * 35, 0, 120)
|
||||
|
||||
saturation = min(abs(self._acceleration_x[i] * 1.5), 1)
|
||||
lightness = np.interp(saturation, [0.0, 1.0], [0.95, 0.62])
|
||||
alpha = np.interp(lin_grad_point, [0.75 / 2.0, 0.75], [0.4, 0.0])
|
||||
|
||||
# Use HSL to RGB conversion
|
||||
color = self._hsla_to_color(path_hue / 360.0, saturation, lightness, alpha)
|
||||
|
||||
gradient_stops.append(lin_grad_point)
|
||||
segment_colors.append(color)
|
||||
|
||||
# Skip a point, unless next is last
|
||||
i += 1 + (1 if (i + 2) < max_len else 0)
|
||||
|
||||
# Store the gradient in the path object
|
||||
self._exp_gradient.colors = segment_colors
|
||||
self._exp_gradient.stops = gradient_stops
|
||||
|
||||
def _update_lead_vehicle(self, d_rel, v_rel, point, rect):
|
||||
speed_buff, lead_buff = 10.0, 40.0
|
||||
|
||||
# Calculate fill alpha
|
||||
fill_alpha = 0
|
||||
if d_rel < lead_buff:
|
||||
fill_alpha = 255 * (1.0 - (d_rel / lead_buff))
|
||||
if v_rel < 0:
|
||||
fill_alpha += 255 * (-1 * (v_rel / speed_buff))
|
||||
fill_alpha = min(fill_alpha, 255)
|
||||
|
||||
# Calculate size and position
|
||||
sz = np.clip((25 * 30) / (d_rel / 3 + 30), 15.0, 30.0) * 1
|
||||
x = np.clip(point[0], 0.0, rect.width - sz / 2)
|
||||
y = min(point[1], rect.height - sz * 0.6)
|
||||
|
||||
g_xo = sz / 5
|
||||
g_yo = sz / 10
|
||||
|
||||
glow = [(x + (sz * 1.35) + g_xo, y + sz + g_yo), (x, y - g_yo), (x - (sz * 1.35) - g_xo, y + sz + g_yo)]
|
||||
chevron = [(x + (sz * 1.25), y + sz), (x, y), (x - (sz * 1.25), y + sz)]
|
||||
|
||||
return LeadVehicle(glow=glow, chevron=chevron, fill_alpha=int(fill_alpha))
|
||||
|
||||
def _get_ll_color(self, prob: float, adjacent: bool, left: bool):
|
||||
alpha = np.clip(prob, 0.0, 0.7)
|
||||
if adjacent:
|
||||
_base_color = LANE_LINE_COLORS.get(ui_state.status, LANE_LINE_COLORS[UIStatus.DISENGAGED])
|
||||
color = rl.Color(_base_color.r, _base_color.g, _base_color.b, int(alpha * 255))
|
||||
|
||||
# turn adjacent lls orange if torque is high
|
||||
torque = self._torque_filter.x
|
||||
high_torque = abs(torque) > 0.6
|
||||
if high_torque and (left == (torque > 0)):
|
||||
color = blend_colors(
|
||||
color,
|
||||
rl.Color(255, 115, 0, int(alpha * 255)), # orange
|
||||
np.interp(abs(torque), [0.6, 0.8], [0.0, 1.0])
|
||||
)
|
||||
else:
|
||||
color = rl.Color(255, 255, 255, int(alpha * 255))
|
||||
|
||||
if ui_state.status == UIStatus.DISENGAGED:
|
||||
color = rl.Color(0, 0, 0, int(alpha * 255))
|
||||
|
||||
return color
|
||||
|
||||
def _draw_lane_lines(self):
|
||||
"""Draw lane lines and road edges. Two closest lines should be green (lane line or road edges)."""
|
||||
offset = np.array([self._rect.x, self._rect.y], dtype=np.float32)
|
||||
|
||||
for i, lane_line in enumerate(self._lane_lines):
|
||||
if lane_line.projected_points.size == 0:
|
||||
continue
|
||||
|
||||
color = self._get_ll_color(float(self._lane_line_probs[i]), i in (1, 2), i in (0, 1))
|
||||
draw_polygon(self._rect, lane_line.projected_points + offset, color)
|
||||
|
||||
for i, road_edge in enumerate(self._road_edges):
|
||||
if road_edge.projected_points.size == 0:
|
||||
continue
|
||||
|
||||
# if closest lane lines are not confident, make road edges green
|
||||
color = self._get_ll_color(float(1.0 - self._road_edge_stds[i]), float(self._lane_line_probs[i + 1]) < 0.25, i == 0)
|
||||
draw_polygon(self._rect, road_edge.projected_points + offset, color)
|
||||
|
||||
def _draw_path(self, sm):
|
||||
"""Draw path with dynamic coloring based on mode and throttle state."""
|
||||
if not self._path.projected_points.size:
|
||||
return
|
||||
|
||||
allow_throttle = sm['longitudinalPlan'].allowThrottle or not self._longitudinal_control
|
||||
self._blend_filter.update(int(allow_throttle))
|
||||
|
||||
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 self._experimental_mode:
|
||||
# Draw with acceleration coloring
|
||||
if ui_state.status == UIStatus.DISENGAGED:
|
||||
draw_polygon(self._rect, path_pts, rl.Color(0, 0, 0, 90))
|
||||
elif len(self._exp_gradient.colors) > 1:
|
||||
draw_polygon(self._rect, path_pts, gradient=self._exp_gradient)
|
||||
else:
|
||||
draw_polygon(self._rect, path_pts, rl.Color(255, 255, 255, 30))
|
||||
else:
|
||||
# Blend throttle/no throttle colors based on transition
|
||||
blend_factor = round(self._blend_filter.x * 100) / 100
|
||||
blended_colors = self._blend_colors(NO_THROTTLE_COLORS, THROTTLE_COLORS, blend_factor)
|
||||
gradient = Gradient(
|
||||
start=(0.0, 1.0), # Bottom of path
|
||||
end=(0.0, 0.0), # Top of path
|
||||
colors=blended_colors,
|
||||
stops=[0.0, 0.5, 1.0],
|
||||
)
|
||||
|
||||
if ui_state.status == UIStatus.DISENGAGED:
|
||||
draw_polygon(self._rect, path_pts, rl.Color(0, 0, 0, 90))
|
||||
else:
|
||||
draw_polygon(self._rect, path_pts, gradient=gradient)
|
||||
|
||||
def _draw_lead_indicator(self):
|
||||
# Draw lead vehicles if available
|
||||
for lead in self._lead_vehicles:
|
||||
if not lead.glow or not lead.chevron:
|
||||
continue
|
||||
|
||||
rl.draw_triangle_fan(lead.glow, len(lead.glow), rl.Color(218, 202, 37, 255))
|
||||
rl.draw_triangle_fan(lead.chevron, len(lead.chevron), rl.Color(201, 34, 49, lead.fill_alpha))
|
||||
|
||||
@staticmethod
|
||||
def _get_path_length_idx(pos_x_array: np.ndarray, path_height: float) -> int:
|
||||
"""Get the index corresponding to the given path height"""
|
||||
if len(pos_x_array) == 0:
|
||||
return 0
|
||||
indices = np.where(pos_x_array <= path_height)[0]
|
||||
return indices[-1] if indices.size > 0 else 0
|
||||
|
||||
def _map_to_screen(self, in_x, in_y, in_z):
|
||||
"""Project a point in car space to screen space"""
|
||||
input_pt = np.array([in_x, in_y, in_z])
|
||||
pt = self._car_space_transform @ input_pt
|
||||
|
||||
if abs(pt[2]) < 1e-6:
|
||||
return None
|
||||
|
||||
x, y = pt[0] / pt[2], pt[1] / pt[2]
|
||||
|
||||
clip = self._clip_region
|
||||
if not (clip.x <= x <= clip.x + clip.width and clip.y <= y <= clip.y + clip.height):
|
||||
return None
|
||||
|
||||
return (x, y)
|
||||
|
||||
def _map_line_to_polygon(self, line: np.ndarray, y_off: float, z_off: float, max_idx: int, allow_invert: bool = True) -> np.ndarray:
|
||||
"""Convert 3D line to 2D polygon for rendering."""
|
||||
if line.shape[0] == 0:
|
||||
return np.empty((0, 2), dtype=np.float32)
|
||||
|
||||
# Slice points and filter non-negative x-coordinates
|
||||
points = line[:max_idx + 1]
|
||||
points = points[points[:, 0] >= 0]
|
||||
if points.shape[0] == 0:
|
||||
return np.empty((0, 2), dtype=np.float32)
|
||||
|
||||
N = points.shape[0]
|
||||
# Generate left and right 3D points in one array using broadcasting
|
||||
offsets = np.array([[0, -y_off, z_off], [0, y_off, z_off]], dtype=np.float32)
|
||||
points_3d = points[None, :, :] + offsets[:, None, :] # Shape: 2xNx3
|
||||
points_3d = points_3d.reshape(2 * N, 3) # Shape: (2*N)x3
|
||||
|
||||
# Transform all points to projected space in one operation
|
||||
proj = self._car_space_transform @ points_3d.T # Shape: 3x(2*N)
|
||||
proj = proj.reshape(3, 2, N)
|
||||
left_proj = proj[:, 0, :]
|
||||
right_proj = proj[:, 1, :]
|
||||
|
||||
# Filter points where z is sufficiently large
|
||||
valid_proj = (np.abs(left_proj[2]) >= 1e-6) & (np.abs(right_proj[2]) >= 1e-6)
|
||||
if not np.any(valid_proj):
|
||||
return np.empty((0, 2), dtype=np.float32)
|
||||
|
||||
# Compute screen coordinates
|
||||
left_screen = left_proj[:2, valid_proj] / left_proj[2, valid_proj][None, :]
|
||||
right_screen = right_proj[:2, valid_proj] / right_proj[2, valid_proj][None, :]
|
||||
|
||||
# Define clip region bounds
|
||||
clip = self._clip_region
|
||||
x_min, x_max = clip.x, clip.x + clip.width
|
||||
y_min, y_max = clip.y, clip.y + clip.height
|
||||
|
||||
# Filter points within clip region
|
||||
left_in_clip = (
|
||||
(left_screen[0] >= x_min) & (left_screen[0] <= x_max) &
|
||||
(left_screen[1] >= y_min) & (left_screen[1] <= y_max)
|
||||
)
|
||||
right_in_clip = (
|
||||
(right_screen[0] >= x_min) & (right_screen[0] <= x_max) &
|
||||
(right_screen[1] >= y_min) & (right_screen[1] <= y_max)
|
||||
)
|
||||
both_in_clip = left_in_clip & right_in_clip
|
||||
|
||||
if not np.any(both_in_clip):
|
||||
return np.empty((0, 2), dtype=np.float32)
|
||||
|
||||
# Select valid and clipped points
|
||||
left_screen = left_screen[:, both_in_clip]
|
||||
right_screen = right_screen[:, both_in_clip]
|
||||
|
||||
# Handle Y-coordinate inversion on hills
|
||||
if not allow_invert and left_screen.shape[1] > 1:
|
||||
y = left_screen[1, :] # y-coordinates
|
||||
keep = y == np.minimum.accumulate(y)
|
||||
if not np.any(keep):
|
||||
return np.empty((0, 2), dtype=np.float32)
|
||||
left_screen = left_screen[:, keep]
|
||||
right_screen = right_screen[:, keep]
|
||||
|
||||
return np.vstack((left_screen.T, right_screen[:, ::-1].T)).astype(np.float32)
|
||||
|
||||
@staticmethod
|
||||
def _hsla_to_color(h, s, l, a):
|
||||
rgb = colorsys.hls_to_rgb(h, l, s)
|
||||
return rl.Color(
|
||||
int(rgb[0] * 255),
|
||||
int(rgb[1] * 255),
|
||||
int(rgb[2] * 255),
|
||||
int(a * 255)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _blend_colors(begin_colors, end_colors, t):
|
||||
if t >= 1.0:
|
||||
return end_colors
|
||||
if t <= 0.0:
|
||||
return begin_colors
|
||||
|
||||
inv_t = 1.0 - t
|
||||
return [rl.Color(
|
||||
int(inv_t * start.r + t * end.r),
|
||||
int(inv_t * start.g + t * end.g),
|
||||
int(inv_t * start.b + t * end.b),
|
||||
int(inv_t * start.a + t * end.a)
|
||||
) for start, end in zip(begin_colors, end_colors, strict=True)]
|
||||
@@ -0,0 +1,268 @@
|
||||
import math
|
||||
import time
|
||||
from functools import wraps
|
||||
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
|
||||
from openpilot.system.ui.widgets import Widget
|
||||
from openpilot.common.filter_simple import FirstOrderFilter
|
||||
|
||||
# TODO: arc_bar_pts doesn't consider rounded end caps part of the angle span
|
||||
TORQUE_ANGLE_SPAN = 12.7
|
||||
|
||||
DEBUG = False
|
||||
|
||||
|
||||
def quantized_lru_cache(maxsize=128):
|
||||
def decorator(func):
|
||||
cache = OrderedDict()
|
||||
@wraps(func)
|
||||
def wrapper(r_mid, thickness, a0_deg, a1_deg, **kwargs):
|
||||
# Quantize inputs: balanced for smoothness vs cache effectiveness
|
||||
key = (round(r_mid),
|
||||
round(thickness), # 1px precision for smoother height transitions
|
||||
round(a0_deg * 10) / 10, # 0.1° precision for smoother angle transitions
|
||||
round(a1_deg * 10) / 10,
|
||||
tuple(sorted(kwargs.items())))
|
||||
|
||||
if key in cache:
|
||||
cache.move_to_end(key)
|
||||
else:
|
||||
if len(cache) >= maxsize:
|
||||
cache.popitem(last=False)
|
||||
|
||||
result = func(r_mid, thickness, a0_deg, a1_deg, **kwargs)
|
||||
cache[key] = result
|
||||
return cache[key]
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
@quantized_lru_cache(maxsize=256)
|
||||
def arc_bar_pts(r_mid: float, thickness: float,
|
||||
a0_deg: float, a1_deg: float,
|
||||
*, max_points: int = 100, cap_segs: int = 10,
|
||||
cap_radius: float = 7, px_per_seg: float = 2.0) -> np.ndarray:
|
||||
"""Return Nx2 np.float32 points for a single closed polygon (rounded thick arc), centered at origin."""
|
||||
|
||||
def get_cap(left: bool, a_deg: float):
|
||||
# end cap at a1: center (a1), sweep a1→a1+180 (skip endpoints to avoid dupes)
|
||||
# quarter arc (outer corner) at a1 with fixed pixel radius cap_radius
|
||||
|
||||
nx, ny = math.cos(math.radians(a_deg)), math.sin(math.radians(a_deg)) # outward normal
|
||||
tx, ty = -ny, nx # tangent (CCW)
|
||||
|
||||
mx, my = nx * r_mid, ny * r_mid # mid-point at a1
|
||||
if DEBUG:
|
||||
rl.draw_circle(int(mx), int(my), 4, rl.PURPLE)
|
||||
|
||||
ex = mx + nx * (half - cap_radius)
|
||||
ey = my + ny * (half - cap_radius)
|
||||
|
||||
if DEBUG:
|
||||
rl.draw_circle(int(ex), int(ey), 2, rl.WHITE)
|
||||
|
||||
# sweep 90° in the local (t,n) frame: from outer edge toward inside
|
||||
if not left:
|
||||
alpha = np.deg2rad(np.linspace(90, 0, cap_segs + 2))[1:-1]
|
||||
else:
|
||||
alpha = np.deg2rad(np.linspace(180, 90, cap_segs + 2))[1:-1]
|
||||
cap_end = np.c_[ex + np.cos(alpha) * cap_radius * tx + np.sin(alpha) * cap_radius * nx,
|
||||
ey + np.cos(alpha) * cap_radius * ty + np.sin(alpha) * cap_radius * ny]
|
||||
|
||||
# bottom quarter (inner corner) at a1
|
||||
ex2 = mx + nx * (-half + cap_radius)
|
||||
ey2 = my + ny * (-half + cap_radius)
|
||||
if DEBUG:
|
||||
rl.draw_circle(int(ex2), int(ey2), 2, rl.WHITE)
|
||||
|
||||
if not left:
|
||||
alpha2 = np.deg2rad(np.linspace(0, -90, cap_segs + 1))[:-1] # include 0 once, exclude -90
|
||||
else:
|
||||
alpha2 = np.deg2rad(np.linspace(90 - 90 - 90, 0 - 90 - 90, cap_segs + 1))[:-1]
|
||||
cap_end_bot = np.c_[ex2 + np.cos(alpha2) * cap_radius * tx + np.sin(alpha2) * cap_radius * nx,
|
||||
ey2 + np.cos(alpha2) * cap_radius * ty + np.sin(alpha2) * cap_radius * ny]
|
||||
|
||||
# append to the top quarter
|
||||
if not left:
|
||||
cap_end = np.vstack((cap_end, cap_end_bot))
|
||||
else:
|
||||
cap_end = np.vstack((cap_end_bot, cap_end))
|
||||
|
||||
return cap_end
|
||||
|
||||
if a1_deg < a0_deg:
|
||||
a0_deg, a1_deg = a1_deg, a0_deg
|
||||
half = thickness * 0.5
|
||||
|
||||
cap_radius = min(cap_radius, half)
|
||||
|
||||
span = max(1e-3, a1_deg - a0_deg)
|
||||
|
||||
# pick arc segment count from arc length, clamp to shader points[] budget
|
||||
arc_len = r_mid * math.radians(span)
|
||||
arc_segs = max(6, int(arc_len / px_per_seg))
|
||||
max_arc = (max_points - (4 * cap_segs + 3)) // 2
|
||||
arc_segs = max(6, min(arc_segs, max_arc))
|
||||
|
||||
# outer arc a0→a1
|
||||
ang_o = np.deg2rad(np.linspace(a0_deg, a1_deg, arc_segs + 1))
|
||||
outer = np.c_[np.cos(ang_o) * (r_mid + half),
|
||||
np.sin(ang_o) * (r_mid + half)]
|
||||
|
||||
# end cap at a1
|
||||
cap_end = get_cap(False, a1_deg)
|
||||
|
||||
# inner arc a1→a0
|
||||
ang_i = np.deg2rad(np.linspace(a1_deg, a0_deg, arc_segs + 1))
|
||||
inner = np.c_[np.cos(ang_i) * (r_mid - half),
|
||||
np.sin(ang_i) * (r_mid - half)]
|
||||
|
||||
# start cap at a0
|
||||
cap_start = get_cap(True, a0_deg)
|
||||
|
||||
pts = np.vstack((outer, cap_end, inner, cap_start, outer[:1])).astype(np.float32)
|
||||
|
||||
# Rotate to start from middle of cap for proper triangulation
|
||||
pts = np.roll(pts, cap_segs, axis=0)
|
||||
|
||||
if DEBUG:
|
||||
n = len(pts)
|
||||
idx = int(time.monotonic() * 12) % max(1, n) # speed: 12 pts/sec
|
||||
for i, (x, y) in enumerate(pts):
|
||||
j = (i - idx) % n # rotate the gradient
|
||||
t = j / n
|
||||
color = rl.Color(255, int(255 * (1 - t)), int(255 * t), 255)
|
||||
rl.draw_circle(int(x), int(y), 2, color)
|
||||
|
||||
return pts
|
||||
|
||||
|
||||
DEFAULT_MAX_LAT_ACCEL = 3.0 # m/s^2
|
||||
|
||||
|
||||
class TorqueBar(Widget):
|
||||
def __init__(self, demo: bool = False, scale: float = 1.0, always: bool = False):
|
||||
super().__init__()
|
||||
self._demo = demo
|
||||
self._scale = scale
|
||||
self._always = always
|
||||
self._torque_filter = FirstOrderFilter(0, 0.1, 1 / gui_app.target_fps)
|
||||
self._torque_line_alpha_filter = FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps)
|
||||
|
||||
def update_filter(self, value: float):
|
||||
"""Update the torque filter value (for demo mode)."""
|
||||
self._torque_filter.update(value)
|
||||
|
||||
def _update_state(self):
|
||||
if self._demo:
|
||||
return
|
||||
|
||||
# torque line
|
||||
if ui_state.sm['controlsState'].lateralControlState.which() in ('angleState', 'curvatureState'):
|
||||
controls_state = ui_state.sm['controlsState']
|
||||
car_state = ui_state.sm['carState']
|
||||
live_parameters = ui_state.sm['liveParameters']
|
||||
car_control = ui_state.sm['carControl']
|
||||
|
||||
# Include lateral accel error in estimated torque utilization
|
||||
actual_lateral_accel = controls_state.curvature * car_state.vEgo ** 2
|
||||
desired_lateral_accel = controls_state.desiredCurvature * car_state.vEgo ** 2
|
||||
accel_diff = (desired_lateral_accel - actual_lateral_accel)
|
||||
|
||||
# Include road roll in estimated torque utilization
|
||||
# Roll is less accurate near standstill, so reduce its effect at low speed
|
||||
roll_compensation = live_parameters.roll * ACCELERATION_DUE_TO_GRAVITY * np.interp(car_state.vEgo, [5, 15], [0.0, 1.0])
|
||||
lateral_acceleration = actual_lateral_accel - roll_compensation
|
||||
max_lateral_acceleration = ui_state.CP.maxLateralAccel if ui_state.CP else DEFAULT_MAX_LAT_ACCEL
|
||||
|
||||
if not car_control.latActive:
|
||||
self._torque_filter.update(0.0)
|
||||
else:
|
||||
self._torque_filter.update(np.clip((lateral_acceleration + accel_diff) / max_lateral_acceleration, -1, 1))
|
||||
else:
|
||||
self._torque_filter.update(-ui_state.sm['carOutput'].actuatorsOutput.torque)
|
||||
|
||||
def _render(self, rect: rl.Rectangle) -> None:
|
||||
# adjust y pos with torque
|
||||
torque_line_offset = np.interp(abs(self._torque_filter.x), [0.5, 1], [22 * self._scale, 26 * self._scale])
|
||||
torque_line_height = np.interp(abs(self._torque_filter.x), [0.5, 1], [14 * self._scale, 56 * self._scale])
|
||||
|
||||
# animate alpha and angle span
|
||||
if not self._demo:
|
||||
self._torque_line_alpha_filter.update(ui_state.status not in (UIStatus.DISENGAGED, UIStatus.LONG_ONLY))
|
||||
else:
|
||||
self._torque_line_alpha_filter.update(1.0)
|
||||
|
||||
torque_line_bg_alpha = np.interp(abs(self._torque_filter.x), [0.5, 1.0], [0.25, 0.5])
|
||||
torque_line_bg_color = rl.Color(255, 255, 255, int(255 * torque_line_bg_alpha * self._torque_line_alpha_filter.x))
|
||||
if ui_state.status not in (UIStatus.ENGAGED, UIStatus.LAT_ONLY) and not self._demo:
|
||||
torque_line_bg_color = rl.Color(255, 255, 255, int(255 * 0.15 * self._torque_line_alpha_filter.x))
|
||||
|
||||
# draw curved line polygon torque bar
|
||||
torque_line_radius = 1200 * self._scale
|
||||
top_angle = -90
|
||||
torque_bg_angle_span = self._torque_line_alpha_filter.x * TORQUE_ANGLE_SPAN
|
||||
torque_start_angle = top_angle - torque_bg_angle_span / 2
|
||||
torque_end_angle = top_angle + torque_bg_angle_span / 2
|
||||
# centerline radius & center (you already have these values)
|
||||
mid_r = torque_line_radius + torque_line_height / 2
|
||||
|
||||
cx = rect.x + rect.width / 2 + 8 # offset 8px to right of camera feed
|
||||
cy = rect.y + rect.height + torque_line_radius - torque_line_offset
|
||||
offset = np.array([cx, cy], dtype=np.float32)
|
||||
|
||||
# draw bg torque indicator line
|
||||
bg_pts = arc_bar_pts(mid_r, torque_line_height, torque_start_angle, torque_end_angle, cap_radius=7 * self._scale) + offset
|
||||
draw_polygon(rect, bg_pts, color=torque_line_bg_color)
|
||||
|
||||
# draw torque indicator line
|
||||
a0s = top_angle
|
||||
a1s = a0s + torque_bg_angle_span / 2 * self._torque_filter.x
|
||||
sl_pts = arc_bar_pts(mid_r, torque_line_height, a0s, a1s, cap_radius=7 * self._scale) + offset
|
||||
|
||||
# draw beautiful gradient from center to 65% of the bg torque bar width
|
||||
start_grad_pt = cx / rect.width
|
||||
if self._torque_filter.x < 0:
|
||||
end_grad_pt = (cx * (1 - 0.65) + (min(bg_pts[:, 0]) * 0.65)) / rect.width
|
||||
else:
|
||||
end_grad_pt = (cx * (1 - 0.65) + (max(bg_pts[:, 0]) * 0.65)) / rect.width
|
||||
|
||||
# fade to orange as we approach max torque
|
||||
start_color = blend_colors(
|
||||
rl.Color(255, 255, 255, int(255 * 0.9 * self._torque_line_alpha_filter.x)),
|
||||
rl.Color(255, 200, 0, int(255 * self._torque_line_alpha_filter.x)), # yellow
|
||||
max(0, abs(self._torque_filter.x) - 0.75) * 4,
|
||||
)
|
||||
end_color = blend_colors(
|
||||
rl.Color(255, 255, 255, int(255 * 0.9 * self._torque_line_alpha_filter.x)),
|
||||
rl.Color(255, 115, 0, int(255 * self._torque_line_alpha_filter.x)), # orange
|
||||
max(0, abs(self._torque_filter.x) - 0.75) * 4,
|
||||
)
|
||||
|
||||
if ui_state.status not in (UIStatus.ENGAGED, UIStatus.LAT_ONLY) and not self._demo:
|
||||
start_color = end_color = rl.Color(255, 255, 255, int(255 * 0.35 * self._torque_line_alpha_filter.x))
|
||||
|
||||
gradient = Gradient(
|
||||
start=(start_grad_pt, 0),
|
||||
end=(end_grad_pt, 0),
|
||||
colors=[
|
||||
start_color,
|
||||
end_color,
|
||||
],
|
||||
stops=[0.0, 1.0],
|
||||
)
|
||||
|
||||
draw_polygon(rect, sl_pts, gradient=gradient)
|
||||
|
||||
# draw center torque bar dot
|
||||
if abs(self._torque_filter.x) < 0.5:
|
||||
dot_y = self._rect.y + self._rect.height - torque_line_offset - torque_line_height / 2
|
||||
rl.draw_circle(int(cx), int(dot_y), (10 // 2 * self._scale),
|
||||
rl.Color(182, 182, 182, int(255 * 0.9 * self._torque_line_alpha_filter.x)))
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
import gc
|
||||
import weakref
|
||||
import pytest
|
||||
|
||||
# FIXME: known small leaks not worth worrying about at the moment
|
||||
KNOWN_LEAKS = {
|
||||
"openpilot.selfdrive.ui.mici.onroad.driver_camera_dialog.DriverCameraView",
|
||||
"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.driver_camera_dialog.DriverCameraDialog",
|
||||
"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
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="segfaults")
|
||||
def test_dialogs_do_not_leak():
|
||||
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.driver_camera_dialog import DriverCameraDialog as MiciDriverCameraDialog
|
||||
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.driver_camera_dialog import DriverCameraDialog as TiciDriverCameraDialog
|
||||
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
|
||||
MiciDriverCameraDialog, 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
|
||||
TiciDriverCameraDialog, 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__":
|
||||
test_dialogs_do_not_leak()
|
||||
@@ -0,0 +1,422 @@
|
||||
import math
|
||||
import pyray as rl
|
||||
from typing import Union
|
||||
from enum import Enum
|
||||
from collections.abc import Callable
|
||||
from openpilot.system.ui.widgets import Widget
|
||||
from openpilot.system.ui.widgets.label import UnifiedLabel
|
||||
from openpilot.system.ui.widgets.scroller import DO_ZOOM
|
||||
from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos
|
||||
from openpilot.common.filter_simple import BounceFilter
|
||||
|
||||
try:
|
||||
from openpilot.common.params import Params
|
||||
except ImportError:
|
||||
Params = None
|
||||
|
||||
SCROLLING_SPEED_PX_S = 50
|
||||
COMPLICATION_SIZE = 36
|
||||
LABEL_COLOR = rl.Color(255, 255, 255, int(255 * 0.9))
|
||||
COMPLICATION_GREY = rl.Color(0xAA, 0xAA, 0xAA, 255)
|
||||
PRESSED_SCALE = 1.15 if DO_ZOOM else 1.07
|
||||
|
||||
|
||||
class ScrollState(Enum):
|
||||
PRE_SCROLL = 0
|
||||
SCROLLING = 1
|
||||
POST_SCROLL = 2
|
||||
|
||||
|
||||
class BigCircleButton(Widget):
|
||||
def __init__(self, icon: rl.Texture, red: bool = False, icon_offset: tuple[int, int] = (0, 0)):
|
||||
super().__init__()
|
||||
self._red = red
|
||||
self._icon_offset = icon_offset
|
||||
|
||||
# State
|
||||
self.set_rect(rl.Rectangle(0, 0, 180, 180))
|
||||
self._scale_filter = BounceFilter(1.0, 0.1, 1 / gui_app.target_fps)
|
||||
self._click_delay = 0.075
|
||||
|
||||
# Icons
|
||||
self._txt_icon = icon
|
||||
self._txt_btn_disabled_bg = gui_app.texture("icons_mici/buttons/button_circle_disabled.png", 180, 180)
|
||||
|
||||
self._txt_btn_bg = gui_app.texture("icons_mici/buttons/button_circle.png", 180, 180)
|
||||
self._txt_btn_pressed_bg = gui_app.texture("icons_mici/buttons/button_circle_pressed.png", 180, 180)
|
||||
|
||||
self._txt_btn_red_bg = gui_app.texture("icons_mici/buttons/button_circle_red.png", 180, 180)
|
||||
self._txt_btn_red_pressed_bg = gui_app.texture("icons_mici/buttons/button_circle_red_pressed.png", 180, 180)
|
||||
|
||||
def _draw_content(self, btn_y: float):
|
||||
# draw icon
|
||||
icon_color = rl.Color(255, 255, 255, int(255 * 0.9)) if self.enabled else rl.Color(255, 255, 255, int(255 * 0.35))
|
||||
rl.draw_texture_ex(self._txt_icon, (self._rect.x + (self._rect.width - self._txt_icon.width) / 2 + self._icon_offset[0],
|
||||
btn_y + (self._rect.height - self._txt_icon.height) / 2 + self._icon_offset[1]), 0, 1.0, icon_color)
|
||||
|
||||
def _render(self, _):
|
||||
# draw background
|
||||
txt_bg = self._txt_btn_bg if not self._red else self._txt_btn_red_bg
|
||||
if not self.enabled:
|
||||
txt_bg = self._txt_btn_disabled_bg
|
||||
elif self.is_pressed:
|
||||
txt_bg = self._txt_btn_pressed_bg if not self._red else self._txt_btn_red_pressed_bg
|
||||
|
||||
scale = self._scale_filter.update(PRESSED_SCALE if self.is_pressed else 1.0)
|
||||
btn_x = self._rect.x + (self._rect.width * (1 - scale)) / 2
|
||||
btn_y = self._rect.y + (self._rect.height * (1 - scale)) / 2
|
||||
rl.draw_texture_ex(txt_bg, (btn_x, btn_y), 0, scale, rl.WHITE)
|
||||
|
||||
self._draw_content(btn_y)
|
||||
|
||||
|
||||
class BigCircleToggle(BigCircleButton):
|
||||
def __init__(self, icon: rl.Texture, toggle_callback: Callable | None = None, icon_offset: tuple[int, int] = (0, 0)):
|
||||
super().__init__(icon, False, icon_offset=icon_offset)
|
||||
self._toggle_callback = toggle_callback
|
||||
|
||||
# State
|
||||
self._checked = False
|
||||
|
||||
# Icons
|
||||
self._txt_toggle_enabled = gui_app.texture("icons_mici/buttons/toggle_dot_enabled.png", 66, 66)
|
||||
self._txt_toggle_disabled = gui_app.texture("icons_mici/buttons/toggle_dot_disabled.png", 66, 66)
|
||||
|
||||
def set_checked(self, checked: bool):
|
||||
self._checked = checked
|
||||
|
||||
def _handle_mouse_release(self, mouse_pos: MousePos):
|
||||
super()._handle_mouse_release(mouse_pos)
|
||||
|
||||
self._checked = not self._checked
|
||||
if self._toggle_callback:
|
||||
self._toggle_callback(self._checked)
|
||||
|
||||
def _draw_content(self, btn_y: float):
|
||||
super()._draw_content(btn_y)
|
||||
|
||||
# draw status icon
|
||||
rl.draw_texture_ex(self._txt_toggle_enabled if self._checked else self._txt_toggle_disabled,
|
||||
(self._rect.x + (self._rect.width - self._txt_toggle_enabled.width) / 2, btn_y + 5),
|
||||
0, 1.0, rl.WHITE)
|
||||
|
||||
|
||||
class BigButton(Widget):
|
||||
LABEL_HORIZONTAL_PADDING = 40
|
||||
LABEL_VERTICAL_PADDING = 23 # visually matches 30 in figma
|
||||
|
||||
"""A lightweight stand-in for the Qt BigButton, drawn & updated each frame."""
|
||||
|
||||
def __init__(self, text: str, value: str = "", icon: Union[rl.Texture, None] = None, scroll: bool = False):
|
||||
super().__init__()
|
||||
self.set_rect(rl.Rectangle(0, 0, 402, 180))
|
||||
self.text = text
|
||||
self.value = value
|
||||
self._txt_icon = icon
|
||||
self._scroll = scroll
|
||||
|
||||
self._scale_filter = BounceFilter(1.0, 0.1, 1 / gui_app.target_fps)
|
||||
self._click_delay = 0.075
|
||||
self._shake_start: float | None = None
|
||||
self._grow_animation_until: float | None = None
|
||||
|
||||
self._rotate_icon_t: float | None = None
|
||||
|
||||
self._label = UnifiedLabel(text, font_size=self._get_label_font_size(), font_weight=FontWeight.BOLD,
|
||||
text_color=LABEL_COLOR, alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM, scroll=scroll,
|
||||
line_height=0.9)
|
||||
self._sub_label = UnifiedLabel(value, font_size=COMPLICATION_SIZE, font_weight=FontWeight.ROMAN,
|
||||
text_color=COMPLICATION_GREY, alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM)
|
||||
self._update_label_layout()
|
||||
|
||||
self._load_images()
|
||||
|
||||
def set_icon(self, icon: Union[rl.Texture, None]):
|
||||
self._txt_icon = icon
|
||||
|
||||
def set_rotate_icon(self, rotate: bool):
|
||||
if rotate and self._rotate_icon_t is not None:
|
||||
return
|
||||
self._rotate_icon_t = rl.get_time() if rotate else None
|
||||
|
||||
def _load_images(self):
|
||||
self._txt_default_bg = gui_app.texture("icons_mici/buttons/button_rectangle.png", 402, 180)
|
||||
self._txt_pressed_bg = gui_app.texture("icons_mici/buttons/button_rectangle_pressed.png", 402, 180)
|
||||
self._txt_disabled_bg = gui_app.texture("icons_mici/buttons/button_rectangle_disabled.png", 402, 180)
|
||||
|
||||
def set_touch_valid_callback(self, touch_callback: Callable[[], bool]) -> None:
|
||||
super().set_touch_valid_callback(lambda: touch_callback() and self._grow_animation_until is None)
|
||||
|
||||
def _width_hint(self) -> int:
|
||||
# Single line if scrolling, so hide behind icon if exists
|
||||
icon_size = self._txt_icon.width if self._txt_icon and self._scroll and self.value else 0
|
||||
return int(self._rect.width - self.LABEL_HORIZONTAL_PADDING * 2 - icon_size)
|
||||
|
||||
def _get_label_font_size(self):
|
||||
if len(self.text) <= 18:
|
||||
return 48
|
||||
else:
|
||||
return 42
|
||||
|
||||
def _update_label_layout(self):
|
||||
self._label.set_font_size(self._get_label_font_size())
|
||||
if self.value:
|
||||
self._label.set_alignment_vertical(rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP)
|
||||
else:
|
||||
self._label.set_alignment_vertical(rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM)
|
||||
|
||||
def set_text(self, text: str):
|
||||
self.text = text
|
||||
self._label.set_text(text)
|
||||
self._update_label_layout()
|
||||
|
||||
def set_value(self, value: str):
|
||||
self.value = value
|
||||
self._sub_label.set_text(value)
|
||||
self._update_label_layout()
|
||||
|
||||
def get_value(self) -> str:
|
||||
return self.value
|
||||
|
||||
def get_text(self):
|
||||
return self.text
|
||||
|
||||
def trigger_shake(self):
|
||||
self._shake_start = rl.get_time()
|
||||
|
||||
def trigger_grow_animation(self, duration: float = 0.65):
|
||||
self._grow_animation_until = rl.get_time() + duration
|
||||
|
||||
@property
|
||||
def _shake_offset(self) -> float:
|
||||
SHAKE_DURATION = 0.5
|
||||
SHAKE_AMPLITUDE = 24.0
|
||||
SHAKE_FREQUENCY = 32.0
|
||||
if self._shake_start is None:
|
||||
return 0.0
|
||||
t = rl.get_time() - self._shake_start
|
||||
if t > SHAKE_DURATION:
|
||||
return 0.0
|
||||
decay = 1.0 - t / SHAKE_DURATION
|
||||
return decay * SHAKE_AMPLITUDE * math.sin(t * SHAKE_FREQUENCY)
|
||||
|
||||
def set_position(self, x: float, y: float) -> None:
|
||||
super().set_position(x + self._shake_offset, y)
|
||||
|
||||
def _handle_background(self) -> tuple[rl.Texture, float, float, float]:
|
||||
if self._grow_animation_until is not None:
|
||||
if rl.get_time() >= self._grow_animation_until:
|
||||
self._grow_animation_until = None
|
||||
|
||||
# draw _txt_default_bg
|
||||
txt_bg = self._txt_default_bg
|
||||
if not self.enabled:
|
||||
txt_bg = self._txt_disabled_bg
|
||||
elif self.is_pressed:
|
||||
txt_bg = self._txt_pressed_bg
|
||||
|
||||
scale = self._scale_filter.update(PRESSED_SCALE if self.is_pressed or self._grow_animation_until is not None else 1.0)
|
||||
btn_x = self._rect.x + (self._rect.width * (1 - scale)) / 2
|
||||
btn_y = self._rect.y + (self._rect.height * (1 - scale)) / 2
|
||||
return txt_bg, btn_x, btn_y, scale
|
||||
|
||||
def _draw_content(self, btn_y: float):
|
||||
# LABEL ------------------------------------------------------------------
|
||||
label_x = self._rect.x + self.LABEL_HORIZONTAL_PADDING
|
||||
|
||||
label_color = LABEL_COLOR if self.enabled else rl.Color(255, 255, 255, int(255 * 0.35))
|
||||
self._label.set_color(label_color)
|
||||
label_rect = rl.Rectangle(label_x, btn_y + self.LABEL_VERTICAL_PADDING, self._width_hint(),
|
||||
self._rect.height - self.LABEL_VERTICAL_PADDING * 2)
|
||||
self._label.render(label_rect)
|
||||
|
||||
if self.value:
|
||||
label_y = btn_y + self.LABEL_VERTICAL_PADDING + self._label.get_content_height(self._width_hint())
|
||||
sub_label_height = btn_y + self._rect.height - self.LABEL_VERTICAL_PADDING - label_y
|
||||
sub_label_rect = rl.Rectangle(label_x, label_y, self._width_hint(), sub_label_height)
|
||||
self._sub_label.render(sub_label_rect)
|
||||
|
||||
# ICON -------------------------------------------------------------------
|
||||
if self._txt_icon:
|
||||
rotation = 0
|
||||
if self._rotate_icon_t is not None:
|
||||
rotation = (rl.get_time() - self._rotate_icon_t) * 180
|
||||
|
||||
# draw top right with 30px padding
|
||||
x = self._rect.x + self._rect.width - 30 - self._txt_icon.width / 2
|
||||
y = btn_y + 30 + self._txt_icon.height / 2
|
||||
source_rec = rl.Rectangle(0, 0, self._txt_icon.width, self._txt_icon.height)
|
||||
dest_rec = rl.Rectangle(x, y, self._txt_icon.width, self._txt_icon.height)
|
||||
origin = rl.Vector2(self._txt_icon.width / 2, self._txt_icon.height / 2)
|
||||
rl.draw_texture_pro(self._txt_icon, source_rec, dest_rec, origin, rotation, rl.Color(255, 255, 255, int(255 * 0.9)))
|
||||
|
||||
def _render(self, _):
|
||||
txt_bg, btn_x, btn_y, scale = self._handle_background()
|
||||
|
||||
if self._scroll:
|
||||
# draw black background since images are transparent
|
||||
scaled_rect = rl.Rectangle(btn_x, btn_y, self._rect.width * scale, self._rect.height * scale)
|
||||
rl.draw_rectangle_rounded(scaled_rect, 0.4, 7, rl.Color(0, 0, 0, int(255 * 0.5)))
|
||||
|
||||
self._draw_content(btn_y)
|
||||
rl.draw_texture_ex(txt_bg, (btn_x, btn_y), 0, scale, rl.WHITE)
|
||||
else:
|
||||
rl.draw_texture_ex(txt_bg, (btn_x, btn_y), 0, scale, rl.WHITE)
|
||||
self._draw_content(btn_y)
|
||||
|
||||
|
||||
class BigToggle(BigButton):
|
||||
def __init__(self, text: str, value: str = "", initial_state: bool = False, toggle_callback: Callable | None = None):
|
||||
super().__init__(text, value, "")
|
||||
self._checked = initial_state
|
||||
self._toggle_callback = toggle_callback
|
||||
|
||||
def _load_images(self):
|
||||
super()._load_images()
|
||||
self._txt_enabled_toggle = gui_app.texture("icons_mici/buttons/toggle_pill_enabled.png", 84, 66)
|
||||
self._txt_disabled_toggle = gui_app.texture("icons_mici/buttons/toggle_pill_disabled.png", 84, 66)
|
||||
|
||||
def set_checked(self, checked: bool):
|
||||
self._checked = checked
|
||||
|
||||
def _handle_mouse_release(self, mouse_pos: MousePos):
|
||||
super()._handle_mouse_release(mouse_pos)
|
||||
self._checked = not self._checked
|
||||
if self._toggle_callback:
|
||||
self._toggle_callback(self._checked)
|
||||
|
||||
def _draw_pill(self, x: float, y: float, checked: bool):
|
||||
# draw toggle icon top right
|
||||
if checked:
|
||||
rl.draw_texture_ex(self._txt_enabled_toggle, (x, y), 0, 1.0, rl.WHITE)
|
||||
else:
|
||||
rl.draw_texture_ex(self._txt_disabled_toggle, (x, y), 0, 1.0, rl.WHITE)
|
||||
|
||||
def _draw_content(self, btn_y: float):
|
||||
super()._draw_content(btn_y)
|
||||
|
||||
x = self._rect.x + self._rect.width - self._txt_enabled_toggle.width
|
||||
y = btn_y
|
||||
self._draw_pill(x, y, self._checked)
|
||||
|
||||
|
||||
class BigMultiToggle(BigToggle):
|
||||
def __init__(self, text: str, options: list[str], toggle_callback: Callable | None = None,
|
||||
select_callback: Callable | None = None):
|
||||
super().__init__(text, "", toggle_callback=toggle_callback)
|
||||
assert len(options) > 0
|
||||
self._options = options
|
||||
self._select_callback = select_callback
|
||||
|
||||
self.set_value(self._options[0])
|
||||
|
||||
def _width_hint(self) -> int:
|
||||
return int(self._rect.width - self.LABEL_HORIZONTAL_PADDING * 2 - self._txt_enabled_toggle.width)
|
||||
|
||||
def _handle_mouse_release(self, mouse_pos: MousePos):
|
||||
super()._handle_mouse_release(mouse_pos)
|
||||
cur_idx = self._options.index(self.value)
|
||||
new_idx = (cur_idx + 1) % len(self._options)
|
||||
self.set_value(self._options[new_idx])
|
||||
if self._select_callback:
|
||||
self._select_callback(self.value)
|
||||
|
||||
def _draw_content(self, btn_y: float):
|
||||
# don't draw pill from BigToggle
|
||||
BigButton._draw_content(self, btn_y)
|
||||
|
||||
checked_idx = self._options.index(self.value)
|
||||
|
||||
x = self._rect.x + self._rect.width - self._txt_enabled_toggle.width
|
||||
y = btn_y
|
||||
|
||||
for i in range(len(self._options)):
|
||||
self._draw_pill(x, y, checked_idx == i)
|
||||
y += 35
|
||||
|
||||
|
||||
class GreyBigButton(BigButton):
|
||||
"""Users should manage newlines with this class themselves"""
|
||||
|
||||
LABEL_HORIZONTAL_PADDING = 30
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.set_touch_valid_callback(lambda: False)
|
||||
|
||||
self._rect.width = 476
|
||||
|
||||
self._label.set_font_size(36)
|
||||
self._label.set_font_weight(FontWeight.BOLD)
|
||||
self._label.set_line_height(1.0)
|
||||
|
||||
self._sub_label.set_font_size(36)
|
||||
self._sub_label.set_text_color(rl.Color(255, 255, 255, int(255 * 0.9)))
|
||||
self._sub_label.set_font_weight(FontWeight.DISPLAY_REGULAR)
|
||||
self._sub_label.set_alignment_vertical(rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE if not self._label.text else
|
||||
rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM)
|
||||
self._sub_label.set_line_height(0.95)
|
||||
|
||||
@property
|
||||
def LABEL_VERTICAL_PADDING(self):
|
||||
return BigButton.LABEL_VERTICAL_PADDING if self._label.text else 18
|
||||
|
||||
def _width_hint(self) -> int:
|
||||
return int(self._rect.width - self.LABEL_HORIZONTAL_PADDING * 2)
|
||||
|
||||
def _get_label_font_size(self):
|
||||
return 36
|
||||
|
||||
def _render(self, _):
|
||||
rl.draw_rectangle_rounded(self._rect, 0.4, 10, rl.Color(255, 255, 255, int(255 * 0.15)))
|
||||
self._draw_content(self._rect.y)
|
||||
|
||||
|
||||
class BigMultiParamToggle(BigMultiToggle):
|
||||
def __init__(self, text: str, param: str, options: list[str], toggle_callback: Callable | None = None,
|
||||
select_callback: Callable | None = None):
|
||||
super().__init__(text, options, toggle_callback, select_callback)
|
||||
self._param = param
|
||||
|
||||
self._params = Params()
|
||||
self._load_value()
|
||||
|
||||
def _load_value(self):
|
||||
self.set_value(self._options[self._params.get(self._param) or 0])
|
||||
|
||||
def _handle_mouse_release(self, mouse_pos: MousePos):
|
||||
super()._handle_mouse_release(mouse_pos)
|
||||
new_idx = self._options.index(self.value)
|
||||
self._params.put(self._param, new_idx)
|
||||
|
||||
|
||||
class BigParamControl(BigToggle):
|
||||
def __init__(self, text: str, param: str, toggle_callback: Callable | None = None):
|
||||
super().__init__(text, "", toggle_callback=toggle_callback)
|
||||
self.param = param
|
||||
self.params = Params()
|
||||
self.set_checked(self.params.get_bool(self.param, False))
|
||||
|
||||
def _handle_mouse_release(self, mouse_pos: MousePos):
|
||||
super()._handle_mouse_release(mouse_pos)
|
||||
self.params.put_bool(self.param, self._checked, block=True)
|
||||
|
||||
def refresh(self):
|
||||
self.set_checked(self.params.get_bool(self.param, False))
|
||||
|
||||
|
||||
# TODO: param control base class
|
||||
class BigCircleParamControl(BigCircleToggle):
|
||||
def __init__(self, icon: rl.Texture, param: str, toggle_callback: Callable | None = None,
|
||||
icon_offset: tuple[int, int] = (0, 0)):
|
||||
super().__init__(icon, toggle_callback, icon_offset=icon_offset)
|
||||
self._param = param
|
||||
self.params = Params()
|
||||
self.set_checked(self.params.get_bool(self._param, False))
|
||||
|
||||
def _handle_mouse_release(self, mouse_pos: MousePos):
|
||||
super()._handle_mouse_release(mouse_pos)
|
||||
self.params.put_bool(self._param, self._checked, block=True)
|
||||
|
||||
def refresh(self):
|
||||
self.set_checked(self.params.get_bool(self._param, False))
|
||||
@@ -0,0 +1,240 @@
|
||||
import abc
|
||||
import math
|
||||
import pyray as rl
|
||||
from typing import Union
|
||||
from collections.abc import Callable
|
||||
from openpilot.system.ui.widgets.nav_widget import NavWidget
|
||||
from openpilot.system.ui.widgets.label import UnifiedLabel
|
||||
from openpilot.system.ui.widgets.mici_keyboard import MiciKeyboard
|
||||
from openpilot.system.ui.lib.text_measure import measure_text_cached
|
||||
from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos
|
||||
from openpilot.system.ui.widgets.slider import RedBigSlider, BigSlider
|
||||
from openpilot.common.filter_simple import FirstOrderFilter
|
||||
from openpilot.selfdrive.ui.mici.widgets.button import BigCircleButton, BigButton, GreyBigButton
|
||||
|
||||
DEBUG = False
|
||||
|
||||
PADDING = 20
|
||||
|
||||
|
||||
class BigDialogBase(NavWidget, abc.ABC):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.set_rect(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
|
||||
|
||||
|
||||
class BigDialog(BigDialogBase):
|
||||
def __init__(self, title: str, description: str, icon: Union[rl.Texture, None] = None):
|
||||
super().__init__()
|
||||
self._card = GreyBigButton(title, description, icon)
|
||||
|
||||
def _render(self, _):
|
||||
self._card.render(rl.Rectangle(
|
||||
self._rect.x + self._rect.width / 2 - self._card.rect.width / 2,
|
||||
self._rect.y + self._rect.height / 2 - self._card.rect.height / 2,
|
||||
self._card.rect.width,
|
||||
self._card.rect.height,
|
||||
))
|
||||
|
||||
|
||||
class BigConfirmationDialog(BigDialogBase):
|
||||
def __init__(self, title: str, icon: rl.Texture, confirm_callback: Callable[[], None],
|
||||
exit_on_confirm: bool = True, red: bool = False):
|
||||
super().__init__()
|
||||
self._confirm_callback = confirm_callback
|
||||
self._exit_on_confirm = exit_on_confirm
|
||||
|
||||
self._slider: BigSlider | RedBigSlider
|
||||
if red:
|
||||
self._slider = self._child(RedBigSlider(title, icon, confirm_callback=self._on_confirm))
|
||||
else:
|
||||
self._slider = self._child(BigSlider(title, icon, confirm_callback=self._on_confirm))
|
||||
self._slider.set_enabled(lambda: self.enabled and not self.is_dismissing) # for nav stack + NavWidget
|
||||
|
||||
def _on_confirm(self):
|
||||
if self._exit_on_confirm:
|
||||
self.dismiss(self._confirm_callback)
|
||||
elif self._confirm_callback:
|
||||
self._confirm_callback()
|
||||
|
||||
def _update_state(self):
|
||||
super()._update_state()
|
||||
if self.is_dismissing and not self._slider.confirmed:
|
||||
self._slider.reset()
|
||||
|
||||
def _render(self, _):
|
||||
self._slider.render(self._rect)
|
||||
|
||||
|
||||
class BigInputDialog(BigDialogBase):
|
||||
BACK_TOUCH_AREA_PERCENTAGE = 0.2
|
||||
BACKSPACE_RATE = 25 # hz
|
||||
TEXT_INPUT_SIZE = 35
|
||||
|
||||
def __init__(self,
|
||||
hint: str,
|
||||
default_text: str = "",
|
||||
minimum_length: int = 1,
|
||||
confirm_callback: Callable[[str], None] | None = None,
|
||||
auto_return_to_letters: str = ""):
|
||||
super().__init__()
|
||||
self._hint_label = UnifiedLabel(hint, font_size=35, text_color=rl.Color(255, 255, 255, int(255 * 0.35)),
|
||||
font_weight=FontWeight.MEDIUM)
|
||||
self._keyboard = MiciKeyboard(auto_return_to_letters=auto_return_to_letters)
|
||||
self._keyboard.set_text(default_text)
|
||||
self._keyboard.set_enabled(lambda: self.enabled and not self.is_dismissing) # for nav stack + NavWidget
|
||||
self._minimum_length = minimum_length
|
||||
|
||||
self._backspace_held_time: float | None = None
|
||||
|
||||
self._backspace_img = gui_app.texture("icons_mici/settings/keyboard/backspace.png", 42, 36)
|
||||
self._backspace_img_alpha = FirstOrderFilter(0, 0.05, 1 / gui_app.target_fps)
|
||||
|
||||
self._enter_img = gui_app.texture("icons_mici/settings/keyboard/enter.png", 76, 62)
|
||||
self._enter_disabled_img = gui_app.texture("icons_mici/settings/keyboard/enter_disabled.png", 76, 62)
|
||||
self._enter_img_alpha = FirstOrderFilter(0, 0.05, 1 / gui_app.target_fps)
|
||||
|
||||
# rects for top buttons
|
||||
self._top_left_button_rect = rl.Rectangle(0, 0, 0, 0)
|
||||
self._top_right_button_rect = rl.Rectangle(0, 0, 0, 0)
|
||||
|
||||
def confirm_callback_wrapper():
|
||||
text = self._keyboard.text()
|
||||
self.dismiss((lambda: confirm_callback(text)) if confirm_callback else None)
|
||||
self._confirm_callback = confirm_callback_wrapper
|
||||
|
||||
def _update_state(self):
|
||||
super()._update_state()
|
||||
|
||||
if self.is_dismissing:
|
||||
self._backspace_held_time = None
|
||||
return
|
||||
|
||||
last_mouse_event = gui_app.last_mouse_event
|
||||
if last_mouse_event.left_down and rl.check_collision_point_rec(last_mouse_event.pos, self._top_right_button_rect) and self._backspace_img_alpha.x > 1:
|
||||
if self._backspace_held_time is None:
|
||||
self._backspace_held_time = rl.get_time()
|
||||
|
||||
if rl.get_time() - self._backspace_held_time > 0.5:
|
||||
if gui_app.frame % round(gui_app.target_fps / self.BACKSPACE_RATE) == 0:
|
||||
self._keyboard.backspace()
|
||||
|
||||
else:
|
||||
self._backspace_held_time = None
|
||||
|
||||
def _render(self, _):
|
||||
# draw current text so far below everything. text floats left but always stays in view
|
||||
text = self._keyboard.text()
|
||||
candidate_char = self._keyboard.get_candidate_character()
|
||||
text_size = measure_text_cached(gui_app.font(FontWeight.ROMAN), text + candidate_char or self._hint_label.text, self.TEXT_INPUT_SIZE)
|
||||
|
||||
bg_block_margin = 5
|
||||
text_x = PADDING / 2 + self._enter_img.width + PADDING
|
||||
text_field_rect = rl.Rectangle(text_x, self._rect.y + PADDING - bg_block_margin,
|
||||
self._rect.width - text_x * 2,
|
||||
text_size.y)
|
||||
|
||||
# draw text input
|
||||
# push text left with a gradient on left side if too long
|
||||
if text_size.x > text_field_rect.width:
|
||||
text_x -= text_size.x - text_field_rect.width
|
||||
|
||||
rl.begin_scissor_mode(int(text_field_rect.x), int(text_field_rect.y), int(text_field_rect.width), int(text_field_rect.height))
|
||||
rl.draw_text_ex(gui_app.font(FontWeight.ROMAN), text, rl.Vector2(text_x, text_field_rect.y), self.TEXT_INPUT_SIZE, 0, rl.WHITE)
|
||||
|
||||
# draw grayed out character user is hovering over
|
||||
if candidate_char:
|
||||
candidate_char_size = measure_text_cached(gui_app.font(FontWeight.ROMAN), candidate_char, self.TEXT_INPUT_SIZE)
|
||||
rl.draw_text_ex(gui_app.font(FontWeight.ROMAN), candidate_char,
|
||||
rl.Vector2(min(text_x + text_size.x, text_field_rect.x + text_field_rect.width) - candidate_char_size.x, text_field_rect.y),
|
||||
self.TEXT_INPUT_SIZE, 0, rl.Color(255, 255, 255, 128))
|
||||
|
||||
rl.end_scissor_mode()
|
||||
|
||||
# draw gradient on left side to indicate more text
|
||||
if text_size.x > text_field_rect.width:
|
||||
rl.draw_rectangle_gradient_ex(rl.Rectangle(text_field_rect.x, text_field_rect.y, 80, text_field_rect.height),
|
||||
rl.BLACK, rl.BLANK, rl.BLANK, rl.BLACK)
|
||||
|
||||
# draw cursor
|
||||
blink_alpha = (math.sin(rl.get_time() * 6) + 1) / 2
|
||||
if text:
|
||||
cursor_x = min(text_x + text_size.x + 3, text_field_rect.x + text_field_rect.width)
|
||||
else:
|
||||
cursor_x = text_field_rect.x - 6
|
||||
rl.draw_rectangle_rounded(rl.Rectangle(cursor_x, text_field_rect.y, 4, text_size.y),
|
||||
1, 4, rl.Color(255, 255, 255, int(255 * blink_alpha)))
|
||||
|
||||
# draw backspace icon with nice fade
|
||||
self._backspace_img_alpha.update(255 * bool(text))
|
||||
if self._backspace_img_alpha.x > 1:
|
||||
color = rl.Color(255, 255, 255, int(self._backspace_img_alpha.x))
|
||||
rl.draw_texture_ex(self._backspace_img, rl.Vector2(self._rect.width - self._backspace_img.width - 27, self._rect.y + 14), 0.0, 1.0, color)
|
||||
|
||||
if not text and self._hint_label.text and not candidate_char:
|
||||
# draw description if no text entered yet and not drawing candidate char
|
||||
hint_rect = rl.Rectangle(text_field_rect.x, text_field_rect.y,
|
||||
self._rect.width - text_field_rect.x - PADDING,
|
||||
text_field_rect.height)
|
||||
self._hint_label.render(hint_rect)
|
||||
|
||||
# TODO: move to update state
|
||||
# make rect take up entire area so it's easier to click
|
||||
self._top_left_button_rect = rl.Rectangle(self._rect.x, self._rect.y, text_field_rect.x, self._rect.height - self._keyboard.get_keyboard_height())
|
||||
self._top_right_button_rect = rl.Rectangle(text_field_rect.x + text_field_rect.width, self._rect.y,
|
||||
self._rect.width - (text_field_rect.x + text_field_rect.width), self._top_left_button_rect.height)
|
||||
|
||||
# draw enter button
|
||||
self._enter_img_alpha.update(255 if len(text) >= self._minimum_length else 0)
|
||||
color = rl.Color(255, 255, 255, int(self._enter_img_alpha.x))
|
||||
rl.draw_texture_ex(self._enter_img, rl.Vector2(self._rect.x + PADDING / 2, self._rect.y), 0.0, 1.0, color)
|
||||
color = rl.Color(255, 255, 255, 255 - int(self._enter_img_alpha.x))
|
||||
rl.draw_texture_ex(self._enter_disabled_img, rl.Vector2(self._rect.x + PADDING / 2, self._rect.y), 0.0, 1.0, color)
|
||||
|
||||
# keyboard goes over everything
|
||||
self._keyboard.render(self._rect)
|
||||
|
||||
# draw debugging rect bounds
|
||||
if DEBUG:
|
||||
rl.draw_rectangle_lines_ex(text_field_rect, 1, rl.Color(100, 100, 100, 255))
|
||||
rl.draw_rectangle_lines_ex(self._top_right_button_rect, 1, rl.Color(0, 255, 0, 255))
|
||||
rl.draw_rectangle_lines_ex(self._top_left_button_rect, 1, rl.Color(0, 255, 0, 255))
|
||||
|
||||
def _handle_mouse_press(self, mouse_pos: MousePos):
|
||||
super()._handle_mouse_press(mouse_pos)
|
||||
# TODO: need to track where press was so enter and back can activate on release rather than press
|
||||
# or turn into icon widgets :eyes_open:
|
||||
|
||||
if self.is_dismissing:
|
||||
return
|
||||
|
||||
# handle backspace icon click
|
||||
if rl.check_collision_point_rec(mouse_pos, self._top_right_button_rect) and self._backspace_img_alpha.x > 254:
|
||||
self._keyboard.backspace()
|
||||
elif rl.check_collision_point_rec(mouse_pos, self._top_left_button_rect) and self._enter_img_alpha.x > 254:
|
||||
# handle enter icon click
|
||||
self._confirm_callback()
|
||||
|
||||
|
||||
class BigDialogButton(BigButton):
|
||||
def __init__(self, text: str, value: str = "", icon: Union[str, rl.Texture] = "", description: str = ""):
|
||||
super().__init__(text, value, icon)
|
||||
self._description = description
|
||||
|
||||
def _handle_mouse_release(self, mouse_pos: MousePos):
|
||||
super()._handle_mouse_release(mouse_pos)
|
||||
|
||||
dlg = BigDialog(self.text, self._description)
|
||||
gui_app.push_widget(dlg)
|
||||
|
||||
|
||||
class BigConfirmationCircleButton(BigCircleButton):
|
||||
def __init__(self, title: str, icon: rl.Texture, confirm_callback: Callable[[], None], exit_on_confirm: bool = True,
|
||||
red: bool = False, icon_offset: tuple[int, int] = (0, 0)):
|
||||
super().__init__(icon, red, icon_offset)
|
||||
|
||||
def show_confirm_dialog():
|
||||
gui_app.push_widget(BigConfirmationDialog(title, icon, confirm_callback,
|
||||
exit_on_confirm=exit_on_confirm, red=red))
|
||||
|
||||
self.set_click_callback(show_confirm_dialog)
|
||||
@@ -0,0 +1,111 @@
|
||||
import pyray as rl
|
||||
import qrcode
|
||||
import numpy as np
|
||||
import time
|
||||
|
||||
from openpilot.common.api import Api
|
||||
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:
|
||||
qr = qrcode.QRCode(version=1, error_correction=qrcode.constants.ERROR_CORRECT_L, box_size=10, border=0)
|
||||
qr.add_data(self._get_pairing_url())
|
||||
qr.make(fit=True)
|
||||
|
||||
pil_img = qr.make_image(fill_color="white", back_color="black").convert('RGBA')
|
||||
img_array = np.array(pil_img, dtype=np.uint8)
|
||||
|
||||
if self._qr_texture and self._qr_texture.id != 0:
|
||||
rl.unload_texture(self._qr_texture)
|
||||
|
||||
rl_image = rl.Image()
|
||||
rl_image.data = rl.ffi.cast("void *", img_array.ctypes.data)
|
||||
rl_image.width = pil_img.width
|
||||
rl_image.height = pil_img.height
|
||||
rl_image.mipmaps = 1
|
||||
rl_image.format = rl.PixelFormat.PIXELFORMAT_UNCOMPRESSED_R8G8B8A8
|
||||
|
||||
self._qr_texture = rl.load_texture_from_image(rl_image)
|
||||
except Exception as e:
|
||||
cloudlog.warning(f"QR code generation failed: {e}")
|
||||
self._qr_texture = None
|
||||
|
||||
def _check_qr_refresh(self) -> None:
|
||||
current_time = time.monotonic()
|
||||
if current_time - self._last_qr_generation >= self.QR_REFRESH_INTERVAL:
|
||||
self._generate_qr_code()
|
||||
self._last_qr_generation = current_time
|
||||
|
||||
def _update_state(self):
|
||||
super()._update_state()
|
||||
if ui_state.prime_state.is_paired() and not self.is_dismissing:
|
||||
self.dismiss()
|
||||
|
||||
def _render(self, rect: rl.Rectangle):
|
||||
self._check_qr_refresh()
|
||||
|
||||
self._render_qr_code()
|
||||
|
||||
label_x = self._rect.x + 8 + self._rect.height + 24
|
||||
self._pair_label.set_max_width(int(self._rect.width - label_x))
|
||||
self._pair_label.set_position(label_x, self._rect.y + 16)
|
||||
self._pair_label.render()
|
||||
|
||||
rl.draw_texture_ex(self._txt_pair, rl.Vector2(label_x, self._rect.y + self._rect.height - self._txt_pair.height - 16),
|
||||
0.0, 1.0, rl.Color(255, 255, 255, int(255 * 0.35)))
|
||||
|
||||
def _render_qr_code(self) -> None:
|
||||
if not self._qr_texture:
|
||||
error_font = gui_app.font(FontWeight.BOLD)
|
||||
rl.draw_text_ex(
|
||||
error_font, "QR Code Error", rl.Vector2(self._rect.x + 20, self._rect.y + self._rect.height // 2 - 15), 30, 0.0, rl.RED
|
||||
)
|
||||
return
|
||||
|
||||
scale = self._rect.height / self._qr_texture.height
|
||||
pos = rl.Vector2(round(self._rect.x + 8), round(self._rect.y))
|
||||
rl.draw_texture_ex(self._qr_texture, pos, 0.0, scale, rl.WHITE)
|
||||
|
||||
def __del__(self):
|
||||
if self._qr_texture and self._qr_texture.id != 0:
|
||||
rl.unload_texture(self._qr_texture)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
gui_app.init_window("pairing device")
|
||||
pairing = PairingDialog()
|
||||
gui_app.push_widget(pairing)
|
||||
try:
|
||||
for _ in gui_app.render():
|
||||
pass
|
||||
finally:
|
||||
del pairing
|
||||
Reference in new issue
Block a user