From 08aeeabc9bc8276933642fa23a998ddd211f6a7a Mon Sep 17 00:00:00 2001 From: Dean Lee Date: Tue, 10 Jun 2025 00:56:56 +0800 Subject: [PATCH] ui: add FirehoseLayout to settings (#35505) add FirehoseLayout --- selfdrive/ui/layouts/settings/firehose.py | 165 ++++++++++++++++++++++ selfdrive/ui/layouts/settings/settings.py | 13 +- selfdrive/ui/qt/offroad/firehose.cc | 2 +- system/ui/lib/wrap_text.py | 97 +++++++------ 4 files changed, 224 insertions(+), 53 deletions(-) create mode 100644 selfdrive/ui/layouts/settings/firehose.py diff --git a/selfdrive/ui/layouts/settings/firehose.py b/selfdrive/ui/layouts/settings/firehose.py new file mode 100644 index 0000000000..4f3ed6f3a0 --- /dev/null +++ b/selfdrive/ui/layouts/settings/firehose.py @@ -0,0 +1,165 @@ +import pyray as rl +import time +import threading + +from openpilot.common.api import Api, api_get +from openpilot.common.params import Params +from openpilot.common.swaglog import cloudlog +from openpilot.system.ui.lib.application import gui_app, Widget, FontWeight +from openpilot.system.ui.lib.wrap_text import wrap_text +from openpilot.system.ui.lib.scroll_panel import GuiScrollPanel +from openpilot.selfdrive.ui.ui_state import ui_state + + +TITLE = "Firehose Mode" +DESCRIPTION = ( + "openpilot learns to drive by watching humans, like you, drive.\n\n" + + "Firehose Mode allows you to maximize your training data uploads to improve " + + "openpilot's driving models. More data means bigger models, which means better Experimental Mode." +) +INSTRUCTIONS = ( + "For maximum effectiveness, bring your device inside and connect to a good USB-C adapter and Wi-Fi weekly.\n\n" + + "Firehose Mode can also work while you're driving if connected to a hotspot or unlimited SIM card.\n\n" + + "Frequently Asked Questions\n\n" + + "Does it matter how or where I drive? Nope, just drive as you normally would.\n\n" + + "Do all of my segments get pulled in Firehose Mode? No, we selectively pull a subset of your segments.\n\n" + + "What's a good USB-C adapter? Any fast phone or laptop charger should be fine.\n\n" + + "Does it matter which software I run? Yes, only upstream openpilot (and particular forks) are able to be used for training." +) + + +class FirehoseLayout(Widget): + PARAM_KEY = "ApiCache_FirehoseStats" + GREEN = rl.Color(46, 204, 113, 255) + RED = rl.Color(231, 76, 60, 255) + GRAY = rl.Color(68, 68, 68, 255) + LIGHT_GRAY = rl.Color(228, 228, 228, 255) + UPDATE_INTERVAL = 30 # seconds + + def __init__(self): + super().__init__() + self.params = Params() + self.segment_count = int(self.params.get(self.PARAM_KEY, encoding='utf8') or 0) + self.scroll_panel = GuiScrollPanel() + + self.running = True + self.update_thread = threading.Thread(target=self._update_loop, daemon=True) + self.update_thread.start() + self.last_update_time = 0 + + def __del__(self): + self.running = False + if self.update_thread and self.update_thread.is_alive(): + self.update_thread.join(timeout=1.0) + + def _render(self, rect: rl.Rectangle): + # Calculate content dimensions + content_width = rect.width - 80 + content_height = self._calculate_content_height(int(content_width)) + content_rect = rl.Rectangle(rect.x, rect.y, rect.width, content_height) + + # Handle scrolling and render with clipping + scroll_offset = self.scroll_panel.handle_scroll(rect, content_rect) + rl.begin_scissor_mode(int(rect.x), int(rect.y), int(rect.width), int(rect.height)) + self._render_content(rect, scroll_offset) + rl.end_scissor_mode() + + def _calculate_content_height(self, content_width: int) -> int: + height = 80 # Top margin + + # Title + height += 100 + 40 + + # Description + desc_font = gui_app.font(FontWeight.NORMAL) + desc_lines = wrap_text(desc_font, DESCRIPTION, 45, content_width) + height += len(desc_lines) * 45 + 40 + + # Status section + height += 32 # Separator + status_text, _ = self._get_status() + status_lines = wrap_text(gui_app.font(FontWeight.BOLD), status_text, 60, content_width) + height += len(status_lines) * 60 + 20 + + # Contribution count (if available) + if self.segment_count > 0: + contrib_text = f"{self.segment_count} segment(s) of your driving is in the training dataset so far." + contrib_lines = wrap_text(gui_app.font(FontWeight.BOLD), contrib_text, 52, content_width) + height += len(contrib_lines) * 52 + 20 + + # Instructions section + height += 32 # Separator + inst_lines = wrap_text(gui_app.font(FontWeight.NORMAL), INSTRUCTIONS, 40, content_width) + height += len(inst_lines) * 40 + 40 # Bottom margin + + return height + + def _render_content(self, rect: rl.Rectangle, scroll_offset: rl.Vector2): + x = int(rect.x + 40) + y = int(rect.y + 40 + scroll_offset.y) + w = int(rect.width - 80) + + # Title + title_font = gui_app.font(FontWeight.MEDIUM) + rl.draw_text_ex(title_font, TITLE, rl.Vector2(x, y), 100, 0, rl.WHITE) + y += 140 + + # Description + y = self._draw_wrapped_text(x, y, w, DESCRIPTION, gui_app.font(FontWeight.NORMAL), 45, rl.WHITE) + y += 40 + + # Separator + rl.draw_rectangle(x, y, w, 2, self.GRAY) + y += 30 + + # Status + status_text, status_color = self._get_status() + y = self._draw_wrapped_text(x, y, w, status_text, gui_app.font(FontWeight.BOLD), 60, status_color) + y += 20 + + # Contribution count (if available) + if self.segment_count > 0: + contrib_text = f"{self.segment_count} segment(s) of your driving is in the training dataset so far." + y = self._draw_wrapped_text(x, y, w, contrib_text, gui_app.font(FontWeight.BOLD), 52, rl.WHITE) + y += 20 + + # Separator + rl.draw_rectangle(x, y, w, 2, self.GRAY) + y += 30 + + # Instructions + self._draw_wrapped_text(x, y, w, INSTRUCTIONS, gui_app.font(FontWeight.NORMAL), 40, self.LIGHT_GRAY) + + def _draw_wrapped_text(self, x, y, width, text, font, size, color): + wrapped = wrap_text(font, text, size, width) + for line in wrapped: + rl.draw_text_ex(font, line, rl.Vector2(x, y), size, 0, color) + y += size + return y + + def _get_status(self) -> tuple[str, rl.Color]: + network_type = ui_state.sm["deviceState"].networkType + network_metered = ui_state.sm["deviceState"].networkMetered + + if not network_metered and network_type != 0: # Not metered and connected + return "ACTIVE", self.GREEN + else: + return "INACTIVE: connect to an unmetered network", self.RED + + def _fetch_firehose_stats(self): + try: + dongle_id = self.params.get("DongleId", encoding='utf8') or "" + identity_token = Api(dongle_id).get_token() + response = api_get(f"v1/devices/{dongle_id}/firehose_stats", access_token=identity_token) + if response.status_code == 200: + data = response.json() + self.segment_count = data.get("firehose", 0) + self.params.put(self.PARAM_KEY, str(self.segment_count)) + except Exception as e: + cloudlog.debug(f"Failed to fetch firehose stats: {e}") + + def _update_loop(self): + while self.running: + if not ui_state.started: + self._fetch_firehose_stats() + time.sleep(self.UPDATE_INTERVAL) diff --git a/selfdrive/ui/layouts/settings/settings.py b/selfdrive/ui/layouts/settings/settings.py index 1c47d49d31..356c9e70cd 100644 --- a/selfdrive/ui/layouts/settings/settings.py +++ b/selfdrive/ui/layouts/settings/settings.py @@ -5,10 +5,10 @@ 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.firehose import FirehoseLayout 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, Widget -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 @@ -61,7 +61,7 @@ class SettingsLayout(Widget): PanelType.NETWORK: PanelInfo("Network", NetworkLayout(), 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.FIREHOSE: PanelInfo("Firehose", FirehoseLayout(), rl.Rectangle(0, 0, 0, 0)), PanelType.DEVELOPER: PanelInfo("Developer", DeveloperLayout(), rl.Rectangle(0, 0, 0, 0)), } @@ -142,15 +142,6 @@ class SettingsLayout(Widget): 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 diff --git a/selfdrive/ui/qt/offroad/firehose.cc b/selfdrive/ui/qt/offroad/firehose.cc index 80de9cfa40..aa158b4c7b 100644 --- a/selfdrive/ui/qt/offroad/firehose.cc +++ b/selfdrive/ui/qt/offroad/firehose.cc @@ -20,7 +20,7 @@ FirehosePanel::FirehosePanel(SettingsWindow *parent) : QWidget((QWidget*)parent) layout->setSpacing(20); // header - QLabel *title = new QLabel(tr("🔥 Firehose Mode 🔥")); + QLabel *title = new QLabel(tr("Firehose Mode")); title->setStyleSheet("font-size: 100px; font-weight: 500; font-family: 'Noto Color Emoji';"); layout->addWidget(title, 0, Qt::AlignCenter); diff --git a/system/ui/lib/wrap_text.py b/system/ui/lib/wrap_text.py index 98f0693fc5..35dc5ac401 100644 --- a/system/ui/lib/wrap_text.py +++ b/system/ui/lib/wrap_text.py @@ -40,49 +40,64 @@ def wrap_text(font: rl.Font, text: str, font_size: int, max_width: int) -> list[ if not text or max_width <= 0: return [] - words = text.split() - if not words: - return [] + # Split text by newlines first to preserve explicit line breaks + paragraphs = text.split('\n') + all_lines: list[str] = [] + + for paragraph in paragraphs: + # Handle empty paragraphs (preserve empty lines) + if not paragraph.strip(): + all_lines.append("") + continue - lines: list[str] = [] - current_line: list[str] = [] - current_width = 0 - space_width = int(measure_text_cached(font, " ", font_size).x) + # Process each paragraph separately + words = paragraph.split() + if not words: + all_lines.append("") + continue - for word in words: - word_width = int(measure_text_cached(font, word, font_size).x) + 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 - # 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 + # Add remaining words + if current_line: + lines.append(" ".join(current_line)) - # Break the long word into parts - lines.extend(_break_long_word(font, word, font_size, max_width)) - continue + # Add all lines from this paragraph + all_lines.extend(lines) - # 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 + return all_lines