Files
carrotpilot/selfdrive/ui/mici/layouts/settings/firehose.py
2026-03-21 16:12:45 +09:00

223 lines
8.3 KiB
Python

import requests
import threading
import time
import pyray as rl
from openpilot.common.api import api_get
from openpilot.common.params import Params
from openpilot.common.swaglog import cloudlog
from openpilot.selfdrive.ui.lib.api_helpers import get_token
from openpilot.selfdrive.ui.ui_state import ui_state, device
from openpilot.system.athena.registration import UNREGISTERED_DONGLE_ID
from openpilot.system.ui.lib.application import gui_app, FontWeight, FONT_SCALE
from openpilot.system.ui.lib.wrap_text import wrap_text
from openpilot.system.ui.lib.scroll_panel2 import GuiScrollPanel2
from openpilot.system.ui.lib.multilang import tr, trn, tr_noop
from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.widgets.scroller import NavRawScrollPanel
TITLE = tr_noop("Firehose Mode")
DESCRIPTION = tr_noop(
"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_INTRO = tr_noop(
"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."
)
FAQ_HEADER = tr_noop("Frequently Asked Questions")
FAQ_ITEMS = [
(tr_noop("Does it matter how or where I drive?"), tr_noop("Nope, just drive as you normally would.")),
(tr_noop("Do all of my segments get pulled in Firehose Mode?"), tr_noop("No, we selectively pull a subset of your segments.")),
(tr_noop("What's a good USB-C adapter?"), tr_noop("Any fast phone or laptop charger should be fine.")),
(tr_noop("Does it matter which software I run?"), tr_noop("Yes, only upstream openpilot (and particular forks) are able to be used for training.")),
]
class FirehoseLayoutBase(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._session = requests.Session() # reuse session to reduce SSL handshake overhead
self._segment_count = self._get_segment_count()
self._scroll_panel = GuiScrollPanel2(horizontal=False)
self._content_height = 0
self._running = True
self._update_thread = threading.Thread(target=self._update_loop, daemon=True)
self._update_thread.start()
def __del__(self):
self._running = False
try:
if self._update_thread and self._update_thread.is_alive():
self._update_thread.join(timeout=1.0)
except Exception:
pass
def show_event(self):
super().show_event()
self._scroll_panel.set_offset(0)
def _get_segment_count(self) -> int:
stats = self._params.get(self.PARAM_KEY)
if not stats:
return 0
try:
return int(stats.get("firehose", 0))
except Exception:
cloudlog.exception(f"Failed to decode firehose stats: {stats}")
return 0
def _render(self, rect: rl.Rectangle):
# compute total content height for scrolling
content_height = self._measure_content_height(rect)
scroll_offset = self._scroll_panel.update(rect, content_height)
# start drawing with offset
x = rect.x + 40
y = rect.y + 40 + scroll_offset
w = rect.width - 80
# Title
title_text = tr(TITLE)
title_font = gui_app.font(FontWeight.BOLD)
title_size = 64
rl.draw_text_ex(title_font, title_text, rl.Vector2(x, y), title_size, 0, rl.WHITE)
y += int(title_size * FONT_SCALE) + 20
# Description
y = self._draw_wrapped_text(x, y, w, tr(DESCRIPTION), gui_app.font(FontWeight.ROMAN), 36, rl.WHITE)
y += 20
# Separator
rl.draw_rectangle_rec(rl.Rectangle(x, y, w, 2), self.GRAY)
y += 20
# Status
status_text, status_color = self._get_status()
y = self._draw_wrapped_text(x, y, w, status_text, gui_app.font(FontWeight.BOLD), 48, status_color)
y += 20
# Contribution count (if available)
if self._segment_count > 0:
contrib_text = trn("{} segment of your driving is in the training dataset so far.",
"{} segments of your driving is in the training dataset so far.", self._segment_count).format(self._segment_count)
y = self._draw_wrapped_text(x, y, w, contrib_text, gui_app.font(FontWeight.BOLD), 42, rl.WHITE)
y += 20
# Separator
rl.draw_rectangle_rec(rl.Rectangle(x, y, w, 2), self.GRAY)
y += 20
# Instructions intro
y = self._draw_wrapped_text(x, y, w, tr(INSTRUCTIONS_INTRO), gui_app.font(FontWeight.ROMAN), 32, self.LIGHT_GRAY)
y += 20
# FAQ Header
y = self._draw_wrapped_text(x, y, w, tr(FAQ_HEADER), gui_app.font(FontWeight.BOLD), 44, rl.WHITE)
y += 20
# FAQ Items
for question, answer in FAQ_ITEMS:
y = self._draw_wrapped_text(x, y, w, tr(question), gui_app.font(FontWeight.BOLD), 32, self.LIGHT_GRAY)
y = self._draw_wrapped_text(x, y, w, tr(answer), gui_app.font(FontWeight.ROMAN), 32, self.LIGHT_GRAY)
y += 20
def _draw_wrapped_text(self, x, y, width, text, font, font_size, color):
wrapped = wrap_text(font, text, font_size, width)
for line in wrapped:
rl.draw_text_ex(font, line, rl.Vector2(x, y), font_size, 0, color)
y += int(font_size * FONT_SCALE)
return y
def _measure_content_height(self, rect: rl.Rectangle) -> int:
# Rough measurement using the same wrapping as rendering
w = int(rect.width - 80)
y = 40
# Title
title_size = 72
y += int(title_size * FONT_SCALE) + 20
# Description
desc_lines = wrap_text(gui_app.font(FontWeight.ROMAN), tr(DESCRIPTION), 36, w)
y += int(len(desc_lines) * 36 * FONT_SCALE) + 20
# Separator + Status
y += 2 + 20
status_text, _ = self._get_status()
status_lines = wrap_text(gui_app.font(FontWeight.BOLD), status_text, 48, w)
y += int(len(status_lines) * 48 * FONT_SCALE) + 20
# Contribution count
if self._segment_count > 0:
contrib_text = trn("{} segment of your driving is in the training dataset so far.",
"{} segments of your driving is in the training dataset so far.", self._segment_count).format(self._segment_count)
contrib_lines = wrap_text(gui_app.font(FontWeight.BOLD), contrib_text, 42, w)
y += int(len(contrib_lines) * 42 * FONT_SCALE) + 20
# Separator + Instructions
y += 2 + 20
# Instructions intro
intro_lines = wrap_text(gui_app.font(FontWeight.ROMAN), tr(INSTRUCTIONS_INTRO), 32, w)
y += int(len(intro_lines) * 32 * FONT_SCALE) + 20
# FAQ Header
faq_header_lines = wrap_text(gui_app.font(FontWeight.BOLD), tr(FAQ_HEADER), 44, w)
y += int(len(faq_header_lines) * 44 * FONT_SCALE) + 20
# FAQ Items
for question, answer in FAQ_ITEMS:
q_lines = wrap_text(gui_app.font(FontWeight.BOLD), tr(question), 32, w)
y += int(len(q_lines) * 32 * FONT_SCALE)
a_lines = wrap_text(gui_app.font(FontWeight.ROMAN), tr(answer), 32, w)
y += int(len(a_lines) * 32 * FONT_SCALE) + 20
# bottom padding
y += 40
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 tr("ACTIVE"), self.GREEN
else:
return tr("INACTIVE: connect to an unmetered network"), self.RED
def _fetch_firehose_stats(self):
try:
dongle_id = self._params.get("DongleId")
if not dongle_id or dongle_id == UNREGISTERED_DONGLE_ID:
return
identity_token = get_token(dongle_id)
response = api_get(f"v1/devices/{dongle_id}/firehose_stats", access_token=identity_token, session=self._session)
if response.status_code == 200:
data = response.json()
self._segment_count = data.get("firehose", 0)
self._params.put(self.PARAM_KEY, data)
except Exception as e:
cloudlog.error(f"Failed to fetch firehose stats: {e}")
def _update_loop(self):
while self._running:
if not ui_state.started and device._awake:
self._fetch_firehose_stats()
time.sleep(self.UPDATE_INTERVAL)
class FirehoseLayout(NavRawScrollPanel, FirehoseLayoutBase):
pass