This commit is contained in:
firestar5683
2026-04-11 21:56:46 -05:00
parent d2e5f06395
commit d43b7d0d3f
187 changed files with 5499 additions and 6222 deletions
+103 -243
View File
@@ -1,23 +1,17 @@
import time
import datetime
import re
import time
from cereal import log
import pyray as rl
from collections.abc import Callable
from openpilot.system.ui.widgets.label import gui_label, MiciLabel, UnifiedLabel
from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.lib.application import gui_app, FontWeight, DEFAULT_TEXT_COLOR, MousePos
from openpilot.starpilot.common.starpilot_variables import MODELS_PATH
from openpilot.starpilot.common.experimental_state import (
CEStatus,
next_manual_ce_status,
requested_experimental_mode,
sync_manual_ce_state,
)
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
from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos
from openpilot.selfdrive.ui.ui_state import ui_state
from openpilot.system.ui.text import wrap_text
from openpilot.system.version import training_version
from openpilot.system.hardware import PC
from openpilot.system.version import RELEASE_BRANCHES
HEAD_BUTTON_FONT_SIZE = 40
HOME_PADDING = 8
@@ -35,57 +29,56 @@ NETWORK_TYPES = {
}
class DeviceStatus(Widget):
class NetworkIcon(Widget):
def __init__(self):
super().__init__()
self.set_rect(rl.Rectangle(0, 0, 300, 175))
self._update_state()
self._version_text = self._get_version_text()
self.set_rect(rl.Rectangle(0, 0, 54, 44)) # max size of all icons
self._net_type = NetworkType.none
self._net_strength = 0
self._do_welcome()
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)
def _do_welcome(self):
# Keep onboarding bypass for desktop UI runs only.
if PC:
ui_state.params.put("CompletedTrainingVersion", training_version)
def refresh(self):
self._update_state()
self._version_text = self._get_version_text()
def _get_version_text(self) -> str:
brand = "starpilot"
description = ui_state.params.get("UpdaterCurrentDescription")
return f"{brand} {description}" if description else brand
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):
# TODO: refresh function that can be called periodically, not at 60 fps, so we can update version
# update system status
self._system_status = "SYSTEM READY ✓" if ui_state.panda_type != log.PandaState.PandaType.unknown else "BOOTING UP..."
# update network status
strength = ui_state.sm['deviceState'].networkStrength.raw
strength_text = "" * strength + "" * (4 - strength) # ◌ also works
network_type = NETWORK_TYPES[ui_state.sm['deviceState'].networkType.raw]
self._network_status = f"{network_type} {strength_text}"
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, _):
# draw status
status_rect = rl.Rectangle(self._rect.x, self._rect.y, self._rect.width, 40)
gui_label(status_rect, self._system_status, font_size=HEAD_BUTTON_FONT_SIZE, color=DEFAULT_TEXT_COLOR,
font_weight=FontWeight.BOLD, alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER)
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 network status
network_rect = rl.Rectangle(self._rect.x, self._rect.y + 60, self._rect.width, 40)
gui_label(network_rect, self._network_status, font_size=40, color=DEFAULT_TEXT_COLOR,
font_weight=FontWeight.MEDIUM, alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER)
draw_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
# draw version
version_font_size = 30
version_rect = rl.Rectangle(self._rect.x, self._rect.y + 140, self._rect.width + 20, 40)
wrapped_text = '\n'.join(wrap_text(self._version_text, version_font_size, version_rect.width))
gui_label(version_rect, wrapped_text, font_size=version_font_size, color=DEFAULT_TEXT_COLOR,
font_weight=FontWeight.MEDIUM, alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT)
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):
@@ -100,117 +93,46 @@ class MiciHomeLayout(Widget):
self._version_text = None
self._experimental_mode = False
self._safe_mode = False
self._current_model_name = "default"
self._settings_txt = gui_app.texture("icons_mici/settings.png", 48, 48)
self._experimental_txt = gui_app.texture("icons_mici/experimental_mode.png", 48, 48)
self._mic_txt = gui_app.texture("icons_mici/microphone.png", 48, 48)
self._experimental_icon = IconWidget("icons_mici/experimental_mode.png", (48, 48))
self._mic_icon = IconWidget("icons_mici/microphone.png", (32, 46))
self._net_type = NETWORK_TYPES.get(NetworkType.none)
self._net_strength = 0
self._status_bar_layout = HBoxLayout([
IconWidget("icons_mici/settings.png", (48, 48), opacity=0.9),
NetworkIcon(),
self._experimental_icon,
self._mic_icon,
], spacing=18)
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, 44)
self._wifi_low_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_low.png", 50, 44)
self._wifi_medium_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_medium.png", 50, 44)
self._wifi_full_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_full.png", 50, 44)
self._cell_none_txt = gui_app.texture("icons_mici/settings/network/cell_strength_none.png", 55, 35)
self._cell_low_txt = gui_app.texture("icons_mici/settings/network/cell_strength_low.png", 55, 35)
self._cell_medium_txt = gui_app.texture("icons_mici/settings/network/cell_strength_medium.png", 55, 35)
self._cell_high_txt = gui_app.texture("icons_mici/settings/network/cell_strength_high.png", 55, 35)
self._cell_full_txt = gui_app.texture("icons_mici/settings/network/cell_strength_full.png", 55, 35)
self._openpilot_label = MiciLabel("starpilot", font_size=96, color=rl.Color(255, 255, 255, int(255 * 0.9)), font_weight=FontWeight.DISPLAY)
self._version_label = MiciLabel("", font_size=36, font_weight=FontWeight.ROMAN)
self._large_version_label = MiciLabel("", font_size=64, color=rl.GRAY, font_weight=FontWeight.ROMAN)
self._date_label = MiciLabel("", font_size=36, color=rl.GRAY, font_weight=FontWeight.ROMAN)
self._openpilot_label = UnifiedLabel("starpilot", 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 = MiciLabel("", font_size=36, color=rl.GRAY, font_weight=FontWeight.ROMAN)
self._version_commit_label = UnifiedLabel("", font_size=36, text_color=rl.GRAY, font_weight=FontWeight.ROMAN, max_width=480, wrap_text=False)
def show_event(self):
super().show_event()
self._version_text = self._get_version_text()
self._update_network_status(ui_state.sm['deviceState'])
self._update_params()
def _update_params(self):
self._safe_mode = ui_state.params.get_bool("SafeMode")
self._experimental_mode = requested_experimental_mode(ui_state.params, ui_state.params_memory)
self._experimental_mode = ui_state.params.get_bool("ExperimentalMode")
def _clean_name(value: str) -> str:
def _clean_model_name(value: str) -> str:
return re.sub(r"[🗺️👀📡]", "", value).replace("(Default)", "").strip()
def _decode_default(value) -> str:
if isinstance(value, bytes):
return value.decode("utf-8", errors="ignore").strip()
return str(value or "").strip()
current_name = _clean_model_name(ui_state.params.get("DrivingModelName", encoding="utf-8") or "")
if not current_name:
default_name = ui_state.params.get_default_value("DrivingModelName")
if isinstance(default_name, bytes):
default_name = default_name.decode("utf-8", errors="ignore")
current_name = _clean_model_name(str(default_name or ""))
model_key = (ui_state.params.get("Model", encoding="utf-8") or
ui_state.params.get("DrivingModel", encoding="utf-8") or "").strip()
current_param_name = _clean_name(ui_state.params.get("DrivingModelName", encoding="utf-8") or "")
available_models = [entry.strip() for entry in (ui_state.params.get("AvailableModels", encoding="utf-8") or "").split(",")]
available_names = [entry.strip() for entry in (ui_state.params.get("AvailableModelNames", encoding="utf-8") or "").split(",")]
model_versions = [entry.strip() for entry in (ui_state.params.get("ModelVersions", encoding="utf-8") or "").split(",")]
model_name_map = {
key: _clean_name(name)
for key, name in zip(available_models, available_names)
if key and _clean_name(name)
}
model_version_map = {
key: version
for key, version in zip(available_models, model_versions)
if key and version
}
default_key = _decode_default(ui_state.params.get_default_value("DrivingModel") or
ui_state.params.get_default_value("Model")) or "sc"
default_name = _clean_name(_decode_default(ui_state.params.get_default_value("DrivingModelName"))) or "South Carolina"
def _is_model_installed(key: str) -> bool:
if not key:
return False
# Built-in default model is always available.
if key == default_key:
return True
if (MODELS_PATH / f"{key}.thneed").is_file():
return True
version = model_version_map.get(key, "")
required = [
f"{key}_driving_policy_tinygrad.pkl",
f"{key}_driving_vision_tinygrad.pkl",
f"{key}_driving_policy_metadata.pkl",
f"{key}_driving_vision_metadata.pkl",
]
if version == "v12":
required.extend([
f"{key}_driving_off_policy_tinygrad.pkl",
f"{key}_driving_off_policy_metadata.pkl",
])
return all((MODELS_PATH / filename).is_file() for filename in required)
# If a stale custom model is selected but not actually installed, show default.
if model_key and not _is_model_installed(model_key):
model_key = default_key
resolved_name = ""
if model_key in model_name_map:
resolved_name = model_name_map[model_key]
elif model_key.endswith("2") and model_key[:-1] in model_name_map:
resolved_name = model_name_map[model_key[:-1]]
elif model_key == default_key or (model_key.endswith("2") and model_key[:-1] == default_key):
resolved_name = default_name
if not resolved_name and current_param_name:
resolved_name = current_param_name
if not resolved_name:
resolved_name = default_name if (not model_key or model_key == default_key) else model_key
self._current_model_name = resolved_name
current_key = (ui_state.params.get("Model", encoding="utf-8") or
ui_state.params.get("DrivingModel", encoding="utf-8") or "").strip()
self._current_model_name = current_name or current_key or "default"
def _update_state(self):
if self.is_pressed and not self._is_pressed_prev:
@@ -223,33 +145,18 @@ class MiciHomeLayout(Widget):
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 and not self._safe_mode:
if ui_state.params.get_bool("ConditionalExperimental"):
current_status = ui_state.params_memory.get_int("CEStatus", default=CEStatus["OFF"])
override_value = next_manual_ce_status(current_status, self._experimental_mode)
ui_state.params_memory.put_int("CEStatus", override_value)
sync_manual_ce_state(ui_state.params, override_value)
self._experimental_mode = override_value == CEStatus["USER_OVERRIDDEN"]
else:
self._experimental_mode = not self._experimental_mode
ui_state.params.put_bool("ExperimentalMode", self._experimental_mode)
if ui_state.has_longitudinal_control:
self._experimental_mode = not self._experimental_mode
ui_state.params.put("ExperimentalMode", self._experimental_mode)
self._mouse_down_t = None
self._did_long_press = True
if rl.get_time() - self._last_refresh > 5.0:
device_state = ui_state.sm['deviceState']
self._update_network_status(device_state)
# Update version text
self._version_text = self._get_version_text()
self._last_refresh = rl.get_time()
self._update_params()
def _update_network_status(self, device_state):
self._net_type = device_state.networkType
strength = device_state.networkStrength
self._net_strength = max(0, min(5, strength.raw + 1)) if strength.raw > 0 else 0
def set_callbacks(self, on_settings: Callable | None = None):
self._on_settings_click = on_settings
@@ -260,17 +167,22 @@ class MiciHomeLayout(Widget):
self._did_long_press = False
def _get_version_text(self) -> tuple[str, str, str, str] | None:
description = ui_state.params.get("UpdaterCurrentDescription")
version = ui_state.params.get("Version")
branch = ui_state.params.get("GitBranch")
commit = ui_state.params.get("GitCommit")
if description is not None and len(description) > 0:
# Expect "version / branch / commit / date"; be tolerant of other formats
try:
version, branch, commit, date = description.split(" / ")
return version, branch, commit, date
except Exception:
return None
if not all((version, branch, commit)):
return None
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?
@@ -279,83 +191,31 @@ class MiciHomeLayout(Widget):
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.rect.width + 10, version_pos.y)
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.rect.width - self._date_label.rect.width - 32)
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(" " + self._current_model_name)
self._branch_label.set_position(version_pos.x + self._version_label.rect.width + self._date_label.rect.width + 20, version_pos.y)
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()
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()
else:
self._branch_label.set_max_width(gui_app.width - 32)
self._branch_label.set_text(self._current_model_name)
self._branch_label.set_position(text_pos.x, text_pos.y + self._openpilot_label.font_size + 16)
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()
self._render_bottom_status_bar()
def _render_bottom_status_bar(self):
# ***** Center-aligned bottom section icons *****
self._experimental_icon.set_visible(self._experimental_mode)
self._mic_icon.set_visible(ui_state.recording_audio)
# TODO: refactor repeated icon drawing into a small loop
ITEM_SPACING = 18
Y_CENTER = 24
last_x = self.rect.x + HOME_PADDING
# Draw settings icon in bottom left corner
rl.draw_texture(self._settings_txt, int(last_x), int(self._rect.y + self.rect.height - self._settings_txt.height / 2 - Y_CENTER),
rl.Color(255, 255, 255, int(255 * 0.9)))
last_x = last_x + self._settings_txt.width + ITEM_SPACING
# draw network
if self._net_type == NetworkType.wifi:
# There is no 1
draw_net_txt = {0: self._wifi_none_txt,
2: self._wifi_low_txt,
3: self._wifi_medium_txt,
4: self._wifi_full_txt,
5: self._wifi_full_txt}.get(self._net_strength, self._wifi_low_txt)
rl.draw_texture(draw_net_txt, int(last_x),
int(self._rect.y + self.rect.height - draw_net_txt.height / 2 - Y_CENTER), rl.Color(255, 255, 255, int(255 * 0.9)))
last_x += draw_net_txt.width + ITEM_SPACING
elif self._net_type in (NetworkType.cell2G, NetworkType.cell3G, NetworkType.cell4G, NetworkType.cell5G):
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)
rl.draw_texture(draw_net_txt, int(last_x),
int(self._rect.y + self.rect.height - draw_net_txt.height / 2 - Y_CENTER), rl.Color(255, 255, 255, int(255 * 0.9)))
last_x += draw_net_txt.width + ITEM_SPACING
else:
# No network
# Offset by difference in height between slashless and slash icons to make center align match
rl.draw_texture(self._wifi_slash_txt, int(last_x), int(self._rect.y + self.rect.height - self._wifi_slash_txt.height / 2 -
(self._wifi_slash_txt.height - self._wifi_none_txt.height) / 2 - Y_CENTER),
rl.Color(255, 255, 255, 255))
last_x += self._wifi_slash_txt.width + ITEM_SPACING
# draw experimental icon
if self._experimental_mode:
rl.draw_texture(self._experimental_txt, int(last_x),
int(self._rect.y + self.rect.height - self._experimental_txt.height / 2 - Y_CENTER), rl.Color(255, 255, 255, 255))
last_x += self._experimental_txt.width + ITEM_SPACING
# draw microphone icon when recording audio is enabled
if ui_state.recording_audio:
rl.draw_texture(self._mic_txt, int(last_x),
int(self._rect.y + self.rect.height - self._mic_txt.height / 2 - Y_CENTER), rl.Color(255, 255, 255, 255))
last_x += self._mic_txt.width + ITEM_SPACING
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)
+43 -72
View File
@@ -1,7 +1,5 @@
import pyray as rl
from enum import IntEnum
import cereal.messaging as messaging
from openpilot.system.hardware import PC
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
@@ -16,18 +14,12 @@ from openpilot.system.ui.lib.application import gui_app
ONROAD_DELAY = 2.5 # seconds
class MainState(IntEnum):
MAIN = 0
SETTINGS = 1
class MiciMainLayout(Widget):
class MiciMainLayout(Scroller):
def __init__(self):
super().__init__()
super().__init__(snap_items=True, spacing=0, pad=0, scroll_indicator=False, edge_shadows=False)
self._pm = messaging.PubMaster(['bookmarkButton'])
self._current_mode: MainState | None = None
self._prev_onroad = False
self._prev_standstill = False
self._onroad_time_delay: float | None = None
@@ -44,46 +36,37 @@ class MiciMainLayout(Widget):
# 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 = Scroller([
self._scroller.add_widgets([
self._alerts_layout,
self._home_layout,
self._onroad_layout,
], spacing=0, pad_start=0, pad_end=0)
])
self._scroller.set_reset_scroll_at_show(False)
# Disable scrolling when onroad is interacting with bookmark
self._scroller.set_scrolling_enabled(lambda: not self._onroad_layout.is_swiping_left())
self._layouts = {
MainState.MAIN: self._scroller,
MainState.SETTINGS: self._settings_layout,
}
# Set callbacks
self._setup_callbacks()
# Skip onboarding on desktop; keep normal flow on device.
self._onboarding_window = None
if not PC:
self._onboarding_window = OnboardingWindow()
if not self._onboarding_window.completed:
gui_app.set_modal_overlay(self._onboarding_window)
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)
def _setup_callbacks(self):
self._home_layout.set_callbacks(on_settings=self._on_settings_clicked)
self._settings_layout.set_callbacks(on_close=self._on_settings_closed)
self._home_layout.set_callbacks(on_settings=lambda: gui_app.push_widget(self._settings_layout))
self._onroad_layout.set_click_callback(lambda: self._scroll_to(self._home_layout))
device.add_interactive_timeout_callback(self._set_mode_for_started)
device.add_interactive_timeout_callback(self._on_interactive_timeout)
def _scroll_to(self, layout: Widget):
layout_x = int(layout.rect.x)
self._scroller.scroll_to(layout_x, smooth=True)
def _render(self, _):
# Initial show event
if self._current_mode is None:
self._set_mode(MainState.MAIN)
if not self._setup:
if self._alerts_layout.active_alerts() > 0:
self._scroller.scroll_to(self._alerts_layout.rect.x)
@@ -92,59 +75,47 @@ class MiciMainLayout(Widget):
self._setup = True
# Render
if self._current_mode == MainState.MAIN:
self._scroller.render(self._rect)
elif self._current_mode == MainState.SETTINGS:
self._settings_layout.render(self._rect)
self._handle_transitions()
def _set_mode(self, mode: MainState):
if mode != self._current_mode:
if self._current_mode is not None:
self._layouts[self._current_mode].hide_event()
self._layouts[mode].show_event()
self._current_mode = mode
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._set_mode_for_started(True)
# delay so we show home for a bit after starting
if self._onroad_time_delay is not None and rl.get_time() - self._onroad_time_delay >= ONROAD_DELAY:
self._set_mode_for_started(True)
self._onroad_time_delay = None
CS = ui_state.sm["carState"]
if not CS.standstill and self._prev_standstill:
self._set_mode(MainState.MAIN)
self._scroll_to(self._onroad_layout)
self._prev_standstill = CS.standstill
def _set_mode_for_started(self, onroad_transition: bool = False):
if ui_state.started:
CS = ui_state.sm["carState"]
# Only go onroad if car starts or is not at a standstill
if not CS.standstill or onroad_transition:
self._set_mode(MainState.MAIN)
self._scroll_to(self._onroad_layout)
else:
# Stay in settings if car turns off while in settings
if not onroad_transition or self._current_mode != MainState.SETTINGS:
self._set_mode(MainState.MAIN)
self._scroll_to(self._home_layout)
def _on_settings_clicked(self):
self._set_mode(MainState.SETTINGS)
# 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
def _on_settings_closed(self):
self._set_mode(MainState.MAIN)
# 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')
+6 -8
View File
@@ -144,7 +144,7 @@ class AlertItem(Widget):
bg_texture = self._bg_small_pressed if self.is_pressed else self._bg_small
# Draw background
rl.draw_texture(bg_texture, int(self._rect.x), int(self._rect.y), rl.WHITE)
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
@@ -183,22 +183,20 @@ class AlertItem(Widget):
icon_texture = self._icon_orange
icon_x = self._rect.x + self.ALERT_WIDTH - self.ALERT_PADDING - self.ICON_SIZE
icon_y = self._rect.y + self.ALERT_PADDING
rl.draw_texture(icon_texture, int(icon_x), int(icon_y), rl.WHITE)
rl.draw_texture_ex(icon_texture, rl.Vector2(icon_x, icon_y), 0.0, 1.0, rl.WHITE)
class MiciOffroadAlerts(Widget):
class MiciOffroadAlerts(Scroller):
"""Offroad alerts layout with vertical scrolling."""
def __init__(self):
super().__init__()
# Create vertical scroller
super().__init__(horizontal=False, spacing=12, pad=0)
self.params = Params()
self.sorted_alerts: list[AlertData] = []
self.alert_items: list[AlertItem] = []
self._last_refresh = 0.0
# Create vertical scroller
self._scroller = Scroller([], horizontal=False, spacing=12, pad_start=0, pad_end=0, snap_items=False)
# Create empty state label
self._empty_label = UnifiedLabel(tr("no alerts"), 65, FontWeight.DISPLAY, rl.WHITE,
alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER,
@@ -289,7 +287,7 @@ class MiciOffroadAlerts(Widget):
def show_event(self):
"""Reset scroll position when shown and refresh alerts."""
self._scroller.show_event()
super().show_event()
self._last_refresh = time.monotonic()
self.refresh()
+172 -282
View File
@@ -1,35 +1,27 @@
from enum import IntEnum
import weakref
import math
import time
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.hardware import HARDWARE
from openpilot.system.ui.lib.application import FontWeight, gui_app
from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.widgets.button import SmallButton, SmallCircleIconButton
from openpilot.system.ui.widgets.label import UnifiedLabel
from openpilot.system.ui.widgets.slider import SmallSlider
from openpilot.system.ui.mici_setup import TermsHeader, TermsPage as SetupTermsPage
from openpilot.selfdrive.ui.ui_state import ui_state, device
from openpilot.selfdrive.ui.mici.onroad.driver_state import DriverStateRenderer
from openpilot.selfdrive.ui.mici.onroad.driver_camera_dialog import DriverCameraDialog
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.system.version import terms_version, training_version
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
class OnboardingState(IntEnum):
TERMS = 0
ONBOARDING = 1
DECLINE = 2
class DriverCameraSetupDialog(DriverCameraDialog):
class DriverCameraSetupDialog(BaseDriverCameraDialog):
def __init__(self):
super().__init__(no_escape=True)
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()
@@ -43,7 +35,7 @@ class DriverCameraSetupDialog(DriverCameraDialog):
gui_label(rect, tr("camera starting"), font_size=64, font_weight=FontWeight.BOLD,
alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER)
rl.end_scissor_mode()
return -1
return
# Position dmoji on opposite side from driver
is_rhd = self.driver_state_renderer.is_rhd
@@ -56,92 +48,64 @@ class DriverCameraSetupDialog(DriverCameraDialog):
self._draw_face_detection(rect)
rl.end_scissor_mode()
return -1
class TrainingGuidePreDMTutorial(SetupTermsPage):
def __init__(self, continue_callback):
super().__init__(continue_callback, continue_text="continue")
self._title_header = TermsHeader("driver monitoring setup", gui_app.texture("icons_mici/setup/green_dm.png", 60, 60))
class TrainingGuidePreDMTutorial(NavScroller):
def __init__(self, continue_callback: Callable[[], None]):
super().__init__()
self._dm_label = UnifiedLabel("Next, we'll ensure comma four is mounted properly.\n\nIf it does not have a clear view of the driver, " +
"unplug and remount before continuing.", 42,
FontWeight.ROMAN)
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("", "openpilot 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)
@property
def _content_height(self):
return self._dm_label.rect.y + self._dm_label.rect.height - self._scroll_panel.get_offset()
def _render_content(self, scroll_offset):
self._title_header.render(rl.Rectangle(
self._rect.x + 16,
self._rect.y + 16 + scroll_offset,
self._title_header.rect.width,
self._title_header.rect.height,
))
self._dm_label.render(rl.Rectangle(
self._rect.x + 16,
self._title_header.rect.y + self._title_header.rect.height + 16,
self._rect.width - 32,
self._dm_label.get_content_height(int(self._rect.width - 32)),
))
ui_state.params.put_bool_nonblocking("IsDriverViewEnabled", True)
class DMBadFaceDetected(SetupTermsPage):
def __init__(self, continue_callback, back_callback):
super().__init__(continue_callback, back_callback, continue_text="power off")
self._title_header = TermsHeader("make sure comma four can see your face", gui_app.texture("icons_mici/setup/orange_dm.png", 60, 60))
self._dm_label = UnifiedLabel("Re-mount if your face is occluded or driver monitoring has difficulty tracking your face.", 42, FontWeight.ROMAN)
class DMBadFaceDetected(NavScroller):
def __init__(self):
super().__init__()
@property
def _content_height(self):
return self._dm_label.rect.y + self._dm_label.rect.height - self._scroll_panel.get_offset()
back_button = BigPillButton("back")
back_button.set_click_callback(self.dismiss)
def _render_content(self, scroll_offset):
self._title_header.render(rl.Rectangle(
self._rect.x + 16,
self._rect.y + 16 + scroll_offset,
self._title_header.rect.width,
self._title_header.rect.height,
))
self._dm_label.render(rl.Rectangle(
self._rect.x + 16,
self._title_header.rect.y + self._title_header.rect.height + 16,
self._rect.width - 32,
self._dm_label.get_content_height(int(self._rect.width - 32)),
))
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(Widget):
class TrainingGuideDMTutorial(NavWidget):
PROGRESS_DURATION = 4
LOOKING_THRESHOLD_DEG = 30.0
def __init__(self, continue_callback):
def __init__(self, continue_callback: Callable[[], None]):
super().__init__()
self._back_button = SmallCircleIconButton(gui_app.texture("icons_mici/setup/driver_monitoring/dm_question.png", 48, 48))
self._back_button.set_click_callback(self._show_bad_face_page)
self._good_button = SmallCircleIconButton(gui_app.texture("icons_mici/setup/driver_monitoring/dm_check.png", 48, 35))
# Wrap the continue callback to restore settings
def wrapped_continue_callback():
device.set_offroad_brightness(None)
continue_callback()
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(wrapped_continue_callback)
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._step_start_time = time.monotonic()
self._dialog = DriverCameraSetupDialog()
self._bad_face_page = DMBadFaceDetected(HARDWARE.shutdown, self._hide_bad_face_page)
self._should_show_bad_face_page = False
self._bad_face_page = DMBadFaceDetected()
# Disable driver monitoring model when device times out for inactivity
def inactivity_callback():
@@ -149,23 +113,10 @@ class TrainingGuideDMTutorial(Widget):
device.add_interactive_timeout_callback(inactivity_callback)
def _show_bad_face_page(self):
self._bad_face_page.show_event()
self.hide_event()
self._should_show_bad_face_page = True
def _hide_bad_face_page(self):
self._bad_face_page.hide_event()
self.show_event()
self._should_show_bad_face_page = False
def show_event(self):
super().show_event()
self._dialog.show_event()
self._progress.x = 0.0
self._step_start_time = time.monotonic()
device.set_offroad_brightness(100)
def _update_state(self):
super()._update_state()
@@ -174,10 +125,6 @@ class TrainingGuideDMTutorial(Widget):
sm = ui_state.sm
if sm.recv_frame.get("driverMonitoringState", 0) == 0:
# Fallback for devices where DM model isn't publishing during onboarding:
# allow manual continue once camera is active so setup isn't hard-blocked.
if self._dialog._camera_view.frame and (time.monotonic() - self._step_start_time) > 2.5:
self._good_button.set_enabled(True)
return
dm_state = sm["driverMonitoringState"]
@@ -190,7 +137,8 @@ class TrainingGuideDMTutorial(Widget):
looking_center = False
# stay at 100% once reached
if (dm_state.faceDetected and looking_center) or self._progress.x > 0.99:
in_bad_face = gui_app.get_active_widget() == self._bad_face_page
if ((dm_state.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)
@@ -201,13 +149,12 @@ class TrainingGuideDMTutorial(Widget):
self._good_button.set_enabled(self._progress.x >= 0.999)
def _render(self, _):
if self._should_show_bad_face_page:
return self._bad_face_page.render(self._rect)
self._dialog.render(self._rect)
rl.draw_rectangle_gradient_v(int(self._rect.x), int(self._rect.y + self._rect.height - 80),
int(self._rect.width), 80, rl.BLANK, rl.BLACK)
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
@@ -260,238 +207,181 @@ class TrainingGuideDMTutorial(Widget):
))
# 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(SetupTermsPage):
def __init__(self, continue_callback):
def on_back():
ui_state.params.put_bool("RecordFront", False)
continue_callback()
def on_continue():
ui_state.params.put_bool("RecordFront", True)
continue_callback()
super().__init__(on_continue, back_callback=on_back, back_text="no", continue_text="yes")
self._title_header = TermsHeader("improve driver monitoring", gui_app.texture("icons_mici/setup/green_dm.png", 60, 60))
self._dm_label = UnifiedLabel("Do you want to upload driver camera data?", 42,
FontWeight.ROMAN)
def show_event(self):
super().show_event()
# Disable driver monitoring model after last step
ui_state.params.put_bool("IsDriverViewEnabled", False)
@property
def _content_height(self):
return self._dm_label.rect.y + self._dm_label.rect.height - self._scroll_panel.get_offset()
def _render_content(self, scroll_offset):
self._title_header.render(rl.Rectangle(
self._rect.x + 16,
self._rect.y + 16 + scroll_offset,
self._title_header.rect.width,
self._title_header.rect.height,
))
self._dm_label.render(rl.Rectangle(
self._rect.x + 16,
self._title_header.rect.y + self._title_header.rect.height + 16,
self._rect.width - 32,
self._dm_label.get_content_height(int(self._rect.width - 32)),
))
class TrainingGuideAttentionNotice(SetupTermsPage):
def __init__(self, continue_callback):
super().__init__(continue_callback, continue_text="continue")
self._title_header = TermsHeader("driver assistance", gui_app.texture("icons_mici/setup/warning.png", 60, 60))
self._warning_label = UnifiedLabel("1. openpilot is a driver assistance system.\n\n" +
"2. You must pay attention at all times.\n\n" +
"3. You must be ready to take over at any time.\n\n" +
"4. You are fully responsible for driving the car.", 42,
FontWeight.ROMAN)
@property
def _content_height(self):
return self._warning_label.rect.y + self._warning_label.rect.height - self._scroll_panel.get_offset()
def _render_content(self, scroll_offset):
self._title_header.render(rl.Rectangle(
self._rect.x + 16,
self._rect.y + 16 + scroll_offset,
self._title_header.rect.width,
self._title_header.rect.height,
))
self._warning_label.render(rl.Rectangle(
self._rect.x + 16,
self._title_header.rect.y + self._title_header.rect.height + 16,
self._rect.width - 32,
self._warning_label.get_content_height(int(self._rect.width - 32)),
))
class TrainingGuide(Widget):
def __init__(self, completed_callback=None):
class TrainingGuideRecordFront(NavScroller):
def __init__(self, continue_callback: Callable[[], None]):
super().__init__()
self._completed_callback = completed_callback
self._step = 0
self_ref = weakref.ref(self)
def on_accept():
ui_state.params.put_bool_nonblocking("RecordFront", True)
continue_callback()
def on_continue():
if obj := self_ref():
obj._advance_step()
def on_decline():
ui_state.params.put_bool_nonblocking("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 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 openpilot?", "scroll to continue",
gui_app.texture("icons_mici/setup/green_info.png", 64, 64)),
GreyBigButton("", "1. openpilot 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=on_continue),
TrainingGuidePreDMTutorial(continue_callback=on_continue),
TrainingGuideDMTutorial(continue_callback=on_continue),
TrainingGuideRecordFront(continue_callback=on_continue),
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),
]
def show_event(self):
super().show_event()
device.set_override_interactive_timeout(300)
def hide_event(self):
super().hide_event()
device.set_override_interactive_timeout(None)
def _advance_step(self):
if self._step < len(self._steps) - 1:
self._step += 1
self._steps[self._step].show_event()
else:
self._step = 0
if self._completed_callback:
self._completed_callback()
self._child(self._steps[0])
self._steps[0].set_enabled(lambda: self.enabled and not self.is_dismissing) # for nav stack
def _render(self, _):
if self._step < len(self._steps):
self._steps[self._step].render(self._rect)
return -1
self._steps[0].render(self._rect)
class DeclinePage(Widget):
def __init__(self, back_callback=None):
class QRCodeWidget(Widget):
def __init__(self, url: str, size: int = 170):
super().__init__()
self._uninstall_slider = SmallSlider("uninstall openpilot", self._on_uninstall)
self.set_rect(rl.Rectangle(0, 0, size, size))
self._size = size
self._qr_texture: rl.Texture | None = None
self._generate_qr(url)
self._back_button = SmallButton("back")
self._back_button.set_click_callback(back_callback)
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)
self._warning_header = TermsHeader("you must accept the\nterms to use openpilot",
gui_app.texture("icons_mici/setup/red_warning.png", 66, 60))
pil_img = qr.make_image(fill_color="white", back_color="black").convert('RGBA')
img_array = np.array(pil_img, dtype=np.uint8)
def _on_uninstall(self):
ui_state.params.put_bool("DoUninstall", True)
gui_app.request_close()
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, _):
self._warning_header.render(rl.Rectangle(
self._rect.x + 16,
self._rect.y + 16,
self._warning_header.rect.width,
self._warning_header.rect.height,
))
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)
self._back_button.set_opacity(1 - self._uninstall_slider.slider_percentage)
self._back_button.render(rl.Rectangle(
self._rect.x + 8,
self._rect.y + self._rect.height - self._back_button.rect.height,
self._back_button.rect.width,
self._back_button.rect.height,
))
self._uninstall_slider.render(rl.Rectangle(
self._rect.x + self._rect.width - self._uninstall_slider.rect.width,
self._rect.y + self._rect.height - self._uninstall_slider.rect.height,
self._uninstall_slider.rect.width,
self._uninstall_slider.rect.height,
))
def __del__(self):
if self._qr_texture and self._qr_texture.id != 0:
rl.unload_texture(self._qr_texture)
class TermsPage(SetupTermsPage):
def __init__(self, on_accept=None, on_decline=None):
super().__init__(on_accept, on_decline, "decline")
class TermsPage(Scroller):
def __init__(self, on_accept, on_decline):
super().__init__()
info_txt = gui_app.texture("icons_mici/setup/green_info.png", 60, 60)
self._title_header = TermsHeader("terms & conditions", info_txt)
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_label = UnifiedLabel("You must accept the Terms and Conditions to use openpilot. " +
"Read the latest terms at https://comma.ai/terms before continuing.", 36,
FontWeight.ROMAN)
self._terms_header = GreyBigButton("terms and\nconditions", "scroll to continue",
gui_app.texture("icons_mici/setup/green_info.png", 64, 64))
self._must_accept_card = GreyBigButton("", "You must accept the Terms & Conditions to use openpilot.")
@property
def _content_height(self):
return self._terms_label.rect.y + self._terms_label.rect.height - self._scroll_panel.get_offset()
self._scroller.add_widgets([
self._terms_header,
GreyBigButton("swipe for QR code", "or go to https://comma.ai/terms",
gui_app.texture("icons_mici/setup/small_slider/slider_arrow.png", 64, 56, flip_x=True)),
QRCodeWidget("https://comma.ai/terms"),
self._must_accept_card,
self._accept_button,
self._decline_button,
])
def _render_content(self, scroll_offset):
self._title_header.set_position(self._rect.x + 16, self._rect.y + 12 + scroll_offset)
self._title_header.render()
self._terms_label.render(rl.Rectangle(
self._rect.x + 16,
self._title_header.rect.y + self._title_header.rect.height + self.ITEM_SPACING,
self._rect.width - 100,
self._terms_label.get_content_height(int(self._rect.width - 100)),
))
def _render(self, _):
rl.draw_rectangle_rec(self._rect, rl.BLACK)
super()._render(_)
class OnboardingWindow(Widget):
def __init__(self):
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
self._training_done: bool = ui_state.params.get("CompletedTrainingVersion") == training_version
self._state = OnboardingState.TERMS if not self._accepted_terms else OnboardingState.ONBOARDING
self.set_rect(rl.Rectangle(0, 0, 458, gui_app.height))
self.set_rect(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
# Windows
self._terms = TermsPage(on_accept=self._on_terms_accepted, on_decline=self._on_terms_declined)
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._training_guide = TrainingGuide(completed_callback=self._on_completed_training)
self._decline_page = DeclinePage(back_callback=self._on_decline_back)
self._training_guide.set_enabled(lambda: self.enabled) # for nav stack
def _on_uninstall(self):
ui_state.params.put_bool("DoUninstall", True)
def show_event(self):
super().show_event()
device.set_override_interactive_timeout(300)
device.set_offroad_brightness(100)
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._training_done
def _on_terms_declined(self):
self._state = OnboardingState.DECLINE
def _on_decline_back(self):
self._state = OnboardingState.TERMS
def close(self):
ui_state.params.put_bool("IsDriverViewEnabled", False)
gui_app.set_modal_overlay(None)
ui_state.params.put_bool_nonblocking("IsDriverViewEnabled", False)
self._completed_callback()
def _on_terms_accepted(self):
ui_state.params.put("HasAcceptedTerms", terms_version)
self._state = OnboardingState.ONBOARDING
gui_app.push_widget(self._training_guide)
def _on_completed_training(self):
ui_state.params.put("CompletedTrainingVersion", training_version)
self.close()
def _render(self, _):
if self._state == OnboardingState.TERMS:
self._terms.render(self._rect)
elif self._state == OnboardingState.ONBOARDING:
self._training_guide.render(self._rect)
elif self._state == OnboardingState.DECLINE:
self._decline_page.render(self._rect)
return -1
rl.draw_rectangle_rec(self._rect, rl.BLACK)
self._terms.render(self._rect)
+132 -99
View File
@@ -1,9 +1,6 @@
import pyray as rl
from collections.abc import Callable
from openpilot.common.time_helpers import system_time_valid
from openpilot.system.ui.widgets.scroller import Scroller
from openpilot.selfdrive.ui.mici.widgets.button import BigButton, BigToggle, BigParamControl
from openpilot.system.ui.widgets.scroller import NavScroller
from openpilot.selfdrive.ui.mici.widgets.button import BigButton, BigToggle, BigParamControl, BigCircleParamControl
from openpilot.selfdrive.ui.mici.widgets.dialog import BigDialog, BigInputDialog, BigMultiOptionDialog
from openpilot.selfdrive.ui.mici.layouts.settings.fingerprint_catalog import (
FingerprintModelOption,
@@ -12,106 +9,119 @@ from openpilot.selfdrive.ui.mici.layouts.settings.fingerprint_catalog import (
shorten_model_label,
)
from openpilot.system.ui.lib.application import gui_app
from openpilot.system.ui.widgets import NavWidget
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 SshKeyAction
from openpilot.selfdrive.ui.widgets.ssh_key import SshKeyFetcher
class DeveloperLayoutMici(NavWidget):
def __init__(self, back_callback: Callable):
class DeveloperLayoutMici(NavScroller):
def __init__(self):
super().__init__()
self.set_back_callback(back_callback)
self._ssh_fetcher = SshKeyFetcher(ui_state.params)
self._make_options, self._models_by_make, self._models_by_value, self._make_by_model = get_fingerprint_catalog()
def github_username_callback(username: str):
if username:
ssh_keys = SshKeyAction()
ssh_keys._fetch_ssh_key(username)
if not ssh_keys._error_message:
self._ssh_keys_btn.set_value(username)
else:
dlg = BigDialog("", ssh_keys._error_message)
gui_app.set_modal_overlay(dlg)
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, confirm_callback=github_username_callback)
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.set_modal_overlay(dlg)
dlg = BigDialog("", "Please connect to Wi-Fi to fetch your key.")
gui_app.push_widget(dlg)
return
gui_app.set_modal_overlay(dlg)
gui_app.push_widget(dlg)
txt_ssh = gui_app.texture("icons_mici/settings/developer/ssh.png", 77, 44)
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)
# Fingerprint controls
(
self._make_options,
self._models_by_make,
self._models_by_value,
self._make_by_model,
) = get_fingerprint_catalog()
self._car_make_btn = BigButton("car make", self._get_display_make())
self._car_make_btn.set_click_callback(self._open_make_selector)
self._car_model_btn = BigButton("car model", self._get_display_model())
self._car_model_btn.set_click_callback(self._open_model_selector)
self._force_fingerprint_toggle = BigParamControl(
"disable auto fingerprint", "ForceFingerprint", toggle_callback=lambda checked: restart_needed_callback(checked)
"disable auto fingerprint", "ForceFingerprint", toggle_callback=restart_needed_callback,
)
# adb, ssh, ssh keys, debug mode, joystick debug mode, longitudinal maneuver mode, ip address
# ******** Main Scroller ********
self._adb_toggle = BigParamControl("enable ADB", "AdbEnabled")
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._use_prebuilt_toggle = BigParamControl("use prebuilt binaries", "UsePrebuilt")
self._ssh_toggle = BigParamControl("enable SSH", "SshEnabled")
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._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._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 = Scroller(
[
self._adb_toggle,
self._use_prebuilt_toggle,
self._ssh_toggle,
self._ssh_keys_btn,
self._car_make_btn,
self._car_model_btn,
self._force_fingerprint_toggle,
self._joystick_toggle,
self._long_maneuver_toggle,
self._alpha_long_toggle,
self._debug_mode_toggle,
],
snap_items=False,
scroll_indicator=True,
edge_shadows=True,
)
self._scroller.add_widgets([
self._adb_toggle,
self._ssh_toggle,
self._ssh_keys_btn,
self._car_make_btn,
self._car_model_btn,
self._force_fingerprint_toggle,
self._use_prebuilt_toggle,
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),
("UsePrebuilt", self._use_prebuilt_toggle),
("SshEnabled", self._ssh_toggle),
("ForceFingerprint", self._force_fingerprint_toggle),
("UsePrebuilt", self._use_prebuilt_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._car_make_btn, self._car_model_btn, self._force_fingerprint_toggle, self._joystick_toggle)
engaged_blocked_toggles = (self._long_maneuver_toggle, self._alpha_long_toggle)
onroad_blocked_toggles = (
self._adb_toggle,
self._car_make_btn,
self._car_model_btn,
self._force_fingerprint_toggle,
self._use_prebuilt_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:
@@ -128,23 +138,43 @@ class DeveloperLayoutMici(NavWidget):
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._car_make_btn.set_value(self._get_display_make())
self._car_model_btn.set_value(self._get_display_model())
self._update_toggles()
def _show_option_dialog(self, title: str, options: list[str], current: str, on_selected):
dialog_holder: dict[str, BigMultiOptionDialog] = {}
def on_confirm():
on_selected(dialog_holder["dialog"].get_selected_option())
dialog = BigMultiOptionDialog(options=options, default=current, right_btn_callback=on_confirm)
dialog_holder["dialog"] = dialog
gui_app.push_widget(dialog)
def _get_display_make(self) -> str:
make = ui_state.params.get("CarMake") or ""
make = ui_state.params.get("CarMake", encoding="utf-8") or ""
if make:
return make
model = ui_state.params.get("CarModel") or ""
model = ui_state.params.get("CarModel", encoding="utf-8") or ""
if model:
return self._make_by_model.get(model, format_fingerprint_value(model.split("_", 1)[0]))
return "Select"
def _get_selected_model_option(self) -> FingerprintModelOption | None:
model = ui_state.params.get("CarModel") or ""
model = ui_state.params.get("CarModel", encoding="utf-8") or ""
if not model:
return None
model_name = ui_state.params.get("CarModelName") or ""
make = ui_state.params.get("CarMake") or self._make_by_model.get(model, "")
model_name = ui_state.params.get("CarModelName", encoding="utf-8") or ""
make = ui_state.params.get("CarMake", encoding="utf-8") or self._make_by_model.get(model, "")
if make and model_name:
for option in self._models_by_make.get(make, ()):
if option.value == model and option.label == model_name:
@@ -157,8 +187,8 @@ class DeveloperLayoutMici(NavWidget):
if selected_option is not None:
return selected_option.button_label
model = ui_state.params.get("CarModel") or ""
model_name = ui_state.params.get("CarModelName") or ""
model = ui_state.params.get("CarModel", encoding="utf-8") or ""
model_name = ui_state.params.get("CarModelName", encoding="utf-8") or ""
if model:
model_option = self._models_by_value.get(model)
if model_option is not None:
@@ -197,54 +227,40 @@ class DeveloperLayoutMici(NavWidget):
def _open_make_selector(self):
options = list(self._make_options)
if not options:
gui_app.set_modal_overlay(BigDialog("No fingerprint list available", ""))
gui_app.push_widget(BigDialog("", "No fingerprint list available"))
return
current_make = self._get_display_make()
default_make = current_make if current_make in options else options[0]
def on_selected():
selected_make = option_dialog.get_selected_option()
def on_selected(selected_make: str):
self._set_car_make(selected_make)
current_model = ui_state.params.get("CarModel") or ""
current_model = ui_state.params.get("CarModel", encoding="utf-8") or ""
available_models = {option.value for option in self._models_by_make.get(selected_make, ())}
if current_model not in available_models:
default_model = self._models_by_make[selected_make][0]
self._set_car_model(default_model)
if current_model not in available_models and self._models_by_make.get(selected_make):
self._set_car_model(self._models_by_make[selected_make][0])
option_dialog = BigMultiOptionDialog(options=options, default=default_make, right_btn_callback=on_selected)
gui_app.set_modal_overlay(option_dialog)
self._show_option_dialog("select make", options, default_make, on_selected)
def _open_model_selector(self):
make = self._get_display_make()
model_options = self._models_by_make.get(make, ())
if not model_options:
gui_app.set_modal_overlay(BigDialog("Select a car make first", ""))
gui_app.push_widget(BigDialog("", "Select a car make first"))
return
current_model = ui_state.params.get("CarModel") or ""
current_model_name = ui_state.params.get("CarModelName") or ""
current_model = ui_state.params.get("CarModel", encoding="utf-8") or ""
current_model_name = ui_state.params.get("CarModelName", encoding="utf-8") or ""
option_labels = [option.option_label for option in model_options]
selected_by_label = {option.option_label: option for option in model_options}
default_model = next((option.option_label for option in model_options if option.value == current_model and option.label == current_model_name), None)
if default_model is None:
default_model = next((option.option_label for option in model_options if option.value == current_model), option_labels[0])
def on_selected():
selected_model = selected_by_label[option_dialog.get_selected_option()]
self._set_car_model(selected_model)
def on_selected(selected_label: str):
self._set_car_model(selected_by_label[selected_label])
option_dialog = BigMultiOptionDialog(options=option_labels, default=default_model, right_btn_callback=on_selected)
gui_app.set_modal_overlay(option_dialog)
def show_event(self):
super().show_event()
self._scroller.show_event()
self._update_toggles()
def _render(self, rect: rl.Rectangle):
self._scroller.render(rect)
self._show_option_dialog("select model", option_labels, default_model, on_selected)
def _update_toggles(self):
ui_state.update_params()
@@ -252,7 +268,7 @@ class DeveloperLayoutMici(NavWidget):
# CP gating
if ui_state.CP is not None:
alpha_avail = ui_state.CP.alphaLongitudinalAvailable
if not alpha_avail:
if not alpha_avail or ui_state.is_release:
self._alpha_long_toggle.set_visible(False)
ui_state.params.remove("AlphaLongitudinalEnabled")
else:
@@ -263,8 +279,12 @@ class DeveloperLayoutMici(NavWidget):
if not long_man_enabled:
self._long_maneuver_toggle.set_checked(False)
ui_state.params.put_bool("LongitudinalManeuverMode", False)
lat_man_enabled = ui_state.is_offroad()
self._lat_maneuver_toggle.set_enabled(lat_man_enabled)
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
@@ -278,11 +298,24 @@ class DeveloperLayoutMici(NavWidget):
ui_state.params.put_bool("JoystickDebugMode", state)
ui_state.params.put_bool("LongitudinalManeuverMode", False)
self._long_maneuver_toggle.set_checked(False)
ui_state.params.put_bool("LateralManeuverMode", False)
self._lat_maneuver_toggle.set_checked(False)
def _on_long_maneuver_mode(self, state: bool):
ui_state.params.put_bool("LongitudinalManeuverMode", state)
ui_state.params.put_bool("JoystickDebugMode", False)
self._joystick_toggle.set_checked(False)
ui_state.params.put_bool("LateralManeuverMode", False)
self._lat_maneuver_toggle.set_checked(False)
restart_needed_callback(state)
def _on_lat_maneuver_mode(self, state: bool):
ui_state.params.put_bool("LateralManeuverMode", state)
ui_state.params.put_bool("ExperimentalMode", False)
ui_state.params.put_bool("JoystickDebugMode", False)
self._joystick_toggle.set_checked(False)
ui_state.params.put_bool("LongitudinalManeuverMode", False)
self._long_maneuver_toggle.set_checked(False)
restart_needed_callback(state)
def _on_alpha_long_enabled(self, state: bool):
+94 -321
View File
@@ -1,47 +1,52 @@
import os
import threading
import json
import hashlib
import secrets
import string
import pyray as rl
from enum import IntEnum
from collections.abc import Callable
from pathlib import Path
from openpilot.common.basedir import BASEDIR
from openpilot.common.params import Params
from openpilot.common.swaglog import cloudlog
from openpilot.common.time_helpers import system_time_valid
from openpilot.system.ui.widgets.scroller import Scroller
from openpilot.system.ui.lib.scroll_panel2 import GuiScrollPanel2
from openpilot.selfdrive.ui.mici.widgets.button import BigButton, BigCircleButton, BigParamControl
from openpilot.selfdrive.ui.mici.widgets.dialog import BigMultiOptionDialog, BigDialog, BigConfirmationDialogV2, BigInputDialog
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
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, NavWidget
from openpilot.system.ui.widgets import Widget
from openpilot.selfdrive.ui.ui_state import device, ui_state
from openpilot.system.ui.widgets.label import MiciLabel
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
from openpilot.system.hardware import PC
from openpilot.system.hardware.hw import Paths
import qrcode
import numpy as np
class MiciFccModal(NavWidget):
BACK_TOUCH_AREA_PERCENTAGE = 0.1
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_nonblocking("IsDriverViewEnabled", False)
class MiciFccModal(NavRawScrollPanel):
def __init__(self, file_path: str | None = None, text: str | None = None):
super().__init__()
self.set_back_callback(lambda: gui_app.set_modal_overlay(None))
self._content = HtmlRenderer(file_path=file_path, text=text)
self._scroll_panel = GuiScrollPanel2(horizontal=False)
self._fcc_logo = gui_app.texture("icons_mici/settings/device/fcc_logo.png", 76, 64)
def _render(self, rect: rl.Rectangle):
@@ -58,39 +63,32 @@ class MiciFccModal(NavWidget):
rl.draw_texture_ex(self._fcc_logo, fcc_pos, 0.0, 1.0, rl.WHITE)
return -1
def _engaged_confirmation_callback(callback: Callable, action_text: str):
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()
red = False
if action_text == "power off":
icon = "icons_mici/settings/device/power.png"
red = True
elif action_text == "reboot":
icon = "icons_mici/settings/device/reboot.png"
elif action_text == "reset":
icon = "icons_mici/settings/device/lkas.png"
elif action_text == "reset driver monitoring":
icon = "icons_mici/settings/device/cameras.png"
elif action_text == "uninstall":
icon = "icons_mici/settings/device/uninstall.png"
else:
# TODO: check
icon = "icons_mici/settings/comma_icon.png"
dlg: BigConfirmationDialogV2 | BigDialog = BigConfirmationDialogV2(f"slide to\n{action_text.lower()}", icon, red=red,
exit_on_confirm=action_text in {"reset", "reset driver monitoring"},
confirm_callback=confirm_callback)
gui_app.set_modal_overlay(dlg)
gui_app.push_widget(BigConfirmationDialog(f"slide to\n{action_text.lower()}", icon, confirm_callback, exit_on_confirm=exit_on_confirm, red=red))
else:
dlg = BigDialog(f"Disengage to {action_text}", "")
gui_app.set_modal_overlay(dlg)
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):
@@ -100,14 +98,15 @@ class DeviceInfoLayoutMici(Widget):
self.set_rect(rl.Rectangle(0, 0, 360, 180))
params = Params()
header_color = rl.Color(255, 255, 255, int(255 * 0.9))
subheader_color = rl.Color(255, 255, 255, int(255 * 0.9 * 0.65))
max_width = int(self._rect.width - 20)
self._dongle_id_label = MiciLabel("device ID", 48, width=max_width, color=header_color, font_weight=FontWeight.DISPLAY)
self._dongle_id_text_label = MiciLabel(params.get("DongleId") or 'N/A', 32, width=max_width, color=subheader_color, font_weight=FontWeight.ROMAN)
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 = MiciLabel("serial", 48, color=header_color, font_weight=FontWeight.DISPLAY)
self._serial_number_text_label = MiciLabel(params.get("HardwareSerial") or 'N/A', 32, width=max_width, color=subheader_color, font_weight=FontWeight.ROMAN)
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)
@@ -131,9 +130,14 @@ class UpdaterState(IntEnum):
class PairBigButton(BigButton):
def __init__(self):
super().__init__("pair", "connect.comma.ai", "icons_mici/settings/comma_icon.png")
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():
@@ -152,191 +156,27 @@ class PairBigButton(BigButton):
return
dlg: BigDialog | PairingDialog
if not system_time_valid():
dlg = BigDialog(tr("Please connect to Wi-Fi to complete initial pairing"), "")
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"), "")
dlg = BigDialog("", tr("Device must be registered with the comma.ai backend to pair."))
else:
dlg = PairingDialog()
gui_app.set_modal_overlay(dlg)
class GalaxyQRDialog(NavWidget):
def __init__(self, url: str):
super().__init__()
self._url = url
self._qr_texture: rl.Texture | None = None
self.set_back_callback(lambda: gui_app.set_modal_overlay(None))
self._title = MiciLabel("pair with galaxy", 48, font_weight=FontWeight.BOLD,
color=rl.Color(255, 255, 255, int(255 * 0.9)), line_height=40, wrap_text=True)
self._generate_qr_code()
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._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"Galaxy QR generation failed: {e}")
self._qr_texture = None
def _render(self, rect: rl.Rectangle):
if self._qr_texture is not None:
scale = rect.height / self._qr_texture.height
pos = rl.Vector2(rect.x + 8, rect.y)
rl.draw_texture_ex(self._qr_texture, pos, 0.0, scale, rl.WHITE)
else:
rl.draw_text_ex(
gui_app.font(FontWeight.BOLD),
"QR Code Error",
rl.Vector2(rect.x + 20, rect.y + rect.height // 2 - 15),
30,
0.0,
rl.RED,
)
label_x = rect.x + 8 + rect.height + 24
self._title.set_width(int(rect.width - label_x))
self._title.set_position(label_x, rect.y + 28)
self._title.render()
return -1
def __del__(self):
if self._qr_texture and self._qr_texture.id != 0:
rl.unload_texture(self._qr_texture)
class GalaxyBigButton(BigButton):
_SLUG_CHARS = string.ascii_letters + string.digits
def __init__(self):
super().__init__("galaxy", "", gui_app.starpilot_texture("../system/the_pond/assets/images/main_logo.png", 64, 64))
self._galaxy_dir = Path(Paths.comma_home()) / "starpilot" / "data" / "galaxy" if PC else Path("/data/galaxy")
self._auth_path = self._galaxy_dir / "glxyauth"
self._session_path = self._galaxy_dir / "glxysession"
self._slug_path = self._galaxy_dir / "glxyslug"
def _is_paired(self) -> bool:
try:
return len(self._auth_path.read_text(encoding="utf-8").strip()) == 64
except Exception:
return False
def _get_slug(self) -> str:
try:
return self._slug_path.read_text(encoding="utf-8").strip()
except Exception:
return ""
def _show_qr(self):
slug = self._get_slug()
if not slug:
gui_app.set_modal_overlay(BigDialog("Galaxy is not paired yet.", ""))
return
gui_app.set_modal_overlay(GalaxyQRDialog(f"https://galaxy.firestar.link/{slug}"))
def _pair_with_pin(self, pin: str):
clean_pin = str(pin or "").strip()
if len(clean_pin) < 6:
gui_app.set_modal_overlay(BigDialog("PIN must be at least 6 characters.", ""))
return
try:
self._galaxy_dir.mkdir(parents=True, exist_ok=True)
self._auth_path.write_text(hashlib.sha256(clean_pin.encode("utf-8")).hexdigest(), encoding="utf-8")
self._session_path.write_text(secrets.token_hex(32), encoding="utf-8")
slug = "".join(secrets.choice(self._SLUG_CHARS) for _ in range(16))
self._slug_path.write_text(slug, encoding="utf-8")
except Exception as e:
cloudlog.warning(f"Galaxy pairing write failed: {e}")
gui_app.set_modal_overlay(BigDialog("Failed to pair with Galaxy.", ""))
return
self._show_qr()
def _unpair(self):
for path in (self._auth_path, self._session_path, self._slug_path):
try:
path.unlink(missing_ok=True)
except TypeError:
# Python < 3.8 fallback
if path.exists():
path.unlink()
except Exception as e:
cloudlog.warning(f"Galaxy unpair cleanup failed for {path}: {e}")
def _handle_mouse_release(self, mouse_pos: MousePos):
super()._handle_mouse_release(mouse_pos)
if self._is_paired():
show_qr_option = "show qr"
unpair_option = "unpair"
def on_option_selected():
selected = option_dialog.get_selected_option()
if selected == show_qr_option:
self._show_qr()
elif selected == unpair_option:
confirm = BigConfirmationDialogV2(
"slide to\nunpair galaxy",
"icons_mici/settings/device/uninstall.png",
red=True,
confirm_callback=self._unpair,
)
gui_app.set_modal_overlay(confirm)
option_dialog = BigMultiOptionDialog(
options=[show_qr_option, unpair_option],
default=show_qr_option,
right_btn_callback=on_option_selected,
)
gui_app.set_modal_overlay(option_dialog)
return
pin_dialog = BigInputDialog(
hint="enter galaxy pin...",
default_text="",
minimum_length=6,
confirm_callback=self._pair_with_pin,
)
gui_app.set_modal_overlay(pin_dialog)
def _update_state(self):
self.set_value("paired" if self._is_paired() else "pair")
gui_app.push_widget(dlg)
UPDATER_TIMEOUT = 10.0 # seconds to wait for updater to respond
UPDATE_SCREEN_TIMEOUT = 180 # Keep display awake for 3 minutes during long-running update phases.
EXTENDED_TIMEOUT_UPDATER_STATES = {"downloading...", "finalizing update..."}
class UpdateOpenpilotBigButton(BigButton):
def __init__(self):
self._txt_update_icon = gui_app.texture("icons_mici/settings/device/update.png", 64, 64)
self._txt_reboot_icon = gui_app.texture("icons_mici/settings/device/reboot.png", 64, 64)
self._txt_update_icon = gui_app.texture("icons_mici/settings/device/update.png", 64, 75)
self._txt_reboot_icon = gui_app.texture("icons_mici/settings/device/reboot.png", 64, 70)
self._txt_up_to_date_icon = gui_app.texture("icons_mici/settings/device/up_to_date.png", 64, 64)
super().__init__("update openpilot", "", self._txt_update_icon)
self._waiting_for_updater_t: float | None = None
self._hide_value_t: float | None = None
self._state: UpdaterState = UpdaterState.IDLE
self._extended_timeout_enabled = False
ui_state.add_offroad_transition_callback(self.offroad_transition)
@@ -345,9 +185,11 @@ class UpdateOpenpilotBigButton(BigButton):
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.set_modal_overlay(dlg)
dlg = BigDialog("", tr("Please connect to Wi-Fi to update."))
gui_app.push_widget(dlg)
return
self.set_enabled(False)
@@ -364,17 +206,6 @@ class UpdateOpenpilotBigButton(BigButton):
threading.Thread(target=run, daemon=True).start()
def hide_event(self):
super().hide_event()
self._set_extended_timeout(False)
def _set_extended_timeout(self, enabled: bool):
if self._extended_timeout_enabled == enabled:
return
self._extended_timeout_enabled = enabled
device.set_override_interactive_timeout(UPDATE_SCREEN_TIMEOUT if enabled else None)
def set_value(self, value: str):
super().set_value(value)
if value:
@@ -383,13 +214,13 @@ class UpdateOpenpilotBigButton(BigButton):
self.set_text("update openpilot")
def _update_state(self):
super()._update_state()
if ui_state.started:
self._set_extended_timeout(False)
self.set_enabled(False)
return
updater_state = ui_state.params.get("UpdaterState") or ""
should_extend_timeout = updater_state in EXTENDED_TIMEOUT_UPDATER_STATES
failed_count = ui_state.params.get("UpdateFailedCount")
failed = False if failed_count is None else int(failed_count) > 0
@@ -411,7 +242,7 @@ class UpdateOpenpilotBigButton(BigButton):
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 to respond")
self.set_value("updater failed\nto respond")
self._state = UpdaterState.IDLE
self._hide_value_t = rl.get_time()
@@ -453,16 +284,12 @@ class UpdateOpenpilotBigButton(BigButton):
if self._state != UpdaterState.WAITING_FOR_UPDATER:
self._waiting_for_updater_t = None
self._set_extended_timeout(should_extend_timeout)
class DeviceLayoutMici(NavWidget):
def __init__(self, back_callback: Callable):
class DeviceLayoutMici(NavScroller):
def __init__(self):
super().__init__()
self._fcc_dialog: HtmlModal | None = None
self._driver_camera: DriverCameraDialog | None = None
self._training_guide: TrainingGuide | None = None
def power_off_callback():
ui_state.params.put_bool("DoShutdown", True)
@@ -479,106 +306,52 @@ class DeviceLayoutMici(NavWidget):
params.remove("LiveDelay")
params.put_bool("OnroadCycleRequested", True)
def reset_driver_monitoring_callback():
params = ui_state.params
params.remove("IsRhdDetected")
params.put_bool("OnroadCycleRequested", True)
def uninstall_openpilot_callback():
ui_state.params.put_bool("DoUninstall", True)
reset_driver_monitoring_btn = BigButton("reset driver monitoring calibration", "", "icons_mici/settings/device/cameras.png")
reset_driver_monitoring_btn.set_click_callback(lambda: _engaged_confirmation_callback(reset_driver_monitoring_callback, "reset driver monitoring"))
reset_calibration_btn = EngagedConfirmationButton("reset calibration", "reset", gui_app.texture("icons_mici/settings/device/lkas.png", 122, 64),
reset_calibration_callback)
reset_calibration_btn = BigButton("reset calibration", "", "icons_mici/settings/device/lkas.png")
reset_calibration_btn.set_click_callback(lambda: _engaged_confirmation_callback(reset_calibration_callback, "reset"))
uninstall_openpilot_btn = EngagedConfirmationButton("uninstall openpilot", "uninstall",
gui_app.texture("icons_mici/settings/device/uninstall.png", 64, 64),
uninstall_openpilot_callback, exit_on_confirm=False)
uninstall_openpilot_btn = BigButton("uninstall openpilot", "", "icons_mici/settings/device/uninstall.png")
uninstall_openpilot_btn.set_click_callback(lambda: _engaged_confirmation_callback(uninstall_openpilot_callback, "uninstall"))
reboot_btn = EngagedConfirmationCircleButton("reboot", gui_app.texture("icons_mici/settings/device/reboot.png", 64, 70),
reboot_callback, exit_on_confirm=False)
reboot_btn = BigCircleButton("icons_mici/settings/device/reboot.png", red=False)
reboot_btn.set_click_callback(lambda: _engaged_confirmation_callback(reboot_callback, "reboot"))
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)
self._power_off_btn = BigCircleButton("icons_mici/settings/device/power.png", red=True)
self._power_off_btn.set_click_callback(lambda: _engaged_confirmation_callback(power_off_callback, "power off"))
self._load_languages()
def language_callback():
def selected_language_callback():
selected_language = dlg.get_selected_option()
ui_state.params.put("LanguageSetting", self._languages[selected_language])
current_language_name = ui_state.params.get("LanguageSetting")
current_language = next(name for name, lang in self._languages.items() if lang == current_language_name)
dlg = BigMultiOptionDialog(list(self._languages), default=current_language, right_btn_callback=selected_language_callback)
gui_app.set_modal_overlay(dlg)
# lang_button = BigButton("change language", "", "icons_mici/settings/device/language.png")
# lang_button.set_click_callback(language_callback)
regulatory_btn = BigButton("regulatory info", "", "icons_mici/settings/device/info.png")
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 camera preview", "", "icons_mici/settings/device/cameras.png")
driver_cam_btn.set_click_callback(self._show_driver_camera)
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 training guide", "", "icons_mici/settings/device/info.png")
review_training_guide_btn.set_click_callback(self._on_review_training_guide)
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())
self._scroller = Scroller([
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(),
UpdateOpenpilotBigButton(),
BigParamControl("automatically update starpilot", "AutomaticUpdates"),
PairBigButton(),
review_training_guide_btn,
driver_cam_btn,
reset_driver_monitoring_btn,
# lang_button,
terms_btn,
regulatory_btn,
reset_calibration_btn,
uninstall_openpilot_btn,
regulatory_btn,
reboot_btn,
self._power_off_btn,
], snap_items=False, scroll_indicator=True, edge_shadows=True)
# Set up back navigation
self.set_back_callback(back_callback)
# Hide power off button when onroad
ui_state.add_offroad_transition_callback(self._offroad_transition)
])
def _on_regulatory(self):
if not self._fcc_dialog:
self._fcc_dialog = MiciFccModal(os.path.join(BASEDIR, "selfdrive/assets/offroad/mici_fcc.html"))
gui_app.set_modal_overlay(self._fcc_dialog, callback=setattr(self, '_fcc_dialog', None))
def _offroad_transition(self):
self._power_off_btn.set_visible(ui_state.is_offroad())
def _show_driver_camera(self):
if not self._driver_camera:
self._driver_camera = DriverCameraDialog()
gui_app.set_modal_overlay(self._driver_camera, callback=lambda result: setattr(self, '_driver_camera', None))
def _on_review_training_guide(self):
if not self._training_guide:
def completed_callback():
gui_app.set_modal_overlay(None)
self._training_guide = TrainingGuide(completed_callback=completed_callback)
gui_app.set_modal_overlay(self._training_guide, callback=lambda result: setattr(self, '_training_guide', None))
def _load_languages(self):
with open(os.path.join(BASEDIR, "selfdrive/ui/translations/languages.json")) as f:
self._languages = json.load(f)
def show_event(self):
super().show_event()
self._scroller.show_event()
def _render(self, rect: rl.Rectangle):
self._scroller.render(rect)
gui_app.push_widget(self._fcc_dialog)
@@ -18,7 +18,7 @@ from openpilot.selfdrive.ui.mici.widgets.button import BigButton
from openpilot.selfdrive.ui.mici.widgets.dialog import BigDialog, BigDialogBase, BigMultiOptionDialog
from openpilot.selfdrive.ui.ui_state import ui_state
from openpilot.system.ui.lib.application import gui_app, FontWeight
from openpilot.system.ui.widgets import DialogResult, Widget
from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.widgets.label import gui_label
import pyray as rl
@@ -75,17 +75,19 @@ def _split_param(param_value: str | None) -> list[str]:
class DownloadProgressDialog(BigDialogBase):
def __init__(self, params_memory: Params, is_downloading: Callable[[], bool], cancel_callback: Callable[[], None],
is_terminal_progress: Callable[[str], bool]):
is_terminal_progress: Callable[[str], bool], on_close: Callable[[], None] | None = None):
super().__init__()
self._params_memory = params_memory
self._is_downloading = is_downloading
self._cancel_callback = cancel_callback
self._is_terminal_progress = is_terminal_progress
self._on_close = on_close
self._progress = 0.0
self._status = "Downloading..."
self._terminal_progress_since = 0.0
self._downloading = False
self._dismissed = False
self._cancel_btn = DownloadActionButton("cancel download")
self._cancel_btn.set_click_callback(self._cancel_callback)
@@ -140,7 +142,9 @@ class DownloadProgressDialog(BigDialogBase):
if self._terminal_progress_since == 0.0:
self._terminal_progress_since = time.monotonic()
elif time.monotonic() - self._terminal_progress_since >= _DOWNLOAD_DIALOG_CLOSE_SECONDS:
self._ret = DialogResult.CONFIRM
if not self._dismissed:
self._dismissed = True
self.dismiss(self._on_close)
else:
self._terminal_progress_since = 0.0
@@ -235,8 +239,6 @@ class DownloadProgressDialog(BigDialogBase):
alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER,
)
return self._ret
class DownloadActionButton(Widget):
def __init__(self, label: str):
@@ -276,7 +278,7 @@ class DownloadActionButton(Widget):
class DrivingModelBigButton(BigButton):
def __init__(self):
super().__init__("driving model", "", "icons_mici/settings/device/lkas.png")
super().__init__("driving model", "", gui_app.texture("icons_mici/settings/device/lkas.png", 72, 56))
self._params = Params()
self._params_memory = Params(memory=True)
self._model_manager = ModelManager(self._params, self._params_memory)
@@ -285,12 +287,17 @@ class DrivingModelBigButton(BigButton):
self._active_job = ""
self._manifest_last_refresh_mono = 0.0
self._terminal_progress_since = 0.0
self._sub_label.set_font_size(32)
self._sub_label._scroll = True
self._sub_label._elide = False
self._sub_label._wrap_text = False
self.set_click_callback(self._open_manager_menu)
self.refresh()
def show_event(self):
super().show_event()
self._sub_label.reset_scroll()
self.refresh()
# Always fetch manifest once when this settings pane opens.
self._maybe_refresh_manifest(force=(self._manifest_last_refresh_mono == 0.0))
@@ -298,6 +305,31 @@ class DrivingModelBigButton(BigButton):
def refresh(self):
self._update_button_value()
def set_value(self, value: str):
super().set_value(value)
self._sub_label.reset_scroll()
def _get_label_font_size(self):
return 42
def _width_hint(self) -> int:
icon_width = self._txt_icon.width + 16 if self._txt_icon else 0
return int(self._rect.width - self.LABEL_HORIZONTAL_PADDING * 2 - icon_width)
def _show_option_dialog(self, title: str, options: list[str], current: str | None, on_selected: Callable[[str], None],
back_callback: Callable[[], None] | None = None):
dialog_holder: dict[str, BigMultiOptionDialog] = {}
def on_confirm():
on_selected(dialog_holder["dialog"].get_selected_option())
default_option = current if current in options else None
dialog = BigMultiOptionDialog(options=options, default=default_option, right_btn_callback=on_confirm)
if back_callback is not None:
dialog.set_back_callback(back_callback)
dialog_holder["dialog"] = dialog
gui_app.push_widget(dialog)
def _update_state(self):
super()._update_state()
self._process_terminal_progress()
@@ -315,8 +347,7 @@ class DrivingModelBigButton(BigButton):
if not options:
return
def on_confirm():
value = option_dialog.get_selected_option()
def on_selected(value: str):
if value == "set sort mode":
self._open_sort_mode_dialog()
elif value == "switch model":
@@ -328,9 +359,7 @@ class DrivingModelBigButton(BigButton):
elif value == "refresh manifest":
self._maybe_refresh_manifest(force=True)
default_option = "switch model" if "switch model" in options else options[0]
option_dialog = BigMultiOptionDialog(options=options, default=default_option, right_btn_callback=on_confirm)
gui_app.set_modal_overlay(option_dialog)
self._show_option_dialog("driving model", options, None, on_selected)
def _open_switch_dialog(self):
self._maybe_refresh_manifest(force=False)
@@ -346,7 +375,8 @@ class DrivingModelBigButton(BigButton):
return
current_key = self._get_current_model_key()
self._show_model_dialog("Select Driving Model", installed, current_key, self._switch_model)
self._show_model_dialog("Select Driving Model", installed, current_key, self._switch_model,
back_callback=self._open_manager_menu)
def _open_download_dialog(self):
if ui_state.started:
@@ -365,7 +395,8 @@ class DrivingModelBigButton(BigButton):
self._show_message("All models downloaded", "No additional models are available.", return_to_manager=True)
return
self._show_model_dialog("Download Driving Model", missing, "", self._start_model_download)
self._show_model_dialog("Download Driving Model", missing, "", self._start_model_download,
back_callback=self._open_manager_menu)
def _download_all_missing(self):
if ui_state.started:
@@ -398,22 +429,20 @@ class DrivingModelBigButton(BigButton):
options = [_SORT_MODE_LABELS[mode] for mode in _SORT_MODES]
current_mode = self._get_sort_mode()
def on_confirm():
selected_label = sort_dialog.get_selected_option()
def on_selected(selected_label: str):
selected_mode = _LABEL_TO_SORT_MODE.get(selected_label, _SORT_MODE_ALPHABETICAL)
self._params.put(_SORT_MODE_PARAM, selected_mode)
self._open_manager_menu_if_no_overlay()
self._open_manager_menu()
sort_dialog = BigMultiOptionDialog(options=options, default=_SORT_MODE_LABELS[current_mode], right_btn_callback=on_confirm)
sort_dialog.set_back_callback(self._open_manager_menu)
gui_app.set_modal_overlay(sort_dialog)
self._show_option_dialog("sort mode", options, _SORT_MODE_LABELS[current_mode], on_selected,
back_callback=self._open_manager_menu)
def _get_sort_mode(self) -> str:
mode = (self._params.get(_SORT_MODE_PARAM, encoding="utf-8") or "").strip()
return mode if mode in _SORT_MODES else _SORT_MODE_ALPHABETICAL
def _show_model_dialog(self, title: str, entries: list[ModelEntry], current_key: str,
on_selected: Callable[[str], None]):
on_selected: Callable[[str], None], back_callback: Callable[[], None] | None = None):
options, option_to_key, key_to_option = self._build_model_options(entries)
if not options:
self._show_message("No models available", "Refresh manifest and try again.", return_to_manager=True)
@@ -421,15 +450,12 @@ class DrivingModelBigButton(BigButton):
default_option = key_to_option.get(current_key, options[0])
def on_confirm():
model_key = option_to_key.get(model_dialog.get_selected_option())
def on_dialog_selected(selected_option: str):
model_key = option_to_key.get(selected_option)
if model_key:
on_selected(model_key)
self._open_manager_menu_if_no_overlay()
model_dialog = BigMultiOptionDialog(options=options, default=default_option, right_btn_callback=on_confirm)
model_dialog.set_back_callback(self._open_manager_menu)
gui_app.set_modal_overlay(model_dialog)
self._show_option_dialog(title, options, default_option, on_dialog_selected, back_callback=back_callback)
def _build_model_options(self, entries: list[ModelEntry]) -> tuple[list[str], dict[str, str], dict[str, str]]:
# Ensure display names are unique before applying status text (date/favorite).
@@ -712,21 +738,18 @@ class DrivingModelBigButton(BigButton):
lower = progress.lower()
return any(pattern in lower for pattern in _TERMINAL_PROGRESS_PATTERNS)
def _open_manager_menu_if_no_overlay(self):
if gui_app._modal_overlay.overlay is None:
self._open_manager_menu()
def _show_download_progress_dialog(self):
dialog = DownloadProgressDialog(
params_memory=self._params_memory,
is_downloading=self._is_download_job_running,
cancel_callback=self._cancel_download,
is_terminal_progress=self._is_terminal_progress,
on_close=self._open_manager_menu,
)
gui_app.set_modal_overlay(dialog, callback=lambda _result: self._open_manager_menu())
gui_app.push_widget(dialog)
def _show_message(self, title: str, description: str, return_to_manager: bool = False):
dialog = BigDialog(title, description)
if return_to_manager:
dialog.set_back_callback(self._open_manager_menu)
gui_app.set_modal_overlay(dialog)
gui_app.push_widget(dialog)
+10 -16
View File
@@ -13,7 +13,8 @@ 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, NavWidget
from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.widgets.scroller import NavRawScrollPanel
TITLE = tr_noop("Firehose Mode")
DESCRIPTION = tr_noop(
@@ -80,12 +81,12 @@ class FirehoseLayoutBase(Widget):
def _render(self, rect: rl.Rectangle):
# compute total content height for scrolling
content_height = self._measure_content_height(rect)
scroll_offset = round(self._scroll_panel.update(rect, content_height))
scroll_offset = self._scroll_panel.update(rect, content_height)
# start drawing with offset
x = int(rect.x + 40)
y = int(rect.y + 40 + scroll_offset)
w = int(rect.width - 80)
x = rect.x + 40
y = rect.y + 40 + scroll_offset
w = rect.width - 80
# Title
title_text = tr(TITLE)
@@ -99,7 +100,7 @@ class FirehoseLayoutBase(Widget):
y += 20
# Separator
rl.draw_rectangle(x, y, w, 2, self.GRAY)
rl.draw_rectangle_rec(rl.Rectangle(x, y, w, 2), self.GRAY)
y += 20
# Status
@@ -115,7 +116,7 @@ class FirehoseLayoutBase(Widget):
y += 20
# Separator
rl.draw_rectangle(x, y, w, 2, self.GRAY)
rl.draw_rectangle_rec(rl.Rectangle(x, y, w, 2), self.GRAY)
y += 20
# Instructions intro
@@ -132,9 +133,6 @@ class FirehoseLayoutBase(Widget):
y = self._draw_wrapped_text(x, y, w, tr(answer), gui_app.font(FontWeight.ROMAN), 32, self.LIGHT_GRAY)
y += 20
# return value not used by NavWidget
return -1
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:
@@ -220,9 +218,5 @@ class FirehoseLayoutBase(Widget):
time.sleep(self.UPDATE_INTERVAL)
class FirehoseLayout(FirehoseLayoutBase, NavWidget):
BACK_TOUCH_AREA_PERCENTAGE = 0.1
def __init__(self, back_callback):
super().__init__()
self.set_back_callback(back_callback)
class FirehoseLayout(NavRawScrollPanel, FirehoseLayoutBase):
pass
@@ -0,0 +1,159 @@
import hashlib
import secrets
import string
from pathlib import Path
import numpy as np
import pyray as rl
import qrcode
from openpilot.common.swaglog import cloudlog
from openpilot.selfdrive.ui.mici.widgets.button import BigButton
from openpilot.selfdrive.ui.mici.widgets.dialog import BigDialog, BigConfirmationDialog, BigInputDialog, BigMultiOptionDialog
from openpilot.system.hardware import PC
from openpilot.system.hardware.hw import Paths
from openpilot.system.ui.lib.application import FontWeight, gui_app
from openpilot.system.ui.widgets.label import UnifiedLabel
from openpilot.system.ui.widgets.nav_widget import NavWidget
class GalaxyQRDialog(NavWidget):
def __init__(self, url: str):
super().__init__()
self._url = url
self._qr_texture: rl.Texture | None = None
self._title = UnifiedLabel("pair with galaxy", font_size=48, font_weight=FontWeight.BOLD, line_height=0.8)
self._generate_qr_code()
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._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"Galaxy QR generation failed: {e}")
self._qr_texture = None
def _render(self, rect: rl.Rectangle):
if self._qr_texture is None:
rl.draw_text_ex(gui_app.font(FontWeight.BOLD), "QR Code Error", rl.Vector2(rect.x + 20, rect.y + rect.height / 2 - 15), 30, 0.0, rl.RED)
return
scale = rect.height / self._qr_texture.height
pos = rl.Vector2(round(rect.x + 8), round(rect.y))
rl.draw_texture_ex(self._qr_texture, pos, 0.0, scale, rl.WHITE)
label_x = rect.x + 8 + rect.height + 24
self._title.set_max_width(int(rect.width - label_x))
self._title.set_position(label_x, rect.y + 16)
self._title.render()
def __del__(self):
if self._qr_texture and self._qr_texture.id != 0:
rl.unload_texture(self._qr_texture)
class GalaxyBigButton(BigButton):
_SLUG_CHARS = string.ascii_letters + string.digits
def __init__(self):
super().__init__("galaxy", "", gui_app.texture("icons_mici/settings/galaxy.png", 64, 64))
self._galaxy_dir = Path(Paths.comma_home()) / "starpilot" / "data" / "galaxy" if PC else Path("/data/galaxy")
self._auth_path = self._galaxy_dir / "glxyauth"
self._session_path = self._galaxy_dir / "glxysession"
self._slug_path = self._galaxy_dir / "glxyslug"
def _get_label_font_size(self):
return 64
def _is_paired(self) -> bool:
try:
return len(self._auth_path.read_text(encoding="utf-8").strip()) == 64
except Exception:
return False
def _get_slug(self) -> str:
try:
return self._slug_path.read_text(encoding="utf-8").strip()
except Exception:
return ""
def _show_qr(self):
slug = self._get_slug()
if not slug:
gui_app.push_widget(BigDialog("", "Galaxy is not paired yet."))
return
gui_app.push_widget(GalaxyQRDialog(f"https://galaxy.firestar.link/{slug}"))
def _pair_with_pin(self, pin: str):
clean_pin = str(pin or "").strip()
if len(clean_pin) < 6:
gui_app.push_widget(BigDialog("", "PIN must be at least 6 characters."))
return
try:
self._galaxy_dir.mkdir(parents=True, exist_ok=True)
self._auth_path.write_text(hashlib.sha256(clean_pin.encode("utf-8")).hexdigest(), encoding="utf-8")
self._session_path.write_text(secrets.token_hex(32), encoding="utf-8")
slug = "".join(secrets.choice(self._SLUG_CHARS) for _ in range(16))
self._slug_path.write_text(slug, encoding="utf-8")
except Exception as e:
cloudlog.warning(f"Galaxy pairing write failed: {e}")
gui_app.push_widget(BigDialog("", "Failed to pair with Galaxy."))
return
self._show_qr()
def _unpair(self):
for path in (self._auth_path, self._session_path, self._slug_path):
try:
path.unlink(missing_ok=True)
except TypeError:
if path.exists():
path.unlink()
except Exception as e:
cloudlog.warning(f"Galaxy unpair cleanup failed for {path}: {e}")
def _handle_mouse_release(self, mouse_pos):
super()._handle_mouse_release(mouse_pos)
if self._is_paired():
dialog_holder: dict[str, BigMultiOptionDialog] = {}
def on_confirm():
selection = dialog_holder["dialog"].get_selected_option()
if selection == "show qr":
self._show_qr()
elif selection == "unpair":
gui_app.push_widget(
BigConfirmationDialog(
"slide to\nunpair galaxy",
gui_app.texture("icons_mici/settings/device/uninstall.png", 64, 64),
self._unpair,
red=True,
)
)
dialog = BigMultiOptionDialog(options=["show qr", "unpair"], default="show qr", right_btn_callback=on_confirm)
dialog_holder["dialog"] = dialog
gui_app.push_widget(dialog)
return
gui_app.push_widget(BigInputDialog("enter galaxy pin...", default_text="", minimum_length=6, confirm_callback=self._pair_with_pin))
def _update_state(self):
self.set_value("paired" if self._is_paired() else "pair")
@@ -1,204 +1,60 @@
import pyray as rl
from enum import IntEnum
from collections.abc import Callable
from openpilot.system.ui.widgets.scroller import Scroller
from openpilot.selfdrive.ui.mici.layouts.settings.network.wifi_ui import WifiUIMici, WifiIcon, normalize_ssid
from openpilot.selfdrive.ui.mici.widgets.button import BigButton, BigMultiToggle, BigToggle, BigParamControl
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.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.widgets import NavWidget
from openpilot.system.ui.lib.wifi_manager import WifiManager, Network, MeteredType
from openpilot.system.ui.lib.wifi_manager import WifiManager, ConnectStatus, SecurityType, normalize_ssid
class NetworkPanelType(IntEnum):
NONE = 0
WIFI = 1
class NetworkLayoutMici(NavWidget):
def __init__(self, back_callback: Callable):
super().__init__()
self._current_panel = NetworkPanelType.WIFI
self.set_back_enabled(lambda: self._current_panel == NetworkPanelType.NONE)
self._wifi_manager = WifiManager()
self._wifi_manager.set_active(False)
self._wifi_ui = WifiUIMici(self._wifi_manager, back_callback=lambda: self._switch_to_panel(NetworkPanelType.NONE))
self._wifi_manager.add_callbacks(
networks_updated=self._on_network_updated,
)
_tethering_icon = "icons_mici/settings/network/tethering.png"
# ******** Tethering ********
def tethering_toggle_callback(checked: bool):
self._tethering_toggle_btn.set_enabled(False)
self._network_metered_btn.set_enabled(False)
self._wifi_manager.set_tethering_active(checked)
self._tethering_toggle_btn = BigToggle("enable tethering", "", toggle_callback=tethering_toggle_callback)
def tethering_password_callback(password: str):
if password:
self._wifi_manager.set_tethering_password(password)
def tethering_password_clicked():
tethering_password = self._wifi_manager.tethering_password
dlg = BigInputDialog("enter password...", tethering_password, minimum_length=8,
confirm_callback=tethering_password_callback)
gui_app.set_modal_overlay(dlg)
txt_tethering = gui_app.texture(_tethering_icon, 64, 53)
self._tethering_password_btn = BigButton("tethering password", "", txt_tethering)
self._tethering_password_btn.set_click_callback(tethering_password_clicked)
# ******** IP Address ********
self._ip_address_btn = BigButton("IP Address", "Not connected")
# ******** 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)
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)
wifi_button = BigButton("wi-fi", "not connected", self._wifi_slash_txt)
wifi_button.set_click_callback(lambda: self._switch_to_panel(NetworkPanelType.WIFI))
self._wifi_button = wifi_button
# ******** Advanced settings ********
# ******** Roaming toggle ********
self._roaming_btn = BigParamControl("enable roaming", "GsmRoaming", toggle_callback=self._toggle_roaming)
# ******** 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", toggle_callback=self._toggle_cellular_metered)
# Main scroller ----------------------------------
self._scroller = Scroller([
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,
# */
self._ip_address_btn,
], snap_items=False, scroll_indicator=True, edge_shadows=True)
# Set initial config
roaming_enabled = ui_state.params.get_bool("GsmRoaming")
metered = ui_state.params.get_bool("GsmMetered")
self._wifi_manager.update_gsm_settings(roaming_enabled, ui_state.params.get("GsmApn") or "", metered)
# Set up back navigation
self.set_back_callback(back_callback)
super().__init__("wi-fi", "not connected", self._wifi_slash_txt, scroll=True)
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._current_panel = NetworkPanelType.NONE
self._wifi_ui.show_event()
self._scroller.show_event()
def hide_event(self):
super().hide_event()
self._wifi_ui.hide_event()
def _toggle_roaming(self, checked: bool):
self._wifi_manager.update_gsm_settings(checked, ui_state.params.get("GsmApn") or "", ui_state.params.get_bool("GsmMetered"))
def _edit_apn(self):
def update_apn(apn: str):
apn = apn.strip()
if apn == "":
ui_state.params.remove("GsmApn")
else:
ui_state.params.put("GsmApn", apn)
self._wifi_manager.update_gsm_settings(ui_state.params.get_bool("GsmRoaming"), apn, ui_state.params.get_bool("GsmMetered"))
current_apn = ui_state.params.get("GsmApn") or ""
dlg = BigInputDialog("enter APN", current_apn, minimum_length=0, confirm_callback=update_apn)
gui_app.set_modal_overlay(dlg)
def _toggle_cellular_metered(self, checked: bool):
self._wifi_manager.update_gsm_settings(ui_state.params.get_bool("GsmRoaming"), ui_state.params.get("GsmApn") or "", checked)
def _on_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._network_metered_btn.set_enabled(lambda: not tethering_active and bool(self._wifi_manager.ipv4_address))
self._tethering_toggle_btn.set_checked(tethering_active)
connected_network = next((network for network in networks if network.is_connected), None)
if connected_network is not None:
self._wifi_button.set_value(normalize_ssid(connected_network.ssid))
strength = round(connected_network.strength / 100 * 2)
if strength >= 2:
self._wifi_button.set_icon(self._wifi_full_txt)
elif strength == 1:
self._wifi_button.set_icon(self._wifi_medium_txt)
else:
self._wifi_button.set_icon(self._wifi_low_txt)
# 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:
self._wifi_button.set_value("not connected")
self._wifi_button.set_icon(self._wifi_slash_txt)
display_network = None
self.set_text("wi-fi")
self.set_value("not connected")
# Update IP address
self._ip_address_btn.set_value(self._wifi_manager.ipv4_address or "Not connected")
# 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'))
def _switch_to_panel(self, panel_type: NetworkPanelType):
if panel_type == NetworkPanelType.WIFI:
self._wifi_ui.show_event()
self._current_panel = panel_type
def _render(self, rect: rl.Rectangle):
self._wifi_manager.process_callbacks()
if self._current_panel == NetworkPanelType.WIFI:
self._wifi_ui.render(rect)
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._scroller.render(rect)
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)
@@ -1,5 +0,0 @@
def should_show_forget_button(network=None, *, is_saved: bool = False, is_connected: bool = False) -> bool:
if network is not None:
return bool(network.is_saved or network.is_connected)
return bool(is_saved or is_connected)
@@ -0,0 +1,154 @@
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", toggle_callback=self._toggle_roaming)
# ******** 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", toggle_callback=self._toggle_cellular_metered)
# 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,
# */
])
# Set initial config
roaming_enabled = ui_state.params.get_bool("GsmRoaming")
metered = ui_state.params.get_bool("GsmMetered")
self._wifi_manager.update_gsm_settings(roaming_enabled, ui_state.params.get("GsmApn") or "", metered)
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 _toggle_roaming(self, checked: bool):
self._wifi_manager.update_gsm_settings(checked, ui_state.params.get("GsmApn") or "", ui_state.params.get_bool("GsmMetered"))
def _edit_apn(self):
def update_apn(apn: str):
apn = apn.strip()
if apn == "":
ui_state.params.remove("GsmApn")
else:
ui_state.params.put("GsmApn", apn)
self._wifi_manager.update_gsm_settings(ui_state.params.get_bool("GsmRoaming"), apn, ui_state.params.get_bool("GsmMetered"))
current_apn = ui_state.params.get("GsmApn") or ""
dlg = BigInputDialog("enter APN...", current_apn, minimum_length=0, confirm_callback=update_apn)
gui_app.push_widget(dlg)
def _toggle_cellular_metered(self, checked: bool):
self._wifi_manager.update_gsm_settings(ui_state.params.get_bool("GsmRoaming"), ui_state.params.get("GsmApn") or "", checked)
def _on_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'))
@@ -4,407 +4,339 @@ import pyray as rl
from collections.abc import Callable
from openpilot.common.swaglog import cloudlog
from openpilot.system.ui.widgets.label import UnifiedLabel
from openpilot.selfdrive.ui.mici.widgets.dialog import BigMultiOptionDialog, BigInputDialog, BigDialogOptionButton, BigConfirmationDialogV2
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, NavWidget
from openpilot.system.ui.lib.wifi_manager import WifiManager, Network, SecurityType
from openpilot.selfdrive.ui.mici.layouts.settings.network.action_state import should_show_forget_button
def normalize_ssid(ssid: str) -> str:
return ssid.replace("", "'") # for iPhone hotspots
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):
def _render(self, _):
cx = int(self._rect.x + 70)
cy = int(self._rect.y + self._rect.height / 2 - 50)
RADIUS = 8
SPACING = 24 # center-to-center: diameter (16) + gap (8)
Y_MAG = 11.2
y_mag = 20
anim_scale = 5
spacing = 28
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 = cx - spacing + i * spacing
y = int(cy + min(math.sin((rl.get_time() - i * 0.2) * anim_scale) * y_mag, 0))
alpha = int(np.interp(cy - y, [0, y_mag], [255 * 0.45, 255 * 0.9]))
rl.draw_circle(x, y, 10, rl.Color(255, 255, 255, alpha))
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):
def __init__(self, network: Network):
super().__init__()
self.set_rect(rl.Rectangle(0, 0, 89, 64))
self.set_rect(rl.Rectangle(0, 0, 48 + 5, 36 + 5))
self._wifi_low_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_low.png", 89, 64)
self._wifi_medium_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_medium.png", 89, 64)
self._wifi_full_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_full.png", 89, 64)
self._lock_txt = gui_app.texture("icons_mici/settings/network/new/lock.png", 23, 32)
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 | None = None
self._scale = 1.0
self._network: Network = network
self._network_missing = False # if network disappeared from scan results
def set_current_network(self, network: Network):
def update_network(self, network: Network):
self._network = network
def set_scale(self, scale: float):
self._scale = scale
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, _):
if self._network is None:
return
# Determine which wifi strength icon to use
strength = round(self._network.strength / 100 * 2)
if strength == 2:
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
icon_x = int(self._rect.x + (self._rect.width - strength_icon.width * self._scale) // 2)
icon_y = int(self._rect.y + (self._rect.height - strength_icon.height * self._scale) // 2)
rl.draw_texture_ex(strength_icon, (icon_x, icon_y), 0.0, self._scale, rl.WHITE)
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_scale = self._scale * 1.1
lock_x = int(icon_x + 1 + strength_icon.width * self._scale - self._lock_txt.width * lock_scale / 2)
lock_y = int(icon_y + 1 + strength_icon.height * self._scale - self._lock_txt.height * lock_scale / 2)
rl.draw_texture_ex(self._lock_txt, (lock_x, lock_y), 0.0, lock_scale, rl.WHITE)
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 WifiItem(BigDialogOptionButton):
LEFT_MARGIN = 20
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):
super().__init__(network.ssid)
self.set_rect(rl.Rectangle(0, 0, gui_app.width, self.HEIGHT))
self._selected_txt = gui_app.texture("icons_mici/settings/network/new/wifi_selected.png", 48, 96)
def __init__(self, network: Network, wifi_manager: WifiManager):
super().__init__(normalize_ssid(network.ssid), scroll=True)
self._network = network
self._wifi_icon = WifiIcon()
self._wifi_icon.set_current_network(network)
def set_current_network(self, network: Network):
self._network = network
self._wifi_icon.set_current_network(network)
def _render(self, _):
if self._network.is_connected:
selected_x = int(self._rect.x - self._selected_txt.width / 2)
selected_y = int(self._rect.y + (self._rect.height - self._selected_txt.height) / 2)
rl.draw_texture(self._selected_txt, selected_x, selected_y, rl.WHITE)
self._wifi_icon.set_scale((1.0 if self._selected else 0.65) * 0.7)
self._wifi_icon.render(rl.Rectangle(
self._rect.x + self.LEFT_MARGIN,
self._rect.y,
self.SELECTED_HEIGHT,
self._rect.height
))
if self._selected:
self._label.set_font_size(self.SELECTED_HEIGHT)
self._label.set_color(rl.Color(255, 255, 255, int(255 * 0.9)))
self._label.set_font_weight(FontWeight.DISPLAY)
else:
self._label.set_font_size(self.HEIGHT)
self._label.set_color(rl.Color(255, 255, 255, int(255 * 0.58)))
self._label.set_font_weight(FontWeight.DISPLAY_REGULAR)
label_offset = self.LEFT_MARGIN + self._wifi_icon.rect.width + 20
label_rect = rl.Rectangle(self._rect.x + label_offset, self._rect.y, self._rect.width - label_offset, self._rect.height)
self._label.set_text(normalize_ssid(self._network.ssid))
self._label.render(label_rect)
class ConnectButton(Widget):
def __init__(self):
super().__init__()
self._bg_txt = gui_app.texture("icons_mici/settings/network/new/connect_button.png", 410, 100)
self._bg_pressed_txt = gui_app.texture("icons_mici/settings/network/new/connect_button_pressed.png", 410, 100)
self._bg_full_txt = gui_app.texture("icons_mici/settings/network/new/full_connect_button.png", 520, 100)
self._bg_full_pressed_txt = gui_app.texture("icons_mici/settings/network/new/full_connect_button_pressed.png", 520, 100)
self._full: bool = False
self._label = UnifiedLabel("", 36, FontWeight.MEDIUM, rl.Color(255, 255, 255, int(255 * 0.9)),
alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER,
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE)
@property
def full(self) -> bool:
return self._full
def set_full(self, full: bool):
self._full = full
self.set_rect(rl.Rectangle(0, 0, 520 if self._full else 410, 100))
def set_label(self, text: str):
self._label.set_text(text)
def _render(self, _):
if self._full:
bg_txt = self._bg_full_pressed_txt if self.is_pressed and self.enabled else self._bg_full_txt
else:
bg_txt = self._bg_pressed_txt if self.is_pressed and self.enabled else self._bg_txt
rl.draw_texture(bg_txt, int(self._rect.x), int(self._rect.y), rl.WHITE)
self._label.set_text_color(rl.Color(255, 255, 255, int(255 * 0.9) if self.enabled else int(255 * 0.9 * 0.65)))
self._label.render(self._rect)
class ForgetButton(Widget):
HORIZONTAL_MARGIN = 8
def __init__(self, forget_network: Callable, open_network_manage_page):
super().__init__()
self._forget_network = forget_network
self._open_network_manage_page = open_network_manage_page
self._bg_txt = gui_app.texture("icons_mici/settings/network/new/forget_button.png", 100, 100)
self._bg_pressed_txt = gui_app.texture("icons_mici/settings/network/new/forget_button_pressed.png", 100, 100)
self._trash_txt = gui_app.texture("icons_mici/settings/network/new/trash.png", 32, 36)
self.set_rect(rl.Rectangle(0, 0, 100 + self.HORIZONTAL_MARGIN * 2, 100))
def _handle_mouse_release(self, mouse_pos: MousePos):
super()._handle_mouse_release(mouse_pos)
dlg = BigConfirmationDialogV2("slide to forget", "icons_mici/settings/network/new/trash.png", red=True,
confirm_callback=self._forget_network)
gui_app.set_modal_overlay(dlg, callback=self._open_network_manage_page)
def _render(self, _):
bg_txt = self._bg_pressed_txt if self.is_pressed else self._bg_txt
rl.draw_texture(bg_txt, int(self._rect.x + self.HORIZONTAL_MARGIN), int(self._rect.y), rl.WHITE)
trash_x = int(self._rect.x + (self._rect.width - self._trash_txt.width) // 2)
trash_y = int(self._rect.y + (self._rect.height - self._trash_txt.height) // 2)
rl.draw_texture(self._trash_txt, trash_x, trash_y, rl.WHITE)
class NetworkInfoPage(NavWidget):
def __init__(self, wifi_manager, connect_callback: Callable, forget_callback: Callable, open_network_manage_page: Callable):
super().__init__()
self._wifi_manager = wifi_manager
self.set_rect(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
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)
self._wifi_icon = WifiIcon()
self._forget_btn = ForgetButton(lambda: forget_callback(self._network.ssid) if self._network is not None else None,
open_network_manage_page)
self._connect_btn = ConnectButton()
self._connect_btn.set_click_callback(lambda: connect_callback(self._network.ssid) if self._network is not None else None)
# Eager state (not sourced from Network)
self._network_missing = False
self._network_forgetting = False
self._wrong_password = False
self._title = UnifiedLabel("", 64, FontWeight.DISPLAY, rl.Color(255, 255, 255, int(255 * 0.9)),
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE, scroll=True)
self._subtitle = UnifiedLabel("", 36, FontWeight.ROMAN, rl.Color(255, 255, 255, int(255 * 0.9 * 0.65)),
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE)
self.set_back_callback(lambda: gui_app.set_modal_overlay(None))
# State
self._network: Network | None = None
self._connecting: Callable[[], str | None] | None = None
self._show_forget_btn = False
def show_event(self):
super().show_event()
self._title.reset_scroll()
def update_networks(self, networks: dict[str, Network]):
# update current network from latest scan results
for ssid, network in networks.items():
if self._network is not None and ssid == self._network.ssid:
self.set_current_network(network)
break
else:
# network disappeared, close page
gui_app.set_modal_overlay(None)
def _update_state(self):
super()._update_state()
# Modal overlays stop main UI rendering, so we need to call here
self._wifi_manager.process_callbacks()
if self._network is None:
return
self._show_forget_btn = should_show_forget_button(self._network)
self._connect_btn.set_full(not self._show_forget_btn)
if self._is_connecting:
self._connect_btn.set_label("connecting...")
self._connect_btn.set_enabled(False)
elif self._network.is_connected:
self._connect_btn.set_label("disconnect")
self._connect_btn.set_enabled(True)
elif self._network.security_type == SecurityType.UNSUPPORTED:
self._connect_btn.set_label("connect")
self._connect_btn.set_enabled(False)
else: # saved or unknown
self._connect_btn.set_label("connect")
self._connect_btn.set_enabled(True)
self._title.set_text(normalize_ssid(self._network.ssid))
if self._network.security_type == SecurityType.OPEN:
self._subtitle.set_text("open")
elif self._network.security_type == SecurityType.UNSUPPORTED:
self._subtitle.set_text("unsupported")
else:
self._subtitle.set_text("secured")
def set_current_network(self, network: Network):
def update_network(self, network: Network):
self._network = network
self._wifi_icon.set_current_network(network)
self._wifi_icon.update_network(network)
def set_connecting(self, is_connecting: Callable[[], str | None]):
self._connecting = is_connecting
# 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 _is_connecting(self):
if self._connecting is None or self._network is None:
return False
is_connecting = self._connecting() == self._network.ssid
return is_connecting
def network_forgetting(self) -> bool:
return self._network_forgetting
def _render(self, _):
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 + 32,
self._rect.y + (self._rect.height - self._connect_btn.rect.height - self._wifi_icon.rect.height) / 2,
self._rect.x + 30,
btn_y + 30,
self._wifi_icon.rect.width,
self._wifi_icon.rect.height,
))
self._title.render(rl.Rectangle(
self._rect.x + self._wifi_icon.rect.width + 32 + 32,
self._rect.y + 32 - 16,
self._rect.width - (self._wifi_icon.rect.width + 32 + 32),
64,
))
self._subtitle.render(rl.Rectangle(
self._rect.x + self._wifi_icon.rect.width + 32 + 32,
self._rect.y + 32 + 64 - 16,
self._rect.width - (self._wifi_icon.rect.width + 32 + 32),
48,
))
self._connect_btn.render(rl.Rectangle(
self._rect.x + 8,
self._rect.y + self._rect.height - self._connect_btn.rect.height,
self._connect_btn.rect.width,
self._connect_btn.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,
self._rect.y + self._rect.height - self._forget_btn.rect.height,
btn_y + self._rect.height - self._forget_btn.rect.height,
self._forget_btn.rect.width,
self._forget_btn.rect.height,
))
return -1
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 WifiUIMici(BigMultiOptionDialog):
# Wait this long after user interacts with widget to update network list
INACTIVITY_TIMEOUT = 1
class ForgetButton(Widget):
MARGIN = 12 # bottom and right
def __init__(self, wifi_manager: WifiManager, back_callback: Callable):
super().__init__([], None, None, right_btn_callback=None)
def __init__(self, forget_network: Callable):
super().__init__()
self._forget_network = forget_network
# Set up back navigation
self.set_back_callback(back_callback)
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))
self._network_info_page = NetworkInfoPage(wifi_manager, self._connect_to_network, self._forget_network, self._open_network_manage_page)
self._network_info_page.set_connecting(lambda: self._connecting)
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()
self._wifi_manager = wifi_manager
self._connecting: str | None = None
self._networks: dict[str, Network] = {}
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()
# widget state
self._last_interaction_time = -float('inf')
self._restore_selection = False
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,
activated=self._on_activated,
forgotten=self._on_forgotten,
networks_updated=self._on_network_updated,
disconnected=self._on_disconnected,
)
@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):
# Call super to prepare scroller; selection scroll is handled dynamically
# Re-sort scroller items and update from latest scan results
super().show_event()
self._wifi_manager.set_active(True)
self._last_interaction_time = -float('inf')
def hide_event(self):
super().hide_event()
self._wifi_manager.set_active(False)
def _open_network_manage_page(self, result=None):
self._network_info_page.update_networks(self._networks)
gui_app.set_modal_overlay(self._network_info_page)
def _forget_network(self, ssid: str):
network = self._networks.get(ssid)
if network is None:
cloudlog.warning(f"Trying to forget unknown network: {ssid}")
return
self._wifi_manager.forget_connection(network.ssid)
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()
self._network_info_page.update_networks(self._networks)
def _update_buttons(self):
# Don't update buttons while user is actively interacting
if rl.get_time() - self._last_interaction_time < self.INACTIVITY_TIMEOUT:
return
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():
# pop and re-insert to eliminate stuttering on update (prevents position lost for a frame)
network_button_idx = next((i for i, btn in enumerate(self._scroller._items) if btn.option == network.ssid), None)
if network_button_idx is not None:
network_button = self._scroller._items.pop(network_button_idx)
# Update network on existing button
network_button.set_current_network(network)
if network.ssid in existing:
existing[network.ssid].update_network(network)
else:
network_button = WifiItem(network)
btn = WifiButton(network, self._wifi_manager)
btn.set_click_callback(lambda ssid=network.ssid: self._connect_to_network(ssid))
self._scroller.add_widget(btn)
self._scroller.add_widget(network_button)
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)
# remove networks no longer present
self._scroller._items[:] = [btn for btn in self._scroller._items if btn.option in self._networks]
# try to restore previous selection to prevent jumping from adding/removing/reordering buttons
self._restore_selection = 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):
if password:
self._connecting = ssid
self._wifi_manager.connect_to_network(ssid, password)
self._update_buttons()
def _on_option_selected(self, option: str):
super()._on_option_selected(option)
if option in self._networks:
self._network_info_page.set_current_network(self._networks[option])
self._open_network_manage_page()
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)
@@ -412,51 +344,48 @@ class WifiUIMici(BigMultiOptionDialog):
cloudlog.warning(f"Trying to connect to unknown network: {ssid}")
return
if network.is_connected:
self._wifi_manager.disconnect_network(network.ssid)
return
if network.is_saved:
self._connecting = network.ssid
if self._wifi_manager.is_connection_saved(network.ssid):
self._wifi_manager.activate_connection(network.ssid)
self._update_buttons()
elif network.security_type == SecurityType.OPEN:
self._connecting = network.ssid
self._wifi_manager.connect_to_network(network.ssid, "")
self._update_buttons()
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):
hint = "incorrect password..." if incorrect_password else "enter password..."
dlg = BigInputDialog(hint, "", minimum_length=8,
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))
# go back to the manage network page
gui_app.set_modal_overlay(dlg, self._open_network_manage_page)
gui_app.push_widget(dlg)
def _on_activated(self):
self._connecting = None
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 _on_forgotten(self):
self._connecting = None
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
def _on_disconnected(self):
self._connecting = 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)
def _update_state(self):
super()._update_state()
if self.is_pressed:
self._last_interaction_time = rl.get_time()
def _render(self, _):
# Update Scroller layout and restore current selection whenever buttons are updated, before first render
current_selection = self.get_selected_option()
if self._restore_selection and current_selection in self._networks:
self._scroller._layout()
BigMultiOptionDialog._on_option_selected(self, current_selection)
self._restore_selection = None
super()._render(_)
if not self._networks:
self._loading_animation.render(self._rect)
self._move_network_to_front(self._wifi_manager.wifi_state.ssid)
+40 -90
View File
@@ -1,34 +1,19 @@
import pyray as rl
from dataclasses import dataclass
from enum import IntEnum
from collections.abc import Callable
from openpilot.common.params import Params
from openpilot.system.ui.widgets.scroller import Scroller
from openpilot.system.ui.widgets.scroller import NavScroller
from openpilot.selfdrive.ui.mici.widgets.button import BigButton, BigMultiToggle
from openpilot.selfdrive.ui.mici.layouts.settings.toggles import TogglesLayoutMici
from openpilot.selfdrive.ui.mici.layouts.settings.network import NetworkLayoutMici
from openpilot.selfdrive.ui.mici.layouts.settings.device import DeviceLayoutMici, PairBigButton, GalaxyBigButton
from openpilot.selfdrive.ui.mici.layouts.settings.driving_model import DrivingModelBigButton
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.firehose import FirehoseLayout
from openpilot.selfdrive.ui.mici.layouts.settings.driving_model import DrivingModelBigButton
from openpilot.selfdrive.ui.mici.layouts.settings.galaxy import GalaxyBigButton
from openpilot.selfdrive.ui.mici.layouts.settings.visuals import VisualsLayoutMici
from openpilot.system.ui.lib.application import gui_app, FontWeight
from openpilot.system.ui.widgets import Widget, NavWidget
class PanelType(IntEnum):
TOGGLES = 0
NETWORK = 1
DEVICE = 2
DEVELOPER = 3
USER_MANUAL = 4
FIREHOSE = 5
@dataclass
class PanelInfo:
name: str
instance: Widget
class SettingsBigButton(BigButton):
def _get_label_font_size(self):
return 64
class ForceDriveStateBigButton(BigMultiToggle):
@@ -37,6 +22,12 @@ class ForceDriveStateBigButton(BigMultiToggle):
self._params = Params()
self.refresh()
def _get_label_font_size(self):
return 40
def _width_hint(self) -> int:
return int(self._rect.width - self.LABEL_HORIZONTAL_PADDING * 2 - self._txt_enabled_toggle.width - 20)
def _handle_mouse_release(self, mouse_pos):
super()._handle_mouse_release(mouse_pos)
self._apply_mode(self.value)
@@ -61,92 +52,51 @@ class ForceDriveStateBigButton(BigMultiToggle):
self.set_value("off")
class SettingsLayout(NavWidget):
class SettingsLayout(NavScroller):
def __init__(self):
super().__init__()
self._params = Params()
self._current_panel = None # PanelType.DEVICE
toggles_btn = BigButton("toggles", "", "icons_mici/settings/toggles_icon.png")
toggles_btn.set_click_callback(lambda: self._set_current_panel(PanelType.TOGGLES))
network_btn = BigButton("network", "", "icons_mici/settings/network/wifi_strength_full.png")
network_btn.set_click_callback(lambda: self._set_current_panel(PanelType.NETWORK))
device_btn = BigButton("device", "", "icons_mici/settings/device_icon.png")
device_btn.set_click_callback(lambda: self._set_current_panel(PanelType.DEVICE))
developer_btn = BigButton("developer", "", "icons_mici/settings/developer_icon.png")
developer_btn.set_click_callback(lambda: self._set_current_panel(PanelType.DEVELOPER))
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))
firehose_btn = BigButton("firehose", "", "icons_mici/settings/comma_icon.png")
firehose_btn.set_click_callback(lambda: self._set_current_panel(PanelType.FIREHOSE))
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))
visuals_panel = VisualsLayoutMici()
visuals_btn = SettingsBigButton("visuals", "", gui_app.texture("icons_mici/settings/device/cameras.png", 64, 64))
visuals_btn.set_click_callback(lambda: gui_app.push_widget(visuals_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))
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))
self._force_drive_state_btn = ForceDriveStateBigButton()
self._driving_model_btn = DrivingModelBigButton()
galaxy_btn = GalaxyBigButton()
self._scroller = Scroller([
self._scroller.add_widgets([
toggles_btn,
network_btn,
self._force_drive_state_btn,
self._driving_model_btn,
device_btn,
self._driving_model_btn,
visuals_btn,
galaxy_btn,
PairBigButton(),
GalaxyBigButton(),
#BigDialogButton("manual", "", "icons_mici/settings/manual_icon.png", "Check out the mici user\nmanual at comma.ai/setup"),
firehose_btn,
developer_btn,
], snap_items=False, scroll_indicator=True, edge_shadows=True)
# Set up back navigation
self.set_back_callback(self.close_settings)
self.set_back_enabled(lambda: self._current_panel is None)
self._panels = {
PanelType.TOGGLES: PanelInfo("Toggles", TogglesLayoutMici(back_callback=lambda: self._set_current_panel(None))),
PanelType.NETWORK: PanelInfo("Network", NetworkLayoutMici(back_callback=lambda: self._set_current_panel(None))),
PanelType.DEVICE: PanelInfo("Device", DeviceLayoutMici(back_callback=lambda: self._set_current_panel(None))),
PanelType.DEVELOPER: PanelInfo("Developer", DeveloperLayoutMici(back_callback=lambda: self._set_current_panel(None))),
PanelType.FIREHOSE: PanelInfo("Firehose", FirehoseLayout(back_callback=lambda: self._set_current_panel(None))),
}
])
self._font_medium = gui_app.font(FontWeight.MEDIUM)
# Callbacks
self._close_callback: Callable | None = None
def show_event(self):
super().show_event()
self._force_drive_state_btn.refresh()
self._driving_model_btn.refresh()
self._set_current_panel(None)
self._scroller.show_event()
if self._current_panel is not None:
self._panels[self._current_panel].instance.show_event()
def hide_event(self):
super().hide_event()
if self._current_panel is not None:
self._panels[self._current_panel].instance.hide_event()
def set_callbacks(self, on_close: Callable):
self._close_callback = on_close
def _render(self, rect: rl.Rectangle):
if self._current_panel is not None:
self._draw_current_panel()
else:
self._scroller.render(rect)
def _draw_current_panel(self):
panel = self._panels[self._current_panel]
panel.instance.render(self._rect)
def _set_current_panel(self, panel_type: PanelType | None):
if panel_type != self._current_panel:
if self._current_panel is not None:
self._panels[self._current_panel].instance.hide_event()
self._current_panel = panel_type
if self._current_panel is not None:
self._panels[self._current_panel].instance.show_event()
def close_settings(self):
if self._close_callback:
self._close_callback()
+5 -13
View File
@@ -1,21 +1,17 @@
import pyray as rl
from collections.abc import Callable
from cereal import log
from openpilot.system.ui.widgets.scroller import Scroller
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.system.ui.widgets import NavWidget
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(NavWidget):
def __init__(self, back_callback: Callable):
class TogglesLayoutMici(NavScroller):
def __init__(self):
super().__init__()
self.set_back_callback(back_callback)
self._personality_toggle = BigMultiParamToggle("driving personality", "LongitudinalPersonality", ["aggressive", "standard", "relaxed"])
self._safe_mode_btn = BigParamControl("safe mode", "SafeMode", toggle_callback=restart_needed_callback)
@@ -27,7 +23,7 @@ class TogglesLayoutMici(NavWidget):
record_mic = BigParamControl("record & upload mic audio", "RecordAudio", toggle_callback=restart_needed_callback)
enable_openpilot = BigParamControl("enable openpilot", "OpenpilotEnabledToggle", toggle_callback=restart_needed_callback)
self._scroller = Scroller([
self._scroller.add_widgets([
self._personality_toggle,
self._safe_mode_btn,
self._experimental_btn,
@@ -37,7 +33,7 @@ class TogglesLayoutMici(NavWidget):
record_front,
record_mic,
enable_openpilot,
], snap_items=False, scroll_indicator=True, edge_shadows=True)
])
# Toggle lists
self._refresh_toggles = (
@@ -72,7 +68,6 @@ class TogglesLayoutMici(NavWidget):
def show_event(self):
super().show_event()
self._scroller.show_event()
self._update_toggles()
def _update_toggles(self):
@@ -105,6 +100,3 @@ class TogglesLayoutMici(NavWidget):
# 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 _render(self, rect: rl.Rectangle):
self._scroller.render(rect)
@@ -0,0 +1,74 @@
from openpilot.common.params import Params
from openpilot.selfdrive.ui.mici.widgets.button import BigButton, BigParamControl
from openpilot.selfdrive.ui.mici.widgets.dialog import BigDialog, BigMultiOptionDialog
from openpilot.system.ui.lib.application import gui_app
from openpilot.system.ui.widgets.scroller import NavScroller
CAMERA_VIEW_LABELS = ["Auto", "Driver", "Standard", "Wide"]
class CameraViewBigButton(BigButton):
def __init__(self):
super().__init__("camera view", "", gui_app.texture("icons_mici/settings/device/cameras.png", 64, 64))
self._params = Params()
self.set_click_callback(self._show_selector)
self.refresh()
def refresh(self):
current_idx = self._params.get_int("CameraView", return_default=True, default=3)
current_idx = max(0, min(current_idx, len(CAMERA_VIEW_LABELS) - 1))
self.set_value(CAMERA_VIEW_LABELS[current_idx].lower())
def _show_selector(self):
current_idx = self._params.get_int("CameraView", return_default=True, default=3)
current_idx = max(0, min(current_idx, len(CAMERA_VIEW_LABELS) - 1))
dialog_holder: dict[str, BigMultiOptionDialog] = {}
def on_confirm():
try:
idx = CAMERA_VIEW_LABELS.index(dialog_holder["dialog"].get_selected_option())
except ValueError:
gui_app.push_widget(BigDialog("", "Invalid camera view"))
return
self._params.put_int("CameraView", idx)
self.refresh()
dialog = BigMultiOptionDialog(options=CAMERA_VIEW_LABELS, default=CAMERA_VIEW_LABELS[current_idx], right_btn_callback=on_confirm)
dialog_holder["dialog"] = dialog
gui_app.push_widget(dialog)
class VisualsLayoutMici(NavScroller):
def __init__(self):
super().__init__()
self._camera_view_btn = CameraViewBigButton()
self._driver_camera_btn = BigParamControl("driver camera on reverse", "DriverCamera")
self._stopped_timer_btn = BigParamControl("stopped timer", "StoppedTimer")
self._speed_limit_signs_btn = BigParamControl("speed limit signs", "ShowSpeedLimits")
self._slc_confirmation_btn = BigParamControl("confirm new speed limits", "SLCConfirmation")
self._slc_confirmation_lower_btn = BigParamControl("confirm lower limits", "SLCConfirmationLower")
self._slc_confirmation_higher_btn = BigParamControl("confirm higher limits", "SLCConfirmationHigher")
self._scroller.add_widgets([
self._camera_view_btn,
self._driver_camera_btn,
self._stopped_timer_btn,
self._speed_limit_signs_btn,
self._slc_confirmation_btn,
self._slc_confirmation_lower_btn,
self._slc_confirmation_higher_btn,
])
def show_event(self):
super().show_event()
self._refresh()
def _update_state(self):
super()._update_state()
self._refresh()
def _refresh(self):
self._camera_view_btn.refresh()
confirmation_enabled = self._slc_confirmation_btn.params.get_bool("SLCConfirmation")
self._slc_confirmation_lower_btn.set_visible(confirmation_enabled)
self._slc_confirmation_higher_btn.set_visible(confirmation_enabled)
+6 -23
View File
@@ -98,8 +98,6 @@ class AlertRenderer(Widget):
self._prev_alert: Alert | None = None
self._text_gen_time = 0
self._alert_text2_gen = ''
self._last_started_frame = -1
self._below_steer_speed_shown_this_drive = False
# animation filters
# TODO: use 0.1 but with proper alert height calculation
@@ -113,23 +111,15 @@ class AlertRenderer(Widget):
self._load_icons()
def _load_icons(self):
self._txt_turn_signal_left = gui_app.texture('icons_mici/onroad/turn_signal_left.png', 100, 91)
self._txt_turn_signal_right = gui_app.texture('icons_mici/onroad/turn_signal_right.png', 100, 91)
self._txt_blind_spot_left = gui_app.texture('icons_mici/onroad/blind_spot_left.png', 108, 128)
self._txt_blind_spot_right = gui_app.texture('icons_mici/onroad/blind_spot_right.png', 108, 128)
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']
# Reset per-drive one-shot alert state on each new onroad session.
if ui_state.started and ui_state.started_frame != self._last_started_frame:
self._last_started_frame = ui_state.started_frame
self._below_steer_speed_shown_this_drive = False
elif not ui_state.started:
self._last_started_frame = -1
self._below_steer_speed_shown_this_drive = False
# Check if selfdriveState messages have stopped arriving
if not sm.updated['selfdriveState']:
recv_frame = sm.recv_frame['selfdriveState']
@@ -155,13 +145,6 @@ class AlertRenderer(Widget):
# 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)
# Stock-like once-per-drive minimum lateral warning behavior.
if ret.alert_type.startswith("belowSteerSpeed/"):
if self._below_steer_speed_shown_this_drive:
return None
self._below_steer_speed_shown_this_drive = True
self._prev_alert = ret
return ret
@@ -275,8 +258,8 @@ class AlertRenderer(Widget):
else:
icon_alpha = int(min(self._turn_signal_alpha_filter.x, 255))
rl.draw_texture(alert_layout.icon.texture, pos_x, int(self._rect.y + alert_layout.icon.margin_y),
rl.Color(255, 255, 255, int(icon_alpha * self._alpha_filter.x)))
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
+8 -13
View File
@@ -51,13 +51,9 @@ if TICI:
void main() {
vec4 color = texture(texture0, fragTexCoord);
// Keep the onroad camera feed full-color in every driving state.
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));
} else {
color.rgb *= 0.85; // 85% opacity
color.rgb = color.rgb;
}
if (enhance_driver == 1) {
float brightness = 1.1;
@@ -82,12 +78,9 @@ else:
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);
// Keep the onroad camera feed full-color in every driving state.
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
rgb = rgb;
}
// TODO: the images out of camerad need some more correction and
// the ui should apply a gamma curve for the device display
@@ -324,8 +317,10 @@ class CameraView(Widget):
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)
if self._engaged_loc >= 0:
rl.set_shader_value(self.shader, self._engaged_loc, self._engaged_val, rl.ShaderUniformDataType.SHADER_UNIFORM_INT)
if self._enhance_driver_loc >= 0:
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():
+13 -3
View File
@@ -34,8 +34,14 @@ class ConfidenceBall(Widget):
if self._demo:
return
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])))
lateral_ui_active = ui_state.status != UIStatus.DISENGAGED or ui_state.always_on_lateral_active
# animate status dot in from bottom
if not lateral_ui_active:
self._confidence_filter.update(-0.5)
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(
@@ -50,7 +56,7 @@ class ConfidenceBall(Widget):
dot_height = self._rect.y + dot_height
# confidence zones
if ui_state.status != UIStatus.OVERRIDE or self._demo:
if ui_state.status == UIStatus.ENGAGED or ui_state.always_on_lateral_active 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)
@@ -65,6 +71,10 @@ class ConfidenceBall(Widget):
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)
@@ -7,7 +7,8 @@ from openpilot.selfdrive.ui.ui_state import ui_state, device
from openpilot.selfdrive.selfdrived.events import EVENTS, ET
from openpilot.system.ui.lib.application import gui_app, FontWeight
from openpilot.system.ui.lib.multilang import tr
from openpilot.system.ui.widgets import NavWidget
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
EventName = log.OnroadEvent.EventName
@@ -24,19 +25,15 @@ class DriverCameraView(CameraView):
return base
class DriverCameraDialog(NavWidget):
def __init__(self, no_escape=False):
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
if not no_escape:
# TODO: this can grow unbounded, should be given some thought
device.add_interactive_timeout_callback(lambda: gui_app.set_modal_overlay(None))
self.set_back_callback(lambda: gui_app.set_modal_overlay(None))
self.set_back_enabled(not no_escape)
# Load eye icons
self._eye_fill_texture = None
@@ -85,7 +82,7 @@ class DriverCameraDialog(NavWidget):
alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER)
rl.end_scissor_mode()
self._publish_alert_sound(None)
return -1
return
driver_data = self._draw_face_detection(rect)
if driver_data is not None:
@@ -103,7 +100,7 @@ class DriverCameraDialog(NavWidget):
self._render_dm_alerts(rect)
rl.end_scissor_mode()
return -1
return
def _publish_alert_sound(self, dm_state):
"""Publish selfdriveState with only alertSound field set"""
@@ -217,13 +214,20 @@ class DriverCameraDialog(NavWidget):
rl.draw_texture_v(self._eye_fill_texture, (eye_x, eye_y), rl.Color(255, 255, 255, int(255 * fill_opacity)))
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()
driver_camera_view.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
finally:
driver_camera_view.close()
+8 -9
View File
@@ -61,7 +61,7 @@ class DriverStateRenderer(Widget):
self._dm_cone = gui_app.texture("icons_mici/onroad/driver_monitoring/dm_cone.png", cone_and_person_size, cone_and_person_size)
center_size = round(36 / self.BASE_SIZE * self._rect.width)
self._dm_center = gui_app.texture("icons_mici/onroad/driver_monitoring/dm_center.png", center_size, center_size)
self._dm_background = gui_app.texture("icons_mici/onroad/driver_monitoring/dm_background.png", self._rect.width, self._rect.height)
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
@@ -88,15 +88,14 @@ class DriverStateRenderer(Widget):
if DEBUG:
rl.draw_rectangle_lines_ex(self._rect, 1, rl.RED)
rl.draw_texture(self._dm_background,
int(self._rect.x),
int(self._rect.y),
rl.Color(255, 255, 255, int(255 * self._fade_filter.x)))
rl.draw_texture_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(self._dm_person,
int(self._rect.x + (self._rect.width - self._dm_person.width) / 2),
int(self._rect.y + (self._rect.height - self._dm_person.height) / 2),
rl.Color(255, 255, 255, int(255 * 0.9 * self._fade_filter.x)))
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)))
if self.effective_active:
source_rect = rl.Rectangle(0, 0, self._dm_cone.width, self._dm_cone.height)
+2 -1
View File
@@ -264,12 +264,13 @@ class HudRenderer(Widget):
def _draw_steering_wheel(self, rect: rl.Rectangle) -> None:
wheel_txt = self._txt_wheel_critical if self._show_wheel_critical else self._txt_wheel
lateral_ui_active = ui_state.status == UIStatus.ENGAGED or ui_state.always_on_lateral_active
if self._show_wheel_critical:
self._wheel_alpha_filter.update(255)
self._wheel_y_filter.update(0)
else:
if ui_state.status == UIStatus.DISENGAGED:
if not lateral_ui_active and ui_state.status == UIStatus.DISENGAGED:
self._wheel_alpha_filter.update(0)
self._wheel_y_filter.update(wheel_txt.height / 2)
else:
+5 -2
View File
@@ -6,7 +6,7 @@ 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
from openpilot.selfdrive.ui.ui_state import ui_state, UIStatus
from openpilot.selfdrive.ui.mici.onroad import blend_colors
from openpilot.selfdrive.ui.mici.onroad.starpilot_status import get_border_color, get_path_edge_color
from openpilot.system.ui.lib.application import gui_app
@@ -331,7 +331,8 @@ class ModelRenderer(Widget):
if not self._path.projected_points.size:
return
allow_throttle = sm['longitudinalPlan'].allowThrottle or not self._longitudinal_control
lateral_ui_active = ui_state.status == UIStatus.ENGAGED or ui_state.always_on_lateral_active
allow_throttle = sm['longitudinalPlan'].allowThrottle or not self._longitudinal_control or ui_state.always_on_lateral_active
self._blend_filter.update(int(allow_throttle))
if self._experimental_mode:
@@ -345,6 +346,8 @@ class ModelRenderer(Widget):
# 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)
if lateral_ui_active and blend_factor < 1.0:
blended_colors = self._blend_colors(blended_colors, THROTTLE_COLORS, 0.65)
gradient = Gradient(
start=(0.0, 1.0), # Bottom of path
end=(0.0, 0.0), # Top of path
+19 -8
View File
@@ -145,6 +145,9 @@ def arc_bar_pts(cx: float, cy: float,
return pts
DEFAULT_MAX_LAT_ACCEL = 3.0 # m/s^2
class TorqueBar(Widget):
def __init__(self, demo: bool = False):
super().__init__()
@@ -165,16 +168,23 @@ class TorqueBar(Widget):
controls_state = ui_state.sm['controlsState']
car_state = ui_state.sm['carState']
live_parameters = ui_state.sm['liveParameters']
lateral_acceleration = controls_state.curvature * car_state.vEgo ** 2 - live_parameters.roll * ACCELERATION_DUE_TO_GRAVITY
# TODO: pull from carparams
max_lateral_acceleration = 3
car_control = ui_state.sm['carControl']
# from selfdrived
# 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)
self._torque_filter.update(min(max(lateral_acceleration / max_lateral_acceleration + accel_diff, -1), 1))
# 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)
@@ -182,16 +192,17 @@ class TorqueBar(Widget):
# adjust y pos with torque
torque_line_offset = np.interp(abs(self._torque_filter.x), [0.5, 1], [22, 26])
torque_line_height = np.interp(abs(self._torque_filter.x), [0.5, 1], [14, 56])
lateral_ui_active = ui_state.status == UIStatus.ENGAGED or ui_state.always_on_lateral_active
# animate alpha and angle span
if not self._demo:
self._torque_line_alpha_filter.update(1.0)
self._torque_line_alpha_filter.update(lateral_ui_active or ui_state.status == UIStatus.OVERRIDE)
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 != UIStatus.ENGAGED and not self._demo:
if not lateral_ui_active and ui_state.status != UIStatus.OVERRIDE 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
@@ -234,7 +245,7 @@ class TorqueBar(Widget):
max(0, abs(self._torque_filter.x) - 0.75) * 4,
)
if ui_state.status != UIStatus.ENGAGED and not self._demo:
if not lateral_ui_active and ui_state.status != UIStatus.OVERRIDE 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(
+119
View File
@@ -0,0 +1,119 @@
import pyray as rl
rl.set_config_flags(rl.ConfigFlags.FLAG_WINDOW_HIDDEN)
import gc
import weakref
import pytest
from openpilot.system.ui.lib.application import gui_app
from openpilot.system.ui.widgets import Widget
# 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
# 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: Widget) -> list[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():
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()
+172 -140
View File
@@ -1,11 +1,11 @@
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 MiciLabel
from openpilot.system.ui.widgets.label import UnifiedLabel
from openpilot.system.ui.widgets.scroller import DO_ZOOM
from openpilot.system.ui.lib.text_measure import measure_text_cached
from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos
from openpilot.common.filter_simple import BounceFilter
@@ -16,8 +16,7 @@ except ImportError:
SCROLLING_SPEED_PX_S = 50
COMPLICATION_SIZE = 36
LABEL_COLOR = rl.WHITE
LABEL_HORIZONTAL_PADDING = 40
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
@@ -29,50 +28,51 @@ class ScrollState(Enum):
class BigCircleButton(Widget):
def __init__(self, icon: str, red: bool = False):
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._press_state_enabled = True
self._scale_filter = BounceFilter(1.0, 0.1, 1 / gui_app.target_fps)
self._click_delay = 0.075
# Icons
self._txt_icon = gui_app.texture(icon, 64, 53)
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_hover.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_hover.png", 180, 180)
self._txt_btn_red_pressed_bg = gui_app.texture("icons_mici/buttons/button_circle_red_pressed.png", 180, 180)
def set_enable_pressed_state(self, pressed: bool):
self._press_state_enabled = pressed
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 and self._press_state_enabled:
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 and self._press_state_enabled else 1.0)
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)
# draw icon
icon_color = rl.WHITE if self.enabled else rl.Color(255, 255, 255, int(255 * 0.35))
rl.draw_texture(self._txt_icon, int(self._rect.x + (self._rect.width - self._txt_icon.width) / 2),
int(self._rect.y + (self._rect.height - self._txt_icon.height) / 2), icon_color)
self._draw_content(btn_y)
class BigCircleToggle(BigCircleButton):
def __init__(self, icon: str, toggle_callback: Callable = None):
super().__init__(icon, False)
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
@@ -80,7 +80,7 @@ class BigCircleToggle(BigCircleButton):
# 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", 70, 70) # TODO: why discrepancy?
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
@@ -92,62 +92,47 @@ class BigCircleToggle(BigCircleButton):
if self._toggle_callback:
self._toggle_callback(self._checked)
def _render(self, _):
super()._render(_)
def _draw_content(self, btn_y: float):
super()._draw_content(btn_y)
# draw status icon
rl.draw_texture(self._txt_toggle_enabled if self._checked else self._txt_toggle_disabled,
int(self._rect.x + (self._rect.width - self._txt_toggle_enabled.width) / 2),
int(self._rect.y + 5), rl.WHITE)
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[str, rl.Texture] = ""):
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.set_icon(icon)
self._label_font_size_override: int | None = None
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_font = gui_app.font(FontWeight.DISPLAY)
self._value_font = gui_app.font(FontWeight.ROMAN)
self._label = MiciLabel(text, font_size=self._get_label_font_size(), width=int(self._rect.width - LABEL_HORIZONTAL_PADDING * 2),
font_weight=FontWeight.DISPLAY, color=LABEL_COLOR,
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM, wrap_text=True)
self._sub_label = MiciLabel(value, font_size=COMPLICATION_SIZE, width=int(self._rect.width - LABEL_HORIZONTAL_PADDING * 2),
font_weight=FontWeight.ROMAN, color=COMPLICATION_GREY,
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM, wrap_text=True)
self._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()
# internal state
self._scroll_offset = 0 # in pixels
self._needs_scroll = measure_text_cached(self._label_font, text, self._get_label_font_size()).x + 25 > self._rect.width
self._scroll_timer = 0
self._scroll_state = ScrollState.PRE_SCROLL
def set_icon(self, icon: Union[str, rl.Texture]):
self._txt_icon = gui_app.texture(icon, 64, 64) if isinstance(icon, str) and len(icon) else icon
def _refresh_label_metrics(self):
font_size = self._label_font_size_override if self._label_font_size_override is not None else self._get_label_font_size()
self._label.set_font_size(font_size)
self._needs_scroll = measure_text_cached(self._label_font, self.text, font_size).x + 25 > self._rect.width
self._scroll_offset = 0
self._scroll_timer = 0
self._scroll_state = ScrollState.PRE_SCROLL
def _set_label_font_size_override(self, font_size: int | None):
self._label_font_size_override = font_size
self._refresh_label_metrics()
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:
@@ -158,32 +143,37 @@ class BigButton(Widget):
self._txt_default_bg = gui_app.texture("icons_mici/buttons/button_rectangle.png", 402, 180)
self._txt_pressed_bg = gui_app.texture("icons_mici/buttons/button_rectangle_pressed.png", 402, 180)
self._txt_disabled_bg = gui_app.texture("icons_mici/buttons/button_rectangle_disabled.png", 402, 180)
self._txt_hover_bg = gui_app.texture("icons_mici/buttons/button_rectangle_hover.png", 402, 180)
def 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) < 12:
font_size = 64
elif len(self.text) < 17:
font_size = 48
elif len(self.text) < 20:
font_size = 42
if len(self.text) <= 18:
return 48
else:
font_size = 36
return 42
def _update_label_layout(self):
self._label.set_font_size(self._get_label_font_size())
if self.value:
font_size -= 20
return font_size
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._refresh_label_metrics()
self._update_label_layout()
def set_value(self, value: str):
self.value = value
self._sub_label.set_text(value)
self._refresh_label_metrics()
self._update_label_layout()
def get_value(self) -> str:
return self.value
@@ -191,64 +181,60 @@ class BigButton(Widget):
def get_text(self):
return self.text
def _update_state(self):
# hold on text for a bit, scroll, hold again, reset
if self._needs_scroll:
"""`dt` should be seconds since last frame (rl.get_frame_time())."""
# TODO: this comment is generated by GPT, prob wrong and misused
dt = rl.get_frame_time()
def trigger_shake(self):
self._shake_start = rl.get_time()
self._scroll_timer += dt
if self._scroll_state == ScrollState.PRE_SCROLL:
if self._scroll_timer < 0.5:
return
self._scroll_state = ScrollState.SCROLLING
self._scroll_timer = 0
def trigger_grow_animation(self, duration: float = 0.65):
self._grow_animation_until = rl.get_time() + duration
elif self._scroll_state == ScrollState.SCROLLING:
self._scroll_offset -= SCROLLING_SPEED_PX_S * dt
# reset when text has completely left the button + 50 px gap
# TODO: use global constant for 30+30 px gap
# TODO: add std Widget padding option integrated into the self._rect
full_len = measure_text_cached(self._label_font, self.text, self._get_label_font_size()).x + 30 + 30
if self._scroll_offset < (self._rect.width - full_len):
self._scroll_state = ScrollState.POST_SCROLL
self._scroll_timer = 0
@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)
elif self._scroll_state == ScrollState.POST_SCROLL:
# wait for a bit before starting to scroll again
if self._scroll_timer < 0.75:
return
self._scroll_state = ScrollState.PRE_SCROLL
self._scroll_timer = 0
self._scroll_offset = 0
def 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
def _render(self, _):
# draw _txt_default_bg
txt_bg = self._txt_default_bg
if not self.enabled:
txt_bg = self._txt_disabled_bg
elif self.is_pressed:
txt_bg = self._txt_hover_bg
txt_bg = self._txt_pressed_bg
scale = self._scale_filter.update(PRESSED_SCALE if self.is_pressed else 1.0)
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
rl.draw_texture_ex(txt_bg, (btn_x, btn_y), 0, scale, rl.WHITE)
return txt_bg, btn_x, btn_y, scale
def _draw_content(self, btn_y: float):
# LABEL ------------------------------------------------------------------
lx = self._rect.x + LABEL_HORIZONTAL_PADDING
ly = btn_y + self._rect.height - 33 # - 40# - self._get_label_font_size() / 2
if self.value:
self._sub_label.set_position(lx, ly)
ly -= self._sub_label.font_size + 9
self._sub_label.render()
label_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)
self._label.set_position(lx, ly)
self._label.render()
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:
@@ -256,23 +242,35 @@ class BigButton(Widget):
if self._rotate_icon_t is not None:
rotation = (rl.get_time() - self._rotate_icon_t) * 180
# drop top right with 30px padding
# draw top right with 30px padding
x = self._rect.x + self._rect.width - 30 - self._txt_icon.width / 2
y = self._rect.y + 30 + self._txt_icon.height / 2
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(int(x), int(y), 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.WHITE)
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):
def __init__(self, text: str, value: str = "", initial_state: bool = False, toggle_callback: Callable | None = None):
super().__init__(text, value, "")
self._checked = initial_state
self._toggle_callback = toggle_callback
self._set_label_font_size_override(48)
def _load_images(self):
super()._load_images()
self._txt_enabled_toggle = gui_app.texture("icons_mici/buttons/toggle_pill_enabled.png", 84, 66)
@@ -290,35 +288,30 @@ class BigToggle(BigButton):
def _draw_pill(self, x: float, y: float, checked: bool):
# draw toggle icon top right
if checked:
rl.draw_texture(self._txt_enabled_toggle, int(x), int(y), rl.WHITE)
rl.draw_texture_ex(self._txt_enabled_toggle, (x, y), 0, 1.0, rl.WHITE)
else:
rl.draw_texture(self._txt_disabled_toggle, int(x), int(y), rl.WHITE)
rl.draw_texture_ex(self._txt_disabled_toggle, (x, y), 0, 1.0, rl.WHITE)
def _render(self, _):
super()._render(_)
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 = self._rect.y
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,
select_callback: Callable = None):
def __init__(self, text: str, options: list[str], toggle_callback: Callable | None = None,
select_callback: Callable | None = None):
super().__init__(text, "", toggle_callback=toggle_callback)
assert len(options) > 0
self._options = options
self._select_callback = select_callback
self._label.set_width(int(self._rect.width - LABEL_HORIZONTAL_PADDING * 2 - self._txt_enabled_toggle.width))
# Keep the title size stable when the selected option changes.
self._set_label_font_size_override(self._get_label_font_size())
self.set_value(self._options[0])
def _get_label_font_size(self):
font_size = super()._get_label_font_size()
return font_size - 6
def _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)
@@ -328,22 +321,60 @@ class BigMultiToggle(BigToggle):
if self._select_callback:
self._select_callback(self.value)
def _render(self, _):
BigButton._render(self, _)
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 = self._rect.y
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,
select_callback: Callable = None):
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
@@ -360,7 +391,7 @@ class BigMultiParamToggle(BigMultiToggle):
class BigParamControl(BigToggle):
def __init__(self, text: str, param: str, toggle_callback: Callable = None):
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()
@@ -376,8 +407,9 @@ class BigParamControl(BigToggle):
# TODO: param control base class
class BigCircleParamControl(BigCircleToggle):
def __init__(self, icon: str, param: str, toggle_callback: Callable = None):
super().__init__(icon, toggle_callback)
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))
+146 -212
View File
@@ -1,19 +1,18 @@
import abc
import math
import pyray as rl
from typing import Union
from typing import Union, cast
from collections.abc import Callable
from typing import cast
from openpilot.system.ui.widgets import Widget, NavWidget, DialogResult
from openpilot.system.ui.widgets.label import UnifiedLabel, gui_label
from openpilot.system.ui.widgets import Widget
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.wrap_text import wrap_text
from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos, MouseEvent
from openpilot.system.ui.widgets.scroller import Scroller
from openpilot.system.ui.widgets.slider import RedBigSlider, BigSlider
from openpilot.common.filter_simple import FirstOrderFilter
from openpilot.selfdrive.ui.mici.widgets.button import BigButton
from openpilot.selfdrive.ui.mici.widgets.button import BigCircleButton, BigButton, GreyBigButton
from openpilot.selfdrive.ui.mici.widgets.side_button import SideButton
DEBUG = False
@@ -22,162 +21,80 @@ PADDING = 20
class BigDialogBase(NavWidget, abc.ABC):
def __init__(self, right_btn: str | None = None, right_btn_callback: Callable | None = None):
def __init__(self):
super().__init__()
self._ret = DialogResult.NO_ACTION
self.set_rect(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
self.set_back_callback(lambda: setattr(self, '_ret', DialogResult.CANCEL))
self._right_btn = None
if right_btn:
def right_btn_callback_wrapper():
gui_app.set_modal_overlay(None)
if right_btn_callback:
right_btn_callback()
self._right_btn = SideButton(right_btn)
self._right_btn.set_click_callback(right_btn_callback_wrapper)
# move to right side
self._right_btn._rect.x = self._rect.x + self._rect.width - self._right_btn._rect.width
def _render(self, _) -> DialogResult:
"""
Allows `gui_app.set_modal_overlay(BigDialog(...))`.
The overlay runner keeps calling until result != NO_ACTION.
"""
if self._right_btn:
self._right_btn.set_position(self._right_btn._rect.x, self._rect.y)
self._right_btn.render()
return self._ret
class BigDialog(BigDialogBase):
def __init__(self,
title: str,
description: str,
right_btn: str | None = None,
right_btn_callback: Callable | None = None):
super().__init__(right_btn, right_btn_callback)
self._title = title
self._description = description
def __init__(self, title: str, description: str, icon: Union[rl.Texture, None] = None):
super().__init__()
self._card = GreyBigButton(title, description, icon)
def _render(self, _) -> DialogResult:
super()._render(_)
# draw title
# TODO: we desperately need layouts
# TODO: coming up with these numbers manually is a pain and not scalable
# TODO: no clue what any of these numbers mean. VBox and HBox would remove all of this shite
max_width = self._rect.width - PADDING * 2
if self._right_btn:
max_width -= self._right_btn._rect.width
title_font_size = 50
desc_font_size = 30
title_lines = wrap_text(gui_app.font(FontWeight.BOLD), self._title, title_font_size, int(max_width))
if not title_lines:
title_lines = [""]
title_line_height = max(int(title_font_size * 1.2), int(measure_text_cached(gui_app.font(FontWeight.BOLD), "Ag", title_font_size).y))
text_x_offset = 0
title_x = int(self._rect.x + text_x_offset + PADDING)
title_y = int(self._rect.y + PADDING)
for i, line in enumerate(title_lines):
line_rect = rl.Rectangle(
title_x,
title_y + i * title_line_height,
int(max_width),
int(title_line_height),
)
gui_label(line_rect, line, title_font_size, font_weight=FontWeight.BOLD,
alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER,
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP)
# draw description
desc_lines = wrap_text(gui_app.font(FontWeight.MEDIUM), self._description, desc_font_size, int(max_width))
if not desc_lines:
desc_lines = [""]
desc_line_height = max(int(desc_font_size * 1.25), int(measure_text_cached(gui_app.font(FontWeight.MEDIUM), "Ag", desc_font_size).y))
desc_y = max(
int(self._rect.y + self._rect.height / 3),
title_y + title_line_height * len(title_lines) + 22,
)
for i, line in enumerate(desc_lines):
line_rect = rl.Rectangle(
title_x,
desc_y + i * desc_line_height,
int(max_width),
int(desc_line_height),
)
gui_label(line_rect, line, desc_font_size, font_weight=FontWeight.MEDIUM,
alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER,
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP)
return self._ret
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 BigConfirmationDialogV2(BigDialogBase):
def __init__(self, title: str, icon: str, red: bool = False,
exit_on_confirm: bool = True,
confirm_callback: Callable | None = None):
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
icon_txt = gui_app.texture(icon, 64, 53)
self._slider: BigSlider | RedBigSlider
if red:
self._slider = RedBigSlider(title, icon_txt, confirm_callback=self._on_confirm)
self._slider = self._child(RedBigSlider(title, icon, confirm_callback=self._on_confirm))
else:
self._slider = BigSlider(title, icon_txt, confirm_callback=self._on_confirm)
self._slider.set_enabled(lambda: not self._swiping_away)
def show_event(self):
super().show_event()
self._slider.show_event()
def hide_event(self):
super().hide_event()
self._slider.hide_event()
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._confirm_callback:
self._confirm_callback()
if self._exit_on_confirm:
self._ret = DialogResult.CONFIRM
self.dismiss(self._confirm_callback)
elif self._confirm_callback:
self._confirm_callback()
def _update_state(self):
super()._update_state()
if self._swiping_away and not self._slider.confirmed:
self._slider.reset(reset_shimmer=False)
if self.is_dismissing and not self._slider.confirmed:
self._slider.reset()
def _render(self, _) -> DialogResult:
def _render(self, _):
self._slider.render(self._rect)
return self._ret
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):
super().__init__(None, None)
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()
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", 44, 44)
self._backspace_img = gui_app.texture("icons_mici/settings/keyboard/backspace.png", 42, 36)
self._backspace_img_alpha = FirstOrderFilter(0, 0.05, 1 / gui_app.target_fps)
self._enter_img = gui_app.texture("icons_mici/settings/keyboard/confirm.png", 44, 44)
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
@@ -185,14 +102,17 @@ class BigInputDialog(BigDialogBase):
self._top_right_button_rect = rl.Rectangle(0, 0, 0, 0)
def confirm_callback_wrapper():
self._ret = DialogResult.CONFIRM
if confirm_callback:
confirm_callback(self._keyboard.text())
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:
@@ -206,64 +126,60 @@ class BigInputDialog(BigDialogBase):
self._backspace_held_time = None
def _render(self, _):
text_input_size = 35
# 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, text_input_size)
text_x = PADDING * 2 + self._enter_img.width
text_size = measure_text_cached(gui_app.font(FontWeight.ROMAN), text + candidate_char or self._hint_label.text, self.TEXT_INPUT_SIZE)
# text needs to move left if we're at the end where right button is
text_rect = rl.Rectangle(text_x,
int(self._rect.y + PADDING),
# clip width to right button when in view
int(self._rect.width - text_x - PADDING * 2 - self._enter_img.width + 5), # TODO: why 5?
int(text_size.y))
# draw rounded background for text input
bg_block_margin = 5
text_field_rect = rl.Rectangle(text_rect.x - bg_block_margin, text_rect.y - bg_block_margin,
text_rect.width + bg_block_margin * 2, text_input_size + bg_block_margin * 2)
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_rect.width:
text_x -= text_size.x - text_rect.width
if text_size.x > text_field_rect.width:
text_x -= text_size.x - text_field_rect.width
rl.begin_scissor_mode(int(text_rect.x), int(text_rect.y), int(text_rect.width), int(text_rect.height))
rl.draw_text_ex(gui_app.font(FontWeight.ROMAN), text, rl.Vector2(text_x, text_rect.y), text_input_size, 0, rl.WHITE)
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, text_input_size)
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_rect.x + text_rect.width) - candidate_char_size.x, text_rect.y),
text_input_size, 0, rl.Color(255, 255, 255, 128))
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_rect.width:
rl.draw_rectangle_gradient_h(int(text_rect.x), int(text_rect.y), 80, int(text_rect.height),
rl.BLACK, rl.BLANK)
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:
blink_alpha = (math.sin(rl.get_time() * 6) + 1) / 2
cursor_x = min(text_x + text_size.x + 3, text_rect.x + text_rect.width)
rl.draw_rectangle_rounded(rl.Rectangle(int(cursor_x), int(text_rect.y), 4, int(text_size.y)),
1, 4, rl.Color(255, 255, 255, int(255 * blink_alpha)))
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(self._backspace_img, int(self._rect.width - self._enter_img.width - 15), int(text_field_rect.y), color)
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
self._hint_label.render(text_field_rect)
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
@@ -271,10 +187,12 @@ class BigInputDialog(BigDialogBase):
self._top_right_button_rect = rl.Rectangle(text_field_rect.x + text_field_rect.width, self._rect.y,
self._rect.width - (text_field_rect.x + text_field_rect.width), self._top_left_button_rect.height)
self._enter_img_alpha.update(255 if (len(text) >= self._minimum_length) else 255 * 0.35)
if self._enter_img_alpha.x > 1:
color = rl.Color(255, 255, 255, int(self._enter_img_alpha.x))
rl.draw_texture(self._enter_img, int(self._rect.x + 15), int(text_field_rect.y), color)
# 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)
@@ -282,16 +200,17 @@ class BigInputDialog(BigDialogBase):
# 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(text_rect, 1, rl.Color(0, 255, 0, 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))
return self._ret
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()
@@ -300,6 +219,30 @@ class BigInputDialog(BigDialogBase):
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)
class BigDialogOptionButton(Widget):
HEIGHT = 64
SELECTED_HEIGHT = 74
@@ -308,12 +251,15 @@ class BigDialogOptionButton(Widget):
super().__init__()
self.option = option
self.set_rect(rl.Rectangle(0, 0, int(gui_app.width / 2 + 220), self.HEIGHT))
self._selected = False
self._label = UnifiedLabel(option, font_size=70, text_color=rl.Color(255, 255, 255, int(255 * 0.58)),
font_weight=FontWeight.DISPLAY_REGULAR, alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE,
scroll=True)
self._label = UnifiedLabel(
option,
font_size=70,
text_color=rl.Color(255, 255, 255, int(255 * 0.58)),
font_weight=FontWeight.DISPLAY_REGULAR,
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE,
scroll=True,
)
def show_event(self):
super().show_event()
@@ -324,10 +270,6 @@ class BigDialogOptionButton(Widget):
self._rect.height = self.SELECTED_HEIGHT if selected else self.HEIGHT
def _render(self, _):
if DEBUG:
rl.draw_rectangle_lines_ex(self._rect, 1, rl.Color(0, 255, 0, 255))
# FIXME: offset x by -45 because scroller centers horizontally
if self._selected:
self._label.set_font_size(self.SELECTED_HEIGHT)
self._label.set_color(rl.Color(255, 255, 255, int(255 * 0.9)))
@@ -340,56 +282,63 @@ class BigDialogOptionButton(Widget):
self._label.render(self._rect)
class BigMultiOptionDialog(BigDialogBase):
BACK_TOUCH_AREA_PERCENTAGE = 0.1
class BigMultiOptionDialog(NavWidget):
BACK_TOUCH_AREA_PERCENTAGE = 0.25
def __init__(self, options: list[str], default: str | None,
right_btn: str | None = 'check', right_btn_callback: Callable[[], None] = None):
super().__init__(right_btn, right_btn_callback=right_btn_callback)
right_btn: str | None = "check", right_btn_callback: Callable[[], None] | None = None):
super().__init__()
self._options = options
if default is not None:
assert default in options
if default not in options:
default = options[0] if options else None
self._right_btn_callback = right_btn_callback
self._default_option: str | None = default
self._selected_option: str = self._default_option or (options[0] if len(options) > 0 else "")
self._selected_option: str = self._default_option or (options[0] if options else "")
self._last_selected_option: str = self._selected_option
# Widget doesn't differentiate between click and drag
self._can_click = True
self._scroller = Scroller([], horizontal=False, pad_start=100, pad_end=100, spacing=0, snap_items=True)
self._scroller = self._child(Scroller(horizontal=False, pad=100, spacing=0, snap_items=True,
scroll_indicator=False, edge_shadows=False))
self._scroll_inner = self._scroller._scroller
self._right_btn = self._child(SideButton(right_btn or "check")) if right_btn is not None else None
if self._right_btn is not None:
self._scroller.set_enabled(lambda: not cast(Widget, self._right_btn).is_pressed)
self._right_btn.set_click_callback(self._confirm_selection)
self._scroller.set_enabled(lambda: self.enabled and not self.is_dismissing and not self._right_btn.is_pressed)
else:
self._scroller.set_enabled(lambda: self.enabled and not self.is_dismissing)
for option in options:
self._scroller.add_widget(BigDialogOptionButton(option))
self._scroll_inner.add_widget(BigDialogOptionButton(option))
def show_event(self):
super().show_event()
self._scroller.show_event()
if self._default_option is not None:
self._on_option_selected(self._default_option)
def get_selected_option(self) -> str:
return self._selected_option
def _confirm_selection(self):
self.dismiss(self._right_btn_callback)
def _on_option_selected(self, option: str):
y_pos = 0.0
for btn in self._scroller._items:
for btn in self._scroll_inner.items:
btn = cast(BigDialogOptionButton, btn)
if btn.option == option:
rect_center_y = self._rect.y + self._rect.height / 2
if btn._selected:
height = btn.rect.height
else:
# when selecting an option under current, account for changing heights
btn_center_y = btn.rect.y + btn.rect.height / 2 # not accurate, just to determine direction
btn_center_y = btn.rect.y + btn.rect.height / 2
height_offset = BigDialogOptionButton.SELECTED_HEIGHT - BigDialogOptionButton.HEIGHT
height = (BigDialogOptionButton.HEIGHT - height_offset) if rect_center_y < btn_center_y else BigDialogOptionButton.SELECTED_HEIGHT
y_pos = rect_center_y - (btn.rect.y + height / 2)
break
self._scroller.scroll_to(-y_pos)
self._scroll_inner.scroll_to(-y_pos)
def _selected_option_changed(self):
pass
@@ -400,9 +349,7 @@ class BigMultiOptionDialog(BigDialogBase):
def _handle_mouse_event(self, mouse_event: MouseEvent) -> None:
super()._handle_mouse_event(mouse_event)
# # TODO: add generic _handle_mouse_click handler to Widget
if not self._scroller.scroll_panel.is_touch_valid():
if not self._scroll_inner.scroll_panel.is_touch_valid():
self._can_click = False
def _handle_mouse_release(self, mouse_pos: MousePos):
@@ -411,8 +358,7 @@ class BigMultiOptionDialog(BigDialogBase):
if not self._can_click:
return
# select current option
for btn in self._scroller._items:
for btn in self._scroll_inner.items:
btn = cast(BigDialogOptionButton, btn)
if btn.option == self._selected_option:
self._on_option_selected(btn.option)
@@ -420,39 +366,27 @@ class BigMultiOptionDialog(BigDialogBase):
def _update_state(self):
super()._update_state()
if not self.is_dismissing:
self._nav_bar.set_alpha(1.0)
# get selection by whichever button is closest to center
center_y = self._rect.y + self._rect.height / 2
closest_btn = (None, float('inf'))
for btn in self._scroller._items:
closest_btn = (None, float("inf"))
for btn in self._scroll_inner.items:
dist_y = abs((btn.rect.y + btn.rect.height / 2) - center_y)
if dist_y < closest_btn[1]:
closest_btn = (btn, dist_y)
if closest_btn[0]:
for btn in self._scroller._items:
if closest_btn[0] is not None:
for btn in self._scroll_inner.items:
btn.set_selected(btn.option == closest_btn[0].option)
self._selected_option = closest_btn[0].option
# Signal to subclasses if selection changed
if self._selected_option != self._last_selected_option:
self._selected_option_changed()
self._last_selected_option = self._selected_option
def _render(self, _):
super()._render(_)
if self._right_btn is not None:
self._right_btn.set_position(self._rect.x + self._rect.width - self._right_btn.rect.width, self._rect.y)
self._right_btn.render()
self._scroller.render(self._rect)
return self._ret
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.set_modal_overlay(dlg)
+11 -16
View File
@@ -7,9 +7,9 @@ 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 import NavWidget
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 MiciLabel
from openpilot.system.ui.widgets.label import UnifiedLabel
class PairingDialog(NavWidget):
@@ -19,14 +19,12 @@ class PairingDialog(NavWidget):
def __init__(self):
super().__init__()
self.set_back_callback(lambda: gui_app.set_modal_overlay(None))
self._params = Params()
self._qr_texture: rl.Texture | None = None
self._last_qr_generation = float("-inf")
self._txt_pair = gui_app.texture("icons_mici/settings/device/pair.png", 84, 64)
self._pair_label = MiciLabel("pair with comma connect", 48, font_weight=FontWeight.BOLD,
color=rl.Color(255, 255, 255, int(255 * 0.9)), line_height=40, wrap_text=True)
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:
@@ -69,24 +67,22 @@ class PairingDialog(NavWidget):
def _update_state(self):
super()._update_state()
if ui_state.prime_state.is_paired():
self._playing_dismiss_animation = True
if ui_state.prime_state.is_paired() and not self.is_dismissing:
self.dismiss()
def _render(self, rect: rl.Rectangle) -> int:
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_width(int(self._rect.width - label_x))
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)))
return -1
def _render_qr_code(self) -> None:
if not self._qr_texture:
error_font = gui_app.font(FontWeight.BOLD)
@@ -96,7 +92,7 @@ class PairingDialog(NavWidget):
return
scale = self._rect.height / self._qr_texture.height
pos = rl.Vector2(self._rect.x + 8, self._rect.y)
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):
@@ -107,10 +103,9 @@ class PairingDialog(NavWidget):
if __name__ == "__main__":
gui_app.init_window("pairing device")
pairing = PairingDialog()
gui_app.push_widget(pairing)
try:
for _ in gui_app.render():
result = pairing.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
if result != -1:
break
pass
finally:
del pairing
+5 -10
View File
@@ -1,11 +1,8 @@
import pyray as rl
from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.lib.application import gui_app
# ---------------------------------------------------------------------------
# Constants extracted from the original Qt style
# ---------------------------------------------------------------------------
# TODO: this should be corrected, but Scroller relies on this being incorrect :/
WIDTH, HEIGHT = 112, 240
@@ -15,17 +12,15 @@ class SideButton(Widget):
self.type = btn_type
self.set_rect(rl.Rectangle(0, 0, WIDTH, HEIGHT))
# load pre-rendered button images
if btn_type not in ("check", "back"):
btn_type = "back"
btn_img_path = f"icons_mici/buttons/button_side_{btn_type}.png"
btn_img_pressed_path = f"icons_mici/buttons/button_side_{btn_type}_pressed.png"
self._txt_btn, self._txt_btn_back = gui_app.texture(btn_img_path, 100, 224), gui_app.texture(btn_img_pressed_path, 100, 224)
self._txt_btn = gui_app.texture(btn_img_path, 100, 224)
self._txt_btn_back = gui_app.texture(btn_img_pressed_path, 100, 224)
def _render(self, _) -> bool:
def _render(self, _):
x = int(self._rect.x + 12)
y = int(self._rect.y + (self._rect.height - self._txt_btn.height) / 2)
rl.draw_texture(self._txt_btn if not self.is_pressed else self._txt_btn_back,
x, y, rl.WHITE)
rl.draw_texture(self._txt_btn if not self.is_pressed else self._txt_btn_back, x, y, rl.WHITE)
return False
+1 -1
View File
@@ -325,6 +325,7 @@ class AugmentedRoadView(CameraView):
if __name__ == "__main__":
gui_app.init_window("OnRoad Camera View")
road_camera_view = AugmentedRoadView(ROAD_CAM)
gui_app.push_widget(road_camera_view)
print("***press space to switch camera view***")
try:
for _ in gui_app.render():
@@ -333,6 +334,5 @@ if __name__ == "__main__":
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()
+3 -3
View File
@@ -14,7 +14,7 @@ class DriverCameraDialog(CameraView):
super().__init__("camerad", VisionStreamType.VISION_STREAM_DRIVER)
self.driver_state_renderer = DriverStateRenderer()
# TODO: this can grow unbounded, should be given some thought
device.add_interactive_timeout_callback(lambda: gui_app.set_modal_overlay(None))
device.add_interactive_timeout_callback(gui_app.pop_widget)
ui_state.params.put_bool("IsDriverViewEnabled", True)
def hide_event(self):
@@ -24,7 +24,7 @@ class DriverCameraDialog(CameraView):
def _handle_mouse_release(self, _):
super()._handle_mouse_release(_)
gui_app.set_modal_overlay(None)
gui_app.pop_widget()
def __del__(self):
self.close()
@@ -103,9 +103,9 @@ if __name__ == "__main__":
gui_app.init_window("Driver Camera View")
driver_camera_view = DriverCameraDialog()
gui_app.push_widget(driver_camera_view)
try:
for _ in gui_app.render():
ui_state.update()
driver_camera_view.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
finally:
driver_camera_view.close()
+1 -1
View File
@@ -63,7 +63,7 @@ class ExpButton(Widget):
texture = self._txt_exp if self._held_or_actual_mode() else self._txt_wheel
rl.draw_circle(center_x, center_y, self._rect.width / 2, self._black_bg)
rl.draw_texture(texture, center_x - texture.width // 2, center_y - texture.height // 2, self._white_color)
rl.draw_texture_ex(texture, rl.Vector2(center_x - texture.width / 2, center_y - texture.height / 2), 0.0, 1.0, self._white_color)
def _held_or_actual_mode(self):
now = time.monotonic()
+1 -1
View File
@@ -86,7 +86,7 @@ class HudRenderer(Widget):
v_cruise_cluster = car_state.vCruiseCluster
self.set_speed = (
controls_state.vCruiseDEPRECATED if v_cruise_cluster == 0.0 else v_cruise_cluster
controls_state.deprecated.vCruise if v_cruise_cluster == 0.0 else v_cruise_cluster
)
self.is_cruise_set = 0 < self.set_speed < SET_SPEED_NA
self.is_cruise_available = self.set_speed != -1
Executable → Regular
+16 -36
View File
@@ -1,55 +1,35 @@
#!/usr/bin/env python3
import os
import pyray as rl
from openpilot.system.hardware import TICI
from openpilot.common.realtime import config_realtime_process, set_core_affinity
from openpilot.common.watchdog import kick_watchdog
from openpilot.system.ui.lib.application import gui_app
from openpilot.selfdrive.ui.stall_monitor import UIStallMonitor
from openpilot.selfdrive.ui.layouts.main import MainLayout
from openpilot.selfdrive.ui.mici.layouts.main import MiciMainLayout
from openpilot.selfdrive.ui.ui_state import ui_state
BIG_UI = gui_app.big_ui()
def main():
cores = {5, }
config_realtime_process(0, 51)
gui_app.init_window("UI")
if gui_app.big_ui():
main_layout = MainLayout()
if BIG_UI:
from openpilot.selfdrive.ui.layouts.main import MainLayout
MainLayout()
else:
main_layout = MiciMainLayout()
main_layout.set_rect(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
stall_monitor = UIStallMonitor("raylib_ui")
gui_app.set_progress_hook(stall_monitor.progress)
stall_monitor.progress("ui.loop_ready")
stall_monitor.start()
from openpilot.selfdrive.ui.mici.layouts.main import MiciMainLayout
MiciMainLayout()
def render_layout() -> None:
stall_monitor.progress("ui.before_layout_render")
main_layout.render()
stall_monitor.progress("ui.after_layout_render")
try:
for should_render in gui_app.render(render_callback=render_layout):
stall_monitor.progress("ui.loop_iteration")
kick_watchdog()
stall_monitor.progress("ui.after_watchdog")
ui_state.update()
stall_monitor.progress("ui.after_state_update")
if should_render:
# reaffine after power save offlines our core
if TICI and os.sched_getaffinity(0) != cores:
try:
set_core_affinity(list(cores))
except OSError:
pass
stall_monitor.progress("ui.loop_idle")
finally:
gui_app.set_progress_hook(None)
stall_monitor.stop()
for should_render in gui_app.render():
ui_state.update()
if should_render:
# reaffine after power save offlines our core
if TICI and os.sched_getaffinity(0) != cores:
try:
set_core_affinity(list(cores))
except OSError:
pass
if __name__ == "__main__":
+46
View File
@@ -25,6 +25,52 @@ from openpilot.system.ui.widgets.list_view import (
VALUE_FONT_SIZE = 48
class SshKeyFetcher:
HTTP_TIMEOUT = 15 # seconds
def __init__(self, params: Params):
self._params = params
self._on_response: Callable[[str | None], None] | None = None
self._done = False
self._error: str | None = None
def fetch(self, username: str, on_response: Callable[[str | None], None]):
self._error = None
self._done = False
self._on_response = on_response
threading.Thread(target=self._fetch_thread, args=(username,), daemon=True).start()
def update(self):
if not self._done:
return
self._done = False
if self._error is not None:
self.clear()
if self._on_response:
self._on_response(self._error)
def clear(self):
self._params.remove("GithubUsername")
self._params.remove("GithubSshKeys")
def _fetch_thread(self, username: str):
try:
response = requests.get(f"https://github.com/{username}.keys", timeout=self.HTTP_TIMEOUT)
response.raise_for_status()
keys = response.text.strip()
if not keys:
raise requests.exceptions.HTTPError("No SSH keys found")
self._params.put("GithubUsername", username)
self._params.put("GithubSshKeys", keys)
except requests.exceptions.Timeout:
self._error = tr("Request timed out")
except Exception:
self._error = tr("No SSH keys found for user '{}'").format(username)
finally:
self._done = True
class SshKeyActionState(Enum):
LOADING = tr_noop("LOADING")
ADD = tr_noop("ADD")