Merge remote-tracking branch 'sunnypilot/sunnypilot/master-new' into feature/slc

# Conflicts:
#	opendbc_repo
This commit is contained in:
Jason Wen
2025-06-05 23:39:10 -04:00
37 changed files with 1473 additions and 117 deletions
+3
View File
@@ -1,3 +1,6 @@
Version 0.9.10 (2025-06-30)
========================
Version 0.9.9 (2025-05-23)
========================
* New driving model
+1 -1
View File
@@ -363,13 +363,13 @@ SConscript(['rednose/SConscript'])
# Build system services
SConscript([
'system/proclogd/SConscript',
'system/ubloxd/SConscript',
'system/loggerd/SConscript',
])
if arch != "Darwin":
SConscript([
'system/logcatd/SConscript',
'system/proclogd/SConscript',
])
if arch == "larch64":
+1 -1
View File
@@ -1 +1 @@
#define COMMA_VERSION "0.9.9"
#define COMMA_VERSION "0.9.10"
+22
View File
@@ -0,0 +1,22 @@
import os
import time
import struct
from openpilot.system.hardware.hw import Paths
WATCHDOG_FN = f"{Paths.shm_path()}/wd_"
_LAST_KICK = 0.0
def kick_watchdog():
global _LAST_KICK
current_time = time.monotonic()
if current_time - _LAST_KICK < 1.0:
return
try:
with open(f"{WATCHDOG_FN}{os.getpid()}", 'wb') as f:
f.write(struct.pack('<Q', int(current_time * 1e9)))
f.flush()
_LAST_KICK = current_time
except OSError:
pass
+1 -1
Submodule panda updated: 86cf5dc583...5ac4fa5bb0
+1
View File
@@ -260,6 +260,7 @@ lint.flake8-implicit-str-concat.allow-multiline = false
"tools".msg = "Use openpilot.tools"
"pytest.main".msg = "pytest.main requires special handling that is easy to mess up!"
"unittest".msg = "Use pytest"
"pyray.measure_text_ex".msg = "Use openpilot.system.ui.lib.text_measure"
[tool.coverage.run]
concurrency = ["multiprocessing", "thread"]
+2 -1
View File
@@ -129,7 +129,8 @@ class SelfdriveD(CruiseHelper):
# some comma three with NVMe experience NVMe dropouts mid-drive that
# cause loggerd to crash on write, so ignore it only on that platform
self.ignored_processes = set()
if HARDWARE.get_device_type() == 'tici' and os.path.exists('/dev/nvme0'):
nvme_expected = os.path.exists('/dev/nvme0n1') or (not os.path.isfile("/persist/comma/living-in-the-moment"))
if HARDWARE.get_device_type() == 'tici' and nvme_expected:
self.ignored_processes = {'loggerd', }
# Determine startup event
+1 -1
View File
@@ -23,7 +23,7 @@ class DummyFrameReader(BaseFrameReader):
self.frame_count = frame_count
self.frame_type = FrameType.raw
def get(self, idx, count=1, pix_fmt="yuv420p"):
def get(self, idx, count=1, pix_fmt="rgb24"):
if pix_fmt == "rgb24":
shape = (self.h, self.w, 3)
elif pix_fmt == "nv12" or pix_fmt == "yuv420p":
+204 -9
View File
@@ -1,17 +1,212 @@
import time
import pyray as rl
from openpilot.system.ui.lib.label import gui_text_box
from collections.abc import Callable
from enum import IntEnum
from openpilot.common.params import Params
from openpilot.selfdrive.ui.widgets.offroad_alerts import UpdateAlert, OffroadAlert
from openpilot.system.ui.lib.text_measure import measure_text_cached
from openpilot.system.ui.lib.label import gui_label
from openpilot.system.ui.lib.application import gui_app, FontWeight, DEFAULT_TEXT_COLOR
HEADER_HEIGHT = 80
HEAD_BUTTON_FONT_SIZE = 40
CONTENT_MARGIN = 40
SPACING = 25
RIGHT_COLUMN_WIDTH = 750
REFRESH_INTERVAL = 10.0
PRIME_BG_COLOR = rl.Color(51, 51, 51, 255)
class HomeLayoutState(IntEnum):
HOME = 0
UPDATE = 1
ALERTS = 2
class HomeLayout:
def __init__(self):
pass
self.params = Params()
self.update_alert = UpdateAlert()
self.offroad_alert = OffroadAlert()
self.current_state = HomeLayoutState.HOME
self.last_refresh = 0
self.settings_callback: callable | None = None
self.update_available = False
self.alert_count = 0
self.header_rect = rl.Rectangle(0, 0, 0, 0)
self.content_rect = rl.Rectangle(0, 0, 0, 0)
self.left_column_rect = rl.Rectangle(0, 0, 0, 0)
self.right_column_rect = rl.Rectangle(0, 0, 0, 0)
self.update_notif_rect = rl.Rectangle(0, 0, 200, HEADER_HEIGHT - 10)
self.alert_notif_rect = rl.Rectangle(0, 0, 220, HEADER_HEIGHT - 10)
self._setup_callbacks()
def _setup_callbacks(self):
self.update_alert.set_dismiss_callback(lambda: self._set_state(HomeLayoutState.HOME))
self.offroad_alert.set_dismiss_callback(lambda: self._set_state(HomeLayoutState.HOME))
def set_settings_callback(self, callback: Callable):
self.settings_callback = callback
def _set_state(self, state: HomeLayoutState):
self.current_state = state
def render(self, rect: rl.Rectangle):
gui_text_box(
rect,
"Demo Home Layout",
font_size=170,
color=rl.WHITE,
alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER,
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE,
self._update_layout_rects(rect)
current_time = time.time()
if current_time - self.last_refresh >= REFRESH_INTERVAL:
self._refresh()
self.last_refresh = current_time
self._handle_input()
self._render_header()
# Render content based on current state
if self.current_state == HomeLayoutState.HOME:
self._render_home_content()
elif self.current_state == HomeLayoutState.UPDATE:
self._render_update_view()
elif self.current_state == HomeLayoutState.ALERTS:
self._render_alerts_view()
def _update_layout_rects(self, rect: rl.Rectangle):
self.header_rect = rl.Rectangle(
rect.x + CONTENT_MARGIN, rect.y + CONTENT_MARGIN, rect.width - 2 * CONTENT_MARGIN, HEADER_HEIGHT
)
content_y = rect.y + CONTENT_MARGIN + HEADER_HEIGHT + SPACING
content_height = rect.height - CONTENT_MARGIN - HEADER_HEIGHT - SPACING - CONTENT_MARGIN
self.content_rect = rl.Rectangle(
rect.x + CONTENT_MARGIN, content_y, rect.width - 2 * CONTENT_MARGIN, content_height
)
left_width = self.content_rect.width - RIGHT_COLUMN_WIDTH - SPACING
self.left_column_rect = rl.Rectangle(self.content_rect.x, self.content_rect.y, left_width, self.content_rect.height)
self.right_column_rect = rl.Rectangle(
self.content_rect.x + left_width + SPACING, self.content_rect.y, RIGHT_COLUMN_WIDTH, self.content_rect.height
)
self.update_notif_rect.x = self.header_rect.x
self.update_notif_rect.y = self.header_rect.y + (self.header_rect.height - 60) // 2
notif_x = self.header_rect.x + (220 if self.update_available else 0)
self.alert_notif_rect.x = notif_x
self.alert_notif_rect.y = self.header_rect.y + (self.header_rect.height - 60) // 2
def _handle_input(self):
if not rl.is_mouse_button_pressed(rl.MouseButton.MOUSE_BUTTON_LEFT):
return
mouse_pos = rl.get_mouse_position()
if self.update_available and rl.check_collision_point_rec(mouse_pos, self.update_notif_rect):
self._set_state(HomeLayoutState.UPDATE)
return
if self.alert_count > 0 and rl.check_collision_point_rec(mouse_pos, self.alert_notif_rect):
self._set_state(HomeLayoutState.ALERTS)
return
# Content area input handling
if self.current_state == HomeLayoutState.UPDATE:
self.update_alert.handle_input(mouse_pos, True)
elif self.current_state == HomeLayoutState.ALERTS:
self.offroad_alert.handle_input(mouse_pos, True)
def _render_header(self):
font = gui_app.font(FontWeight.MEDIUM)
# Update notification button
if self.update_available:
# Highlight if currently viewing updates
highlight_color = rl.Color(255, 140, 40, 255) if self.current_state == HomeLayoutState.UPDATE else rl.Color(255, 102, 0, 255)
rl.draw_rectangle_rounded(self.update_notif_rect, 0.3, 10, highlight_color)
text = "UPDATE"
text_width = measure_text_cached(font, text, HEAD_BUTTON_FONT_SIZE).x
text_x = self.update_notif_rect.x + (self.update_notif_rect.width - text_width) // 2
text_y = self.update_notif_rect.y + (self.update_notif_rect.height - HEAD_BUTTON_FONT_SIZE) // 2
rl.draw_text_ex(font, text, rl.Vector2(int(text_x), int(text_y)), HEAD_BUTTON_FONT_SIZE, 0, rl.WHITE)
# Alert notification button
if self.alert_count > 0:
# Highlight if currently viewing alerts
highlight_color = rl.Color(255, 70, 70, 255) if self.current_state == HomeLayoutState.ALERTS else rl.Color(226, 44, 44, 255)
rl.draw_rectangle_rounded(self.alert_notif_rect, 0.3, 10, highlight_color)
alert_text = f"{self.alert_count} ALERT{'S' if self.alert_count > 1 else ''}"
text_width = measure_text_cached(font, alert_text, HEAD_BUTTON_FONT_SIZE).x
text_x = self.alert_notif_rect.x + (self.alert_notif_rect.width - text_width) // 2
text_y = self.alert_notif_rect.y + (self.alert_notif_rect.height - HEAD_BUTTON_FONT_SIZE) // 2
rl.draw_text_ex(font, alert_text, rl.Vector2(int(text_x), int(text_y)), HEAD_BUTTON_FONT_SIZE, 0, rl.WHITE)
# Version text (right aligned)
version_text = self._get_version_text()
text_width = measure_text_cached(gui_app.font(FontWeight.NORMAL), version_text, 48).x
version_x = self.header_rect.x + self.header_rect.width - text_width
version_y = self.header_rect.y + (self.header_rect.height - 48) // 2
rl.draw_text_ex(gui_app.font(FontWeight.NORMAL), version_text, rl.Vector2(int(version_x), int(version_y)), 48, 0, DEFAULT_TEXT_COLOR)
def _render_home_content(self):
self._render_left_column()
self._render_right_column()
def _render_update_view(self):
self.update_alert.render(self.content_rect)
def _render_alerts_view(self):
self.offroad_alert.render(self.content_rect)
def _render_left_column(self):
rl.draw_rectangle_rounded(self.left_column_rect, 0.02, 10, PRIME_BG_COLOR)
gui_label(self.left_column_rect, "Prime Widget", 48, alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER)
def _render_right_column(self):
widget_height = (self.right_column_rect.height - SPACING) // 2
exp_rect = rl.Rectangle(
self.right_column_rect.x, self.right_column_rect.y, self.right_column_rect.width, widget_height
)
rl.draw_rectangle_rounded(exp_rect, 0.02, 10, PRIME_BG_COLOR)
gui_label(exp_rect, "Experimental Mode", 36, alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER)
setup_rect = rl.Rectangle(
self.right_column_rect.x,
self.right_column_rect.y + widget_height + SPACING,
self.right_column_rect.width,
widget_height,
)
rl.draw_rectangle_rounded(setup_rect, 0.02, 10, PRIME_BG_COLOR)
gui_label(setup_rect, "Setup", 36, alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER)
def _refresh(self):
self.update_available = self.update_alert.refresh()
self.alert_count = self.offroad_alert.refresh()
self._update_state_priority(self.update_available, self.alert_count > 0)
def _update_state_priority(self, update_available: bool, alerts_present: bool):
current_state = self.current_state
if not update_available and not alerts_present:
self.current_state = HomeLayoutState.HOME
elif update_available and (current_state == HomeLayoutState.HOME or (not alerts_present and current_state == HomeLayoutState.ALERTS)):
self.current_state = HomeLayoutState.UPDATE
elif alerts_present and (current_state == HomeLayoutState.HOME or (not update_available and current_state == HomeLayoutState.UPDATE)):
self.current_state = HomeLayoutState.ALERTS
def _get_version_text(self) -> str:
brand = "openpilot"
description = self.params.get("UpdaterCurrentDescription", encoding='utf-8')
return f"{brand} {description}" if description else brand
+16 -12
View File
@@ -35,6 +35,7 @@ class MainLayout:
self._current_callback = None
self._update_layout_rects(rect)
self._handle_onroad_transition()
self._render_main_content()
self._handle_input()
@@ -47,7 +48,7 @@ class MainLayout:
on_flag=lambda: setattr(self, '_current_callback', self._on_flag_clicked),
)
self._layouts[MainState.SETTINGS].set_callbacks(
on_close=lambda: setattr(self, '_current_callback', self._on_settings_closed)
on_close=lambda: setattr(self, '_current_callback', self._set_mode_for_state)
)
def _update_layout_rects(self, rect):
@@ -57,14 +58,24 @@ class MainLayout:
x_offset = SIDEBAR_WIDTH if self._sidebar_visible else 0
self._content_rect = rl.Rectangle(rect.y + x_offset, rect.y, rect.width - x_offset, rect.height)
def _handle_onroad_transition(self):
if ui_state.started != self._prev_onroad:
self._prev_onroad = ui_state.started
self._set_mode_for_state()
def _set_mode_for_state(self):
if ui_state.started:
self._current_mode = MainState.ONROAD
self._sidebar_visible = False
else:
self._current_mode = MainState.HOME
self._sidebar_visible = True
def _on_settings_clicked(self):
self._current_mode = MainState.SETTINGS
self._sidebar_visible = False
def _on_settings_closed(self):
self._current_mode = MainState.HOME if not ui_state.started else MainState.ONROAD
self._sidebar_visible = True
def _on_flag_clicked(self):
pass
@@ -73,13 +84,6 @@ class MainLayout:
if self._sidebar_visible:
self._sidebar.render(self._sidebar_rect)
if ui_state.started != self._prev_onroad:
self._prev_onroad = ui_state.started
if ui_state.started:
self._current_mode = MainState.ONROAD
else:
self._current_mode = MainState.HOME
content_rect = self._content_rect if self._sidebar_visible else self._window_rect
self._layouts[self._current_mode].render(content_rect)
+19
View File
@@ -0,0 +1,19 @@
import pyray as rl
from openpilot.system.ui.lib.wifi_manager import WifiManagerWrapper
from openpilot.system.ui.widgets.network import WifiManagerUI
class NetworkLayout:
def __init__(self):
self.wifi_manager = WifiManagerWrapper()
self.wifi_ui = WifiManagerUI(self.wifi_manager)
def render(self, rect: rl.Rectangle):
self.wifi_ui.render(rect)
@property
def require_full_screen(self):
return self.wifi_ui.require_full_screen
def shutdown(self):
self.wifi_manager.shutdown()
@@ -0,0 +1,52 @@
from openpilot.system.ui.lib.list_view import ListView, toggle_item
from openpilot.common.params import Params
# Description constants
DESCRIPTIONS = {
'enable_adb': (
"ADB (Android Debug Bridge) allows connecting to your device over USB or over the network. " +
"See https://docs.comma.ai/how-to/connect-to-comma for more info."
),
'joystick_debug_mode': "Preview the driver facing camera to ensure that driver monitoring has good visibility. (vehicle must be off)",
}
class DeveloperLayout:
def __init__(self):
self._params = Params()
items = [
toggle_item(
"Enable ADB",
description=DESCRIPTIONS["enable_adb"],
initial_state=self._params.get_bool("AdbEnabled"),
callback=self._on_enable_adb,
),
toggle_item(
"Joystick Debug Mode",
description=DESCRIPTIONS["joystick_debug_mode"],
initial_state=self._params.get_bool("JoystickDebugMode"),
callback=self._on_joystick_debug_mode,
),
toggle_item(
"Longitudinal Maneuver Mode",
description="",
initial_state=self._params.get_bool("LongitudinalManeuverMode"),
callback=self._on_long_maneuver_mode,
),
toggle_item(
"openpilot Longitudinal Control (Alpha)",
description="",
initial_state=self._params.get_bool("AlphaLongitudinalEnabled"),
callback=self._on_alpha_long_enabled,
),
]
self._list_widget = ListView(items)
def render(self, rect):
self._list_widget.render(rect)
def _on_enable_adb(self): pass
def _on_joystick_debug_mode(self): pass
def _on_long_maneuver_mode(self): pass
def _on_alpha_long_enabled(self): pass
+47
View File
@@ -0,0 +1,47 @@
from openpilot.system.ui.lib.list_view import ListView, text_item, button_item
from openpilot.common.params import Params
from openpilot.system.hardware import TICI
# Description constants
DESCRIPTIONS = {
'pair_device': "Pair your device with comma connect (connect.comma.ai) and claim your comma prime offer.",
'driver_camera': "Preview the driver facing camera to ensure that driver monitoring has good visibility. (vehicle must be off)",
'reset_calibration': (
"openpilot requires the device to be mounted within 4° left or right and within 5° " +
"up or 9° down. openpilot is continuously calibrating, resetting is rarely required."
),
'review_guide': "Review the rules, features, and limitations of openpilot",
}
class DeviceLayout:
def __init__(self):
params = Params()
dongle_id = params.get("DongleId", encoding="utf-8") or "N/A"
serial = params.get("HardwareSerial") or "N/A"
items = [
text_item("Dongle ID", dongle_id),
text_item("Serial", serial),
button_item("Pair Device", "PAIR", DESCRIPTIONS['pair_device'], self._on_pair_device),
button_item("Driver Camera", "PREVIEW", DESCRIPTIONS['driver_camera'], self._on_driver_camera),
button_item("Reset Calibration", "RESET", DESCRIPTIONS['reset_calibration'], self._on_reset_calibration),
button_item("Review Training Guide", "REVIEW", DESCRIPTIONS['review_guide'], self._on_review_training_guide),
]
if TICI:
items.append(button_item("Regulatory", "VIEW", callback=self._on_regulatory))
items.append(button_item("Change Language", "CHANGE", callback=self._on_change_language))
self._list_widget = ListView(items)
def render(self, rect):
self._list_widget.render(rect)
def _on_pair_device(self): pass
def _on_driver_camera(self): pass
def _on_reset_calibration(self): pass
def _on_review_training_guide(self): pass
def _on_regulatory(self): pass
def _on_change_language(self): pass
+35 -26
View File
@@ -3,8 +3,14 @@ from dataclasses import dataclass
from enum import IntEnum
from collections.abc import Callable
from openpilot.common.params import Params
from openpilot.selfdrive.ui.layouts.settings.developer import DeveloperLayout
from openpilot.selfdrive.ui.layouts.settings.device import DeviceLayout
from openpilot.selfdrive.ui.layouts.settings.software import SoftwareLayout
from openpilot.selfdrive.ui.layouts.settings.toggles import TogglesLayout
from openpilot.system.ui.lib.application import gui_app, FontWeight
from openpilot.system.ui.lib.label import gui_text_box
from openpilot.system.ui.lib.text_measure import measure_text_cached
from openpilot.selfdrive.ui.layouts.network import NetworkLayout
# Import individual panels
@@ -46,18 +52,16 @@ class SettingsLayout:
def __init__(self):
self._params = Params()
self._current_panel = PanelType.DEVICE
self._close_btn_pressed = False
self._scroll_offset = 0.0
self._max_scroll = 0.0
# Panel configuration
self._panels = {
PanelType.DEVICE: PanelInfo("Device", None, rl.Rectangle(0, 0, 0, 0)),
PanelType.TOGGLES: PanelInfo("Toggles", None, rl.Rectangle(0, 0, 0, 0)),
PanelType.SOFTWARE: PanelInfo("Software", None, rl.Rectangle(0, 0, 0, 0)),
PanelType.DEVICE: PanelInfo("Device", DeviceLayout(), rl.Rectangle(0, 0, 0, 0)),
PanelType.TOGGLES: PanelInfo("Toggles", TogglesLayout(), rl.Rectangle(0, 0, 0, 0)),
PanelType.SOFTWARE: PanelInfo("Software", SoftwareLayout(), rl.Rectangle(0, 0, 0, 0)),
PanelType.FIREHOSE: PanelInfo("Firehose", None, rl.Rectangle(0, 0, 0, 0)),
PanelType.NETWORK: PanelInfo("Network", None, rl.Rectangle(0, 0, 0, 0)),
PanelType.DEVELOPER: PanelInfo("Developer", None, rl.Rectangle(0, 0, 0, 0)),
PanelType.NETWORK: PanelInfo("Network", NetworkLayout(), rl.Rectangle(0, 0, 0, 0)),
PanelType.DEVELOPER: PanelInfo("Developer", DeveloperLayout(), rl.Rectangle(0, 0, 0, 0)),
}
self._font_medium = gui_app.font(FontWeight.MEDIUM)
@@ -89,12 +93,15 @@ class SettingsLayout:
rect.x + (rect.width - CLOSE_BTN_SIZE) / 2, rect.y + 45, CLOSE_BTN_SIZE, CLOSE_BTN_SIZE
)
close_color = CLOSE_BTN_PRESSED if self._close_btn_pressed else CLOSE_BTN_COLOR
rl.draw_rectangle_rounded(close_btn_rect, 0.5, 20, close_color)
close_text_size = rl.measure_text_ex(self._font_bold, SETTINGS_CLOSE_TEXT, 140, 0)
pressed = (rl.is_mouse_button_down(rl.MouseButton.MOUSE_BUTTON_LEFT) and
rl.check_collision_point_rec(rl.get_mouse_position(), close_btn_rect))
close_color = CLOSE_BTN_PRESSED if pressed else CLOSE_BTN_COLOR
rl.draw_rectangle_rounded(close_btn_rect, 1.0, 20, close_color)
close_text_size = measure_text_cached(self._font_bold, SETTINGS_CLOSE_TEXT, 140)
close_text_pos = rl.Vector2(
close_btn_rect.x + (close_btn_rect.width - close_text_size.x) / 2,
close_btn_rect.y + (close_btn_rect.height - close_text_size.y) / 2 - 20,
close_btn_rect.y + (close_btn_rect.height - close_text_size.y) / 2,
)
rl.draw_text_ex(self._font_bold, SETTINGS_CLOSE_TEXT, close_text_pos, 140, 0, TEXT_SELECTED)
@@ -117,9 +124,8 @@ class SettingsLayout:
# Button styling
is_selected = panel_type == self._current_panel
text_color = TEXT_SELECTED if is_selected else TEXT_NORMAL
# Draw button text (right-aligned)
text_size = rl.measure_text_ex(self._font_medium, panel_info.name, 65, 0)
text_size = measure_text_cached(self._font_medium, panel_info.name, 65)
text_pos = rl.Vector2(
button_rect.x + button_rect.width - text_size.x, button_rect.y + (button_rect.height - text_size.y) / 2
)
@@ -130,21 +136,27 @@ class SettingsLayout:
i += 1
def _draw_current_panel(self, rect: rl.Rectangle):
content_rect = rl.Rectangle(rect.x + PANEL_MARGIN, rect.y + 25, rect.width - (PANEL_MARGIN * 2), rect.height - 50)
rl.draw_rectangle_rounded(content_rect, 0.03, 30, PANEL_COLOR)
gui_text_box(
content_rect,
f"Demo {self._panels[self._current_panel].name} Panel",
font_size=170,
color=rl.WHITE,
alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER,
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE,
rl.draw_rectangle_rounded(
rl.Rectangle(rect.x + 10, rect.y + 10, rect.width - 20, rect.height - 20), 0.04, 30, PANEL_COLOR
)
content_rect = rl.Rectangle(rect.x + PANEL_MARGIN, rect.y + 25, rect.width - (PANEL_MARGIN * 2), rect.height - 50)
# rl.draw_rectangle_rounded(content_rect, 0.03, 30, PANEL_COLOR)
panel = self._panels[self._current_panel]
if panel.instance:
panel.instance.render(content_rect)
else:
gui_text_box(
content_rect,
f"Demo {self._panels[self._current_panel].name} Panel",
font_size=170,
color=rl.WHITE,
alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER,
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE,
)
def handle_mouse_release(self, mouse_pos: rl.Vector2) -> bool:
# Check close button
if rl.check_collision_point_rec(mouse_pos, self._close_btn_rect):
self._close_btn_pressed = True
if self._close_callback:
self._close_callback()
return True
@@ -160,9 +172,6 @@ class SettingsLayout:
def _switch_to_panel(self, panel_type: PanelType):
if panel_type != self._current_panel:
self._current_panel = panel_type
self._scroll_offset = 0.0 # Reset scroll when switching panels
self._transition_progress = 0.0
self._transitioning = True
def set_current_panel(self, index: int, param: str = ""):
panel_types = list(self._panels.keys())
+21
View File
@@ -0,0 +1,21 @@
from openpilot.system.ui.lib.list_view import ListView, button_item, text_item
class SoftwareLayout:
def __init__(self):
items = [
text_item("Current Version", ""),
button_item("Download", "CHECK", callback=self._on_download_update),
button_item("Install Update", "INSTALL", callback=self._on_install_update),
button_item("Target Branch", "SELECT", callback=self._on_select_branch),
button_item("Uninstall", "UNINSTALL", callback=self._on_uninstall),
]
self._list_widget = ListView(items)
def render(self, rect):
self._list_widget.render(rect)
def _on_download_update(self): pass
def _on_install_update(self): pass
def _on_select_branch(self): pass
def _on_uninstall(self): pass
+68
View File
@@ -0,0 +1,68 @@
from openpilot.system.ui.lib.list_view import ListView, toggle_item
from openpilot.common.params import Params
# Description constants
DESCRIPTIONS = {
"OpenpilotEnabledToggle": (
"Use the openpilot system for adaptive cruise control and lane keep driver assistance. " +
"Your attention is required at all times to use this feature."
),
"DisengageOnAccelerator": "When enabled, pressing the accelerator pedal will disengage openpilot.",
"IsLdwEnabled": (
"Receive alerts to steer back into the lane when your vehicle drifts over a detected lane line " +
"without a turn signal activated while driving over 31 mph (50 km/h)."
),
"AlwaysOnDM": "Enable driver monitoring even when openpilot is not engaged.",
'RecordFront': "Upload data from the driver facing camera and help improve the driver monitoring algorithm.",
"IsMetric": "Display speed in km/h instead of mph.",
}
class TogglesLayout:
def __init__(self):
self._params = Params()
items = [
toggle_item(
"Enable openpilot",
DESCRIPTIONS["OpenpilotEnabledToggle"],
self._params.get_bool("OpenpilotEnabledToggle"),
icon="chffr_wheel.png",
),
toggle_item(
"Experimental Mode",
initial_state=self._params.get_bool("ExperimentalMode"),
icon="experimental_white.png",
),
toggle_item(
"Disengage on Accelerator Pedal",
DESCRIPTIONS["DisengageOnAccelerator"],
self._params.get_bool("DisengageOnAccelerator"),
icon="disengage_on_accelerator.png",
),
toggle_item(
"Enable Lane Departure Warnings",
DESCRIPTIONS["IsLdwEnabled"],
self._params.get_bool("IsLdwEnabled"),
icon="warning.png",
),
toggle_item(
"Always-On Driver Monitoring",
DESCRIPTIONS["AlwaysOnDM"],
self._params.get_bool("AlwaysOnDM"),
icon="monitoring.png",
),
toggle_item(
"Record and Upload Driver Camera",
DESCRIPTIONS["RecordFront"],
self._params.get_bool("RecordFront"),
icon="monitoring.png",
),
toggle_item(
"Use Metric System", DESCRIPTIONS["IsMetric"], self._params.get_bool("IsMetric"), icon="monitoring.png"
),
]
self._list_widget = ListView(items)
def render(self, rect):
self._list_widget.render(rect)
+2 -1
View File
@@ -5,6 +5,7 @@ from collections.abc import Callable
from cereal import log
from openpilot.selfdrive.ui.ui_state import ui_state
from openpilot.system.ui.lib.application import gui_app, FontWeight
from openpilot.system.ui.lib.text_measure import measure_text_cached
SIDEBAR_WIDTH = 300
METRIC_HEIGHT = 126
@@ -199,7 +200,7 @@ class Sidebar:
# Draw text
text = f"{metric.label}\n{metric.value}"
text_size = rl.measure_text_ex(self._font_bold, text, 35, 0)
text_size = measure_text_cached(self._font_bold, text, 35)
text_pos = rl.Vector2(
metric_rect.x + 22 + (metric_rect.width - 22 - text_size.x) / 2,
metric_rect.y + (metric_rect.height - text_size.y) / 2
+11 -4
View File
@@ -16,6 +16,8 @@ from openpilot.common.transformations.orientation import rot_from_euler
OpState = log.SelfdriveState.OpenpilotState
CALIBRATED = log.LiveCalibrationData.Status.calibrated
ROAD_CAM = VisionStreamType.VISION_STREAM_ROAD
WIDE_CAM = VisionStreamType.VISION_STREAM_WIDE_ROAD
DEFAULT_DEVICE_CAMERA = DEVICE_CAMERAS["tici", "ar0231"]
BORDER_COLORS = {
@@ -28,6 +30,7 @@ BORDER_COLORS = {
class AugmentedRoadView(CameraView):
def __init__(self, stream_type: VisionStreamType = VisionStreamType.VISION_STREAM_ROAD):
super().__init__("camerad", stream_type)
self._set_placeholder_color(BORDER_COLORS[UIStatus.DISENGAGED])
self.device_camera: DeviceCameraConfig | None = None
self.view_from_calib = view_frame_from_device_frame.copy()
@@ -35,6 +38,7 @@ class AugmentedRoadView(CameraView):
self._last_calib_time: float = 0
self._last_rect_dims = (0.0, 0.0)
self._last_stream_type = stream_type
self._cached_matrix: np.ndarray | None = None
self._content_rect = rl.Rectangle()
@@ -119,6 +123,7 @@ class AugmentedRoadView(CameraView):
current_dims = (self._content_rect.width, self._content_rect.height)
if (self._last_calib_time == calib_time and
self._last_rect_dims == current_dims and
self._last_stream_type == self.stream_type and
self._cached_matrix is not None):
return self._cached_matrix
@@ -154,9 +159,10 @@ class AugmentedRoadView(CameraView):
except (ZeroDivisionError, OverflowError):
x_offset, y_offset = 0, 0
# Update cache values
# Cache the computed transformation matrix to avoid recalculations
self._last_calib_time = calib_time
self._last_rect_dims = current_dims
self._last_stream_type = self.stream_type
self._cached_matrix = np.array([
[zoom * 2 * cx / w, 0, -x_offset / w * 2],
[0, zoom * 2 * cy / h, -y_offset / h * 2],
@@ -175,14 +181,15 @@ class AugmentedRoadView(CameraView):
if __name__ == "__main__":
gui_app.init_window("OnRoad Camera View")
road_camera_view = AugmentedRoadView(VisionStreamType.VISION_STREAM_ROAD)
road_camera_view = AugmentedRoadView(ROAD_CAM)
print("***press space to switch camera view***")
try:
for _ in gui_app.render():
ui_state.update()
if rl.is_key_released(rl.KeyboardKey.KEY_SPACE):
is_wide = road_camera_view.stream_type == VisionStreamType.VISION_STREAM_WIDE_ROAD
road_camera_view.switch_stream(VisionStreamType.VISION_STREAM_ROAD if is_wide else VisionStreamType.VISION_STREAM_WIDE_ROAD)
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()
+83 -10
View File
@@ -57,9 +57,17 @@ else:
class CameraView:
def __init__(self, name: str, stream_type: VisionStreamType):
self.client = VisionIpcClient(name, stream_type, conflate=True)
self._name = name
# Primary stream
self.client = VisionIpcClient(name, stream_type, conflate=True)
self._stream_type = stream_type
self.available_streams: list[VisionStreamType] = []
# Target stream for switching
self._target_client: VisionIpcClient | None = None
self._target_stream_type: VisionStreamType | None = None
self._switching: bool = False
self._texture_needs_update = True
self.last_connection_attempt: float = 0.0
@@ -74,6 +82,8 @@ class CameraView:
self.egl_images: dict[int, EGLImage] = {}
self.egl_texture: rl.Texture | None = None
self._placeholder_color : rl.Color | None = None
# Initialize EGL for zero-copy rendering on TICI
if TICI:
if not init_egl():
@@ -84,13 +94,25 @@ class CameraView:
self.egl_texture = rl.load_texture_from_image(temp_image)
rl.unload_image(temp_image)
def _set_placeholder_color(self, color: rl.Color):
"""Set a placeholder color to be drawn when no frame is available."""
self._placeholder_color = color
def switch_stream(self, stream_type: VisionStreamType) -> None:
if self._stream_type != stream_type:
cloudlog.debug(f'switching stream from {self._stream_type} to {stream_type}')
self._clear_textures()
self.frame = None
self._stream_type = stream_type
self.client = VisionIpcClient(self._name, stream_type, conflate=True)
if self._stream_type == stream_type:
return
if self._switching and self._target_stream_type == stream_type:
return
cloudlog.debug(f'Preparing switch from {self._stream_type} to {stream_type}')
if self._target_client:
del self._target_client
self._target_stream_type = stream_type
self._target_client = VisionIpcClient(self._name, stream_type, conflate=True)
self._switching = True
@property
def stream_type(self) -> VisionStreamType:
@@ -129,7 +151,11 @@ class CameraView:
])
def render(self, rect: rl.Rectangle):
if self._switching:
self._handle_switch()
if not self._ensure_connection():
self._draw_placeholder(rect)
return
# Try to get a new buffer without blocking
@@ -139,6 +165,7 @@ class CameraView:
self.frame = buffer
if not self.frame:
self._draw_placeholder(rect)
return
transform = self._calc_frame_matrix(rect)
@@ -163,6 +190,10 @@ class CameraView:
else:
self._render_textures(src_rect, dst_rect)
def _draw_placeholder(self, rect: rl.Rectangle):
if self._placeholder_color:
rl.draw_rectangle_rec(rect, self._placeholder_color)
def _render_egl(self, src_rect: rl.Rectangle, dst_rect: rl.Rectangle) -> None:
"""Render using EGL for direct buffer access"""
if self.frame is None or self.egl_texture is None:
@@ -214,6 +245,7 @@ class CameraView:
def _ensure_connection(self) -> bool:
if not self.client.is_connected():
self.frame = None
self.available_streams.clear()
# Throttle connection attempts
current_time = rl.get_time()
@@ -225,16 +257,57 @@ class CameraView:
return False
cloudlog.debug(f"Connected to {self._name} stream: {self._stream_type}, buffers: {self.client.num_buffers}")
self._clear_textures()
self._initialize_textures()
self.available_streams = self.client.available_streams(self._name, block=False)
return True
def _handle_switch(self) -> None:
"""Check if target stream is ready and switch immediately."""
if not self._target_client or not self._switching:
return
# Try to connect target if needed
if not self._target_client.is_connected():
if not self._target_client.connect(False) or not self._target_client.num_buffers:
return
cloudlog.debug(f"Target stream connected: {self._target_stream_type}")
# Check if target has frames ready
target_frame = self._target_client.recv(timeout_ms=0)
if target_frame:
self.frame = target_frame # Update current frame to target frame
self._complete_switch()
def _complete_switch(self) -> None:
"""Instantly switch to target stream."""
cloudlog.debug(f"Switching to {self._target_stream_type}")
# Clean up current resources
if self.client:
del self.client
# Switch to target
self.client = self._target_client
self._stream_type = self._target_stream_type
self._texture_needs_update = True
# Reset state
self._target_client = None
self._target_stream_type = None
self._switching = False
# Initialize textures for new stream
self._initialize_textures()
def _initialize_textures(self):
self._clear_textures()
if not TICI:
self.texture_y = rl.load_texture_from_image(rl.Image(None, int(self.client.stride),
int(self.client.height), 1, rl.PixelFormat.PIXELFORMAT_UNCOMPRESSED_GRAYSCALE))
self.texture_uv = rl.load_texture_from_image(rl.Image(None, int(self.client.stride // 2),
int(self.client.height // 2), 1, rl.PixelFormat.PIXELFORMAT_UNCOMPRESSED_GRAY_ALPHA))
return True
def _clear_textures(self):
if self.texture_y and self.texture_y.id:
rl.unload_texture(self.texture_y)
+6 -2
View File
@@ -7,6 +7,7 @@ from openpilot.common.params import Params
from openpilot.selfdrive.ui.ui_state import ui_state
from openpilot.system.ui.lib.application import DEFAULT_FPS
from openpilot.system.ui.lib.shader_polygon import draw_polygon
from openpilot.selfdrive.locationd.calibrationd import HEIGHT_INIT
CLIP_MARGIN = 500
@@ -51,7 +52,7 @@ class ModelRenderer:
self._lane_line_probs = np.zeros(4, dtype=np.float32)
self._road_edge_stds = np.zeros(2, dtype=np.float32)
self._lead_vehicles = [LeadVehicle(), LeadVehicle()]
self._path_offset_z = 1.22
self._path_offset_z = HEIGHT_INIT[0]
# Initialize ModelPoints objects
self._path = ModelPoints()
@@ -99,7 +100,10 @@ class ModelRenderer:
# Update state
self._experimental_mode = sm['selfdriveState'].experimentalMode
self._path_offset_z = sm['liveCalibration'].height[0]
live_calib = sm['liveCalibration']
self._path_offset_z = live_calib.height[0] if live_calib.height else HEIGHT_INIT[0]
if sm.updated['carParams']:
self._longitudinal_control = sm['carParams'].openpilotLongitudinalControl
+4
View File
@@ -1,10 +1,12 @@
#!/usr/bin/env python3
import pyray as rl
from openpilot.common.watchdog import kick_watchdog
from openpilot.system.ui.lib.application import gui_app
from openpilot.selfdrive.ui.layouts.main import MainLayout
from openpilot.selfdrive.ui.ui_state import ui_state
def main():
gui_app.init_window("UI")
main_layout = MainLayout()
@@ -15,6 +17,8 @@ def main():
main_layout.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
kick_watchdog()
if __name__ == "__main__":
main()
+328
View File
@@ -0,0 +1,328 @@
import json
import pyray as rl
from abc import ABC, abstractmethod
from collections.abc import Callable
from dataclasses import dataclass
from openpilot.common.params import Params
from openpilot.system.hardware import HARDWARE
from openpilot.system.ui.lib.scroll_panel import GuiScrollPanel
from openpilot.system.ui.lib.wrap_text import wrap_text
from openpilot.system.ui.lib.text_measure import measure_text_cached
from openpilot.system.ui.lib.application import gui_app, FontWeight
class AlertColors:
HIGH_SEVERITY = rl.Color(226, 44, 44, 255)
LOW_SEVERITY = rl.Color(41, 41, 41, 255)
BACKGROUND = rl.Color(57, 57, 57, 255)
BUTTON = rl.WHITE
BUTTON_TEXT = rl.BLACK
SNOOZE_BG = rl.Color(79, 79, 79, 255)
TEXT = rl.WHITE
class AlertConstants:
BUTTON_SIZE = (400, 125)
SNOOZE_BUTTON_SIZE = (550, 125)
REBOOT_BUTTON_SIZE = (600, 125)
MARGIN = 50
SPACING = 30
FONT_SIZE = 48
BORDER_RADIUS = 30
ALERT_HEIGHT = 120
ALERT_SPACING = 20
@dataclass
class AlertData:
key: str
text: str
severity: int
visible: bool = False
class AbstractAlert(ABC):
def __init__(self, has_reboot_btn: bool = False):
self.params = Params()
self.has_reboot_btn = has_reboot_btn
self.dismiss_callback: Callable | None = None
self.dismiss_btn_rect = rl.Rectangle(0, 0, *AlertConstants.BUTTON_SIZE)
self.snooze_btn_rect = rl.Rectangle(0, 0, *AlertConstants.SNOOZE_BUTTON_SIZE)
self.reboot_btn_rect = rl.Rectangle(0, 0, *AlertConstants.REBOOT_BUTTON_SIZE)
self.snooze_visible = False
self.content_rect = rl.Rectangle(0, 0, 0, 0)
self.scroll_panel_rect = rl.Rectangle(0, 0, 0, 0)
self.scroll_panel = GuiScrollPanel()
def set_dismiss_callback(self, callback: Callable):
self.dismiss_callback = callback
@abstractmethod
def refresh(self) -> bool:
pass
@abstractmethod
def get_content_height(self) -> float:
pass
def handle_input(self, mouse_pos: rl.Vector2, mouse_clicked: bool) -> bool:
# TODO: fix scroll_panel.is_click_valid()
if not mouse_clicked:
return False
if rl.check_collision_point_rec(mouse_pos, self.dismiss_btn_rect):
if self.dismiss_callback:
self.dismiss_callback()
return True
if self.snooze_visible and rl.check_collision_point_rec(mouse_pos, self.snooze_btn_rect):
self.params.put_bool("SnoozeUpdate", True)
if self.dismiss_callback:
self.dismiss_callback()
return True
if self.has_reboot_btn and rl.check_collision_point_rec(mouse_pos, self.reboot_btn_rect):
HARDWARE.reboot()
return True
return False
def render(self, rect: rl.Rectangle):
rl.draw_rectangle_rounded(rect, AlertConstants.BORDER_RADIUS / rect.width, 10, AlertColors.BACKGROUND)
footer_height = AlertConstants.BUTTON_SIZE[1] + AlertConstants.SPACING
content_height = rect.height - 2 * AlertConstants.MARGIN - footer_height
self.content_rect = rl.Rectangle(
rect.x + AlertConstants.MARGIN,
rect.y + AlertConstants.MARGIN,
rect.width - 2 * AlertConstants.MARGIN,
content_height,
)
self.scroll_panel_rect = rl.Rectangle(
self.content_rect.x, self.content_rect.y, self.content_rect.width, self.content_rect.height
)
self._render_scrollable_content()
self._render_footer(rect)
def _render_scrollable_content(self):
content_total_height = self.get_content_height()
content_bounds = rl.Rectangle(0, 0, self.scroll_panel_rect.width, content_total_height)
scroll_offset = self.scroll_panel.handle_scroll(self.scroll_panel_rect, content_bounds)
rl.begin_scissor_mode(
int(self.scroll_panel_rect.x),
int(self.scroll_panel_rect.y),
int(self.scroll_panel_rect.width),
int(self.scroll_panel_rect.height),
)
content_rect_with_scroll = rl.Rectangle(
self.scroll_panel_rect.x,
self.scroll_panel_rect.y + scroll_offset.y,
self.scroll_panel_rect.width,
content_total_height,
)
self._render_content(content_rect_with_scroll)
rl.end_scissor_mode()
@abstractmethod
def _render_content(self, content_rect: rl.Rectangle):
pass
def _render_footer(self, rect: rl.Rectangle):
footer_y = rect.y + rect.height - AlertConstants.MARGIN - AlertConstants.BUTTON_SIZE[1]
font = gui_app.font(FontWeight.MEDIUM)
self.dismiss_btn_rect.x = rect.x + AlertConstants.MARGIN
self.dismiss_btn_rect.y = footer_y
rl.draw_rectangle_rounded(self.dismiss_btn_rect, 0.3, 10, AlertColors.BUTTON)
text = "Close"
text_width = measure_text_cached(font, text, AlertConstants.FONT_SIZE).x
text_x = self.dismiss_btn_rect.x + (AlertConstants.BUTTON_SIZE[0] - text_width) // 2
text_y = self.dismiss_btn_rect.y + (AlertConstants.BUTTON_SIZE[1] - AlertConstants.FONT_SIZE) // 2
rl.draw_text_ex(
font, text, rl.Vector2(int(text_x), int(text_y)), AlertConstants.FONT_SIZE, 0, AlertColors.BUTTON_TEXT
)
if self.snooze_visible:
self.snooze_btn_rect.x = rect.x + rect.width - AlertConstants.MARGIN - AlertConstants.SNOOZE_BUTTON_SIZE[0]
self.snooze_btn_rect.y = footer_y
rl.draw_rectangle_rounded(self.snooze_btn_rect, 0.3, 10, AlertColors.SNOOZE_BG)
text = "Snooze Update"
text_width = measure_text_cached(font, text, AlertConstants.FONT_SIZE).x
text_x = self.snooze_btn_rect.x + (AlertConstants.SNOOZE_BUTTON_SIZE[0] - text_width) // 2
text_y = self.snooze_btn_rect.y + (AlertConstants.SNOOZE_BUTTON_SIZE[1] - AlertConstants.FONT_SIZE) // 2
rl.draw_text_ex(font, text, rl.Vector2(int(text_x), int(text_y)), AlertConstants.FONT_SIZE, 0, AlertColors.TEXT)
elif self.has_reboot_btn:
self.reboot_btn_rect.x = rect.x + rect.width - AlertConstants.MARGIN - AlertConstants.REBOOT_BUTTON_SIZE[0]
self.reboot_btn_rect.y = footer_y
rl.draw_rectangle_rounded(self.reboot_btn_rect, 0.3, 10, AlertColors.BUTTON)
text = "Reboot and Update"
text_width = measure_text_cached(font, text, AlertConstants.FONT_SIZE).x
text_x = self.reboot_btn_rect.x + (AlertConstants.REBOOT_BUTTON_SIZE[0] - text_width) // 2
text_y = self.reboot_btn_rect.y + (AlertConstants.REBOOT_BUTTON_SIZE[1] - AlertConstants.FONT_SIZE) // 2
rl.draw_text_ex(
font, text, rl.Vector2(int(text_x), int(text_y)), AlertConstants.FONT_SIZE, 0, AlertColors.BUTTON_TEXT
)
class OffroadAlert(AbstractAlert):
def __init__(self):
super().__init__(has_reboot_btn=False)
self.sorted_alerts: list[AlertData] = []
def refresh(self):
if not self.sorted_alerts:
self._build_alerts()
active_count = 0
connectivity_needed = False
for alert_data in self.sorted_alerts:
text = ""
bytes_data = self.params.get(alert_data.key)
if bytes_data:
try:
alert_json = json.loads(bytes_data)
text = alert_json.get("text", "").replace("{}", alert_json.get("extra", ""))
except json.JSONDecodeError:
text = ""
alert_data.text = text
alert_data.visible = bool(text)
if alert_data.visible:
active_count += 1
if alert_data.key == "Offroad_ConnectivityNeeded" and alert_data.visible:
connectivity_needed = True
self.snooze_visible = connectivity_needed
return active_count
def get_content_height(self) -> float:
if not self.sorted_alerts:
return 0
total_height = 20
font = gui_app.font(FontWeight.NORMAL)
for alert_data in self.sorted_alerts:
if not alert_data.visible:
continue
text_width = int(self.content_rect.width - 90)
wrapped_lines = wrap_text(font, alert_data.text, AlertConstants.FONT_SIZE, text_width)
line_count = len(wrapped_lines)
text_height = line_count * (AlertConstants.FONT_SIZE + 5)
alert_item_height = max(text_height + 40, AlertConstants.ALERT_HEIGHT)
total_height += alert_item_height + AlertConstants.ALERT_SPACING
if total_height > 20:
total_height = total_height - AlertConstants.ALERT_SPACING + 20
return total_height
def _build_alerts(self):
self.sorted_alerts = []
try:
with open("../selfdrived/alerts_offroad.json", "rb") as f:
alerts_config = json.load(f)
for key, config in sorted(alerts_config.items(), key=lambda x: x[1].get("severity", 0), reverse=True):
severity = config.get("severity", 0)
alert_data = AlertData(key=key, text="", severity=severity)
self.sorted_alerts.append(alert_data)
except (FileNotFoundError, json.JSONDecodeError):
pass
def _render_content(self, content_rect: rl.Rectangle):
y_offset = 20
font = gui_app.font(FontWeight.NORMAL)
for alert_data in self.sorted_alerts:
if not alert_data.visible:
continue
bg_color = AlertColors.HIGH_SEVERITY if alert_data.severity > 0 else AlertColors.LOW_SEVERITY
text_width = int(content_rect.width - 90)
wrapped_lines = wrap_text(font, alert_data.text, AlertConstants.FONT_SIZE, text_width)
line_count = len(wrapped_lines)
text_height = line_count * (AlertConstants.FONT_SIZE + 5)
alert_item_height = max(text_height + 40, AlertConstants.ALERT_HEIGHT)
alert_rect = rl.Rectangle(
content_rect.x + 10,
content_rect.y + y_offset,
content_rect.width - 30,
alert_item_height,
)
rl.draw_rectangle_rounded(alert_rect, 0.2, 10, bg_color)
text_x = alert_rect.x + 30
text_y = alert_rect.y + 20
for i, line in enumerate(wrapped_lines):
rl.draw_text_ex(
font,
line,
rl.Vector2(text_x, text_y + i * (AlertConstants.FONT_SIZE + 5)),
AlertConstants.FONT_SIZE,
0,
AlertColors.TEXT,
)
y_offset += alert_item_height + AlertConstants.ALERT_SPACING
class UpdateAlert(AbstractAlert):
def __init__(self):
super().__init__(has_reboot_btn=True)
self.release_notes = ""
self._wrapped_release_notes = ""
self._cached_content_height: float = 0.0
def refresh(self) -> bool:
update_available: bool = self.params.get_bool("UpdateAvailable")
if update_available:
self.release_notes = self.params.get("UpdaterNewReleaseNotes", encoding='utf-8')
self._cached_content_height = 0
return update_available
def get_content_height(self) -> float:
if not self.release_notes:
return 100
if self._cached_content_height == 0:
self._wrapped_release_notes = self.release_notes
size = measure_text_cached(gui_app.font(FontWeight.NORMAL), self._wrapped_release_notes, AlertConstants.FONT_SIZE)
self._cached_content_height = max(size.y + 60, 100)
return self._cached_content_height
def _render_content(self, content_rect: rl.Rectangle):
if self.release_notes:
rl.draw_text_ex(
gui_app.font(FontWeight.NORMAL),
self._wrapped_release_notes,
rl.Vector2(content_rect.x + 30, content_rect.y + 30),
AlertConstants.FONT_SIZE,
0.0,
AlertColors.TEXT,
)
else:
no_notes_text = "No release notes available."
text_width = rl.measure_text(no_notes_text, AlertConstants.FONT_SIZE)
text_x = content_rect.x + (content_rect.width - text_width) // 2
text_y = content_rect.y + 50
rl.draw_text(no_notes_text, int(text_x), int(text_y), AlertConstants.FONT_SIZE, AlertColors.TEXT)
+1 -2
View File
@@ -16,9 +16,8 @@ import openpilot.system.sentry as sentry
from openpilot.common.basedir import BASEDIR
from openpilot.common.params import Params
from openpilot.common.swaglog import cloudlog
from openpilot.system.hardware.hw import Paths
from openpilot.common.watchdog import WATCHDOG_FN
WATCHDOG_FN = f"{Paths.shm_path()}/wd_"
ENABLE_WATCHDOG = os.getenv("NO_WATCHDOG") is None
+3 -2
View File
@@ -1,5 +1,6 @@
import os
import operator
import platform
from cereal import car, custom
from openpilot.common.params import Params
@@ -102,8 +103,8 @@ procs = [
NativeProcess("camerad", "system/camerad", ["./camerad"], driverview, enabled=not WEBCAM),
PythonProcess("webcamerad", "tools.webcam.camerad", driverview, enabled=WEBCAM),
NativeProcess("logcatd", "system/logcatd", ["./logcatd"], only_onroad),
NativeProcess("proclogd", "system/proclogd", ["./proclogd"], only_onroad),
NativeProcess("logcatd", "system/logcatd", ["./logcatd"], only_onroad, platform.system() != "Darwin"),
NativeProcess("proclogd", "system/proclogd", ["./proclogd"], only_onroad, platform.system() != "Darwin"),
PythonProcess("micd", "system.micd", iscar),
PythonProcess("timed", "system.timed", always_run, enabled=not PC),
+2 -1
View File
@@ -1,6 +1,7 @@
import pyray as rl
from enum import IntEnum
from openpilot.system.ui.lib.application import gui_app, FontWeight
from openpilot.system.ui.lib.text_measure import measure_text_cached
class ButtonStyle(IntEnum):
@@ -99,7 +100,7 @@ def gui_button(
# Handle icon and text positioning
font = gui_app.font(font_weight)
text_size = rl.measure_text_ex(font, text, font_size, 0)
text_size = measure_text_cached(font, text, font_size)
text_pos = rl.Vector2(0, rect.y + (rect.height - text_size.y) // 2) # Vertical centering
# Draw icon if provided
+4 -3
View File
@@ -1,6 +1,7 @@
import pyray as rl
import time
from openpilot.system.ui.lib.application import gui_app
from openpilot.system.ui.lib.text_measure import measure_text_cached
PASSWORD_MASK_CHAR = ""
@@ -60,7 +61,7 @@ class InputBox:
padding = 10
if self._cursor_position > 0:
cursor_x = rl.measure_text_ex(font, display_text[: self._cursor_position], self._font_size, 0).x
cursor_x = measure_text_cached(font, display_text[: self._cursor_position], self._font_size).x
else:
cursor_x = 0
@@ -141,7 +142,7 @@ class InputBox:
if self._show_cursor:
cursor_x = rect.x + padding
if len(display_text) > 0 and self._cursor_position > 0:
cursor_x += rl.measure_text_ex(font, display_text[: self._cursor_position], font_size, 0).x
cursor_x += measure_text_cached(font, display_text[: self._cursor_position], font_size).x
# Apply text offset to cursor position
cursor_x -= self._text_offset
@@ -182,7 +183,7 @@ class InputBox:
min_distance = float('inf')
for i in range(len(self._input_text) + 1):
char_width = rl.measure_text_ex(font, display_text[:i], font_size, 0).x
char_width = measure_text_cached(font, display_text[:i], font_size).x
distance = abs(relative_x - char_width)
if distance < min_distance:
min_distance = distance
+4 -3
View File
@@ -1,5 +1,6 @@
import pyray as rl
from openpilot.system.ui.lib.application import gui_app, FontWeight, DEFAULT_TEXT_SIZE, DEFAULT_TEXT_COLOR
from openpilot.system.ui.lib.text_measure import measure_text_cached
from openpilot.system.ui.lib.utils import GuiStyleContext
@@ -14,7 +15,7 @@ def gui_label(
elide_right: bool = True
):
font = gui_app.font(font_weight)
text_size = rl.measure_text_ex(font, text, font_size, 0)
text_size = measure_text_cached(font, text, font_size)
display_text = text
# Elide text to fit within the rectangle
@@ -24,13 +25,13 @@ def gui_label(
while left < right:
mid = (left + right) // 2
candidate = text[:mid] + ellipsis
candidate_size = rl.measure_text_ex(font, candidate, font_size, 0)
candidate_size = measure_text_cached(font, candidate, font_size)
if candidate_size.x <= rect.width:
left = mid + 1
else:
right = mid
display_text = text[: left - 1] + ellipsis if left > 0 else ellipsis
text_size = rl.measure_text_ex(font, display_text, font_size, 0)
text_size = measure_text_cached(font, display_text, font_size)
# Calculate horizontal position based on alignment
text_x = rect.x + {
+380
View File
@@ -0,0 +1,380 @@
import os
import pyray as rl
from dataclasses import dataclass
from collections.abc import Callable
from abc import ABC, abstractmethod
from openpilot.system.ui.lib.scroll_panel import GuiScrollPanel
from openpilot.system.ui.lib.application import gui_app, FontWeight
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.button import gui_button
from openpilot.system.ui.lib.toggle import Toggle
from openpilot.system.ui.lib.toggle import WIDTH as TOGGLE_WIDTH, HEIGHT as TOGGLE_HEIGHT
LINE_PADDING = 40
LINE_COLOR = rl.GRAY
ITEM_PADDING = 20
ITEM_SPACING = 80
ITEM_BASE_HEIGHT = 170
ITEM_TEXT_FONT_SIZE = 50
ITEM_TEXT_COLOR = rl.WHITE
ITEM_DESC_TEXT_COLOR = rl.Color(128, 128, 128, 255)
ITEM_DESC_FONT_SIZE = 40
ITEM_DESC_V_OFFSET = 130
RIGHT_ITEM_PADDING = 20
ICON_SIZE = 80
BUTTON_WIDTH = 250
BUTTON_HEIGHT = 100
BUTTON_BORDER_RADIUS = 50
BUTTON_FONT_SIZE = 35
BUTTON_FONT_WEIGHT = FontWeight.MEDIUM
# Abstract base class for right-side items
class RightItem(ABC):
def __init__(self, width: int = 100):
self.width = width
self.enabled = True
@abstractmethod
def draw(self, rect: rl.Rectangle) -> bool:
pass
@abstractmethod
def get_width(self) -> int:
pass
class ToggleRightItem(RightItem):
def __init__(self, initial_state: bool = False, width: int = TOGGLE_WIDTH):
super().__init__(width)
self.toggle = Toggle(initial_state=initial_state)
self.state = initial_state
self.enabled = True
def draw(self, rect: rl.Rectangle) -> bool:
if self.toggle.render(rl.Rectangle(rect.x, rect.y + (rect.height - TOGGLE_HEIGHT) / 2, self.width, TOGGLE_HEIGHT)):
self.state = not self.state
return True
return False
def get_width(self) -> int:
return self.width
def set_state(self, state: bool):
self.state = state
self.toggle.set_state(state)
def get_state(self) -> bool:
return self.state
def set_enabled(self, enabled: bool):
self.enabled = enabled
class ButtonRightItem(RightItem):
def __init__(self, text: str, width: int = BUTTON_WIDTH):
super().__init__(width)
self.text = text
self.enabled = True
def draw(self, rect: rl.Rectangle) -> bool:
return (
gui_button(
rl.Rectangle(rect.x, rect.y + (rect.height - BUTTON_HEIGHT) / 2, BUTTON_WIDTH, BUTTON_HEIGHT),
self.text,
border_radius=BUTTON_BORDER_RADIUS,
font_weight=BUTTON_FONT_WEIGHT,
font_size=BUTTON_FONT_SIZE,
is_enabled=self.enabled,
)
== 1
)
def get_width(self) -> int:
return self.width
def set_enabled(self, enabled: bool):
self.enabled = enabled
class TextRightItem(RightItem):
def __init__(self, text: str, color: rl.Color = ITEM_TEXT_COLOR, font_size: int = ITEM_TEXT_FONT_SIZE):
self.text = text
self.color = color
self.font_size = font_size
font = gui_app.font(FontWeight.NORMAL)
text_width = measure_text_cached(font, text, font_size).x
super().__init__(int(text_width + 20))
def draw(self, rect: rl.Rectangle) -> bool:
font = gui_app.font(FontWeight.NORMAL)
text_size = measure_text_cached(font, self.text, self.font_size)
# Center the text in the allocated rectangle
text_x = rect.x + (rect.width - text_size.x) / 2
text_y = rect.y + (rect.height - text_size.y) / 2
rl.draw_text_ex(font, self.text, rl.Vector2(text_x, text_y), self.font_size, 0, self.color)
return False
def get_width(self) -> int:
return self.width
def set_text(self, text: str):
self.text = text
font = gui_app.font(FontWeight.NORMAL)
text_width = measure_text_cached(font, text, self.font_size).x
self.width = int(text_width + 20)
@dataclass
class ListItem:
title: str
icon: str | None = None
description: str | None = None
description_visible: bool = False
rect: "rl.Rectangle | None" = None
callback: Callable | None = None
right_item: RightItem | None = None
# Cached properties for performance
_wrapped_description: str | None = None
_description_height: float = 0
def get_right_item(self) -> RightItem | None:
return self.right_item
def get_item_height(self, font: rl.Font, max_width: int) -> float:
if self.description_visible and self.description:
if not self._wrapped_description:
wrapped_lines = wrap_text(font, self.description, ITEM_DESC_FONT_SIZE, max_width)
self._wrapped_description = "\n".join(wrapped_lines)
self._description_height = len(wrapped_lines) * 20 + 10 # Line height + padding
return ITEM_BASE_HEIGHT + self._description_height - (ITEM_BASE_HEIGHT - ITEM_DESC_V_OFFSET) + ITEM_SPACING
return ITEM_BASE_HEIGHT
def get_content_width(self, total_width: int) -> int:
if self.right_item:
return total_width - self.right_item.get_width() - RIGHT_ITEM_PADDING
return total_width
def get_right_item_rect(self, item_rect: rl.Rectangle) -> rl.Rectangle:
if not self.right_item:
return rl.Rectangle(0, 0, 0, 0)
right_width = self.right_item.get_width()
right_x = item_rect.x + item_rect.width - right_width
right_y = item_rect.y
return rl.Rectangle(right_x, right_y, right_width, ITEM_BASE_HEIGHT)
class ListView:
def __init__(self, items: list[ListItem]):
self._items: list[ListItem] = items
self._last_dim: tuple[float, float] = (0, 0)
self.scroll_panel = GuiScrollPanel()
self._font_normal = gui_app.font(FontWeight.NORMAL)
# Interaction state
self._hovered_item: int = -1
self._last_mouse_pos = rl.Vector2(0, 0)
self._total_height: float = 0
self._visible_range = (0, 0)
def invalid_height_cache(self):
self._last_dim = (0, 0)
def render(self, rect: rl.Rectangle):
if self._last_dim != (rect.width, rect.height):
self._update_item_rects(rect)
self._last_dim = (rect.width, rect.height)
# Update layout and handle scrolling
content_rect = rl.Rectangle(rect.x, rect.y, rect.width, self._total_height)
scroll_offset = self.scroll_panel.handle_scroll(rect, content_rect)
# Handle mouse interaction
if self.scroll_panel.is_click_valid():
self._handle_mouse_interaction(rect, scroll_offset)
# Set scissor mode for clipping
rl.begin_scissor_mode(int(rect.x), int(rect.y), int(rect.width), int(rect.height))
# Calculate visible range for performance
self._calculate_visible_range(rect, -scroll_offset.y)
# Render only visible items
for i in range(self._visible_range[0], min(self._visible_range[1], len(self._items))):
item = self._items[i]
if item.rect:
adjusted_rect = rl.Rectangle(item.rect.x, item.rect.y + scroll_offset.y, item.rect.width, item.rect.height)
self._render_item(item, adjusted_rect, i)
if i != len(self._items) - 1:
rl.draw_line_ex(
rl.Vector2(adjusted_rect.x + LINE_PADDING, adjusted_rect.y + adjusted_rect.height - 1),
rl.Vector2(
adjusted_rect.x + adjusted_rect.width - LINE_PADDING * 2, adjusted_rect.y + adjusted_rect.height - 1
),
1.0,
LINE_COLOR,
)
rl.end_scissor_mode()
def _render_item(self, item: ListItem, rect: rl.Rectangle, index: int):
content_x = rect.x + ITEM_PADDING
text_x = content_x
# Calculate available width for main content
content_width = item.get_content_width(int(rect.width - ITEM_PADDING * 2))
# Draw icon if present
if item.icon:
icon_texture = gui_app.texture(os.path.join("icons", item.icon), ICON_SIZE, ICON_SIZE)
rl.draw_texture(
icon_texture, int(content_x), int(rect.y + (ITEM_BASE_HEIGHT - icon_texture.width) // 2), rl.WHITE
)
text_x += ICON_SIZE + ITEM_PADDING
# Draw main text
text_size = measure_text_cached(self._font_normal, item.title, ITEM_TEXT_FONT_SIZE)
item_y = rect.y + (ITEM_BASE_HEIGHT - text_size.y) // 2
rl.draw_text_ex(self._font_normal, item.title, rl.Vector2(text_x, item_y), ITEM_TEXT_FONT_SIZE, 0, ITEM_TEXT_COLOR)
# Draw description if visible (adjust width for right item)
if item.description_visible and item._wrapped_description:
desc_y = rect.y + ITEM_DESC_V_OFFSET
desc_max_width = int(content_width - (text_x - content_x))
# Re-wrap description if needed due to right item
if (item.right_item and item.description) and not item._wrapped_description:
wrapped_lines = wrap_text(self._font_normal, item.description, ITEM_DESC_FONT_SIZE, desc_max_width)
item._wrapped_description = "\n".join(wrapped_lines)
rl.draw_text_ex(
self._font_normal,
item._wrapped_description,
rl.Vector2(text_x, desc_y),
ITEM_DESC_FONT_SIZE,
0,
ITEM_DESC_TEXT_COLOR,
)
# Draw right item if present
if item.right_item:
right_rect = item.get_right_item_rect(rect)
# Adjust for scroll offset
right_rect.y = right_rect.y
if item.right_item.draw(right_rect):
# Right item was clicked/activated
if item.callback:
item.callback()
def _update_item_rects(self, container_rect: rl.Rectangle) -> None:
current_y: float = 0.0
self._total_height = 0
for item in self._items:
content_width = item.get_content_width(int(container_rect.width - ITEM_PADDING * 2))
item_height = item.get_item_height(self._font_normal, content_width)
item.rect = rl.Rectangle(container_rect.x, container_rect.y + current_y, container_rect.width, item_height)
current_y += item_height
self._total_height += item_height
def _calculate_visible_range(self, rect: rl.Rectangle, scroll_offset: float):
if not self._items:
self._visible_range = (0, 0)
return
visible_top = scroll_offset
visible_bottom = scroll_offset + rect.height
start_idx = 0
end_idx = len(self._items)
# Find first visible item
for i, item in enumerate(self._items):
if item.rect and item.rect.y + item.rect.height >= visible_top:
start_idx = max(0, i - 1)
break
# Find last visible item
for i in range(start_idx, len(self._items)):
item = self._items[i]
if item.rect and item.rect.y > visible_bottom:
end_idx = min(len(self._items), i + 2)
break
self._visible_range = (start_idx, end_idx)
def _handle_mouse_interaction(self, rect: rl.Rectangle, scroll_offset: rl.Vector2):
mouse_pos = rl.get_mouse_position()
self._hovered_item = -1
if not rl.check_collision_point_rec(mouse_pos, rect):
return
content_mouse_y = mouse_pos.y - rect.y - scroll_offset.y
for i, item in enumerate(self._items):
if item.rect:
# Check if mouse is within this item's bounds in content space
if (
mouse_pos.x >= rect.x
and mouse_pos.x <= rect.x + rect.width
and content_mouse_y >= item.rect.y
and content_mouse_y <= item.rect.y + item.rect.height
):
item_screen_y = item.rect.y + scroll_offset.y
if item_screen_y < rect.height and item_screen_y + item.rect.height > 0:
self._hovered_item = i
break
# Handle click on main item (not right item)
if rl.is_mouse_button_released(rl.MouseButton.MOUSE_BUTTON_LEFT) and self._hovered_item >= 0:
item = self._items[self._hovered_item]
# Check if click was on right item area
if item.right_item and item.rect:
adjusted_rect = rl.Rectangle(item.rect.x, item.rect.y + scroll_offset.y, item.rect.width, item.rect.height)
right_rect = item.get_right_item_rect(adjusted_rect)
if rl.check_collision_point_rec(mouse_pos, right_rect):
# Click was handled by right item, don't process main item click
return
# Toggle description visibility if item has description
if item.description:
item.description_visible = not item.description_visible
# Force layout update when description visibility changes
self._last_dim = (0, 0)
# Call item callback
if item.callback:
item.callback()
# Factory functions
def simple_item(title: str, callback: Callable | None = None) -> ListItem:
return ListItem(title=title, callback=callback)
def toggle_item(
title: str, description: str = None, initial_state: bool = False, callback: Callable | None = None, icon: str = ""
) -> ListItem:
toggle = ToggleRightItem(initial_state=initial_state)
return ListItem(title=title, description=description, right_item=toggle, icon=icon, callback=callback)
def button_item(title: str, button_text: str, description: str = None, callback: Callable | None = None) -> ListItem:
button = ButtonRightItem(text=button_text)
return ListItem(title=title, description=description, right_item=button, callback=callback)
def text_item(title: str, value: str, description: str = None, callback: Callable | None = None) -> ListItem:
text_item = TextRightItem(text=value, color=rl.Color(170, 170, 170, 255))
return ListItem(title=title, description=description, right_item=text_item, callback=callback)
+2 -1
View File
@@ -4,10 +4,11 @@ _cache: dict[int, rl.Vector2] = {}
def measure_text_cached(font: rl.Font, text: str, font_size: int, spacing: int = 0) -> rl.Vector2:
"""Caches text measurements to avoid redundant calculations."""
key = hash((font.texture.id, text, font_size, spacing))
if key in _cache:
return _cache[key]
result = rl.measure_text_ex(font, text, font_size, spacing)
result = rl.measure_text_ex(font, text, font_size, spacing) # noqa: TID251
_cache[key] = result
return result
+36 -17
View File
@@ -1,56 +1,75 @@
import pyray as rl
ON_COLOR = rl.Color(0, 255, 0, 255)
ON_COLOR = rl.Color(51, 171, 76, 255)
OFF_COLOR = rl.Color(0x39, 0x39, 0x39, 255)
KNOB_COLOR = rl.WHITE
DISABLED_ON_COLOR = rl.Color(0x22, 0x77, 0x22, 255) # Dark green when disabled + on
DISABLED_OFF_COLOR = rl.Color(0x39, 0x39, 0x39, 255)
DISABLED_KNOB_COLOR = rl.Color(0x88, 0x88, 0x88, 255)
WIDTH, HEIGHT = 160, 80
BG_HEIGHT = 60
ANIMATION_SPEED = 8.0
class Toggle:
def __init__(self, x, y, initial_state=False):
def __init__(self, initial_state=False):
self._state = initial_state
self._rect = rl.Rectangle(x, y, WIDTH, HEIGHT)
self._enabled = True
self._rect = rl.Rectangle(0, 0, WIDTH, HEIGHT)
self._progress = 1.0 if initial_state else 0.0
self._target = self._progress
def handle_input(self):
if not self._enabled:
return 0
if rl.is_mouse_button_pressed(rl.MOUSE_LEFT_BUTTON):
if rl.check_collision_point_rec(rl.get_mouse_position(), self._rect):
self._state = not self._state
self._target = 1.0 if self._state else 0.0
return 1
return 0
def get_state(self):
return self._state
def set_state(self, state: bool):
self._state = state
self._target = 1.0 if state else 0.0
def set_enabled(self, enabled: bool):
self._enabled = enabled
def is_enabled(self):
return self._enabled
def update(self):
if abs(self._progress - self._target) > 0.01:
delta = rl.get_frame_time() * ANIMATION_SPEED
self._progress += delta if self._progress < self._target else -delta
self._progress = max(0.0, min(1.0, self._progress))
def render(self):
self. update()
def render(self, rect: rl.Rectangle):
self._rect.x, self._rect.y = rect.x, rect.y
self.update()
if self._enabled:
bg_color = self._blend_color(OFF_COLOR, ON_COLOR, self._progress)
knob_color = KNOB_COLOR
else:
bg_color = self._blend_color(DISABLED_OFF_COLOR, DISABLED_ON_COLOR, self._progress)
knob_color = DISABLED_KNOB_COLOR
# Draw background
bg_rect = rl.Rectangle(self._rect.x + 5, self._rect.y + 10, WIDTH - 10, BG_HEIGHT)
bg_color = self._blend_color(OFF_COLOR, ON_COLOR, self._progress)
rl.draw_rectangle_rounded(bg_rect, 1.0, 10, bg_color)
# Draw knob
knob_x = self._rect.x + HEIGHT / 2 + (WIDTH - HEIGHT) * self._progress
knob_y = self._rect.y + HEIGHT / 2
rl.draw_circle(int(knob_x), int(knob_y), HEIGHT / 2, KNOB_COLOR)
rl.draw_circle(int(knob_x), int(knob_y), HEIGHT / 2, knob_color)
return self.handle_input()
def _blend_color(self, c1, c2, t):
return rl.Color(int(c1.r + (c2.r - c1.r) * t), int(c1.g + (c2.g - c1.g) * t), int(c1.b + (c2.b - c1.b) * t), 255)
if __name__ == "__main__":
from openpilot.system.ui.lib.application import gui_app
gui_app.init_window("Text toggle example")
toggle = Toggle(100, 100)
for _ in gui_app.render():
toggle.handle_input()
toggle.render()
+87
View File
@@ -0,0 +1,87 @@
import pyray as rl
from openpilot.system.ui.lib.text_measure import measure_text_cached
def _break_long_word(font: rl.Font, word: str, font_size: int, max_width: int) -> list[str]:
if not word:
return []
parts = []
remaining = word
while remaining:
if measure_text_cached(font, remaining, font_size).x <= max_width:
parts.append(remaining)
break
# Binary search for the longest substring that fits
left, right = 1, len(remaining)
best_fit = 1
while left <= right:
mid = (left + right) // 2
substring = remaining[:mid]
width = measure_text_cached(font, substring, font_size).x
if width <= max_width:
best_fit = mid
left = mid + 1
else:
right = mid - 1
# Add the part that fits
parts.append(remaining[:best_fit])
remaining = remaining[best_fit:]
return parts
def wrap_text(font: rl.Font, text: str, font_size: int, max_width: int) -> list[str]:
if not text or max_width <= 0:
return []
words = text.split()
if not words:
return []
lines: list[str] = []
current_line: list[str] = []
current_width = 0
space_width = int(measure_text_cached(font, " ", font_size).x)
for word in words:
word_width = int(measure_text_cached(font, word, font_size).x)
# Check if word alone exceeds max width (need to break the word)
if word_width > max_width:
# Finish current line if it has content
if current_line:
lines.append(" ".join(current_line))
current_line = []
current_width = 0
# Break the long word into parts
lines.extend(_break_long_word(font, word, font_size, max_width))
continue
# Calculate width if we add this word
needed_width = current_width
if current_line: # Need space before word
needed_width += space_width
needed_width += word_width
# Check if word fits on current line
if needed_width <= max_width:
current_line.append(word)
current_width = needed_width
else:
# Start new line with this word
if current_line:
lines.append(" ".join(current_line))
current_line = [word]
current_width = word_width
# Add remaining words
if current_line:
lines.append(" ".join(current_line))
return lines
+2 -1
View File
@@ -4,6 +4,7 @@ import threading
import time
from openpilot.system.ui.lib.application import gui_app
from openpilot.system.ui.lib.text_measure import measure_text_cached
from openpilot.system.ui.lib.window import BaseWindow
from openpilot.system.ui.text import wrap_text
@@ -78,7 +79,7 @@ class SpinnerRenderer:
rl.draw_rectangle_rounded(bar, 1, 10, rl.WHITE)
elif wrapped_lines:
for i, line in enumerate(wrapped_lines):
text_size = rl.measure_text_ex(gui_app.font(), line, FONT_SIZE, 0.0)
text_size = measure_text_cached(gui_app.font(), line, FONT_SIZE)
rl.draw_text_ex(gui_app.font(), line, rl.Vector2(center.x - text_size.x / 2, y_pos + i * LINE_HEIGHT),
FONT_SIZE, 0.0, rl.WHITE)
+2 -1
View File
@@ -3,6 +3,7 @@ import re
import time
import pyray as rl
from openpilot.system.hardware import HARDWARE, PC
from openpilot.system.ui.lib.text_measure import measure_text_cached
from openpilot.system.ui.lib.button import gui_button, ButtonStyle
from openpilot.system.ui.lib.scroll_panel import GuiScrollPanel
from openpilot.system.ui.lib.application import gui_app
@@ -33,7 +34,7 @@ def wrap_text(text, font_size, max_width):
while len(words):
word = words.pop(0)
test_line = current_line + word + (words.pop(0) if words else "")
if rl.measure_text_ex(font, test_line, font_size, 0).x <= max_width:
if measure_text_cached(font, test_line, font_size).x <= max_width:
current_line = test_line
else:
lines.append(current_line)
+4 -4
View File
@@ -209,7 +209,7 @@ class BaseFrameReader:
def close(self):
pass
def get(self, num, count=1, pix_fmt="yuv420p"):
def get(self, num, count=1, pix_fmt="rgb24"):
raise NotImplementedError
@@ -497,7 +497,7 @@ class GOPFrameReader(BaseFrameReader):
return self.frame_cache[(num, pix_fmt)]
def get(self, num, count=1, pix_fmt="yuv420p"):
def get(self, num, count=1, pix_fmt="rgb24"):
assert self.frame_count is not None
if num + count > self.frame_count:
@@ -523,12 +523,12 @@ class StreamFrameReader(StreamGOPReader, GOPFrameReader):
GOPFrameReader.__init__(self, readahead, readbehind)
def GOPFrameIterator(gop_reader, pix_fmt):
def GOPFrameIterator(gop_reader, pix_fmt='rgb24'):
dec = VideoStreamDecompressor(gop_reader.fn, gop_reader.vid_fmt, gop_reader.w, gop_reader.h, pix_fmt)
yield from dec.read()
def FrameIterator(fn, pix_fmt, **kwargs):
def FrameIterator(fn, pix_fmt='rgb24', **kwargs):
fr = FrameReader(fn, **kwargs)
if isinstance(fr, GOPReader):
yield from GOPFrameIterator(fr, pix_fmt)
+10 -10
View File
@@ -1,22 +1,22 @@
# Run openpilot with webcam on PC
What's needed:
- Ubuntu 24.04 ([WSL2 is not supported](https://github.com/commaai/openpilot/issues/34216))
- Ubuntu 24.04 ([WSL2 is not supported](https://github.com/commaai/openpilot/issues/34216)) or macOS
- GPU (recommended)
- Two USB webcams, at least 720p and 78 degrees FOV (e.g. Logitech C920/C615)
- [Car harness](https://comma.ai/shop/products/comma-car-harness) with black panda to connect to your car
- [Panda paw](https://comma.ai/shop/products/panda-paw) or USB-A to USB-A cable to connect panda to your computer
That's it!
- One USB webcam, at least 720p and 78 degrees FOV (e.g. Logitech C920/C615, NexiGo N60)
- [Car harness](https://comma.ai/shop/products/comma-car-harness)
- [panda](https://comma.ai/shop/panda)
- USB-A to USB-A cable to connect panda to your computer
## Setup openpilot
- Follow [this readme](../README.md) to install and build the requirements
- Install OpenCL Driver
- Install OpenCL Driver (Ubuntu)
```
sudo apt install pocl-opencl-icd
```
## Connect the hardware
- Connect the road facing camera first, then the driver facing camera
- Connect the camera first
- Connect your computer to panda
## GO
@@ -24,12 +24,12 @@ sudo apt install pocl-opencl-icd
USE_WEBCAM=1 system/manager/manager.py
```
- Start the car, then the UI should show the road webcam's view
- Adjust and secure the webcams.
- Adjust and secure the webcam
- Finish calibration and engage!
## Specify Cameras
Use the `ROAD_CAM`, `DRIVER_CAM`, and optional `WIDE_CAM` environment variables to specify which camera is which (ie. `DRIVER_CAM=2` uses `/dev/video2` for the driver-facing camera):
Use the `ROAD_CAM` (default 0) and optional `DRIVER_CAM`, `WIDE_CAM` environment variables to specify which camera is which (ie. `ROAD_CAM=1` uses `/dev/video1`, on Ubuntu, for the road camera):
```
USE_WEBCAM=1 ROAD_CAM=4 WIDE_CAM=6 system/manager/manager.py
USE_WEBCAM=1 ROAD_CAM=1 system/manager/manager.py
```
+7 -2
View File
@@ -9,14 +9,19 @@ from cereal import messaging
from openpilot.tools.webcam.camera import Camera
from openpilot.common.realtime import Ratekeeper
ROAD_CAM = os.getenv("ROAD_CAM", "0")
WIDE_CAM = os.getenv("WIDE_CAM")
DRIVER_CAM = os.getenv("DRIVER_CAM")
CameraType = namedtuple("CameraType", ["msg_name", "stream_type", "cam_id"])
CAMERAS = [
CameraType("roadCameraState", VisionStreamType.VISION_STREAM_ROAD, os.getenv("ROAD_CAM", "0")),
CameraType("driverCameraState", VisionStreamType.VISION_STREAM_DRIVER, os.getenv("DRIVER_CAM", "2")),
CameraType("roadCameraState", VisionStreamType.VISION_STREAM_ROAD, ROAD_CAM)
]
if WIDE_CAM:
CAMERAS.append(CameraType("wideRoadCameraState", VisionStreamType.VISION_STREAM_WIDE_ROAD, WIDE_CAM))
if DRIVER_CAM:
CAMERAS.append(CameraType("driverCameraState", VisionStreamType.VISION_STREAM_DRIVER, DRIVER_CAM))
class Camerad:
def __init__(self):