From 541bd4d4d9e9b06214c4e15320b9bd109aec63d8 Mon Sep 17 00:00:00 2001 From: Dean Lee Date: Sat, 7 Jun 2025 04:20:05 +0800 Subject: [PATCH] ui: switch spinner and text window back to standalone process (#35470) switch spinner and text window back to standalone process --- common/spinner.py | 52 ++++++++++++++++++++++++ common/text_window.py | 63 +++++++++++++++++++++++++++++ system/athena/registration.py | 2 +- system/manager/build.py | 12 +++--- system/manager/manager.py | 3 +- system/ui/lib/window.py | 58 --------------------------- system/ui/spinner.py | 74 +++++++++++++++++------------------ system/ui/text.py | 26 ++++-------- 8 files changed, 165 insertions(+), 125 deletions(-) create mode 100755 common/spinner.py create mode 100755 common/text_window.py delete mode 100644 system/ui/lib/window.py diff --git a/common/spinner.py b/common/spinner.py new file mode 100755 index 0000000000..12a816eaf8 --- /dev/null +++ b/common/spinner.py @@ -0,0 +1,52 @@ +import os +import subprocess +from openpilot.common.basedir import BASEDIR + + +class Spinner: + def __init__(self): + try: + self.spinner_proc = subprocess.Popen(["./spinner.py"], + stdin=subprocess.PIPE, + cwd=os.path.join(BASEDIR, "system", "ui"), + close_fds=True) + except OSError: + self.spinner_proc = None + + def __enter__(self): + return self + + def update(self, spinner_text: str): + if self.spinner_proc is not None: + self.spinner_proc.stdin.write(spinner_text.encode('utf8') + b"\n") + try: + self.spinner_proc.stdin.flush() + except BrokenPipeError: + pass + + def update_progress(self, cur: float, total: float): + self.update(str(round(100 * cur / total))) + + def close(self): + if self.spinner_proc is not None: + self.spinner_proc.kill() + try: + self.spinner_proc.communicate(timeout=2.) + except subprocess.TimeoutExpired: + print("WARNING: failed to kill spinner") + self.spinner_proc = None + + def __del__(self): + self.close() + + def __exit__(self, exc_type, exc_value, traceback): + self.close() + + +if __name__ == "__main__": + import time + with Spinner() as s: + s.update("Spinner text") + time.sleep(5.0) + print("gone") + time.sleep(5.0) diff --git a/common/text_window.py b/common/text_window.py new file mode 100755 index 0000000000..358243d1f1 --- /dev/null +++ b/common/text_window.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +import os +import time +import subprocess +from openpilot.common.basedir import BASEDIR + + +class TextWindow: + def __init__(self, text): + try: + self.text_proc = subprocess.Popen(["./text.py", text], + stdin=subprocess.PIPE, + cwd=os.path.join(BASEDIR, "system", "ui"), + close_fds=True) + except OSError: + self.text_proc = None + + def get_status(self): + if self.text_proc is not None: + self.text_proc.poll() + return self.text_proc.returncode + return None + + def __enter__(self): + return self + + def close(self): + if self.text_proc is not None: + self.text_proc.terminate() + self.text_proc = None + + def wait_for_exit(self): + if self.text_proc is not None: + while True: + if self.get_status() == 1: + return + time.sleep(0.1) + + def __del__(self): + self.close() + + def __exit__(self, exc_type, exc_value, traceback): + self.close() + + +if __name__ == "__main__": + text = """Traceback (most recent call last): + File "./controlsd.py", line 608, in + main() + File "./controlsd.py", line 604, in main + controlsd_thread(sm, pm, logcan) + File "./controlsd.py", line 455, in controlsd_thread + 1/0 +ZeroDivisionError: division by zero""" + print(text) + + with TextWindow(text) as s: + for _ in range(100): + if s.get_status() == 1: + print("Got exit button") + break + time.sleep(0.1) + print("gone") diff --git a/system/athena/registration.py b/system/athena/registration.py index ce7fcea89f..964fbff51e 100755 --- a/system/athena/registration.py +++ b/system/athena/registration.py @@ -7,6 +7,7 @@ from pathlib import Path from datetime import datetime, timedelta, UTC from openpilot.common.api import api_get from openpilot.common.params import Params +from openpilot.common.spinner import Spinner from openpilot.selfdrive.selfdrived.alertmanager import set_offroad_alert from openpilot.system.hardware import HARDWARE, PC from openpilot.system.hardware.hw import Paths @@ -44,7 +45,6 @@ def register(show_spinner=False) -> str | None: cloudlog.warning(f"missing public key: {pubkey}") elif dongle_id is None: if show_spinner: - from openpilot.system.ui.spinner import Spinner spinner = Spinner() spinner.update("registering device") diff --git a/system/manager/build.py b/system/manager/build.py index 771024794f..dfa50535e7 100755 --- a/system/manager/build.py +++ b/system/manager/build.py @@ -5,10 +5,10 @@ from pathlib import Path # NOTE: Do NOT import anything here that needs be built (e.g. params) from openpilot.common.basedir import BASEDIR +from openpilot.common.spinner import Spinner +from openpilot.common.text_window import TextWindow from openpilot.common.swaglog import cloudlog, add_file_handler from openpilot.system.hardware import HARDWARE, AGNOS -from openpilot.system.ui.spinner import Spinner -from openpilot.system.ui.text import TextWindow from openpilot.system.version import get_build_metadata MAX_CACHE_SIZE = 4e9 if "CI" in os.environ else 2e9 @@ -88,7 +88,7 @@ def build(spinner: Spinner, dirty: bool = False, minimal: bool = False) -> None: if __name__ == "__main__": - with Spinner() as spinner: - spinner.update_progress(0, 100) - build_metadata = get_build_metadata() - build(spinner, build_metadata.openpilot.is_dirty, minimal = AGNOS) + spinner = Spinner() + spinner.update_progress(0, 100) + build_metadata = get_build_metadata() + build(spinner, build_metadata.openpilot.is_dirty, minimal = AGNOS) diff --git a/system/manager/manager.py b/system/manager/manager.py index c3ffe28457..89e5a472f2 100755 --- a/system/manager/manager.py +++ b/system/manager/manager.py @@ -9,6 +9,7 @@ from cereal import log import cereal.messaging as messaging import openpilot.system.sentry as sentry from openpilot.common.params import Params, ParamKeyType +from openpilot.common.text_window import TextWindow from openpilot.system.hardware import HARDWARE from openpilot.system.manager.helpers import unblock_stdout, write_onroad_params, save_bootlog from openpilot.system.manager.process import ensure_running @@ -202,8 +203,6 @@ def main() -> None: if __name__ == "__main__": - from openpilot.system.ui.text import TextWindow - unblock_stdout() try: diff --git a/system/ui/lib/window.py b/system/ui/lib/window.py deleted file mode 100644 index 989a3b0284..0000000000 --- a/system/ui/lib/window.py +++ /dev/null @@ -1,58 +0,0 @@ -import threading -import time -import os -from typing import Generic, Protocol, TypeVar -from openpilot.common.swaglog import cloudlog -from openpilot.system.ui.lib.application import gui_app - - -class RendererProtocol(Protocol): - def render(self): ... - - -R = TypeVar("R", bound=RendererProtocol) - - -class BaseWindow(Generic[R]): - def __init__(self, title: str): - self._title = title - self._renderer: R | None = None - self._stop_event = threading.Event() - self._thread = threading.Thread(target=self._run) - self._thread.start() - - # wait for the renderer to be initialized - while self._renderer is None and self._thread.is_alive(): - time.sleep(0.01) - - def _create_renderer(self) -> R: - raise NotImplementedError() - - def _run(self): - if os.getenv("CI") is not None: - return - gui_app.init_window(self._title) - self._renderer = self._create_renderer() - try: - for _ in gui_app.render(): - if self._stop_event.is_set(): - break - self._renderer.render() - finally: - gui_app.close() - - def __enter__(self): - return self - - def close(self): - if self._thread.is_alive(): - self._stop_event.set() - self._thread.join(timeout=2.0) - if self._thread.is_alive(): - cloudlog.warning(f"Failed to join {self._title} thread") - - def __del__(self): - self.close() - - def __exit__(self, exc_type, exc_val, exc_tb): - self.close() diff --git a/system/ui/spinner.py b/system/ui/spinner.py index 3825f1300d..0dc201c62f 100755 --- a/system/ui/spinner.py +++ b/system/ui/spinner.py @@ -1,11 +1,10 @@ #!/usr/bin/env python3 import pyray as rl -import threading -import time +import select +import sys 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 # Constants @@ -23,33 +22,27 @@ def clamp(value, min_value, max_value): return max(min(value, max_value), min_value) -class SpinnerRenderer: +class Spinner: def __init__(self): self._comma_texture = gui_app.texture("images/spinner_comma.png", TEXTURE_SIZE, TEXTURE_SIZE) self._spinner_texture = gui_app.texture("images/spinner_track.png", TEXTURE_SIZE, TEXTURE_SIZE, alpha_premultiply=True) self._rotation = 0.0 self._progress: int | None = None self._wrapped_lines: list[str] = [] - self._lock = threading.Lock() def set_text(self, text: str) -> None: - with self._lock: - if text.isdigit(): - self._progress = clamp(int(text), 0, 100) - self._wrapped_lines = [] - else: - self._progress = None - self._wrapped_lines = wrap_text(text, FONT_SIZE, gui_app.width - MARGIN_H) + if text.isdigit(): + self._progress = clamp(int(text), 0, 100) + self._wrapped_lines = [] + else: + self._progress = None + self._wrapped_lines = wrap_text(text, FONT_SIZE, gui_app.width - MARGIN_H) def render(self): - with self._lock: - progress = self._progress - wrapped_lines = self._wrapped_lines - - if wrapped_lines: + if self._wrapped_lines: # Calculate total height required for spinner and text spacing = 50 - total_height = TEXTURE_SIZE + spacing + len(wrapped_lines) * LINE_HEIGHT + total_height = TEXTURE_SIZE + spacing + len(self._wrapped_lines) * LINE_HEIGHT center_y = (gui_app.height - total_height) / 2.0 + TEXTURE_SIZE / 2.0 else: # Center spinner vertically @@ -71,39 +64,42 @@ class SpinnerRenderer: rl.draw_texture_v(self._comma_texture, comma_position, rl.WHITE) # Display the progress bar or text based on user input - if progress is not None: + if self._progress is not None: bar = rl.Rectangle(center.x - PROGRESS_BAR_WIDTH / 2.0, y_pos, PROGRESS_BAR_WIDTH, PROGRESS_BAR_HEIGHT) rl.draw_rectangle_rounded(bar, 1, 10, DARKGRAY) - bar.width *= progress / 100.0 + bar.width *= self._progress / 100.0 rl.draw_rectangle_rounded(bar, 1, 10, rl.WHITE) - elif wrapped_lines: - for i, line in enumerate(wrapped_lines): + elif self._wrapped_lines: + for i, line in enumerate(self._wrapped_lines): 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) -class Spinner(BaseWindow[SpinnerRenderer]): - def __init__(self): - super().__init__("Spinner") - - def _create_renderer(self): - return SpinnerRenderer() - - def update(self, spinner_text: str): - if self._renderer is not None: - self._renderer.set_text(spinner_text) - - def update_progress(self, cur: float, total: float): - self.update(str(round(100 * cur / total))) +def _read_stdin(): + """Non-blocking read of available lines from stdin.""" + lines = [] + while True: + rlist, _, _ = select.select([sys.stdin], [], [], 0.0) + if not rlist: + break + line = sys.stdin.readline().strip() + if line == "": + break + lines.append(line) + return lines def main(): - with Spinner() as s: - s.update("Spinner text") - time.sleep(5) - + gui_app.init_window("Spinner") + spinner = Spinner() + for _ in gui_app.render(): + text_list = _read_stdin() + if text_list: + spinner.set_text(text_list[-1]) + + spinner.render() if __name__ == "__main__": main() diff --git a/system/ui/text.py b/system/ui/text.py index 63d00fbde3..0c1f7c8c4d 100755 --- a/system/ui/text.py +++ b/system/ui/text.py @@ -1,13 +1,12 @@ #!/usr/bin/env python3 import re -import time +import sys 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 -from openpilot.system.ui.lib.window import BaseWindow MARGIN = 50 SPACING = 40 @@ -46,7 +45,7 @@ def wrap_text(text, font_size, max_width): return lines -class TextWindowRenderer: +class TextWindow: def __init__(self, text: str): self._textarea_rect = rl.Rectangle(MARGIN, MARGIN, gui_app.width - MARGIN * 2, gui_app.height - MARGIN * 2) self._wrapped_lines = wrap_text(text, FONT_SIZE, self._textarea_rect.width - 20) @@ -73,20 +72,9 @@ class TextWindowRenderer: HARDWARE.reboot() return ret - -class TextWindow(BaseWindow[TextWindowRenderer]): - def __init__(self, text: str): - self._text = text - super().__init__("Text") - - def _create_renderer(self): - return TextWindowRenderer(self._text) - - def wait_for_exit(self): - while self._thread.is_alive(): - time.sleep(0.01) - - if __name__ == "__main__": - with TextWindow(DEMO_TEXT): - time.sleep(30) + text = sys.argv[1] if len(sys.argv) > 1 else DEMO_TEXT + gui_app.init_window("Text Viewer") + text_window = TextWindow(text) + for _ in gui_app.render(): + text_window.render()