Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs/user/docs_map.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions docs/user/en/carrot-web.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions docs/user/ko/carrot-web.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ Carrot Web은 carrotpilot 장치와 같은 네트워크에 연결된 휴대폰·

예: `http://192.168.0.25:7000`

장치 화면에서 흰색 당근 아이콘을 누르면 현재 주소의 QR 코드가 크게 표시됩니다. C3에서는 왼쪽 아래 버튼, C4에서는 아래쪽 상태 아이콘 줄에서 누릅니다. 같은 네트워크의 휴대폰으로 QR 코드를 스캔하면 바로 접속할 수 있으며, 장치 IP가 바뀌면 열린 QR 코드와 주소도 자동으로 갱신됩니다. 긴 주소는 생략되지 않고 화면 폭에 맞게 글자 크기가 조절됩니다. 주소 아래에는 마지막 QR 갱신 시각이 `시:분:초` 숫자로 표시되고, 오른쪽에는 30초 자동 닫힘 카운트다운이 표시됩니다. QR 화면은 누르면 닫히고, 조작하지 않아도 카운트다운이 끝나면 자동으로 닫힙니다.

Carrot Web은 로컬 장치 관리 화면입니다. 인터넷에 직접 노출하거나 신뢰하지 않는 사람에게 주소, 원격 지원 링크 또는 터미널 접근 권한을 제공하지 마세요.

## 화면 한눈에 보기
Expand Down
Binary file added openpilot/selfdrive/assets/icons/carrot_web.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
117 changes: 117 additions & 0 deletions openpilot/selfdrive/ui/carrot_web.py
Original file line number Diff line number Diff line change
@@ -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()
11 changes: 2 additions & 9 deletions openpilot/selfdrive/ui/layouts/main.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
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
from openpilot.selfdrive.ui.layouts.settings.settings import SettingsLayout, PanelType
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

Expand All @@ -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
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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)

Expand Down
25 changes: 11 additions & 14 deletions openpilot/selfdrive/ui/layouts/sidebar.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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):
Expand Down Expand Up @@ -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()
Expand All @@ -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:
Expand Down
18 changes: 15 additions & 3 deletions openpilot/selfdrive/ui/mici/layouts/home.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,24 +84,28 @@ 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

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)
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down
14 changes: 9 additions & 5 deletions openpilot/selfdrive/ui/mici/layouts/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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)

Expand Down Expand Up @@ -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")
Expand Down
20 changes: 20 additions & 0 deletions openpilot/selfdrive/ui/mici/tests/test_home.py
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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
Loading