mirror of
https://github.com/sunnypilot/sunnypilot.git
synced 2026-07-21 03:52:10 +08:00
Merge remote-tracking branch 'sunnypilot/sunnypilot/master-new' into feature/slc
# Conflicts: # opendbc_repo
This commit is contained in:
@@ -1,3 +1,6 @@
|
||||
Version 0.9.10 (2025-06-30)
|
||||
========================
|
||||
|
||||
Version 0.9.9 (2025-05-23)
|
||||
========================
|
||||
* New driving model
|
||||
|
||||
+1
-1
@@ -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
@@ -1 +1 @@
|
||||
#define COMMA_VERSION "0.9.9"
|
||||
#define COMMA_VERSION "0.9.10"
|
||||
|
||||
@@ -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 opendbc_repo updated: f66c6e6667...68d6cbddbf
+1
-1
Submodule panda updated: 86cf5dc583...5ac4fa5bb0
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 + {
|
||||
|
||||
@@ -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)
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
```
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user