ui: switch spinner and text window back to standalone process (#35470)

switch spinner and text window back to standalone process
pull/35483/head
Dean Lee 3 months ago committed by GitHub
parent 6767bfce44
commit 541bd4d4d9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 52
      common/spinner.py
  2. 63
      common/text_window.py
  3. 2
      system/athena/registration.py
  4. 12
      system/manager/build.py
  5. 3
      system/manager/manager.py
  6. 58
      system/ui/lib/window.py
  7. 74
      system/ui/spinner.py
  8. 26
      system/ui/text.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)

@ -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 <module>
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")

@ -7,6 +7,7 @@ from pathlib import Path
from datetime import datetime, timedelta, UTC from datetime import datetime, timedelta, UTC
from openpilot.common.api import api_get from openpilot.common.api import api_get
from openpilot.common.params import Params from openpilot.common.params import Params
from openpilot.common.spinner import Spinner
from openpilot.selfdrive.selfdrived.alertmanager import set_offroad_alert from openpilot.selfdrive.selfdrived.alertmanager import set_offroad_alert
from openpilot.system.hardware import HARDWARE, PC from openpilot.system.hardware import HARDWARE, PC
from openpilot.system.hardware.hw import Paths 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}") cloudlog.warning(f"missing public key: {pubkey}")
elif dongle_id is None: elif dongle_id is None:
if show_spinner: if show_spinner:
from openpilot.system.ui.spinner import Spinner
spinner = Spinner() spinner = Spinner()
spinner.update("registering device") spinner.update("registering device")

@ -5,10 +5,10 @@ from pathlib import Path
# NOTE: Do NOT import anything here that needs be built (e.g. params) # NOTE: Do NOT import anything here that needs be built (e.g. params)
from openpilot.common.basedir import BASEDIR 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.common.swaglog import cloudlog, add_file_handler
from openpilot.system.hardware import HARDWARE, AGNOS 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 from openpilot.system.version import get_build_metadata
MAX_CACHE_SIZE = 4e9 if "CI" in os.environ else 2e9 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__": if __name__ == "__main__":
with Spinner() as spinner: spinner = Spinner()
spinner.update_progress(0, 100) spinner.update_progress(0, 100)
build_metadata = get_build_metadata() build_metadata = get_build_metadata()
build(spinner, build_metadata.openpilot.is_dirty, minimal = AGNOS) build(spinner, build_metadata.openpilot.is_dirty, minimal = AGNOS)

@ -9,6 +9,7 @@ from cereal import log
import cereal.messaging as messaging import cereal.messaging as messaging
import openpilot.system.sentry as sentry import openpilot.system.sentry as sentry
from openpilot.common.params import Params, ParamKeyType from openpilot.common.params import Params, ParamKeyType
from openpilot.common.text_window import TextWindow
from openpilot.system.hardware import HARDWARE from openpilot.system.hardware import HARDWARE
from openpilot.system.manager.helpers import unblock_stdout, write_onroad_params, save_bootlog from openpilot.system.manager.helpers import unblock_stdout, write_onroad_params, save_bootlog
from openpilot.system.manager.process import ensure_running from openpilot.system.manager.process import ensure_running
@ -202,8 +203,6 @@ def main() -> None:
if __name__ == "__main__": if __name__ == "__main__":
from openpilot.system.ui.text import TextWindow
unblock_stdout() unblock_stdout()
try: try:

@ -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()

