diff --git a/docs/user/docs_map.json b/docs/user/docs_map.json index 47b53afb57..741c051b18 100644 --- a/docs/user/docs_map.json +++ b/docs/user/docs_map.json @@ -112,6 +112,13 @@ "id": "carrot-web-pages", "description": "Carrot Web 주행·화면녹화·도구·로그·터미널 화면", "code_paths": [ + "openpilot/selfdrive/ui/carrot_web.py", + "openpilot/selfdrive/ui/layouts/main.py", + "openpilot/selfdrive/ui/layouts/sidebar.py", + "openpilot/selfdrive/ui/widgets/carrot_web_dialog.py", + "openpilot/selfdrive/ui/widgets/qr_code.py", + "openpilot/selfdrive/ui/mici/layouts/home.py", + "openpilot/selfdrive/ui/mici/layouts/main.py", "openpilot/selfdrive/carrot/carrot_man.py", "openpilot/selfdrive/carrot/web/index.html", "openpilot/selfdrive/carrot/web/js/pages/car.js", diff --git a/docs/user/en/carrot-web.md b/docs/user/en/carrot-web.md index 10516ec2bd..b929ed52fb 100644 --- a/docs/user/en/carrot-web.md +++ b/docs/user/en/carrot-web.md @@ -15,6 +15,8 @@ Carrot Web is a local web interface for viewing and managing carrotpilot from a Example: `http://192.168.0.25:7000` +Select the white carrot icon on the device to show a large QR code for its current address. On C3, use the bottom-left button; on C4, use the bottom status-icon row. Scan it with a phone on the same network to connect. If the device IP changes while the QR screen is open, both the QR code and displayed address update automatically. Long addresses scale to fit instead of being shortened. The last QR refresh time appears below the address as numeric `HH:MM:SS`, with the 30-second auto-close countdown on the right. Tap the QR screen to close it, or leave it open and it closes when the countdown reaches zero. + Carrot Web is a local device-management interface. Do not expose it directly to the internet or give its address, a remote-support link, or terminal access to an untrusted person. ## Pages at a glance diff --git a/docs/user/ko/carrot-web.md b/docs/user/ko/carrot-web.md index bcf3e66d7c..58e5022b39 100644 --- a/docs/user/ko/carrot-web.md +++ b/docs/user/ko/carrot-web.md @@ -15,6 +15,8 @@ Carrot Web은 carrotpilot 장치와 같은 네트워크에 연결된 휴대폰· 예: `http://192.168.0.25:7000` +장치 화면에서 흰색 당근 아이콘을 누르면 현재 주소의 QR 코드가 크게 표시됩니다. C3에서는 왼쪽 아래 버튼, C4에서는 아래쪽 상태 아이콘 줄에서 누릅니다. 같은 네트워크의 휴대폰으로 QR 코드를 스캔하면 바로 접속할 수 있으며, 장치 IP가 바뀌면 열린 QR 코드와 주소도 자동으로 갱신됩니다. 긴 주소는 생략되지 않고 화면 폭에 맞게 글자 크기가 조절됩니다. 주소 아래에는 마지막 QR 갱신 시각이 `시:분:초` 숫자로 표시되고, 오른쪽에는 30초 자동 닫힘 카운트다운이 표시됩니다. QR 화면은 누르면 닫히고, 조작하지 않아도 카운트다운이 끝나면 자동으로 닫힙니다. + Carrot Web은 로컬 장치 관리 화면입니다. 인터넷에 직접 노출하거나 신뢰하지 않는 사람에게 주소, 원격 지원 링크 또는 터미널 접근 권한을 제공하지 마세요. ## 화면 한눈에 보기 diff --git a/openpilot/selfdrive/assets/icons/carrot_web.png b/openpilot/selfdrive/assets/icons/carrot_web.png new file mode 100644 index 0000000000..ed8fbfe5b1 Binary files /dev/null and b/openpilot/selfdrive/assets/icons/carrot_web.png differ diff --git a/openpilot/selfdrive/ui/carrot_web.py b/openpilot/selfdrive/ui/carrot_web.py new file mode 100644 index 0000000000..aa7eb62bed --- /dev/null +++ b/openpilot/selfdrive/ui/carrot_web.py @@ -0,0 +1,117 @@ +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime +from ipaddress import ip_address +from math import ceil, floor + +from openpilot.selfdrive.ui.widgets.qr_code import QRCodeTexture + + +CARROT_WEB_PORT = 7000 +CARROT_WEB_ADDRESS_REFRESH_SECONDS = 0.1 +CARROT_WEB_AUTO_CLOSE_SECONDS = 30.0 +CARROT_WEB_MIN_CLOSE_SECONDS = 0.35 + + +def fit_single_line_font_size(preferred_size: int, measured_width: float, max_width: float) -> int: + if preferred_size <= 1 or measured_width <= max_width: + return max(1, preferred_size) + if measured_width <= 0 or max_width <= 0: + return 1 + return max(1, floor(preferred_size * max_width / measured_width)) + + +def build_carrot_web_url(address: str | None) -> str | None: + text = str(address or "").strip() + if not text: + return None + + try: + parsed = ip_address(text) + except ValueError: + return None + + if parsed.is_unspecified or parsed.is_loopback or parsed.is_multicast: + return None + + host = f"[{parsed.compressed}]" if parsed.version == 6 else parsed.compressed + return f"http://{host}:{CARROT_WEB_PORT}" + + +@dataclass(frozen=True) +class CarrotWebAddress: + address: str = "" + url: str | None = None + + +class CarrotWebAddressWatcher: + def __init__(self, params_memory, refresh_interval: float = CARROT_WEB_ADDRESS_REFRESH_SECONDS): + self._params_memory = params_memory + self._refresh_interval = refresh_interval + self._next_refresh_time = 0.0 + self._value = CarrotWebAddress() + + @property + def value(self) -> CarrotWebAddress: + return self._value + + def refresh(self, now: float, force: bool = False) -> bool: + if not force and now < self._next_refresh_time: + return False + + self._next_refresh_time = now + self._refresh_interval + try: + address = str(self._params_memory.get("NetworkAddress") or "").strip() + except Exception: + address = "" + + url = build_carrot_web_url(address) + next_value = CarrotWebAddress(address=address if url else "", url=url) + changed = next_value != self._value + self._value = next_value + return changed + + +class CarrotWebQrSession: + def __init__(self, params_memory, qr_texture: QRCodeTexture | None = None, + timestamp_factory: Callable[[], str] | None = None): + self._address = CarrotWebAddressWatcher(params_memory) + self._qr = qr_texture or QRCodeTexture() + self._timestamp_factory = timestamp_factory or (lambda: datetime.now().strftime("%H:%M:%S")) + self._opened_at = 0.0 + self._updated_time: str | None = None + + @property + def url(self) -> str | None: + return self._address.value.url + + @property + def qr(self) -> QRCodeTexture: + return self._qr + + @property + def updated_time(self) -> str | None: + return self._updated_time + + def _refresh_qr(self) -> None: + self._qr.set_data(self.url) + self._updated_time = self._timestamp_factory() if self.url else None + + def open(self, now: float) -> None: + self._opened_at = now + self._address.refresh(now, force=True) + self._refresh_qr() + + def update(self, now: float) -> bool: + if self._address.refresh(now): + self._refresh_qr() + return now - self._opened_at >= CARROT_WEB_AUTO_CLOSE_SECONDS + + def can_close(self, now: float) -> bool: + return now - self._opened_at >= CARROT_WEB_MIN_CLOSE_SECONDS + + def seconds_until_close(self, now: float) -> int: + return max(0, ceil(CARROT_WEB_AUTO_CLOSE_SECONDS - (now - self._opened_at))) + + def destroy(self) -> None: + self._qr.destroy() diff --git a/openpilot/selfdrive/ui/layouts/main.py b/openpilot/selfdrive/ui/layouts/main.py index 618766cbd3..16b5d9eed2 100644 --- a/openpilot/selfdrive/ui/layouts/main.py +++ b/openpilot/selfdrive/ui/layouts/main.py @@ -1,7 +1,6 @@ import time import pyray as rl from enum import IntEnum -import openpilot.cereal.messaging as messaging from openpilot.system.ui.lib.application import gui_app from openpilot.selfdrive.ui.layouts.sidebar import Sidebar, SIDEBAR_WIDTH from openpilot.selfdrive.ui.layouts.home import HomeLayout @@ -9,6 +8,7 @@ from openpilot.selfdrive.ui.onroad.augmented_road_view import AugmentedRoadView from openpilot.selfdrive.ui.carrot_param_cache import TimedSnapshotCache, read_screen_record from openpilot.selfdrive.ui.ui_state import device, ui_state +from openpilot.selfdrive.ui.widgets.carrot_web_dialog import CarrotWebDialog from openpilot.system.ui.widgets import Widget from openpilot.selfdrive.ui.layouts.onboarding import OnboardingWindow @@ -23,8 +23,6 @@ class MainLayout(Widget): def __init__(self): super().__init__() - self._pm = messaging.PubMaster(['bookmarkButton']) - self._sidebar = Sidebar() self._current_mode = MainState.HOME self._prev_onroad = False @@ -108,7 +106,7 @@ def _render(self, _): def _setup_callbacks(self): self._sidebar.set_callbacks(on_settings=self._on_settings_clicked, - on_flag=self._on_bookmark_clicked, + on_carrot_web=lambda: gui_app.push_widget(CarrotWebDialog()), open_settings=lambda: self.open_settings(PanelType.TOGGLES)) self._layouts[MainState.HOME]._setup_widget.set_open_settings_callback(lambda: self.open_settings(PanelType.FIREHOSE)) self._layouts[MainState.HOME].set_settings_callback(lambda: self.open_settings(PanelType.TOGGLES)) @@ -152,11 +150,6 @@ def open_settings(self, panel_type: PanelType): def _on_settings_clicked(self): self.open_settings(PanelType.DEVICE) - def _on_bookmark_clicked(self): - user_bookmark = messaging.new_message('bookmarkButton') - user_bookmark.valid = True - self._pm.send('bookmarkButton', user_bookmark) - def _on_onroad_clicked(self): self._sidebar.set_visible(not self._sidebar.is_visible) diff --git a/openpilot/selfdrive/ui/layouts/sidebar.py b/openpilot/selfdrive/ui/layouts/sidebar.py index 37fd233f89..c568ce3083 100644 --- a/openpilot/selfdrive/ui/layouts/sidebar.py +++ b/openpilot/selfdrive/ui/layouts/sidebar.py @@ -73,8 +73,7 @@ def __init__(self): self._connect_status = MetricData(tr_noop("CONNECT"), tr_noop("OFFLINE"), Colors.WARNING) self._recording_audio = False - self._home_img = gui_app.texture("images/button_home.png", HOME_BTN.width, HOME_BTN.height) - self._flag_img = gui_app.texture("images/button_flag.png", HOME_BTN.width, HOME_BTN.height) + self._carrot_web_img = gui_app.texture("icons/carrot_web.png", HOME_BTN.width, HOME_BTN.height) self._settings_img = gui_app.texture("images/button_settings.png", SETTINGS_BTN.width, SETTINGS_BTN.height) self._mic_img = gui_app.texture("icons/microphone.png", 30, 30) self._mic_indicator_rect = rl.Rectangle(0, 0, 0, 0) @@ -83,13 +82,13 @@ def __init__(self): # Callbacks self._on_settings_click: Callable | None = None - self._on_flag_click: Callable | None = None + self._on_carrot_web_click: Callable | None = None self._open_settings_callback: Callable | None = None - def set_callbacks(self, on_settings: Callable | None = None, on_flag: Callable | None = None, + def set_callbacks(self, on_settings: Callable | None = None, on_carrot_web: Callable | None = None, open_settings: Callable | None = None): self._on_settings_click = on_settings - self._on_flag_click = on_flag + self._on_carrot_web_click = on_carrot_web self._open_settings_callback = open_settings def _render(self, rect: rl.Rectangle): @@ -147,9 +146,9 @@ def _handle_mouse_release(self, mouse_pos: MousePos): if rl.check_collision_point_rec(mouse_pos, SETTINGS_BTN): if self._on_settings_click: self._on_settings_click() - elif rl.check_collision_point_rec(mouse_pos, HOME_BTN) and ui_state.started: - if self._on_flag_click: - self._on_flag_click() + elif rl.check_collision_point_rec(mouse_pos, HOME_BTN): + if self._on_carrot_web_click: + self._on_carrot_web_click() elif self._recording_audio and rl.check_collision_point_rec(mouse_pos, self._mic_indicator_rect): if self._open_settings_callback: self._open_settings_callback() @@ -163,12 +162,10 @@ def _draw_buttons(self, rect: rl.Rectangle): tint = Colors.BUTTON_PRESSED if settings_down else Colors.BUTTON_NORMAL rl.draw_texture_ex(self._settings_img, rl.Vector2(SETTINGS_BTN.x, SETTINGS_BTN.y), 0.0, 1.0, tint) - # Home/Flag button - flag_pressed = mouse_down and rl.check_collision_point_rec(mouse_pos, HOME_BTN) - button_img = self._flag_img if ui_state.started else self._home_img - - tint = Colors.BUTTON_PRESSED if (ui_state.started and flag_pressed) else Colors.BUTTON_NORMAL - rl.draw_texture_ex(button_img, rl.Vector2(HOME_BTN.x, HOME_BTN.y), 0.0, 1.0, tint) + # Carrot Web button + carrot_web_pressed = mouse_down and rl.check_collision_point_rec(mouse_pos, HOME_BTN) + tint = Colors.BUTTON_PRESSED if carrot_web_pressed else Colors.BUTTON_NORMAL + rl.draw_texture_ex(self._carrot_web_img, rl.Vector2(HOME_BTN.x, HOME_BTN.y), 0.0, 1.0, tint) # Microphone button if self._recording_audio: diff --git a/openpilot/selfdrive/ui/mici/layouts/home.py b/openpilot/selfdrive/ui/mici/layouts/home.py index 11ccfaf288..fda9786984 100644 --- a/openpilot/selfdrive/ui/mici/layouts/home.py +++ b/openpilot/selfdrive/ui/mici/layouts/home.py @@ -84,11 +84,13 @@ class MiciHomeLayout(Widget): def __init__(self): super().__init__() self._on_settings_click: Callable | None = None + self._on_carrot_web_click: Callable | None = None self._last_refresh = 0 self._mouse_down_t: None | float = None self._did_long_press = False self._is_pressed_prev = False + self._carrot_web_pressed = False self._version_text = None self._experimental_mode = False @@ -96,12 +98,14 @@ def __init__(self): self._ip_address = "Offline" self._settings_icon = IconWidget("icons_mici/settings.png", (48, 48), opacity=0.9) + self._carrot_web_icon = IconWidget("icons/carrot_web.png", (48, 48), opacity=0.9) self._experimental_icon = IconWidget("icons_mici/experimental_mode.png", (48, 48)) self._mic_icon = IconWidget("icons_mici/microphone.png", (32, 46)) self._status_bar_layout = HBoxLayout([ IconWidget("icons_mici/settings.png", (48, 48), opacity=0.9), NetworkIcon(), + self._carrot_web_icon, self._experimental_icon, self._mic_icon, ], spacing=18) @@ -135,7 +139,7 @@ def _update_state(self): self._is_pressed_prev = self.is_pressed if self._mouse_down_t is not None: - if time.monotonic() - self._mouse_down_t > 0.5: + if not self._carrot_web_pressed and time.monotonic() - self._mouse_down_t > 0.5: # long gating for experimental mode - only allow toggle if longitudinal control is available if ui_state.has_longitudinal_control: self._experimental_mode = not self._experimental_mode @@ -150,13 +154,21 @@ def _update_state(self): self._last_refresh = rl.get_time() self._update_params() - def set_callbacks(self, on_settings: Callable | None = None): + def set_callbacks(self, on_settings: Callable | None = None, on_carrot_web: Callable | None = None): self._on_settings_click = on_settings + self._on_carrot_web_click = on_carrot_web + + def _handle_mouse_press(self, mouse_pos: MousePos): + self._carrot_web_pressed = rl.check_collision_point_rec(mouse_pos, self._carrot_web_icon.rect) def _handle_mouse_release(self, mouse_pos: MousePos): - if not self._did_long_press: + if self._carrot_web_pressed and rl.check_collision_point_rec(mouse_pos, self._carrot_web_icon.rect): + if self._on_carrot_web_click: + self._on_carrot_web_click() + elif not self._did_long_press: if self._on_settings_click: self._on_settings_click() + self._carrot_web_pressed = False self._did_long_press = False def _get_version_text(self) -> tuple[str, str, str, str] | None: diff --git a/openpilot/selfdrive/ui/mici/layouts/main.py b/openpilot/selfdrive/ui/mici/layouts/main.py index 4cdfcd0589..1456fe0264 100644 --- a/openpilot/selfdrive/ui/mici/layouts/main.py +++ b/openpilot/selfdrive/ui/mici/layouts/main.py @@ -5,6 +5,7 @@ from openpilot.selfdrive.ui.mici.layouts.offroad_alerts import MiciOffroadAlerts from openpilot.selfdrive.ui.mici.onroad.augmented_road_view import AugmentedRoadView from openpilot.selfdrive.ui.ui_state import device, ui_state +from openpilot.selfdrive.ui.widgets.carrot_web_dialog import CarrotWebDialog from openpilot.selfdrive.ui.mici.layouts.onboarding import OnboardingWindow from openpilot.system.ui.widgets import Widget from openpilot.system.ui.widgets.scroller import Scroller @@ -61,7 +62,7 @@ def __init__(self): if not self._onboarding_window.completed: gui_app.push_widget(self._onboarding_window) - # carrot_man + # carrot_man self._last_carrot_cmd_idx = -1 @staticmethod @@ -93,7 +94,7 @@ def _handle_carrot_record_cmd(self, sm) -> bool: return recording print(f"CarrotMan command received: {cmd} {arg} (index {cmd_idx})") self._last_carrot_cmd_idx = cmd_idx - + if not ui_state.started: gui_app.stop_recording() return self._sync_screen_record_state(screen_record) @@ -109,11 +110,14 @@ def _handle_carrot_record_cmd(self, sm) -> bool: gui_app.stop_recording() elif arg == "TOGGLE": gui_app.toggle_recording() - + return self._sync_screen_record_state(screen_record) def _setup_callbacks(self): - self._home_layout.set_callbacks(on_settings=lambda: gui_app.push_widget(self._settings_layout)) + self._home_layout.set_callbacks( + on_settings=lambda: gui_app.push_widget(self._settings_layout), + on_carrot_web=lambda: gui_app.push_widget(CarrotWebDialog()), + ) self._onroad_layout.set_click_callback(lambda: self._scroll_to(self._home_layout)) device.add_interactive_timeout_callback(self._on_interactive_timeout) @@ -157,7 +161,7 @@ def _handle_transitions(self): if self._onroad_time_delay is not None and rl.get_time() - self._onroad_time_delay >= ONROAD_DELAY: gui_app.pop_widgets_to(self, lambda: self._scroll_to(self._onroad_layout)) self._onroad_time_delay = None - + if ui_state.started: show_plot_mode = ui_state.params.get_int("ShowPlotMode") cluster_hud_connected = ui_state.params.get_bool("ClusterHudConnected") diff --git a/openpilot/selfdrive/ui/mici/tests/test_home.py b/openpilot/selfdrive/ui/mici/tests/test_home.py index 6a1ebbf0ca..e29eb1f424 100644 --- a/openpilot/selfdrive/ui/mici/tests/test_home.py +++ b/openpilot/selfdrive/ui/mici/tests/test_home.py @@ -1,6 +1,10 @@ +from types import SimpleNamespace + import pytest +import pyray as rl from openpilot.selfdrive.ui.mici.layouts.home import MiciHomeLayout +from openpilot.system.ui.lib.application import MousePos class FakeParamsMemory: @@ -27,3 +31,19 @@ def test_read_network_address(network_address, expected): assert MiciHomeLayout._read_network_address(params_memory) == expected assert params_memory.requested_keys == ["NetworkAddress"] + + +def test_carrot_web_icon_routes_tap_without_opening_settings(): + calls = [] + layout = object.__new__(MiciHomeLayout) + layout._carrot_web_icon = SimpleNamespace(rect=rl.Rectangle(100, 100, 48, 48)) + layout._on_carrot_web_click = lambda: calls.append("carrot_web") + layout._on_settings_click = lambda: calls.append("settings") + layout._did_long_press = False + layout._carrot_web_pressed = False + + layout._handle_mouse_press(MousePos(120, 120)) + layout._handle_mouse_release(MousePos(120, 120)) + + assert calls == ["carrot_web"] + assert not layout._carrot_web_pressed diff --git a/openpilot/selfdrive/ui/tests/test_carrot_web.py b/openpilot/selfdrive/ui/tests/test_carrot_web.py new file mode 100644 index 0000000000..98021c09bc --- /dev/null +++ b/openpilot/selfdrive/ui/tests/test_carrot_web.py @@ -0,0 +1,122 @@ +import pytest + +from openpilot.selfdrive.ui.carrot_web import ( + CARROT_WEB_AUTO_CLOSE_SECONDS, + CARROT_WEB_MIN_CLOSE_SECONDS, + CarrotWebAddressWatcher, + CarrotWebQrSession, + build_carrot_web_url, + fit_single_line_font_size, +) + + +class FakeParamsMemory: + def __init__(self, value=None): + self.value = value + self.requested_keys = [] + + def get(self, key): + self.requested_keys.append(key) + return self.value + + +class FakeQRCodeTexture: + def __init__(self): + self.values = [] + self.destroyed = False + + def set_data(self, value): + self.values.append(value) + + def destroy(self): + self.destroyed = True + + +@pytest.mark.parametrize( + "address,expected", + [ + (None, None), + ("", None), + ("0.0.0.0", None), + ("127.0.0.1", None), + ("not-an-ip", None), + (" 192.168.43.10\n", "http://192.168.43.10:7000"), + ("2001:db8::1", "http://[2001:db8::1]:7000"), + ], +) +def test_build_carrot_web_url(address, expected): + assert build_carrot_web_url(address) == expected + + +@pytest.mark.parametrize( + "preferred_size,measured_width,max_width,expected", + [ + (26, 250, 274, 26), + (26, 300, 274, 23), + (65, 750, 685, 59), + (26, 300, 0, 1), + ], +) +def test_fit_single_line_font_size(preferred_size, measured_width, max_width, expected): + assert fit_single_line_font_size(preferred_size, measured_width, max_width) == expected + + +def test_address_watcher_polls_at_interval_and_reports_changes(): + params = FakeParamsMemory("192.168.0.2") + watcher = CarrotWebAddressWatcher(params, refresh_interval=0.1) + + assert watcher.refresh(1.0) + assert watcher.value.url == "http://192.168.0.2:7000" + + params.value = "192.168.0.3" + assert not watcher.refresh(1.05) + assert watcher.value.url == "http://192.168.0.2:7000" + + assert watcher.refresh(1.1) + assert watcher.value.url == "http://192.168.0.3:7000" + assert params.requested_keys == ["NetworkAddress", "NetworkAddress"] + + +def test_qr_session_tracks_address_changes_and_clears_stale_qr(): + params = FakeParamsMemory("192.168.43.1") + qr = FakeQRCodeTexture() + timestamps = iter(["16:43:27", "16:44:02"]) + session = CarrotWebQrSession(params, qr_texture=qr, timestamp_factory=lambda: next(timestamps)) + + session.open(10.0) + assert session.url == "http://192.168.43.1:7000" + assert qr.values == ["http://192.168.43.1:7000"] + assert session.updated_time == "16:43:27" + + params.value = "10.0.0.25" + assert not session.update(10.05) + assert qr.values == ["http://192.168.43.1:7000"] + + assert not session.update(10.1) + assert session.url == "http://10.0.0.25:7000" + assert qr.values[-1] == "http://10.0.0.25:7000" + assert session.updated_time == "16:44:02" + + params.value = "0.0.0.0" + assert not session.update(10.2) + assert session.url is None + assert qr.values[-1] is None + assert session.updated_time is None + + +def test_qr_session_close_timing_and_cleanup(): + qr = FakeQRCodeTexture() + session = CarrotWebQrSession(FakeParamsMemory("192.168.0.2"), qr_texture=qr) + session.open(5.0) + + assert session.seconds_until_close(5.0) == 30 + assert session.seconds_until_close(5.1) == 30 + assert session.seconds_until_close(6.0) == 29 + assert session.seconds_until_close(35.0) == 0 + assert not session.can_close(5.0 + CARROT_WEB_MIN_CLOSE_SECONDS - 0.01) + assert session.can_close(5.0 + CARROT_WEB_MIN_CLOSE_SECONDS + 0.001) + assert not session.update(5.0 + CARROT_WEB_AUTO_CLOSE_SECONDS - 0.01) + assert session.update(5.0 + CARROT_WEB_AUTO_CLOSE_SECONDS) + + session.destroy() + assert qr.destroyed diff --git a/openpilot/selfdrive/ui/widgets/carrot_web_dialog.py b/openpilot/selfdrive/ui/widgets/carrot_web_dialog.py new file mode 100644 index 0000000000..6c9aaf8618 --- /dev/null +++ b/openpilot/selfdrive/ui/widgets/carrot_web_dialog.py @@ -0,0 +1,97 @@ +import pyray as rl + +from openpilot.selfdrive.ui.carrot_web import CarrotWebQrSession, fit_single_line_font_size +from openpilot.selfdrive.ui.ui_state import ui_state +from openpilot.system.ui.lib.application import FontWeight, MousePos, gui_app +from openpilot.system.ui.lib.multilang import tr +from openpilot.system.ui.lib.text_measure import measure_text_cached +from openpilot.system.ui.widgets import Widget +from openpilot.system.ui.widgets.label import gui_label + + +class CarrotWebDialog(Widget): + def __init__(self): + super().__init__() + self._session = CarrotWebQrSession(ui_state.params_memory) + + def show_event(self): + super().show_event() + self._session.open(rl.get_time()) + + def _update_state(self): + if self._session.update(rl.get_time()): + gui_app.pop_widget() + + def _handle_mouse_release(self, mouse_pos: MousePos): + if self._session.can_close(rl.get_time()): + gui_app.pop_widget() + + def _render(self, rect: rl.Rectangle): + rl.clear_background(rl.BLACK) + + scale = min(rect.width / 536, rect.height / 240) + content_x = rect.x + (rect.width - 536 * scale) / 2 + content_y = rect.y + (rect.height - 240 * scale) / 2 + + qr_rect = rl.Rectangle( + content_x + 8 * scale, + content_y + 8 * scale, + 224 * scale, + 224 * scale, + ) + + if not self._session.qr.render(qr_rect): + rl.draw_rectangle_rounded(qr_rect, 0.04, 8, rl.Color(38, 38, 38, 255)) + gui_label(qr_rect, tr("Offline"), int(32 * scale), rl.Color(175, 175, 175, 255)) + + text_x = content_x + 254 * scale + text_width = 274 * scale + gui_label( + rl.Rectangle(text_x, content_y + 42 * scale, text_width, 52 * scale), + "Carrot Web", + int(38 * scale), + rl.WHITE, + alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT, + font_weight=FontWeight.BOLD, + ) + + address_text = (self._session.url or tr("Offline")).removeprefix("http://") + preferred_address_size = int(26 * scale) + address_font = gui_app.font() + address_size = fit_single_line_font_size( + preferred_address_size, + measure_text_cached(address_font, address_text, preferred_address_size).x, + text_width, + ) + while address_size > 1 and measure_text_cached(address_font, address_text, address_size).x > text_width: + address_size -= 1 + + gui_label( + rl.Rectangle(text_x, content_y + 104 * scale, text_width, 36 * scale), + address_text, + address_size, + rl.Color(205, 205, 205, 255), + alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT, + alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP, + elide_right=False, + ) + + if self._session.updated_time is not None: + footer_rect = rl.Rectangle(text_x, content_y + 190 * scale, text_width, 28 * scale) + gui_label( + footer_rect, + self._session.updated_time, + int(20 * scale), + rl.Color(135, 135, 135, 255), + alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT, + ) + gui_label( + footer_rect, + f"{self._session.seconds_until_close(rl.get_time())}s", + int(20 * scale), + rl.Color(135, 135, 135, 255), + alignment=rl.GuiTextAlignment.TEXT_ALIGN_RIGHT, + ) + + def __del__(self): + self._session.destroy() diff --git a/openpilot/selfdrive/ui/widgets/qr_code.py b/openpilot/selfdrive/ui/widgets/qr_code.py new file mode 100644 index 0000000000..f974d1c405 --- /dev/null +++ b/openpilot/selfdrive/ui/widgets/qr_code.py @@ -0,0 +1,67 @@ +import numpy as np +import pyray as rl +import qrcode + +from openpilot.common.swaglog import cloudlog + + +class QRCodeTexture: + def __init__(self): + self._data: str | None = None + self._texture: rl.Texture | None = None + + @property + def available(self) -> bool: + return self._texture is not None and self._texture.id != 0 + + def set_data(self, data: str | None) -> bool: + normalized = str(data or "") + if normalized == self._data: + return False + + self.destroy() + self._data = normalized + if not normalized: + return True + + try: + qr = qrcode.QRCode( + error_correction=qrcode.constants.ERROR_CORRECT_M, + box_size=10, + border=4, + ) + qr.add_data(normalized) + qr.make(fit=True) + pil_image = qr.make_image(fill_color="black", back_color="white").convert("RGBA") + pixels = np.asarray(pil_image, dtype=np.uint8) + + image = rl.Image() + image.data = rl.ffi.cast("void *", pixels.ctypes.data) + image.width = pil_image.width + image.height = pil_image.height + image.mipmaps = 1 + image.format = rl.PixelFormat.PIXELFORMAT_UNCOMPRESSED_R8G8B8A8 + + self._texture = rl.load_texture_from_image(image) + rl.set_texture_filter(self._texture, rl.TextureFilter.TEXTURE_FILTER_POINT) + except Exception: + cloudlog.exception("Carrot Web QR code generation failed") + self._texture = None + return True + + def render(self, rect: rl.Rectangle) -> bool: + if not self.available: + return False + + source = rl.Rectangle(0, 0, self._texture.width, self._texture.height) + rl.draw_texture_pro(self._texture, source, rect, rl.Vector2(0, 0), 0.0, rl.WHITE) + return True + + def destroy(self) -> None: + if self.available and rl.is_window_ready(): + rl.unload_texture(self._texture) + self._texture = None + self._data = None + + def __del__(self): + self.destroy()