@ -1,11 +1,10 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import pyray as rl import pyray as rl
import threading import select
import time import sys
from openpilot.system.ui.lib.application import gui_app 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.text_measure import measure_text_cached
from openpilot.system.ui.lib.window import BaseWindow
from openpilot.system.ui.text import wrap_text from openpilot.system.ui.text import wrap_text
# Constants # Constants
@ -23,33 +22,27 @@ def clamp(value, min_value, max_value):
return max(min(value, max_value), min_value) return max(min(value, max_value), min_value)
class SpinnerRenderer: class Spinner:
def __init__(self): def __init__(self):
self._comma_texture = gui_app.texture("images/spinner_comma.png", TEXTURE_SIZE, TEXTURE_SIZE) 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._spinner_texture = gui_app.texture("images/spinner_track.png", TEXTURE_SIZE, TEXTURE_SIZE, alpha_premultiply=True)
self._rotation = 0.0 self._rotation = 0.0
self._progress: int | None = None self._progress: int | None = None
self._wrapped_lines: list[str] = [] self._wrapped_lines: list[str] = []
self._lock = threading.Lock()
def set_text(self, text: str) -> None: def set_text(self, text: str) -> None:
with self._lock: if text.isdigit():
if text.isdigit(): self._progress = clamp(int(text), 0, 100)
self._progress = clamp(int(text), 0, 100) self._wrapped_lines = []
self._wrapped_lines = [] else:
else: self._progress = None
self._progress = None self._wrapped_lines = wrap_text(text, FONT_SIZE, gui_app.width - MARGIN_H)
self._wrapped_lines = wrap_text(text, FONT_SIZE, gui_app.width - MARGIN_H)
def render(self): def render(self):
with self._lock: if self._wrapped_lines:
progress = self._progress
wrapped_lines = self._wrapped_lines
if wrapped_lines:
# Calculate total height required for spinner and text # Calculate total height required for spinner and text
spacing = 50 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 center_y = (gui_app.height - total_height) / 2.0 + TEXTURE_SIZE / 2.0
else: else:
# Center spinner vertically # Center spinner vertically
@ -71,39 +64,42 @@ class SpinnerRenderer:
rl.draw_texture_v(self._comma_texture, comma_position, rl.WHITE) rl.draw_texture_v(self._comma_texture, comma_position, rl.WHITE)
# Display the progress bar or text based on user input # 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) 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) 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) rl.draw_rectangle_rounded(bar, 1, 10, rl.WHITE)
elif wrapped_lines: elif self._wrapped_lines:
for i, line in enumerate(wrapped_lines): for i, line in enumerate(self._wrapped_lines):
text_size = measure_text_cached(gui_app.font(), line, FONT_SIZE) 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), 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) FONT_SIZE, 0.0, rl.WHITE)
class Spinner(BaseWindow[SpinnerRenderer]): def _read_stdin():
def __init__(self): """Non-blocking read of available lines from stdin."""
super().__init__("Spinner") lines = []
while True:
def _create_renderer(self): rlist, _, _ = select.select([sys.stdin], [], [], 0.0)
return SpinnerRenderer() if not rlist:
break
def update(self, spinner_text: str): line = sys.stdin.readline().strip()
if self._renderer is not None: if line == "":
self._renderer.set_text(spinner_text) break
lines.append(line)
def update_progress(self, cur: float, total: float): return lines
self.update(str(round(100 * cur / total)))
def main(): def main():
with Spinner() as s: gui_app.init_window("Spinner")
s.update("Spinner text") spinner = Spinner()
time.sleep(5) for _ in gui_app.render():
text_list = _read_stdin()
if text_list:
spinner.set_text(text_list[-1])
spinner.render()
if __name__ == "__main__": if __name__ == "__main__":
main() main()

@ -1,13 +1,12 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import re import re
import time import sys
import pyray as rl import pyray as rl
from openpilot.system.hardware import HARDWARE, PC from openpilot.system.hardware import HARDWARE, PC
from openpilot.system.ui.lib.text_measure import measure_text_cached 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.button import gui_button, ButtonStyle
from openpilot.system.ui.lib.scroll_panel import GuiScrollPanel from openpilot.system.ui.lib.scroll_panel import GuiScrollPanel
from openpilot.system.ui.lib.application import gui_app from openpilot.system.ui.lib.application import gui_app
from openpilot.system.ui.lib.window import BaseWindow
MARGIN = 50 MARGIN = 50
SPACING = 40 SPACING = 40
@ -46,7 +45,7 @@ def wrap_text(text, font_size, max_width):
return lines return lines
class TextWindowRenderer: class TextWindow:
def __init__(self, text: str): def __init__(self, text: str):
self._textarea_rect = rl.Rectangle(MARGIN, MARGIN, gui_app.width - MARGIN * 2, gui_app.height - MARGIN * 2) 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) self._wrapped_lines = wrap_text(text, FONT_SIZE, self._textarea_rect.width - 20)
@ -73,20 +72,9 @@ class TextWindowRenderer:
HARDWARE.reboot() HARDWARE.reboot()
return ret 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__": if __name__ == "__main__":
with TextWindow(DEMO_TEXT): text = sys.argv[1] if len(sys.argv) > 1 else DEMO_TEXT
time.sleep(30) gui_app.init_window("Text Viewer")
text_window = TextWindow(text)
for _ in gui_app.render():
text_window.render()

Loading…
Cancel
Save