From 4af905a4634d8cea5c18808ada4667ffd53474cf Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Fri, 23 Jan 2026 00:30:46 -0800 Subject: [PATCH 01/82] some work --- selfdrive/selfdrived/selfdrived.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/selfdrive/selfdrived/selfdrived.py b/selfdrive/selfdrived/selfdrived.py index 997c7e37701153..291817539842d7 100755 --- a/selfdrive/selfdrived/selfdrived.py +++ b/selfdrive/selfdrived/selfdrived.py @@ -278,8 +278,9 @@ def update_events(self, CS): safety_mismatch = pandaState.safetyModel not in IGNORED_SAFETY_MODES # safety mismatch allows some time for pandad to set the safety mode and publish it back from panda - if (safety_mismatch and self.sm.frame*DT_CTRL > 10.) or pandaState.safetyRxChecksInvalid or self.mismatch_counter >= 200: - self.events.add(EventName.controlsMismatch) + # TODO: we can't actuate, not important, but why? + # if (safety_mismatch and self.sm.frame*DT_CTRL > 10.) or pandaState.safetyRxChecksInvalid or self.mismatch_counter >= 200: + # self.events.add(EventName.controlsMismatch) if log.PandaState.FaultType.relayMalfunction in pandaState.faults: self.events.add(EventName.relayMalfunction) @@ -351,12 +352,13 @@ def update_events(self, CS): if any((self.sm.frame - self.sm.recv_frame[s])*DT_CTRL > 10. for s in self.sensor_packets): self.events.add(EventName.sensorDataInvalid) - if not REPLAY: - # Check for mismatch between openpilot and car's PCM - cruise_mismatch = CS.cruiseState.enabled and (not self.enabled or not self.CP.pcmCruise) - self.cruise_mismatch_counter = self.cruise_mismatch_counter + 1 if cruise_mismatch else 0 - if self.cruise_mismatch_counter > int(6. / DT_CTRL): - self.events.add(EventName.cruiseMismatch) + # TODO: why failing? + # if not REPLAY: + # # Check for mismatch between openpilot and car's PCM + # cruise_mismatch = CS.cruiseState.enabled and (not self.enabled or not self.CP.pcmCruise) + # self.cruise_mismatch_counter = self.cruise_mismatch_counter + 1 if cruise_mismatch else 0 + # if self.cruise_mismatch_counter > int(6. / DT_CTRL): + # self.events.add(EventName.cruiseMismatch) # Send a "steering required alert" if saturation count has reached the limit if CS.steeringPressed: From 4711c8155d41acc8c30061686c59d30ee1322105 Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Fri, 23 Jan 2026 00:37:47 -0800 Subject: [PATCH 02/82] bump opendbc --- opendbc_repo | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/opendbc_repo b/opendbc_repo index 796ece26acd8b9..9ee44cf28b9beb 160000 --- a/opendbc_repo +++ b/opendbc_repo @@ -1 +1 @@ -Subproject commit 796ece26acd8b9255810ca71941ed72626589ee7 +Subproject commit 9ee44cf28b9bebfec9e4ec30af4e0f232f329b6a From b6015edf5d38a8d92ed9096683f4924f5eaa2268 Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Sat, 24 Jan 2026 21:35:13 -0800 Subject: [PATCH 03/82] epic manual stats --- common/params_keys.h | 3 + selfdrive/ui/mici/layouts/main.py | 35 ++ .../ui/mici/layouts/manual_drive_summary.py | 322 ++++++++++++++++++ .../ui/mici/layouts/settings/manual_stats.py | 266 +++++++++++++++ .../ui/mici/layouts/settings/settings.py | 8 +- .../ui/mici/onroad/augmented_road_view.py | 9 + .../ui/mici/onroad/manual_stats_widget.py | 119 +++++++ 7 files changed, 761 insertions(+), 1 deletion(-) create mode 100644 selfdrive/ui/mici/layouts/manual_drive_summary.py create mode 100644 selfdrive/ui/mici/layouts/settings/manual_stats.py create mode 100644 selfdrive/ui/mici/onroad/manual_stats_widget.py diff --git a/common/params_keys.h b/common/params_keys.h index d6104e749773dc..bb51fbb1a48386 100644 --- a/common/params_keys.h +++ b/common/params_keys.h @@ -84,6 +84,9 @@ inline static std::unordered_map keys = { {"LocationFilterInitialState", {PERSISTENT, BYTES}}, {"LongitudinalManeuverMode", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION, BOOL}}, {"LongitudinalPersonality", {PERSISTENT, INT, std::to_string(static_cast(cereal::LongitudinalPersonality::STANDARD))}}, + {"ManualDriveLiveStats", {CLEAR_ON_MANAGER_START, JSON}}, + {"ManualDriveLastSession", {PERSISTENT, JSON}}, + {"ManualDriveStats", {PERSISTENT, JSON}}, {"NetworkMetered", {PERSISTENT, BOOL}}, {"ObdMultiplexingChanged", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, BOOL}}, {"ObdMultiplexingEnabled", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, BOOL}}, diff --git a/selfdrive/ui/mici/layouts/main.py b/selfdrive/ui/mici/layouts/main.py index b52f9ed39a06f9..d0768fc76dff44 100644 --- a/selfdrive/ui/mici/layouts/main.py +++ b/selfdrive/ui/mici/layouts/main.py @@ -1,9 +1,12 @@ +import json import pyray as rl from enum import IntEnum import cereal.messaging as messaging +from openpilot.common.params import Params from openpilot.selfdrive.ui.mici.layouts.home import MiciHomeLayout from openpilot.selfdrive.ui.mici.layouts.settings.settings import SettingsLayout from openpilot.selfdrive.ui.mici.layouts.offroad_alerts import MiciOffroadAlerts +from openpilot.selfdrive.ui.mici.layouts.manual_drive_summary import ManualDriveSummaryDialog 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.mici.layouts.onboarding import OnboardingWindow @@ -25,6 +28,7 @@ def __init__(self): super().__init__() self._pm = messaging.PubMaster(['bookmarkButton']) + self._params = Params() self._current_mode: MainState | None = None self._prev_onroad = False @@ -32,6 +36,9 @@ def __init__(self): self._onroad_time_delay: float | None = None self._setup = False + # Manual drive summary dialog + self._drive_summary_dialog: ManualDriveSummaryDialog | None = None + # Initialize widgets self._home_layout = MiciHomeLayout() self._alerts_layout = MiciOffroadAlerts() @@ -111,6 +118,8 @@ def _handle_transitions(self): if ui_state.started: self._onroad_time_delay = rl.get_time() else: + # Going offroad - show drive summary if manual car had data + self._show_drive_summary_if_available() self._set_mode_for_started(True) # delay so we show home for a bit after starting @@ -124,6 +133,32 @@ def _handle_transitions(self): self._scroll_to(self._onroad_layout) self._prev_standstill = CS.standstill + def _show_drive_summary_if_available(self): + """End manual stats session and show summary dialog if data exists""" + # Try to end the manual stats session + try: + from opendbc.car.subaru.manual_stats import get_tracker + tracker = get_tracker() + tracker.end_session() + except Exception: + pass + + # Show the summary dialog if there's session data + try: + data = self._params.get("ManualDriveLastSession") + if data: + session = json.loads(data) + # Only show if there's meaningful data (duration > 30s and some activity) + duration = session.get('duration', 0) + has_activity = (session.get('stall_count', 0) > 0 or + session.get('upshift_count', 0) > 0 or + session.get('launch_count', 0) > 0) + if duration > 30 and has_activity: + self._drive_summary_dialog = ManualDriveSummaryDialog() + gui_app.set_modal_overlay(self._drive_summary_dialog) + except Exception: + pass + def _set_mode_for_started(self, onroad_transition: bool = False): if ui_state.started: CS = ui_state.sm["carState"] diff --git a/selfdrive/ui/mici/layouts/manual_drive_summary.py b/selfdrive/ui/mici/layouts/manual_drive_summary.py new file mode 100644 index 00000000000000..ad655ccaf6a0da --- /dev/null +++ b/selfdrive/ui/mici/layouts/manual_drive_summary.py @@ -0,0 +1,322 @@ +""" +Manual Drive Summary Dialog + +Shows end-of-drive statistics for manual transmission driving with +encouraging or critical feedback based on performance. +""" + +import json +import time +import pyray as rl +from typing import Optional, Callable + +from openpilot.common.params import Params +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.widgets import Widget + + +# Colors +GREEN = rl.Color(46, 204, 113, 255) +YELLOW = rl.Color(241, 196, 15, 255) +RED = rl.Color(231, 76, 60, 255) +GRAY = rl.Color(150, 150, 150, 255) +LIGHT_GRAY = rl.Color(200, 200, 200, 255) +BG_COLOR = rl.Color(30, 30, 30, 240) + + +class ManualDriveSummaryDialog(Widget): + """Modal dialog showing end-of-drive manual transmission stats""" + + def __init__(self, dismiss_callback: Optional[Callable] = None): + super().__init__() + self._params = Params() + self._dismiss_callback = dismiss_callback + self._session_data: Optional[dict] = None + self._overall_grade: str = "good" # good, ok, poor + self._card_rank: str = "10" # Poker card rank: 10, J, Q, K, A + self._show_time: float = 0.0 + self._auto_dismiss_after: float = 30.0 # Auto dismiss after 30 seconds + + def show_event(self): + super().show_event() + self._show_time = time.monotonic() + self._load_session() + + def _load_session(self): + """Load the last session data from Params""" + try: + data = self._params.get("ManualDriveLastSession") + if data: + self._session_data = json.loads(data) + self._calculate_grade() + except Exception: + self._session_data = None + + def _calculate_grade(self): + """Calculate overall grade based on session performance""" + if not self._session_data: + self._overall_grade = "ok" + self._card_rank = "10" + return + + # Calculate grade based on stalls, shifts, and launches + stalls = self._session_data.get('stall_count', 0) + lugs = self._session_data.get('lug_count', 0) + + # Shift quality + upshift_total = self._session_data.get('upshift_count', 0) + upshift_good = self._session_data.get('upshift_good', 0) + downshift_total = self._session_data.get('downshift_count', 0) + downshift_good = self._session_data.get('downshift_good', 0) + + # Launch quality + launch_total = self._session_data.get('launch_count', 0) + launch_good = self._session_data.get('launch_good', 0) + launch_stalled = self._session_data.get('launch_stalled', 0) + + # Calculate scores + total_shifts = upshift_total + downshift_total + shift_score = ((upshift_good + downshift_good) / total_shifts * 100) if total_shifts > 0 else 100 + launch_score = (launch_good / launch_total * 100) if launch_total > 0 else 100 + + # Penalties + stall_penalty = stalls * 20 + lug_penalty = lugs * 5 + launch_stall_penalty = launch_stalled * 15 + + overall_score = max(0, min(100, (shift_score + launch_score) / 2 - stall_penalty - lug_penalty - launch_stall_penalty)) + + # Poker card ranking: 10, J, Q, K, A + if overall_score >= 90 and stalls == 0: + self._card_rank = "A" + self._overall_grade = "good" + elif overall_score >= 75 and stalls == 0: + self._card_rank = "K" + self._overall_grade = "good" + elif overall_score >= 60 and stalls <= 1: + self._card_rank = "Q" + self._overall_grade = "ok" + elif overall_score >= 40: + self._card_rank = "J" + self._overall_grade = "ok" + else: + self._card_rank = "10" + self._overall_grade = "poor" + + def _get_header_text(self) -> tuple[str, rl.Color]: + """Get header text and color based on grade""" + if self._overall_grade == "good": + return "Waddle Driver!", GREEN + elif self._overall_grade == "ok": + return "Decent Drive", YELLOW + else: + return "Jackets...", RED + + def _get_encouragement_text(self) -> str: + """Get encouragement or criticism text based on performance""" + if not self._session_data: + return "No data available for this drive." + + stalls = self._session_data.get('stall_count', 0) + lugs = self._session_data.get('lug_count', 0) + launch_stalled = self._session_data.get('launch_stalled', 0) + + upshift_good = self._session_data.get('upshift_good', 0) + upshift_total = self._session_data.get('upshift_count', 0) + downshift_good = self._session_data.get('downshift_good', 0) + downshift_total = self._session_data.get('downshift_count', 0) + launch_good = self._session_data.get('launch_good', 0) + launch_total = self._session_data.get('launch_count', 0) + + messages = [] + + if self._overall_grade == "good": + if self._card_rank == "A": + messages.append("Ace drive! You're a true waddle master!") + elif self._card_rank == "K": + messages.append("King of the road! Waddling like a pro!") + if stalls == 0 and launch_stalled == 0: + messages.append("No stalls!") + if upshift_total > 0 and upshift_good == upshift_total: + messages.append("Perfect upshifts!") + if downshift_total > 0 and downshift_good >= downshift_total * 0.8: + messages.append("Great rev matching!") + if launch_total > 0 and launch_good >= launch_total * 0.8: + messages.append("Smooth launches!") + if not messages: + messages.append("Keep waddling!") + + elif self._overall_grade == "ok": + if self._card_rank == "Q": + messages.append("Queen-level driving - almost there!") + else: + messages.append("Jack of all gears - room to improve!") + if stalls > 0: + messages.append(f"Only {stalls} stall{'s' if stalls > 1 else ''} - improving!") + if lugs > 0: + messages.append(f"Watch RPMs - {lugs} lug{'s' if lugs > 1 else ''}.") + if upshift_total > 0 and upshift_good < upshift_total: + messages.append("Smoother upshifts needed.") + + else: # poor - jackets + messages.append("Time to hang up those jackets and try again!") + if stalls > 2: + messages.append(f"{stalls} stalls - more gas, slower clutch!") + if launch_stalled > 0: + messages.append(f"{launch_stalled} stalled launch{'es' if launch_stalled > 1 else ''} - find that bite point!") + if lugs > 3: + messages.append(f"Lugging {lugs} times - downshift sooner!") + if not messages[1:]: + messages.append("Every pro stalled at first. Keep at it!") + + return " ".join(messages) + + def _handle_mouse_release(self, _): + """Dismiss on tap""" + if self._dismiss_callback: + self._dismiss_callback() + gui_app.dismiss_modal() + + def _render(self, rect: rl.Rectangle): + if not self._session_data: + # Auto-dismiss if no data + if self._dismiss_callback: + self._dismiss_callback() + gui_app.dismiss_modal() + return + + # Auto-dismiss after timeout + if time.monotonic() - self._show_time > self._auto_dismiss_after: + if self._dismiss_callback: + self._dismiss_callback() + gui_app.dismiss_modal() + return + + # Draw semi-transparent background + rl.draw_rectangle(0, 0, gui_app.width, gui_app.height, rl.Color(0, 0, 0, 180)) + + # Dialog dimensions + dialog_w = min(500, gui_app.width - 40) + dialog_h = min(600, gui_app.height - 40) + dialog_x = (gui_app.width - dialog_w) // 2 + dialog_y = (gui_app.height - dialog_h) // 2 + + # Draw dialog background + rl.draw_rectangle_rounded( + rl.Rectangle(dialog_x, dialog_y, dialog_w, dialog_h), + 0.03, 10, BG_COLOR + ) + + # Content area + x = dialog_x + 30 + y = dialog_y + 25 + w = dialog_w - 60 + + # Header + header_text, header_color = self._get_header_text() + font = gui_app.font(FontWeight.BOLD) + rl.draw_text_ex(font, header_text, rl.Vector2(x, y), 48, 0, header_color) + y += 55 + + # Card rank display - poker hand style + card_names = {"A": "Aces", "K": "Kings", "Q": "Queens", "J": "Jacks", "10": "10s"} + card_color = GREEN if self._card_rank in ("A", "K") else (YELLOW if self._card_rank in ("Q", "J") else RED) + card_text = f"Your hand: {card_names[self._card_rank]}" + rl.draw_text_ex(gui_app.font(FontWeight.MEDIUM), card_text, rl.Vector2(x, y), 32, 0, card_color) + y += 45 + + # Duration + duration = self._session_data.get('duration', 0) + duration_min = int(duration // 60) + duration_sec = int(duration % 60) + rl.draw_text_ex(gui_app.font(FontWeight.ROMAN), f"Drive Duration: {duration_min}:{duration_sec:02d}", + rl.Vector2(x, y), 28, 0, GRAY) + y += 45 + + # Separator + rl.draw_rectangle(x, y, w, 2, rl.Color(60, 60, 60, 255)) + y += 15 + + # Stats sections + y = self._draw_stat_section(x, y, w, "Stalls", self._session_data.get('stall_count', 0), target=0, lower_better=True) + y = self._draw_stat_section(x, y, w, "Engine Lugs", self._session_data.get('lug_count', 0), target=0, lower_better=True) + + # Launches + launch_total = self._session_data.get('launch_count', 0) + launch_good = self._session_data.get('launch_good', 0) + launch_stalled = self._session_data.get('launch_stalled', 0) + if launch_total > 0: + y = self._draw_stat_section(x, y, w, "Good Launches", f"{launch_good}/{launch_total}", + target=launch_total, current=launch_good) + if launch_stalled > 0: + y = self._draw_stat_section(x, y, w, "Stalled Launches", launch_stalled, target=0, lower_better=True) + + # Upshifts + upshift_total = self._session_data.get('upshift_count', 0) + upshift_good = self._session_data.get('upshift_good', 0) + if upshift_total > 0: + y = self._draw_stat_section(x, y, w, "Good Upshifts", f"{upshift_good}/{upshift_total}", + target=upshift_total, current=upshift_good) + + # Downshifts + downshift_total = self._session_data.get('downshift_count', 0) + downshift_good = self._session_data.get('downshift_good', 0) + if downshift_total > 0: + y = self._draw_stat_section(x, y, w, "Good Downshifts", f"{downshift_good}/{downshift_total}", + target=downshift_total, current=downshift_good) + + y += 10 + + # Encouragement/criticism text + encouragement = self._get_encouragement_text() + wrapped = wrap_text(gui_app.font(FontWeight.ROMAN), encouragement, 24, w) + for line in wrapped: + rl.draw_text_ex(gui_app.font(FontWeight.ROMAN), line, rl.Vector2(x, y), 24, 0, LIGHT_GRAY) + y += int(24 * FONT_SCALE) + + # Tap to dismiss hint + hint_text = "Tap to dismiss" + hint_font = gui_app.font(FontWeight.ROMAN) + hint_size = 20 + rl.draw_text_ex(hint_font, hint_text, rl.Vector2(dialog_x + dialog_w // 2 - 50, dialog_y + dialog_h - 35), + hint_size, 0, GRAY) + + def _draw_stat_section(self, x: int, y: int, w: int, label: str, value, target=None, + current=None, lower_better=False) -> int: + """Draw a stat row with label and value, colored based on performance""" + font = gui_app.font(FontWeight.MEDIUM) + font_size = 28 + + # Determine color based on target + if target is not None: + if lower_better: + if value == 0: + color = GREEN + elif value <= 2: + color = YELLOW + else: + color = RED + else: + if current is not None: + ratio = current / target if target > 0 else 1 + if ratio >= 0.8: + color = GREEN + elif ratio >= 0.5: + color = YELLOW + else: + color = RED + else: + color = LIGHT_GRAY + else: + color = LIGHT_GRAY + + # Draw label + rl.draw_text_ex(font, label, rl.Vector2(x, y), font_size, 0, LIGHT_GRAY) + + # Draw value (right-aligned) + value_str = str(value) + value_width = rl.measure_text_ex(font, value_str, font_size, 0).x + rl.draw_text_ex(font, value_str, rl.Vector2(x + w - value_width, y), font_size, 0, color) + + return y + 38 diff --git a/selfdrive/ui/mici/layouts/settings/manual_stats.py b/selfdrive/ui/mici/layouts/settings/manual_stats.py new file mode 100644 index 00000000000000..ca198761768c69 --- /dev/null +++ b/selfdrive/ui/mici/layouts/settings/manual_stats.py @@ -0,0 +1,266 @@ +""" +Manual Driving Stats Settings Page + +Shows historical stats and trends for manual transmission driving. +""" + +import json +import pyray as rl + +from openpilot.common.params import Params +from openpilot.system.ui.lib.application import gui_app, FontWeight, FONT_SCALE +from openpilot.system.ui.lib.scroll_panel2 import GuiScrollPanel2 +from openpilot.system.ui.lib.wrap_text import wrap_text +from openpilot.system.ui.widgets import Widget, NavWidget + + +# Colors +GREEN = rl.Color(46, 204, 113, 255) +YELLOW = rl.Color(241, 196, 15, 255) +RED = rl.Color(231, 76, 60, 255) +GRAY = rl.Color(100, 100, 100, 255) +LIGHT_GRAY = rl.Color(180, 180, 180, 255) +WHITE = rl.Color(255, 255, 255, 255) +BG_CARD = rl.Color(45, 45, 45, 255) + + +class ManualStatsLayout(NavWidget): + """Settings page showing historical manual driving stats""" + + def __init__(self, back_callback): + super().__init__() + self._params = Params() + self._scroll_panel = GuiScrollPanel2(horizontal=False) + self._stats: dict = {} + self.set_back_callback(back_callback) + + def show_event(self): + super().show_event() + self._scroll_panel.set_offset(0) + self._load_stats() + + def _load_stats(self): + """Load historical stats from Params""" + try: + data = self._params.get("ManualDriveStats") + if data: + # Params returns dict directly for JSON type + self._stats = data if isinstance(data, dict) else json.loads(data) + else: + self._stats = {} + except Exception: + self._stats = {} + + def _render(self, rect: rl.Rectangle): + content_height = self._measure_content_height(rect) + scroll_offset = round(self._scroll_panel.update(rect, content_height)) + + x = int(rect.x + 20) + y = int(rect.y + 20 + scroll_offset) + w = int(rect.width - 40) + + # Title + font_bold = gui_app.font(FontWeight.BOLD) + font_medium = gui_app.font(FontWeight.MEDIUM) + font_roman = gui_app.font(FontWeight.ROMAN) + + rl.draw_text_ex(font_bold, "Manual Driving Stats", rl.Vector2(x, y), 48, 0, WHITE) + y += 60 + + if not self._stats or self._stats.get('total_drives', 0) == 0: + rl.draw_text_ex(font_roman, "No driving data yet. Get out there and practice!", + rl.Vector2(x, y), 28, 0, GRAY) + return + + # Overview card + y = self._draw_card(x, y, w, "Overview", [ + ("Total Drives", str(self._stats.get('total_drives', 0)), WHITE), + ("Total Drive Time", self._format_time(self._stats.get('total_drive_time', 0)), WHITE), + ("Total Stalls", str(self._stats.get('total_stalls', 0)), self._stall_color(self._stats.get('total_stalls', 0))), + ("Total Lugs", str(self._stats.get('total_lugs', 0)), LIGHT_GRAY), + ]) + y += 15 + + # Shift quality card + total_up = self._stats.get('total_upshifts', 0) + total_down = self._stats.get('total_downshifts', 0) + up_good = self._stats.get('total_upshifts_good', 0) + down_good = self._stats.get('total_downshifts_good', 0) + + up_pct = f"{int(up_good / total_up * 100)}%" if total_up > 0 else "N/A" + down_pct = f"{int(down_good / total_down * 100)}%" if total_down > 0 else "N/A" + + y = self._draw_card(x, y, w, "Shift Quality", [ + ("Total Upshifts", str(total_up), WHITE), + ("Good Upshifts", f"{up_good} ({up_pct})", self._pct_color(up_good, total_up)), + ("Total Downshifts", str(total_down), WHITE), + ("Good Downshifts", f"{down_good} ({down_pct})", self._pct_color(down_good, total_down)), + ]) + y += 15 + + # Launch quality card + total_launches = self._stats.get('total_launches', 0) + good_launches = self._stats.get('total_launches_good', 0) + stalled_launches = self._stats.get('total_launches_stalled', 0) + + launch_pct = f"{int(good_launches / total_launches * 100)}%" if total_launches > 0 else "N/A" + + y = self._draw_card(x, y, w, "Launch Quality", [ + ("Total Launches", str(total_launches), WHITE), + ("Good Launches", f"{good_launches} ({launch_pct})", self._pct_color(good_launches, total_launches)), + ("Stalled Launches", str(stalled_launches), RED if stalled_launches > 0 else GREEN), + ]) + y += 15 + + # Trend card + recent_stalls = self._stats.get('recent_stall_rates', []) + recent_shifts = self._stats.get('recent_shift_scores', []) + + trend_items = [] + if len(recent_stalls) >= 2: + trend = self._calculate_trend(recent_stalls) + trend_text, trend_color = self._trend_text(trend, lower_better=True) + trend_items.append(("Stall Trend", trend_text, trend_color)) + + if len(recent_shifts) >= 2: + trend = self._calculate_trend(recent_shifts) + trend_text, trend_color = self._trend_text(trend, lower_better=False) + trend_items.append(("Shift Score Trend", trend_text, trend_color)) + + if recent_shifts: + avg_score = sum(recent_shifts) / len(recent_shifts) + trend_items.append(("Avg Shift Score (last 10)", f"{int(avg_score)}/100", self._score_color(avg_score))) + + if trend_items: + y = self._draw_card(x, y, w, "Recent Trends", trend_items) + y += 15 + + # Encouragement based on progress (with text wrapping) + y += 10 + encouragement = self._get_encouragement() + wrapped_lines = wrap_text(font_roman, encouragement, 24, w - 10) + for line in wrapped_lines: + rl.draw_text_ex(font_roman, line, rl.Vector2(x, y), 24, 0, LIGHT_GRAY) + y += 30 + + def _draw_card(self, x: int, y: int, w: int, title: str, items: list) -> int: + """Draw a card with title and stat items""" + font_bold = gui_app.font(FontWeight.BOLD) + font_medium = gui_app.font(FontWeight.MEDIUM) + + card_h = 50 + len(items) * 38 + rl.draw_rectangle_rounded(rl.Rectangle(x, y, w, card_h), 0.02, 10, BG_CARD) + + # Title + rl.draw_text_ex(font_bold, title, rl.Vector2(x + 15, y + 12), 32, 0, WHITE) + y += 50 + + # Items + for label, value, color in items: + rl.draw_text_ex(font_medium, label, rl.Vector2(x + 15, y), 26, 0, LIGHT_GRAY) + value_width = rl.measure_text_ex(font_medium, value, 26, 0).x + rl.draw_text_ex(font_medium, value, rl.Vector2(x + w - 15 - value_width, y), 26, 0, color) + y += 38 + + return y + + def _measure_content_height(self, rect: rl.Rectangle) -> int: + """Measure total content height for scrolling""" + y = 20 + 60 # Title + + if not self._stats or self._stats.get('total_drives', 0) == 0: + return y + 40 + + # Overview card + y += 50 + 4 * 38 + 15 + # Shift card + y += 50 + 4 * 38 + 15 + # Launch card + y += 50 + 3 * 38 + 15 + # Trend card (estimate) + y += 50 + 3 * 38 + 15 + # Encouragement (estimate 2-3 lines wrapped) + y += 100 + + return y + 40 # padding + + def _format_time(self, seconds: float) -> str: + """Format seconds as hours:minutes""" + hours = int(seconds // 3600) + minutes = int((seconds % 3600) // 60) + if hours > 0: + return f"{hours}h {minutes}m" + return f"{minutes}m" + + def _stall_color(self, stalls: int) -> rl.Color: + if stalls == 0: + return GREEN + elif stalls < 5: + return YELLOW + return RED + + def _pct_color(self, good: int, total: int) -> rl.Color: + if total == 0: + return GRAY + pct = good / total + if pct >= 0.8: + return GREEN + elif pct >= 0.5: + return YELLOW + return RED + + def _score_color(self, score: float) -> rl.Color: + if score >= 80: + return GREEN + elif score >= 50: + return YELLOW + return RED + + def _calculate_trend(self, values: list) -> float: + """Calculate trend as average change over recent values""" + if len(values) < 2: + return 0.0 + # Compare first half avg to second half avg + mid = len(values) // 2 + first_half = sum(values[:mid]) / mid if mid > 0 else 0 + second_half = sum(values[mid:]) / (len(values) - mid) if len(values) - mid > 0 else 0 + return second_half - first_half + + def _trend_text(self, trend: float, lower_better: bool) -> tuple[str, rl.Color]: + """Get trend text and color""" + if abs(trend) < 0.5: + return "Stable", LIGHT_GRAY + + if lower_better: + if trend < 0: + return "Improving!", GREEN + return "Getting worse", RED + else: + if trend > 0: + return "Improving!", GREEN + return "Getting worse", RED + + def _get_encouragement(self) -> str: + """Get encouragement based on overall progress""" + total_drives = self._stats.get('total_drives', 0) + total_stalls = self._stats.get('total_stalls', 0) + recent_stalls = self._stats.get('recent_stall_rates', []) + + if total_drives == 0: + return "Start driving to see your stats!" + + stall_rate = total_stalls / total_drives if total_drives > 0 else 0 + + if len(recent_stalls) >= 3: + recent_avg = sum(recent_stalls[-3:]) / 3 + if recent_avg == 0: + return "No stalls in recent drives - you're getting the hang of it!" + elif recent_avg < stall_rate: + return "Your recent drives are better than average - keep it up!" + + if stall_rate < 0.5: + return "Less than 1 stall per 2 drives on average - nice work!" + elif stall_rate < 1: + return "About 1 stall per drive - you're learning fast!" + else: + return "Keep practicing! Everyone stalls when learning manual." diff --git a/selfdrive/ui/mici/layouts/settings/settings.py b/selfdrive/ui/mici/layouts/settings/settings.py index a452777748e295..fc4ca77874537b 100644 --- a/selfdrive/ui/mici/layouts/settings/settings.py +++ b/selfdrive/ui/mici/layouts/settings/settings.py @@ -11,6 +11,7 @@ from openpilot.selfdrive.ui.mici.layouts.settings.device import DeviceLayoutMici, PairBigButton from openpilot.selfdrive.ui.mici.layouts.settings.developer import DeveloperLayoutMici from openpilot.selfdrive.ui.mici.layouts.settings.firehose import FirehoseLayout +from openpilot.selfdrive.ui.mici.layouts.settings.manual_stats import ManualStatsLayout from openpilot.system.ui.lib.application import gui_app, FontWeight from openpilot.system.ui.widgets import Widget, NavWidget @@ -22,6 +23,7 @@ class PanelType(IntEnum): DEVELOPER = 3 USER_MANUAL = 4 FIREHOSE = 5 + MANUAL_STATS = 6 @dataclass @@ -48,12 +50,15 @@ def __init__(self): firehose_btn = BigButton("firehose", "", "icons_mici/settings/comma_icon.png") firehose_btn.set_click_callback(lambda: self._set_current_panel(PanelType.FIREHOSE)) + manual_stats_btn = BigButton("MT stats", "", "icons_mici/settings/toggles_icon.png") + manual_stats_btn.set_click_callback(lambda: self._set_current_panel(PanelType.MANUAL_STATS)) + self._scroller = Scroller([ toggles_btn, + manual_stats_btn, # MT Stats right after Toggles network_btn, device_btn, PairBigButton(), - #BigDialogButton("manual", "", "icons_mici/settings/manual_icon.png", "Check out the mici user\nmanual at comma.ai/setup"), firehose_btn, developer_btn, ], snap_items=False) @@ -68,6 +73,7 @@ def __init__(self): PanelType.DEVICE: PanelInfo("Device", DeviceLayoutMici(back_callback=lambda: self._set_current_panel(None))), PanelType.DEVELOPER: PanelInfo("Developer", DeveloperLayoutMici(back_callback=lambda: self._set_current_panel(None))), PanelType.FIREHOSE: PanelInfo("Firehose", FirehoseLayout(back_callback=lambda: self._set_current_panel(None))), + PanelType.MANUAL_STATS: PanelInfo("MT Stats", ManualStatsLayout(back_callback=lambda: self._set_current_panel(None))), } self._font_medium = gui_app.font(FontWeight.MEDIUM) diff --git a/selfdrive/ui/mici/onroad/augmented_road_view.py b/selfdrive/ui/mici/onroad/augmented_road_view.py index 71ca03cccfac94..c8737341a1ee9f 100644 --- a/selfdrive/ui/mici/onroad/augmented_road_view.py +++ b/selfdrive/ui/mici/onroad/augmented_road_view.py @@ -11,6 +11,7 @@ from openpilot.selfdrive.ui.mici.onroad.model_renderer import ModelRenderer from openpilot.selfdrive.ui.mici.onroad.confidence_ball import ConfidenceBall from openpilot.selfdrive.ui.mici.onroad.cameraview import CameraView +from openpilot.selfdrive.ui.mici.onroad.manual_stats_widget import ManualStatsWidget from openpilot.system.ui.lib.application import FontWeight, gui_app, MousePos, MouseEvent from openpilot.system.ui.widgets.label import UnifiedLabel from openpilot.system.ui.widgets import Widget @@ -161,6 +162,9 @@ def __init__(self, bookmark_callback=None, stream_type: VisionStreamType = Visio self._fade_texture = gui_app.texture("icons_mici/onroad/onroad_fade.png") + # Manual stats widget for MT cars + self._manual_stats_widget = ManualStatsWidget() + # debug self._pm = messaging.PubMaster(['uiDebug']) @@ -242,6 +246,11 @@ def _render(self, _): # Use self._content_rect for positioning within camera bounds self._confidence_ball.render(self.rect) + # Manual stats widget for MT cars - check if manual transmission (flag 128) + is_manual = ui_state.CP is not None and bool(ui_state.CP.flags & 128) + self._manual_stats_widget.set_visible(is_manual and ui_state.started) + self._manual_stats_widget.render(self._content_rect) + self._bookmark_icon.render(self.rect) # Draw darkened background and text if not onroad diff --git a/selfdrive/ui/mici/onroad/manual_stats_widget.py b/selfdrive/ui/mici/onroad/manual_stats_widget.py new file mode 100644 index 00000000000000..93ad4d6e397f9b --- /dev/null +++ b/selfdrive/ui/mici/onroad/manual_stats_widget.py @@ -0,0 +1,119 @@ +""" +Live Manual Stats Widget + +Small onroad overlay showing current drive statistics and shift suggestions. +""" + +import json +import pyray as rl + +from openpilot.common.params import Params +from openpilot.system.ui.lib.application import gui_app, FontWeight +from openpilot.system.ui.widgets import Widget + + +# Colors +GREEN = rl.Color(46, 204, 113, 220) +YELLOW = rl.Color(241, 196, 15, 220) +RED = rl.Color(231, 76, 60, 220) +CYAN = rl.Color(52, 152, 219, 220) +WHITE = rl.Color(255, 255, 255, 220) +GRAY = rl.Color(150, 150, 150, 200) +BG_COLOR = rl.Color(0, 0, 0, 160) + + +class ManualStatsWidget(Widget): + """Small widget showing live manual driving stats and shift suggestions""" + + def __init__(self): + super().__init__() + self._params = Params() + self._visible = False + self._stats: dict = {} + self._update_counter = 0 + + def set_visible(self, visible: bool): + self._visible = visible + + def _render(self, rect: rl.Rectangle): + if not self._visible: + return + + # Update stats every ~15 frames (0.25s at 60fps) + self._update_counter += 1 + if self._update_counter >= 15: + self._update_counter = 0 + self._load_stats() + + if not self._stats: + return + + # Widget dimensions + w = 140 + h = 130 + x = int(rect.x + rect.width - w - 10) + y = int(rect.y + 10) + + # Background + rl.draw_rectangle_rounded(rl.Rectangle(x, y, w, h), 0.1, 10, BG_COLOR) + + font = gui_app.font(FontWeight.MEDIUM) + font_bold = gui_app.font(FontWeight.BOLD) + px = x + 10 + py = y + 8 + + # Current gear (big) + gear = self._stats.get('gear', 0) + gear_text = str(gear) if gear > 0 else "N" + rl.draw_text_ex(font_bold, gear_text, rl.Vector2(px, py), 42, 0, WHITE) + + # Shift suggestion next to gear + suggestion = self._stats.get('shift_suggestion', 'ok') + reason = self._stats.get('shift_reason', '') + if suggestion == 'upshift': + rl.draw_text_ex(font_bold, "↑", rl.Vector2(px + 35, py + 5), 36, 0, GREEN) + elif suggestion == 'downshift': + rl.draw_text_ex(font_bold, "↓", rl.Vector2(px + 35, py + 5), 36, 0, YELLOW) + + py += 48 + + # Stats in smaller text + font_size = 20 + line_h = 24 + + # Stalls + stalls = self._stats.get('stalls', 0) + color = GREEN if stalls == 0 else (YELLOW if stalls <= 2 else RED) + rl.draw_text_ex(font, f"Stalls: {stalls}", rl.Vector2(px, py), font_size, 0, color) + py += line_h + + # Lugging indicator + is_lugging = self._stats.get('is_lugging', False) + lugs = self._stats.get('lugs', 0) + if is_lugging: + rl.draw_text_ex(font, "LUGGING!", rl.Vector2(px, py), font_size, 0, RED) + else: + color = GREEN if lugs == 0 else GRAY + rl.draw_text_ex(font, f"Lugs: {lugs}", rl.Vector2(px, py), font_size, 0, color) + py += line_h + + # Shift quality + shifts = self._stats.get('shifts', 0) + good_shifts = self._stats.get('good_shifts', 0) + if shifts > 0: + pct = int(good_shifts / shifts * 100) + color = GREEN if pct >= 80 else (YELLOW if pct >= 50 else RED) + rl.draw_text_ex(font, f"Shifts: {pct}%", rl.Vector2(px, py), font_size, 0, color) + else: + rl.draw_text_ex(font, "Shifts: -", rl.Vector2(px, py), font_size, 0, GRAY) + + def _load_stats(self): + """Load current session stats""" + try: + data = self._params.get("ManualDriveLiveStats") + if data: + self._stats = json.loads(data) + else: + self._stats = {} + except Exception: + self._stats = {} From ca4c42dd513efa9793dd5887f979b51a23fe90c8 Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Sat, 24 Jan 2026 21:55:46 -0800 Subject: [PATCH 04/82] "improvements" --- .../ui/mici/layouts/manual_drive_summary.py | 273 +++++++++--- .../ui/mici/layouts/settings/manual_stats.py | 395 +++++++++++++++++- .../ui/mici/layouts/settings/settings.py | 2 +- 3 files changed, 591 insertions(+), 79 deletions(-) diff --git a/selfdrive/ui/mici/layouts/manual_drive_summary.py b/selfdrive/ui/mici/layouts/manual_drive_summary.py index ad655ccaf6a0da..62c5a82c0b8edf 100644 --- a/selfdrive/ui/mici/layouts/manual_drive_summary.py +++ b/selfdrive/ui/mici/layouts/manual_drive_summary.py @@ -3,6 +3,7 @@ Shows end-of-drive statistics for manual transmission driving with encouraging or critical feedback based on performance. +Poker hand themed with waddle/jacket references. """ import json @@ -20,9 +21,29 @@ GREEN = rl.Color(46, 204, 113, 255) YELLOW = rl.Color(241, 196, 15, 255) RED = rl.Color(231, 76, 60, 255) +ORANGE = rl.Color(230, 126, 34, 255) GRAY = rl.Color(150, 150, 150, 255) LIGHT_GRAY = rl.Color(200, 200, 200, 255) -BG_COLOR = rl.Color(30, 30, 30, 240) +WHITE = rl.Color(255, 255, 255, 255) +BG_COLOR = rl.Color(30, 30, 30, 245) +BG_CARD = rl.Color(45, 45, 45, 255) + +# Poker hand names +HAND_NAMES = { + "A": "Aces", + "K": "Kings", + "Q": "Queens", + "J": "Jacks", + "10": "10s" +} + +HAND_SUBTITLES = { + "A": "Porch-worthy! KP!", + "K": "CCM vibes! QG!", + "Q": "Priest-approved", + "J": "Not SS... yet", + "10": "Jacketed! Huge oof" +} class ManualDriveSummaryDialog(Widget): @@ -33,8 +54,11 @@ def __init__(self, dismiss_callback: Optional[Callable] = None): self._params = Params() self._dismiss_callback = dismiss_callback self._session_data: Optional[dict] = None + self._historical_data: Optional[dict] = None self._overall_grade: str = "good" # good, ok, poor self._card_rank: str = "10" # Poker card rank: 10, J, Q, K, A + self._shift_score: float = 0.0 + self._avg_shift_score: float = 0.0 self._show_time: float = 0.0 self._auto_dismiss_after: float = 30.0 # Auto dismiss after 30 seconds @@ -42,22 +66,47 @@ def show_event(self): super().show_event() self._show_time = time.monotonic() self._load_session() + self._load_historical() def _load_session(self): """Load the last session data from Params""" try: data = self._params.get("ManualDriveLastSession") if data: - self._session_data = json.loads(data) + self._session_data = data if isinstance(data, dict) else json.loads(data) self._calculate_grade() except Exception: self._session_data = None + def _load_historical(self): + """Load historical stats for comparison""" + try: + data = self._params.get("ManualDriveStats") + if data: + self._historical_data = data if isinstance(data, dict) else json.loads(data) + # Calculate average shift score from history + history = self._historical_data.get('session_history', []) + if history: + scores = [] + for s in history[-10:]: # Last 10 sessions + ups = s.get('upshifts', 0) + ups_good = s.get('upshifts_good', 0) + downs = s.get('downshifts', 0) + downs_good = s.get('downshifts_good', 0) + total = ups + downs + if total > 0: + scores.append((ups_good + downs_good) / total * 100) + if scores: + self._avg_shift_score = sum(scores) / len(scores) + except Exception: + self._historical_data = None + def _calculate_grade(self): """Calculate overall grade based on session performance""" if not self._session_data: self._overall_grade = "ok" self._card_rank = "10" + self._shift_score = 0 return # Calculate grade based on stalls, shifts, and launches @@ -77,7 +126,7 @@ def _calculate_grade(self): # Calculate scores total_shifts = upshift_total + downshift_total - shift_score = ((upshift_good + downshift_good) / total_shifts * 100) if total_shifts > 0 else 100 + self._shift_score = ((upshift_good + downshift_good) / total_shifts * 100) if total_shifts > 0 else 100 launch_score = (launch_good / launch_total * 100) if launch_total > 0 else 100 # Penalties @@ -85,7 +134,7 @@ def _calculate_grade(self): lug_penalty = lugs * 5 launch_stall_penalty = launch_stalled * 15 - overall_score = max(0, min(100, (shift_score + launch_score) / 2 - stall_penalty - lug_penalty - launch_stall_penalty)) + overall_score = max(0, min(100, (self._shift_score + launch_score) / 2 - stall_penalty - lug_penalty - launch_stall_penalty)) # Poker card ranking: 10, J, Q, K, A if overall_score >= 90 and stalls == 0: @@ -132,43 +181,55 @@ def _get_encouragement_text(self) -> str: messages = [] if self._overall_grade == "good": - if self._card_rank == "A": - messages.append("Ace drive! You're a true waddle master!") + # Check for perfect drive - Kacper glasses moment + total_shifts = upshift_total + downshift_total + total_good = upshift_good + downshift_good + perfect_shifts = total_shifts > 0 and total_good == total_shifts + perfect_launches = launch_total > 0 and launch_good == launch_total + + if self._card_rank == "A" and stalls == 0 and lugs == 0 and perfect_shifts and perfect_launches: + messages.append("PERFECT! Waddle is driving! Kacper threw his glasses!") + elif self._card_rank == "A": + messages.append("Aces! Porch-worthy waddle, KP earned!") elif self._card_rank == "K": - messages.append("King of the road! Waddling like a pro!") + messages.append("Kings! Waddle energy, CCM vibes!") if stalls == 0 and launch_stalled == 0: messages.append("No stalls!") - if upshift_total > 0 and upshift_good == upshift_total: + if perfect_shifts: + messages.append("Perfect shifts - priest-approved!") + elif upshift_total > 0 and upshift_good == upshift_total: messages.append("Perfect upshifts!") if downshift_total > 0 and downshift_good >= downshift_total * 0.8: messages.append("Great rev matching!") - if launch_total > 0 and launch_good >= launch_total * 0.8: + if perfect_launches: + messages.append("Flawless launches!") + elif launch_total > 0 and launch_good >= launch_total * 0.8: messages.append("Smooth launches!") if not messages: - messages.append("Keep waddling!") + messages.append("Keep channeling waddle!") elif self._overall_grade == "ok": if self._card_rank == "Q": - messages.append("Queen-level driving - almost there!") + messages.append("Queens - almost there!") else: - messages.append("Jack of all gears - room to improve!") + messages.append("Jacks - improving, not SS!") if stalls > 0: - messages.append(f"Only {stalls} stall{'s' if stalls > 1 else ''} - improving!") + messages.append(f"Only {stalls} stall{'s' if stalls > 1 else ''} - shedding jackets!") if lugs > 0: messages.append(f"Watch RPMs - {lugs} lug{'s' if lugs > 1 else ''}.") if upshift_total > 0 and upshift_good < upshift_total: messages.append("Smoother upshifts needed.") else: # poor - jackets - messages.append("Time to hang up those jackets and try again!") + messages.append("Jacketed! Huge oof. SS vibes!") if stalls > 2: messages.append(f"{stalls} stalls - more gas, slower clutch!") if launch_stalled > 0: - messages.append(f"{launch_stalled} stalled launch{'es' if launch_stalled > 1 else ''} - find that bite point!") + messages.append(f"{launch_stalled} stalled launch{'es' if launch_stalled > 1 else ''} - find bite point!") if lugs > 3: - messages.append(f"Lugging {lugs} times - downshift sooner!") + messages.append(f"Lugging {lugs}x - downshift sooner!") if not messages[1:]: - messages.append("Every pro stalled at first. Keep at it!") + messages.append("Even the best got jacketed at first. QG!") return " ".join(messages) @@ -197,8 +258,8 @@ def _render(self, rect: rl.Rectangle): rl.draw_rectangle(0, 0, gui_app.width, gui_app.height, rl.Color(0, 0, 0, 180)) # Dialog dimensions - dialog_w = min(500, gui_app.width - 40) - dialog_h = min(600, gui_app.height - 40) + dialog_w = min(520, gui_app.width - 40) + dialog_h = min(680, gui_app.height - 40) dialog_x = (gui_app.width - dialog_w) // 2 dialog_y = (gui_app.height - dialog_h) // 2 @@ -209,78 +270,162 @@ def _render(self, rect: rl.Rectangle): ) # Content area - x = dialog_x + 30 - y = dialog_y + 25 - w = dialog_w - 60 + x = dialog_x + 25 + y = dialog_y + 20 + w = dialog_w - 50 + + font_bold = gui_app.font(FontWeight.BOLD) + font_medium = gui_app.font(FontWeight.MEDIUM) + font_roman = gui_app.font(FontWeight.ROMAN) # Header header_text, header_color = self._get_header_text() - font = gui_app.font(FontWeight.BOLD) - rl.draw_text_ex(font, header_text, rl.Vector2(x, y), 48, 0, header_color) - y += 55 + rl.draw_text_ex(font_bold, header_text, rl.Vector2(x, y), 44, 0, header_color) + y += 50 - # Card rank display - poker hand style - card_names = {"A": "Aces", "K": "Kings", "Q": "Queens", "J": "Jacks", "10": "10s"} + # Card rank display - poker hand style with subtitle card_color = GREEN if self._card_rank in ("A", "K") else (YELLOW if self._card_rank in ("Q", "J") else RED) - card_text = f"Your hand: {card_names[self._card_rank]}" - rl.draw_text_ex(gui_app.font(FontWeight.MEDIUM), card_text, rl.Vector2(x, y), 32, 0, card_color) - y += 45 + card_text = f"Your hand: {HAND_NAMES[self._card_rank]}" + rl.draw_text_ex(font_medium, card_text, rl.Vector2(x, y), 28, 0, card_color) + # Subtitle + subtitle = HAND_SUBTITLES[self._card_rank] + subtitle_width = rl.measure_text_ex(font_roman, subtitle, 20, 0).x + rl.draw_text_ex(font_roman, subtitle, rl.Vector2(x + w - subtitle_width, y + 4), 20, 0, card_color) + y += 38 # Duration duration = self._session_data.get('duration', 0) duration_min = int(duration // 60) duration_sec = int(duration % 60) - rl.draw_text_ex(gui_app.font(FontWeight.ROMAN), f"Drive Duration: {duration_min}:{duration_sec:02d}", - rl.Vector2(x, y), 28, 0, GRAY) - y += 45 + rl.draw_text_ex(font_roman, f"Drive: {duration_min}:{duration_sec:02d}", + rl.Vector2(x, y), 22, 0, GRAY) + y += 35 - # Separator - rl.draw_rectangle(x, y, w, 2, rl.Color(60, 60, 60, 255)) + # Shift Score Progress Bar with comparison + y = self._draw_score_bar(x, y, w, "Shift Score", self._shift_score, self._avg_shift_score) y += 15 - # Stats sections - y = self._draw_stat_section(x, y, w, "Stalls", self._session_data.get('stall_count', 0), target=0, lower_better=True) - y = self._draw_stat_section(x, y, w, "Engine Lugs", self._session_data.get('lug_count', 0), target=0, lower_better=True) + # Stats in a card + rl.draw_rectangle_rounded(rl.Rectangle(x, y, w, 180), 0.02, 10, BG_CARD) + card_x = x + 15 + card_y = y + 12 + + # Jackets section (stalls + lugs) + stalls = self._session_data.get('stall_count', 0) + lugs = self._session_data.get('lug_count', 0) + jackets_text = "Jackets:" if (stalls > 0 or lugs > 0) else "No Jackets!" + jackets_color = RED if stalls > 0 else (YELLOW if lugs > 0 else GREEN) + rl.draw_text_ex(font_medium, jackets_text, rl.Vector2(card_x, card_y), 24, 0, jackets_color) + card_y += 30 + + card_y = self._draw_mini_stat(card_x, card_y, w - 30, "Stalls", stalls, 0, True) + card_y = self._draw_mini_stat(card_x, card_y, w - 30, "Lugs", lugs, 0, True) + + # Waddle section (launches + shifts) + card_y += 8 + rl.draw_text_ex(font_medium, "Waddle Stats:", rl.Vector2(card_x, card_y), 24, 0, WHITE) + card_y += 30 - # Launches launch_total = self._session_data.get('launch_count', 0) launch_good = self._session_data.get('launch_good', 0) - launch_stalled = self._session_data.get('launch_stalled', 0) - if launch_total > 0: - y = self._draw_stat_section(x, y, w, "Good Launches", f"{launch_good}/{launch_total}", - target=launch_total, current=launch_good) - if launch_stalled > 0: - y = self._draw_stat_section(x, y, w, "Stalled Launches", launch_stalled, target=0, lower_better=True) - - # Upshifts upshift_total = self._session_data.get('upshift_count', 0) upshift_good = self._session_data.get('upshift_good', 0) - if upshift_total > 0: - y = self._draw_stat_section(x, y, w, "Good Upshifts", f"{upshift_good}/{upshift_total}", - target=upshift_total, current=upshift_good) - - # Downshifts downshift_total = self._session_data.get('downshift_count', 0) downshift_good = self._session_data.get('downshift_good', 0) - if downshift_total > 0: - y = self._draw_stat_section(x, y, w, "Good Downshifts", f"{downshift_good}/{downshift_total}", - target=downshift_total, current=downshift_good) - y += 10 + if launch_total > 0: + card_y = self._draw_mini_stat(card_x, card_y, w - 30, "Launches", f"{launch_good}/{launch_total}", launch_total, False, launch_good) + total_shifts = upshift_total + downshift_total + total_good = upshift_good + downshift_good + if total_shifts > 0: + card_y = self._draw_mini_stat(card_x, card_y, w - 30, "Shifts", f"{total_good}/{total_shifts}", total_shifts, False, total_good) + + y += 190 # Encouragement/criticism text encouragement = self._get_encouragement_text() - wrapped = wrap_text(gui_app.font(FontWeight.ROMAN), encouragement, 24, w) + wrapped = wrap_text(font_roman, encouragement, 22, w) for line in wrapped: - rl.draw_text_ex(gui_app.font(FontWeight.ROMAN), line, rl.Vector2(x, y), 24, 0, LIGHT_GRAY) - y += int(24 * FONT_SCALE) + rl.draw_text_ex(font_roman, line, rl.Vector2(x, y), 22, 0, LIGHT_GRAY) + y += 28 # Tap to dismiss hint - hint_text = "Tap to dismiss" - hint_font = gui_app.font(FontWeight.ROMAN) - hint_size = 20 - rl.draw_text_ex(hint_font, hint_text, rl.Vector2(dialog_x + dialog_w // 2 - 50, dialog_y + dialog_h - 35), - hint_size, 0, GRAY) + hint_text = "Tap anywhere to dismiss" + hint_width = rl.measure_text_ex(font_roman, hint_text, 18, 0).x + rl.draw_text_ex(font_roman, hint_text, rl.Vector2(dialog_x + (dialog_w - hint_width) // 2, dialog_y + dialog_h - 30), + 18, 0, GRAY) + + def _draw_score_bar(self, x: int, y: int, w: int, label: str, score: float, avg_score: float) -> int: + """Draw a progress bar showing score vs average""" + font_medium = gui_app.font(FontWeight.MEDIUM) + font_roman = gui_app.font(FontWeight.ROMAN) + + # Label and score + rl.draw_text_ex(font_medium, label, rl.Vector2(x, y), 22, 0, WHITE) + score_text = f"{int(score)}%" + score_color = GREEN if score >= 80 else (YELLOW if score >= 50 else RED) + score_width = rl.measure_text_ex(font_medium, score_text, 22, 0).x + rl.draw_text_ex(font_medium, score_text, rl.Vector2(x + w - score_width, y), 22, 0, score_color) + y += 28 + + # Progress bar background + bar_h = 16 + rl.draw_rectangle_rounded(rl.Rectangle(x, y, w, bar_h), 0.3, 10, rl.Color(60, 60, 60, 255)) + + # Progress bar fill + fill_w = int((score / 100) * w) + if fill_w > 0: + rl.draw_rectangle_rounded(rl.Rectangle(x, y, fill_w, bar_h), 0.3, 10, score_color) + + # Average marker line + if avg_score > 0: + avg_x = x + int((avg_score / 100) * w) + rl.draw_rectangle(avg_x - 1, y - 2, 3, bar_h + 4, WHITE) + + y += bar_h + 6 + + # Comparison text + if avg_score > 0: + diff = score - avg_score + if diff > 5: + comp_text = f"Above avg (+{int(diff)})" + comp_color = GREEN + elif diff < -5: + comp_text = f"Below avg ({int(diff)})" + comp_color = RED + else: + comp_text = "Near average" + comp_color = GRAY + rl.draw_text_ex(font_roman, comp_text, rl.Vector2(x, y), 16, 0, comp_color) + rl.draw_text_ex(font_roman, "| = your avg", rl.Vector2(x + w - 80, y), 16, 0, GRAY) + y += 22 + + return y + + def _draw_mini_stat(self, x: int, y: int, w: int, label: str, value, target, lower_better: bool, current=None) -> int: + """Draw a compact stat row""" + font_roman = gui_app.font(FontWeight.ROMAN) + font_size = 20 + + # Determine color + if lower_better: + if isinstance(value, int): + color = GREEN if value == 0 else (YELLOW if value <= 2 else RED) + else: + color = LIGHT_GRAY + else: + if current is not None and target > 0: + ratio = current / target + color = GREEN if ratio >= 0.8 else (YELLOW if ratio >= 0.5 else RED) + else: + color = LIGHT_GRAY + + rl.draw_text_ex(font_roman, label, rl.Vector2(x, y), font_size, 0, LIGHT_GRAY) + value_str = str(value) + value_width = rl.measure_text_ex(font_roman, value_str, font_size, 0).x + rl.draw_text_ex(font_roman, value_str, rl.Vector2(x + w - value_width, y), font_size, 0, color) + + return y + 26 def _draw_stat_section(self, x: int, y: int, w: int, label: str, value, target=None, current=None, lower_better=False) -> int: diff --git a/selfdrive/ui/mici/layouts/settings/manual_stats.py b/selfdrive/ui/mici/layouts/settings/manual_stats.py index ca198761768c69..efb8e747fe66c1 100644 --- a/selfdrive/ui/mici/layouts/settings/manual_stats.py +++ b/selfdrive/ui/mici/layouts/settings/manual_stats.py @@ -72,8 +72,10 @@ def _render(self, rect: rl.Rectangle): rl.Vector2(x, y), 28, 0, GRAY) return - # Overview card - y = self._draw_card(x, y, w, "Overview", [ + # Overall hand rating + hand_rating, hand_color = self._get_overall_hand() + y = self._draw_card(x, y, w, "Your Hand", [ + ("Overall Rating", hand_rating, hand_color), ("Total Drives", str(self._stats.get('total_drives', 0)), WHITE), ("Total Drive Time", self._format_time(self._stats.get('total_drive_time', 0)), WHITE), ("Total Stalls", str(self._stats.get('total_stalls', 0)), self._stall_color(self._stats.get('total_stalls', 0))), @@ -135,6 +137,23 @@ def _render(self, rect: rl.Rectangle): y = self._draw_card(x, y, w, "Recent Trends", trend_items) y += 15 + # Per-gear smoothness chart + gear_counts = self._stats.get('gear_shift_counts', {}) + gear_jerks = self._stats.get('gear_shift_jerk_totals', {}) + if gear_counts and any(gear_counts.values()): + y = self._draw_gear_chart(x, y, w, gear_counts, gear_jerks) + y += 15 + + # Session history charts + session_history = self._stats.get('session_history', []) + if session_history: + y = self._draw_shift_chart(x, y, w, session_history) + y += 15 + y = self._draw_stalls_chart(x, y, w, session_history) + y += 15 + y = self._draw_launch_chart(x, y, w, session_history) + y += 15 + # Encouragement based on progress (with text wrapping) y += 10 encouragement = self._get_encouragement() @@ -144,11 +163,19 @@ def _render(self, rect: rl.Rectangle): y += 30 def _draw_card(self, x: int, y: int, w: int, title: str, items: list) -> int: - """Draw a card with title and stat items""" + """Draw a card with title and stat items, with wrapping for long values""" font_bold = gui_app.font(FontWeight.BOLD) font_medium = gui_app.font(FontWeight.MEDIUM) - card_h = 50 + len(items) * 38 + # Calculate height - check for items that need wrapping + extra_lines = 0 + max_value_width = w - 140 # Leave space for label + for _, value, _ in items: + value_width = rl.measure_text_ex(font_medium, value, 26, 0).x + if value_width > max_value_width: + extra_lines += 1 + + card_h = 50 + len(items) * 38 + extra_lines * 30 rl.draw_rectangle_rounded(rl.Rectangle(x, y, w, card_h), 0.02, 10, BG_CARD) # Title @@ -159,11 +186,282 @@ def _draw_card(self, x: int, y: int, w: int, title: str, items: list) -> int: for label, value, color in items: rl.draw_text_ex(font_medium, label, rl.Vector2(x + 15, y), 26, 0, LIGHT_GRAY) value_width = rl.measure_text_ex(font_medium, value, 26, 0).x - rl.draw_text_ex(font_medium, value, rl.Vector2(x + w - 15 - value_width, y), 26, 0, color) - y += 38 + + # Check if value needs to wrap to next line + if value_width > max_value_width: + # Draw value on next line, left-aligned with indent + y += 30 + rl.draw_text_ex(font_medium, value, rl.Vector2(x + 25, y), 24, 0, color) + y += 38 + else: + # Draw value right-aligned on same line + rl.draw_text_ex(font_medium, value, rl.Vector2(x + w - 15 - value_width, y), 26, 0, color) + y += 38 return y + def _draw_shift_chart(self, x: int, y: int, w: int, sessions: list) -> int: + """Draw a bar chart showing shift score history""" + import datetime + font_bold = gui_app.font(FontWeight.BOLD) + font_small = gui_app.font(FontWeight.ROMAN) + + chart_h = 200 + rl.draw_rectangle_rounded(rl.Rectangle(x, y, w, chart_h), 0.02, 10, BG_CARD) + + # Title + rl.draw_text_ex(font_bold, "Shift Score History", rl.Vector2(x + 15, y + 12), 28, 0, WHITE) + + # Chart area + chart_x = x + 40 + chart_y = y + 50 + chart_w = w - 60 + chart_inner_h = 90 + + # Draw axis + rl.draw_line(chart_x, chart_y + chart_inner_h, chart_x + chart_w, chart_y + chart_inner_h, GRAY) + rl.draw_line(chart_x, chart_y, chart_x, chart_y + chart_inner_h, GRAY) + + # Y-axis labels + rl.draw_text_ex(font_small, "100", rl.Vector2(x + 10, chart_y - 5), 14, 0, GRAY) + rl.draw_text_ex(font_small, "50", rl.Vector2(x + 15, chart_y + chart_inner_h // 2 - 5), 14, 0, GRAY) + rl.draw_text_ex(font_small, "0", rl.Vector2(x + 22, chart_y + chart_inner_h - 5), 14, 0, GRAY) + + display_sessions = sessions[-12:] if len(sessions) > 12 else sessions + if not display_sessions: + return y + chart_h + + bar_spacing = 4 + bar_w = max(8, (chart_w - bar_spacing * len(display_sessions)) // len(display_sessions)) + + for i, session in enumerate(display_sessions): + ups = session.get('upshifts', 0) + ups_good = session.get('upshifts_good', 0) + downs = session.get('downshifts', 0) + downs_good = session.get('downshifts_good', 0) + total = ups + downs + score = ((ups_good + downs_good) / total * 100) if total > 0 else 100 + + bar_h = int((score / 100) * chart_inner_h) + bar_x = chart_x + i * (bar_w + bar_spacing) + bar_y = chart_y + chart_inner_h - bar_h + + color = GREEN if score >= 80 else (YELLOW if score >= 50 else RED) + rl.draw_rectangle(int(bar_x), int(bar_y), int(bar_w), int(bar_h), color) + + # Day label + timestamp = session.get('timestamp', 0) + if timestamp > 0: + dt = datetime.datetime.fromtimestamp(timestamp) + day_x = bar_x + bar_w // 2 - 4 + rl.draw_text_ex(font_small, str(dt.day), rl.Vector2(day_x, chart_y + chart_inner_h + 4), 13, 0, GRAY) + + # Legend + legend_y = chart_y + chart_inner_h + 22 + rl.draw_text_ex(font_small, "Higher = better shifts. Green 80%+, Yellow 50%+, Red <50%", rl.Vector2(chart_x, legend_y), 14, 0, GRAY) + + return y + chart_h + + def _draw_stalls_chart(self, x: int, y: int, w: int, sessions: list) -> int: + """Draw a bar chart showing stalls and lugs per session""" + import datetime + font_bold = gui_app.font(FontWeight.BOLD) + font_small = gui_app.font(FontWeight.ROMAN) + + chart_h = 180 + rl.draw_rectangle_rounded(rl.Rectangle(x, y, w, chart_h), 0.02, 10, BG_CARD) + + # Title + rl.draw_text_ex(font_bold, "Stalls & Lugs (Jackets)", rl.Vector2(x + 15, y + 12), 28, 0, WHITE) + + # Chart area + chart_x = x + 40 + chart_y = y + 50 + chart_w = w - 60 + chart_inner_h = 70 + + # Find max for scaling + display_sessions = sessions[-12:] if len(sessions) > 12 else sessions + max_issues = max((s.get('stalls', 0) + s.get('lugs', 0) for s in display_sessions), default=1) + max_issues = max(max_issues, 5) # Min scale of 5 + + # Draw axis + rl.draw_line(chart_x, chart_y + chart_inner_h, chart_x + chart_w, chart_y + chart_inner_h, GRAY) + rl.draw_line(chart_x, chart_y, chart_x, chart_y + chart_inner_h, GRAY) + + # Y-axis labels + rl.draw_text_ex(font_small, str(max_issues), rl.Vector2(x + 15, chart_y - 5), 14, 0, GRAY) + rl.draw_text_ex(font_small, "0", rl.Vector2(x + 22, chart_y + chart_inner_h - 5), 14, 0, GRAY) + + if not display_sessions: + return y + chart_h + + bar_spacing = 4 + bar_w = max(8, (chart_w - bar_spacing * len(display_sessions)) // len(display_sessions)) + + for i, session in enumerate(display_sessions): + stalls = session.get('stalls', 0) + lugs = session.get('lugs', 0) + bar_x = chart_x + i * (bar_w + bar_spacing) + + # Stacked bar: stalls (red) on bottom, lugs (orange) on top + stall_h = int((stalls / max_issues) * chart_inner_h) + lug_h = int((lugs / max_issues) * chart_inner_h) + + # Lugs (yellow/orange) - bottom + if lug_h > 0: + rl.draw_rectangle(int(bar_x), int(chart_y + chart_inner_h - lug_h), int(bar_w), int(lug_h), YELLOW) + + # Stalls (red) - stacked on top of lugs + if stall_h > 0: + rl.draw_rectangle(int(bar_x), int(chart_y + chart_inner_h - lug_h - stall_h), int(bar_w), int(stall_h), RED) + + # Day label + timestamp = session.get('timestamp', 0) + if timestamp > 0: + dt = datetime.datetime.fromtimestamp(timestamp) + day_x = bar_x + bar_w // 2 - 4 + rl.draw_text_ex(font_small, str(dt.day), rl.Vector2(day_x, chart_y + chart_inner_h + 4), 13, 0, GRAY) + + # Legend + legend_y = chart_y + chart_inner_h + 22 + rl.draw_rectangle(int(chart_x), int(legend_y + 2), 12, 12, RED) + rl.draw_text_ex(font_small, "Stalls", rl.Vector2(chart_x + 16, legend_y), 14, 0, GRAY) + rl.draw_rectangle(int(chart_x + 70), int(legend_y + 2), 12, 12, YELLOW) + rl.draw_text_ex(font_small, "Lugs", rl.Vector2(chart_x + 86, legend_y), 14, 0, GRAY) + rl.draw_text_ex(font_small, "Lower = fewer jackets!", rl.Vector2(chart_x + 140, legend_y), 14, 0, GRAY) + + return y + chart_h + + def _draw_launch_chart(self, x: int, y: int, w: int, sessions: list) -> int: + """Draw a bar chart showing launch success rate""" + import datetime + font_bold = gui_app.font(FontWeight.BOLD) + font_small = gui_app.font(FontWeight.ROMAN) + + chart_h = 180 + rl.draw_rectangle_rounded(rl.Rectangle(x, y, w, chart_h), 0.02, 10, BG_CARD) + + # Title + rl.draw_text_ex(font_bold, "Launch Success (Waddle Rate)", rl.Vector2(x + 15, y + 12), 28, 0, WHITE) + + # Chart area + chart_x = x + 40 + chart_y = y + 50 + chart_w = w - 60 + chart_inner_h = 70 + + # Draw axis + rl.draw_line(chart_x, chart_y + chart_inner_h, chart_x + chart_w, chart_y + chart_inner_h, GRAY) + rl.draw_line(chart_x, chart_y, chart_x, chart_y + chart_inner_h, GRAY) + + # Y-axis labels + rl.draw_text_ex(font_small, "100%", rl.Vector2(x + 5, chart_y - 5), 14, 0, GRAY) + rl.draw_text_ex(font_small, "0%", rl.Vector2(x + 15, chart_y + chart_inner_h - 5), 14, 0, GRAY) + + display_sessions = sessions[-12:] if len(sessions) > 12 else sessions + if not display_sessions: + return y + chart_h + + bar_spacing = 4 + bar_w = max(8, (chart_w - bar_spacing * len(display_sessions)) // len(display_sessions)) + + for i, session in enumerate(display_sessions): + launches = session.get('launches', 0) + launches_good = session.get('launches_good', 0) + bar_x = chart_x + i * (bar_w + bar_spacing) + + if launches > 0: + pct = (launches_good / launches) * 100 + bar_h = int((pct / 100) * chart_inner_h) + bar_y = chart_y + chart_inner_h - bar_h + color = GREEN if pct >= 80 else (YELLOW if pct >= 50 else RED) + rl.draw_rectangle(int(bar_x), int(bar_y), int(bar_w), int(bar_h), color) + else: + # No launches - draw thin gray bar + rl.draw_rectangle(int(bar_x), int(chart_y + chart_inner_h - 2), int(bar_w), 2, GRAY) + + # Day label + timestamp = session.get('timestamp', 0) + if timestamp > 0: + dt = datetime.datetime.fromtimestamp(timestamp) + day_x = bar_x + bar_w // 2 - 4 + rl.draw_text_ex(font_small, str(dt.day), rl.Vector2(day_x, chart_y + chart_inner_h + 4), 13, 0, GRAY) + + # Legend + legend_y = chart_y + chart_inner_h + 22 + rl.draw_text_ex(font_small, "Higher = smoother launches = more waddle, less jacket!", rl.Vector2(chart_x, legend_y), 14, 0, GRAY) + + return y + chart_h + + def _draw_gear_chart(self, x: int, y: int, w: int, gear_counts: dict, gear_jerks: dict) -> int: + """Draw a bar chart showing shift smoothness into each gear (1-6)""" + font_bold = gui_app.font(FontWeight.BOLD) + font_small = gui_app.font(FontWeight.ROMAN) + + chart_h = 180 + rl.draw_rectangle_rounded(rl.Rectangle(x, y, w, chart_h), 0.02, 10, BG_CARD) + + # Title + rl.draw_text_ex(font_bold, "Waddle Smoothness by Gear", rl.Vector2(x + 15, y + 12), 28, 0, WHITE) + + # Chart area + chart_x = x + 50 + chart_y = y + 50 + chart_w = w - 70 + chart_inner_h = 70 + + # Draw axis + rl.draw_line(chart_x, chart_y + chart_inner_h, chart_x + chart_w, chart_y + chart_inner_h, GRAY) + rl.draw_line(chart_x, chart_y, chart_x, chart_y + chart_inner_h, GRAY) + + # Y-axis labels (smoothness score, higher = better) + rl.draw_text_ex(font_small, "Smooth", rl.Vector2(x + 5, chart_y - 2), 12, 0, GREEN) + rl.draw_text_ex(font_small, "Jerky", rl.Vector2(x + 10, chart_y + chart_inner_h - 10), 12, 0, RED) + + # Calculate smoothness scores for each gear (invert jerk - lower jerk = higher score) + bar_spacing = 12 + bar_w = (chart_w - bar_spacing * 5) // 6 + + for gear in range(1, 7): + count = gear_counts.get(gear, gear_counts.get(str(gear), 0)) + jerk_total = gear_jerks.get(gear, gear_jerks.get(str(gear), 0.0)) + + bar_x = chart_x + (gear - 1) * (bar_w + bar_spacing) + + if count > 0: + avg_jerk = jerk_total / count + # Convert jerk to smoothness score (0-100), lower jerk = higher score + # Jerk of 0 = 100, jerk of 5+ = 0 + smoothness = max(0, min(100, 100 - (avg_jerk * 20))) + + bar_h = int((smoothness / 100) * chart_inner_h) + bar_y = chart_y + chart_inner_h - bar_h + + # Color based on smoothness + if smoothness >= 80: + color = GREEN + elif smoothness >= 50: + color = YELLOW + else: + color = RED + + rl.draw_rectangle(int(bar_x), int(bar_y), int(bar_w), int(bar_h), color) + else: + # No data - draw thin gray bar + rl.draw_rectangle(int(bar_x), int(chart_y + chart_inner_h - 2), int(bar_w), 2, GRAY) + + # Gear label + gear_label = str(gear) + label_x = bar_x + bar_w // 2 - 5 + rl.draw_text_ex(font_small, gear_label, rl.Vector2(label_x, chart_y + chart_inner_h + 6), 16, 0, WHITE) + + # Legend + legend_y = chart_y + chart_inner_h + 28 + rl.draw_text_ex(font_small, "Green = waddle smooth, Red = jerky jackets. Practice weak gears!", rl.Vector2(x + 15, legend_y), 14, 0, GRAY) + + return y + chart_h + def _measure_content_height(self, rect: rl.Rectangle) -> int: """Measure total content height for scrolling""" y = 20 + 60 # Title @@ -171,14 +469,23 @@ def _measure_content_height(self, rect: rl.Rectangle) -> int: if not self._stats or self._stats.get('total_drives', 0) == 0: return y + 40 - # Overview card - y += 50 + 4 * 38 + 15 + # Overview card (now has 5 items with hand rating, +30 for potential wrap) + y += 50 + 5 * 38 + 30 + 15 # Shift card y += 50 + 4 * 38 + 15 # Launch card y += 50 + 3 * 38 + 15 # Trend card (estimate) y += 50 + 3 * 38 + 15 + # Gear chart + if self._stats.get('gear_shift_counts'): + y += 180 + 15 + + # Charts (3 charts) + if self._stats.get('session_history'): + y += 200 + 15 # Shift score chart + y += 180 + 15 # Stalls/lugs chart + y += 180 + 15 # Launch chart # Encouragement (estimate 2-3 lines wrapped) y += 100 @@ -240,27 +547,87 @@ def _trend_text(self, trend: float, lower_better: bool) -> tuple[str, rl.Color]: return "Improving!", GREEN return "Getting worse", RED + def _get_overall_hand(self) -> tuple[str, rl.Color]: + """Calculate overall poker hand rating based on all stats""" + total_drives = self._stats.get('total_drives', 0) + if total_drives == 0: + return "No Cards Yet", GRAY + + total_stalls = self._stats.get('total_stalls', 0) + total_shifts = self._stats.get('total_upshifts', 0) + self._stats.get('total_downshifts', 0) + good_shifts = self._stats.get('upshifts_good', self._stats.get('total_upshifts_good', 0)) + \ + self._stats.get('downshifts_good', self._stats.get('total_downshifts_good', 0)) + + stall_rate = total_stalls / total_drives + shift_pct = (good_shifts / total_shifts * 100) if total_shifts > 0 else 100 + + # Calculate overall score + score = shift_pct - (stall_rate * 10) + + # Recent improvement bonus + recent_scores = self._stats.get('recent_shift_scores', []) + if len(recent_scores) >= 3: + if recent_scores[-1] > recent_scores[0]: + score += 5 # Bonus for improving + + if score >= 98 and stall_rate == 0: + return "Royal Flush - Waddle is driving! Kacper threw his glasses!", GREEN + elif score >= 95 and stall_rate == 0: + return "Royal Flush - Porch-worthy waddle! KP earned!", GREEN + elif score >= 90: + return "Straight Flush - Elite waddle, CCM vibes!", GREEN + elif score >= 85: + return "Four of a Kind - Priest-approved waddle!", GREEN + elif score >= 80: + return "Full House - Solid waddle, not SS!", GREEN + elif score >= 70: + return "Flush - Good waddle, almost KP", YELLOW + elif score >= 60: + return "Straight - Improving, not SS yet", YELLOW + elif score >= 50: + return "Three of a Kind - Getting there, shake off jackets", YELLOW + elif score >= 40: + return "Two Pair - Jackets territory", YELLOW + elif score >= 30: + return "One Pair - Jacketed, huge oof", RED + else: + return "High Card - SS! Full jackets!", RED + def _get_encouragement(self) -> str: """Get encouragement based on overall progress""" total_drives = self._stats.get('total_drives', 0) total_stalls = self._stats.get('total_stalls', 0) recent_stalls = self._stats.get('recent_stall_rates', []) + recent_scores = self._stats.get('recent_shift_scores', []) if total_drives == 0: - return "Start driving to see your stats!" + return "Start driving to see your stats! Time to earn your first waddle KP." stall_rate = total_stalls / total_drives if total_drives > 0 else 0 + # Check for improvement + improving = False + if len(recent_scores) >= 3: + if recent_scores[-1] > recent_scores[0] + 5: + improving = True + if len(recent_stalls) >= 3: recent_avg = sum(recent_stalls[-3:]) / 3 if recent_avg == 0: - return "No stalls in recent drives - you're getting the hang of it!" + # Check for crazy good performance + if len(recent_scores) >= 3 and all(s >= 95 for s in recent_scores[-3:]): + return "3 drives 95%+ NO stalls?! Waddle is driving! Kacper threw his glasses!" + if improving: + return "No stalls AND improving? Waddle energy! QG to KP!" + return "No stalls recent - waddle game strong! Not SS, priest-approved!" elif recent_avg < stall_rate: - return "Your recent drives are better than average - keep it up!" + return "Recent drives better than avg - shedding jackets, channeling waddle!" if stall_rate < 0.5: - return "Less than 1 stall per 2 drives on average - nice work!" + if improving: + return "< 1 stall per 2 drives AND improving! Porch-worthy waddle progress!" + return "< 1 stall per 2 drives - solid waddle vibes, not SS!" elif stall_rate < 1: - return "About 1 stall per drive - you're learning fast!" + return "~1 stall per drive - de-jacketing in progress!" else: - return "Keep practicing! Everyone stalls when learning manual." + return "Keep at it! Even the best got jacketed at first. QG to KP!" diff --git a/selfdrive/ui/mici/layouts/settings/settings.py b/selfdrive/ui/mici/layouts/settings/settings.py index fc4ca77874537b..5523190659900c 100644 --- a/selfdrive/ui/mici/layouts/settings/settings.py +++ b/selfdrive/ui/mici/layouts/settings/settings.py @@ -54,8 +54,8 @@ def __init__(self): manual_stats_btn.set_click_callback(lambda: self._set_current_panel(PanelType.MANUAL_STATS)) self._scroller = Scroller([ + manual_stats_btn, # MT Stats first! toggles_btn, - manual_stats_btn, # MT Stats right after Toggles network_btn, device_btn, PairBigButton(), From 28086684808c978cd9bea95f3115aba4a628ef0a Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Sat, 24 Jan 2026 22:16:20 -0800 Subject: [PATCH 05/82] show summary dialog --- opendbc_repo | 2 +- .../ui/mici/layouts/manual_drive_summary.py | 126 ++++++++---------- .../ui/mici/layouts/settings/manual_stats.py | 56 +++++--- 3 files changed, 96 insertions(+), 88 deletions(-) diff --git a/opendbc_repo b/opendbc_repo index 9ee44cf28b9beb..6b8347257e11fc 160000 --- a/opendbc_repo +++ b/opendbc_repo @@ -1 +1 @@ -Subproject commit 9ee44cf28b9bebfec9e4ec30af4e0f232f329b6a +Subproject commit 6b8347257e11fc5b95538027bf00d845052057b9 diff --git a/selfdrive/ui/mici/layouts/manual_drive_summary.py b/selfdrive/ui/mici/layouts/manual_drive_summary.py index 62c5a82c0b8edf..37119e1a87fe80 100644 --- a/selfdrive/ui/mici/layouts/manual_drive_summary.py +++ b/selfdrive/ui/mici/layouts/manual_drive_summary.py @@ -7,14 +7,14 @@ """ import json -import time import pyray as rl from typing import Optional, Callable from openpilot.common.params import Params from openpilot.system.ui.lib.application import gui_app, FontWeight, FONT_SCALE +from openpilot.system.ui.lib.scroll_panel2 import GuiScrollPanel2 from openpilot.system.ui.lib.wrap_text import wrap_text -from openpilot.system.ui.widgets import Widget +from openpilot.system.ui.widgets import NavWidget # Colors @@ -31,7 +31,7 @@ # Poker hand names HAND_NAMES = { "A": "Aces", - "K": "Kings", + "K": "Kings", "Q": "Queens", "J": "Jacks", "10": "10s" @@ -46,25 +46,27 @@ } -class ManualDriveSummaryDialog(Widget): +class ManualDriveSummaryDialog(NavWidget): """Modal dialog showing end-of-drive manual transmission stats""" def __init__(self, dismiss_callback: Optional[Callable] = None): super().__init__() self._params = Params() - self._dismiss_callback = dismiss_callback + self._scroll_panel = GuiScrollPanel2(horizontal=False) self._session_data: Optional[dict] = None self._historical_data: Optional[dict] = None self._overall_grade: str = "good" # good, ok, poor self._card_rank: str = "10" # Poker card rank: 10, J, Q, K, A self._shift_score: float = 0.0 self._avg_shift_score: float = 0.0 - self._show_time: float = 0.0 - self._auto_dismiss_after: float = 30.0 # Auto dismiss after 30 seconds + # Load data immediately since show_event may not be called for modals + self._load_session() + self._load_historical() + # Set back callback to dismiss modal + self.set_back_callback(lambda: gui_app.set_modal_overlay(None)) def show_event(self): super().show_event() - self._show_time = time.monotonic() self._load_session() self._load_historical() @@ -233,86 +235,77 @@ def _get_encouragement_text(self) -> str: return " ".join(messages) - def _handle_mouse_release(self, _): - """Dismiss on tap""" - if self._dismiss_callback: - self._dismiss_callback() - gui_app.dismiss_modal() + def _measure_content_height(self) -> int: + """Calculate total content height for scrolling""" + font_roman = gui_app.font(FontWeight.ROMAN) + h = 0 + h += 50 # Header + h += 38 # Card rank + h += 35 # Duration + h += 75 # Shift score bar + h += 195 # Stats card + # Encouragement text (estimate) + encouragement = self._get_encouragement_text() + wrapped = wrap_text(font_roman, encouragement, 22, 500) + h += len(wrapped) * 28 + 20 + return h def _render(self, rect: rl.Rectangle): - if not self._session_data: - # Auto-dismiss if no data - if self._dismiss_callback: - self._dismiss_callback() - gui_app.dismiss_modal() - return + # Content area with scrolling + content_rect = rl.Rectangle(rect.x + 10, rect.y + 10, rect.width - 20, rect.height - 20) + content_height = self._measure_content_height() + scroll_offset = round(self._scroll_panel.update(content_rect, content_height)) - # Auto-dismiss after timeout - if time.monotonic() - self._show_time > self._auto_dismiss_after: - if self._dismiss_callback: - self._dismiss_callback() - gui_app.dismiss_modal() - return - - # Draw semi-transparent background - rl.draw_rectangle(0, 0, gui_app.width, gui_app.height, rl.Color(0, 0, 0, 180)) - - # Dialog dimensions - dialog_w = min(520, gui_app.width - 40) - dialog_h = min(680, gui_app.height - 40) - dialog_x = (gui_app.width - dialog_w) // 2 - dialog_y = (gui_app.height - dialog_h) // 2 - - # Draw dialog background - rl.draw_rectangle_rounded( - rl.Rectangle(dialog_x, dialog_y, dialog_w, dialog_h), - 0.03, 10, BG_COLOR - ) - - # Content area - x = dialog_x + 25 - y = dialog_y + 20 - w = dialog_w - 50 + x = int(content_rect.x) + 20 # Padding on left + y = int(content_rect.y) + scroll_offset + w = int(content_rect.width) - 40 # Padding on both sides font_bold = gui_app.font(FontWeight.BOLD) font_medium = gui_app.font(FontWeight.MEDIUM) font_roman = gui_app.font(FontWeight.ROMAN) + # Enable scissor mode to clip content + rl.begin_scissor_mode(int(content_rect.x), int(content_rect.y), int(content_rect.width), int(content_rect.height)) + + # Top section card background (header, hand, duration, score bar) + top_card_h = 200 + rl.draw_rectangle_rounded(rl.Rectangle(x, y, w, top_card_h), 0.02, 10, BG_CARD) + # Header header_text, header_color = self._get_header_text() - rl.draw_text_ex(font_bold, header_text, rl.Vector2(x, y), 44, 0, header_color) - y += 50 + rl.draw_text_ex(font_bold, header_text, rl.Vector2(x + 15, y + 12), 44, 0, header_color) + y += 58 # Card rank display - poker hand style with subtitle card_color = GREEN if self._card_rank in ("A", "K") else (YELLOW if self._card_rank in ("Q", "J") else RED) card_text = f"Your hand: {HAND_NAMES[self._card_rank]}" - rl.draw_text_ex(font_medium, card_text, rl.Vector2(x, y), 28, 0, card_color) + rl.draw_text_ex(font_medium, card_text, rl.Vector2(x + 15, y), 28, 0, card_color) # Subtitle subtitle = HAND_SUBTITLES[self._card_rank] subtitle_width = rl.measure_text_ex(font_roman, subtitle, 20, 0).x - rl.draw_text_ex(font_roman, subtitle, rl.Vector2(x + w - subtitle_width, y + 4), 20, 0, card_color) + rl.draw_text_ex(font_roman, subtitle, rl.Vector2(x + w - subtitle_width - 35, y + 4), 20, 0, card_color) y += 38 # Duration - duration = self._session_data.get('duration', 0) + duration = self._session_data.get('duration', 0) if self._session_data else 0 duration_min = int(duration // 60) duration_sec = int(duration % 60) rl.draw_text_ex(font_roman, f"Drive: {duration_min}:{duration_sec:02d}", - rl.Vector2(x, y), 22, 0, GRAY) + rl.Vector2(x + 15, y), 22, 0, GRAY) y += 35 # Shift Score Progress Bar with comparison - y = self._draw_score_bar(x, y, w, "Shift Score", self._shift_score, self._avg_shift_score) + y = self._draw_score_bar(x + 15, y, w - 30, "Shift Score", self._shift_score, self._avg_shift_score) y += 15 # Stats in a card - rl.draw_rectangle_rounded(rl.Rectangle(x, y, w, 180), 0.02, 10, BG_CARD) + rl.draw_rectangle_rounded(rl.Rectangle(x, y, w, 190), 0.02, 10, BG_CARD) card_x = x + 15 card_y = y + 12 # Jackets section (stalls + lugs) - stalls = self._session_data.get('stall_count', 0) - lugs = self._session_data.get('lug_count', 0) + stalls = self._session_data.get('stall_count', 0) if self._session_data else 0 + lugs = self._session_data.get('lug_count', 0) if self._session_data else 0 jackets_text = "Jackets:" if (stalls > 0 or lugs > 0) else "No Jackets!" jackets_color = RED if stalls > 0 else (YELLOW if lugs > 0 else GREEN) rl.draw_text_ex(font_medium, jackets_text, rl.Vector2(card_x, card_y), 24, 0, jackets_color) @@ -326,21 +319,22 @@ def _render(self, rect: rl.Rectangle): rl.draw_text_ex(font_medium, "Waddle Stats:", rl.Vector2(card_x, card_y), 24, 0, WHITE) card_y += 30 - launch_total = self._session_data.get('launch_count', 0) - launch_good = self._session_data.get('launch_good', 0) - upshift_total = self._session_data.get('upshift_count', 0) - upshift_good = self._session_data.get('upshift_good', 0) - downshift_total = self._session_data.get('downshift_count', 0) - downshift_good = self._session_data.get('downshift_good', 0) + upshift_total = self._session_data.get('upshift_count', 0) if self._session_data else 0 + upshift_good = self._session_data.get('upshift_good', 0) if self._session_data else 0 + downshift_total = self._session_data.get('downshift_count', 0) if self._session_data else 0 + downshift_good = self._session_data.get('downshift_good', 0) if self._session_data else 0 + launch_total = self._session_data.get('launch_count', 0) if self._session_data else 0 + launch_good = self._session_data.get('launch_good', 0) if self._session_data else 0 if launch_total > 0: card_y = self._draw_mini_stat(card_x, card_y, w - 30, "Launches", f"{launch_good}/{launch_total}", launch_total, False, launch_good) + total_shifts = upshift_total + downshift_total total_good = upshift_good + downshift_good if total_shifts > 0: card_y = self._draw_mini_stat(card_x, card_y, w - 30, "Shifts", f"{total_good}/{total_shifts}", total_shifts, False, total_good) - y += 190 + y += 200 # Encouragement/criticism text encouragement = self._get_encouragement_text() @@ -349,11 +343,9 @@ def _render(self, rect: rl.Rectangle): rl.draw_text_ex(font_roman, line, rl.Vector2(x, y), 22, 0, LIGHT_GRAY) y += 28 - # Tap to dismiss hint - hint_text = "Tap anywhere to dismiss" - hint_width = rl.measure_text_ex(font_roman, hint_text, 18, 0).x - rl.draw_text_ex(font_roman, hint_text, rl.Vector2(dialog_x + (dialog_w - hint_width) // 2, dialog_y + dialog_h - 30), - 18, 0, GRAY) + rl.end_scissor_mode() + + return -1 # Keep showing dialog def _draw_score_bar(self, x: int, y: int, w: int, label: str, score: float, avg_score: float) -> int: """Draw a progress bar showing score vs average""" diff --git a/selfdrive/ui/mici/layouts/settings/manual_stats.py b/selfdrive/ui/mici/layouts/settings/manual_stats.py index efb8e747fe66c1..afc7d1e3c9abfd 100644 --- a/selfdrive/ui/mici/layouts/settings/manual_stats.py +++ b/selfdrive/ui/mici/layouts/settings/manual_stats.py @@ -12,6 +12,7 @@ from openpilot.system.ui.lib.scroll_panel2 import GuiScrollPanel2 from openpilot.system.ui.lib.wrap_text import wrap_text from openpilot.system.ui.widgets import Widget, NavWidget +from openpilot.selfdrive.ui.mici.layouts.manual_drive_summary import ManualDriveSummaryDialog # Colors @@ -67,6 +68,16 @@ def _render(self, rect: rl.Rectangle): rl.draw_text_ex(font_bold, "Manual Driving Stats", rl.Vector2(x, y), 48, 0, WHITE) y += 60 + # View Last Drive button + btn_w, btn_h = 340, 65 + btn_rect = rl.Rectangle(x, y, btn_w, btn_h) + btn_color = rl.Color(60, 60, 60, 255) if not rl.check_collision_point_rec(rl.get_mouse_position(), btn_rect) else rl.Color(80, 80, 80, 255) + rl.draw_rectangle_rounded(btn_rect, 0.3, 10, btn_color) + rl.draw_text_ex(font_medium, "View Last Drive Summary", rl.Vector2(x + 20, y + 18), 26, 0, WHITE) + if rl.is_mouse_button_released(rl.MouseButton.MOUSE_BUTTON_LEFT) and rl.check_collision_point_rec(rl.get_mouse_position(), btn_rect): + gui_app.set_modal_overlay(ManualDriveSummaryDialog()) + y += btn_h + 25 + if not self._stats or self._stats.get('total_drives', 0) == 0: rl.draw_text_ex(font_roman, "No driving data yet. Get out there and practice!", rl.Vector2(x, y), 28, 0, GRAY) @@ -86,8 +97,8 @@ def _render(self, rect: rl.Rectangle): # Shift quality card total_up = self._stats.get('total_upshifts', 0) total_down = self._stats.get('total_downshifts', 0) - up_good = self._stats.get('total_upshifts_good', 0) - down_good = self._stats.get('total_downshifts_good', 0) + up_good = self._stats.get('upshifts_good', 0) + down_good = self._stats.get('downshifts_good', 0) up_pct = f"{int(up_good / total_up * 100)}%" if total_up > 0 else "N/A" down_pct = f"{int(down_good / total_down * 100)}%" if total_down > 0 else "N/A" @@ -102,8 +113,8 @@ def _render(self, rect: rl.Rectangle): # Launch quality card total_launches = self._stats.get('total_launches', 0) - good_launches = self._stats.get('total_launches_good', 0) - stalled_launches = self._stats.get('total_launches_stalled', 0) + good_launches = self._stats.get('launches_good', 0) + stalled_launches = self._stats.get('launches_stalled', 0) launch_pct = f"{int(good_launches / total_launches * 100)}%" if total_launches > 0 else "N/A" @@ -169,13 +180,13 @@ def _draw_card(self, x: int, y: int, w: int, title: str, items: list) -> int: # Calculate height - check for items that need wrapping extra_lines = 0 - max_value_width = w - 140 # Leave space for label + max_value_width = w - 220 # Leave space for label, trigger wrap earlier for _, value, _ in items: - value_width = rl.measure_text_ex(font_medium, value, 26, 0).x + value_width = rl.measure_text_ex(font_medium, value, 24, 0).x if value_width > max_value_width: extra_lines += 1 - card_h = 50 + len(items) * 38 + extra_lines * 30 + card_h = 50 + len(items) * 38 + extra_lines * 32 rl.draw_rectangle_rounded(rl.Rectangle(x, y, w, card_h), 0.02, 10, BG_CARD) # Title @@ -184,18 +195,23 @@ def _draw_card(self, x: int, y: int, w: int, title: str, items: list) -> int: # Items for label, value, color in items: - rl.draw_text_ex(font_medium, label, rl.Vector2(x + 15, y), 26, 0, LIGHT_GRAY) - value_width = rl.measure_text_ex(font_medium, value, 26, 0).x + value_width = rl.measure_text_ex(font_medium, value, 24, 0).x - # Check if value needs to wrap to next line + # Check if value needs to wrap to next line (below label) if value_width > max_value_width: - # Draw value on next line, left-aligned with indent - y += 30 - rl.draw_text_ex(font_medium, value, rl.Vector2(x + 25, y), 24, 0, color) - y += 38 + # Draw label + rl.draw_text_ex(font_medium, label, rl.Vector2(x + 15, y), 26, 0, LIGHT_GRAY) + y += 32 + # Draw value on next line, wrapped if needed + wrapped = wrap_text(font_medium, value, 22, w - 40) + for line in wrapped: + rl.draw_text_ex(font_medium, line, rl.Vector2(x + 25, y), 22, 0, color) + y += 26 + y += 6 else: - # Draw value right-aligned on same line - rl.draw_text_ex(font_medium, value, rl.Vector2(x + w - 15 - value_width, y), 26, 0, color) + # Draw label and value on same line + rl.draw_text_ex(font_medium, label, rl.Vector2(x + 15, y), 26, 0, LIGHT_GRAY) + rl.draw_text_ex(font_medium, value, rl.Vector2(x + w - 15 - value_width, y), 24, 0, color) y += 38 return y @@ -465,12 +481,13 @@ def _draw_gear_chart(self, x: int, y: int, w: int, gear_counts: dict, gear_jerks def _measure_content_height(self, rect: rl.Rectangle) -> int: """Measure total content height for scrolling""" y = 20 + 60 # Title + y += 90 # View Last Drive button (65 + 25) if not self._stats or self._stats.get('total_drives', 0) == 0: return y + 40 - # Overview card (now has 5 items with hand rating, +30 for potential wrap) - y += 50 + 5 * 38 + 30 + 15 + # Overview card (now has 5 items with hand rating, +60 for potential wrapped lines) + y += 50 + 5 * 38 + 60 + 15 # Shift card y += 50 + 4 * 38 + 15 # Launch card @@ -555,8 +572,7 @@ def _get_overall_hand(self) -> tuple[str, rl.Color]: total_stalls = self._stats.get('total_stalls', 0) total_shifts = self._stats.get('total_upshifts', 0) + self._stats.get('total_downshifts', 0) - good_shifts = self._stats.get('upshifts_good', self._stats.get('total_upshifts_good', 0)) + \ - self._stats.get('downshifts_good', self._stats.get('total_downshifts_good', 0)) + good_shifts = self._stats.get('upshifts_good', 0) + self._stats.get('downshifts_good', 0) stall_rate = total_stalls / total_drives shift_pct = (good_shifts / total_shifts * 100) if total_shifts > 0 else 100 From 5e68a1dcff17969a4c0fdd2605269bf363f683a7 Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Sat, 24 Jan 2026 22:44:49 -0800 Subject: [PATCH 06/82] fix --- opendbc_repo | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/opendbc_repo b/opendbc_repo index 6b8347257e11fc..fbeaba6b9cf36c 160000 --- a/opendbc_repo +++ b/opendbc_repo @@ -1 +1 @@ -Subproject commit 6b8347257e11fc5b95538027bf00d845052057b9 +Subproject commit fbeaba6b9cf36c076af9ae8877790875d4b232d0 From 7d3468c668867bdaef21bf7eddb1ec91454a4cb0 Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Sat, 24 Jan 2026 22:59:24 -0800 Subject: [PATCH 07/82] even opus doesn't know about monotonic --- opendbc_repo | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/opendbc_repo b/opendbc_repo index fbeaba6b9cf36c..71c6984de0a92b 160000 --- a/opendbc_repo +++ b/opendbc_repo @@ -1 +1 @@ -Subproject commit fbeaba6b9cf36c076af9ae8877790875d4b232d0 +Subproject commit 71c6984de0a92bd8addc83b892d0c950c7028d6c From 6cf41e554b593a66b948ba1bfd6e9b034d00011b Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Sat, 24 Jan 2026 23:31:31 -0800 Subject: [PATCH 08/82] log --- opendbc_repo | 2 +- selfdrive/test/process_replay/process_replay.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/opendbc_repo b/opendbc_repo index 71c6984de0a92b..0d7d65ed5c5510 160000 --- a/opendbc_repo +++ b/opendbc_repo @@ -1 +1 @@ -Subproject commit 71c6984de0a92bd8addc83b892d0c950c7028d6c +Subproject commit 0d7d65ed5c5510f1d16bca0480a19a48bf4f7999 diff --git a/selfdrive/test/process_replay/process_replay.py b/selfdrive/test/process_replay/process_replay.py index 8af72e5f4e7c94..092fbc14f42d0f 100755 --- a/selfdrive/test/process_replay/process_replay.py +++ b/selfdrive/test/process_replay/process_replay.py @@ -743,7 +743,7 @@ def generate_params_config(lr=None, CP=None, fingerprint=None, custom_params=Non def generate_environ_config(CP=None, fingerprint=None, log_dir=None) -> dict[str, Any]: environ_dict = {} - environ_dict["PARAMS_ROOT"] = f"{Paths.shm_path()}/params" + # environ_dict["PARAMS_ROOT"] = f"{Paths.shm_path()}/params" if log_dir is not None: environ_dict["LOG_ROOT"] = log_dir From 6797179a9886bae2360d6012641270a5565b1cf9 Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Sat, 24 Jan 2026 23:44:17 -0800 Subject: [PATCH 09/82] fix load --- opendbc_repo | 2 +- .../ui/mici/onroad/manual_stats_widget.py | 22 ++++++++----------- 2 files changed, 10 insertions(+), 14 deletions(-) diff --git a/opendbc_repo b/opendbc_repo index 0d7d65ed5c5510..0bdceedbac9143 160000 --- a/opendbc_repo +++ b/opendbc_repo @@ -1 +1 @@ -Subproject commit 0d7d65ed5c5510f1d16bca0480a19a48bf4f7999 +Subproject commit 0bdceedbac914364653c5d707ce53319863c0acd diff --git a/selfdrive/ui/mici/onroad/manual_stats_widget.py b/selfdrive/ui/mici/onroad/manual_stats_widget.py index 93ad4d6e397f9b..83ee2f20e78175 100644 --- a/selfdrive/ui/mici/onroad/manual_stats_widget.py +++ b/selfdrive/ui/mici/onroad/manual_stats_widget.py @@ -8,6 +8,7 @@ import pyray as rl from openpilot.common.params import Params +from openpilot.selfdrive.ui.ui_state import ui_state from openpilot.system.ui.lib.application import gui_app, FontWeight from openpilot.system.ui.widgets import Widget @@ -45,8 +46,8 @@ def _render(self, rect: rl.Rectangle): self._update_counter = 0 self._load_stats() - if not self._stats: - return + # Get live data from CarState (always available, doesn't need param) + cs = ui_state.sm['carState'] if ui_state.sm.valid['carState'] else None # Widget dimensions w = 140 @@ -62,14 +63,13 @@ def _render(self, rect: rl.Rectangle): px = x + 10 py = y + 8 - # Current gear (big) - gear = self._stats.get('gear', 0) + # Current gear from CarState (big) - always show this + gear = cs.gearActual if cs else 0 gear_text = str(gear) if gear > 0 else "N" rl.draw_text_ex(font_bold, gear_text, rl.Vector2(px, py), 42, 0, WHITE) - # Shift suggestion next to gear + # Shift suggestion next to gear (from param stats) suggestion = self._stats.get('shift_suggestion', 'ok') - reason = self._stats.get('shift_reason', '') if suggestion == 'upshift': rl.draw_text_ex(font_bold, "↑", rl.Vector2(px + 35, py + 5), 36, 0, GREEN) elif suggestion == 'downshift': @@ -87,8 +87,8 @@ def _render(self, rect: rl.Rectangle): rl.draw_text_ex(font, f"Stalls: {stalls}", rl.Vector2(px, py), font_size, 0, color) py += line_h - # Lugging indicator - is_lugging = self._stats.get('is_lugging', False) + # Lugging indicator - use CarState.isLugging for real-time, param for count + is_lugging = cs.isLugging if cs else False lugs = self._stats.get('lugs', 0) if is_lugging: rl.draw_text_ex(font, "LUGGING!", rl.Vector2(px, py), font_size, 0, RED) @@ -110,10 +110,6 @@ def _render(self, rect: rl.Rectangle): def _load_stats(self): """Load current session stats""" try: - data = self._params.get("ManualDriveLiveStats") - if data: - self._stats = json.loads(data) - else: - self._stats = {} + self._stats = self._params.get("ManualDriveLiveStats") except Exception: self._stats = {} From c9136daadd1db2933499795dcdb9e48c0bf3e38d Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Sun, 25 Jan 2026 00:07:18 -0800 Subject: [PATCH 10/82] rev matching --- opendbc_repo | 2 +- selfdrive/assets/fonts/process.py | 2 +- .../ui/mici/onroad/manual_stats_widget.py | 177 +++++++++++++++--- 3 files changed, 149 insertions(+), 32 deletions(-) diff --git a/opendbc_repo b/opendbc_repo index 0bdceedbac9143..4ab73ae3d4de31 160000 --- a/opendbc_repo +++ b/opendbc_repo @@ -1 +1 @@ -Subproject commit 0bdceedbac914364653c5d707ce53319863c0acd +Subproject commit 4ab73ae3d4de3195cf4b9848ceb54d51e9541d0d diff --git a/selfdrive/assets/fonts/process.py b/selfdrive/assets/fonts/process.py index ddc8b3a8682c23..a998fd2a69210e 100755 --- a/selfdrive/assets/fonts/process.py +++ b/selfdrive/assets/fonts/process.py @@ -10,7 +10,7 @@ LANGUAGES_FILE = TRANSLATIONS_DIR / "languages.json" GLYPH_PADDING = 6 -EXTRA_CHARS = "–‑✓×°§•X⚙✕◀▶✔⌫⇧␣○●↳çêüñ–‑✓×°§•€£¥" +EXTRA_CHARS = "–‑✓×°§•X⚙✕◀▶✔⌫⇧␣○●↳çêüñ–‑✓×°§•€£¥↑↓✗" UNIFONT_LANGUAGES = {"ar", "th", "zh-CHT", "zh-CHS", "ko", "ja"} diff --git a/selfdrive/ui/mici/onroad/manual_stats_widget.py b/selfdrive/ui/mici/onroad/manual_stats_widget.py index 83ee2f20e78175..4969be5d1d7162 100644 --- a/selfdrive/ui/mici/onroad/manual_stats_widget.py +++ b/selfdrive/ui/mici/onroad/manual_stats_widget.py @@ -1,7 +1,8 @@ """ Live Manual Stats Widget -Small onroad overlay showing current drive statistics and shift suggestions. +Small onroad overlay showing current drive statistics, RPM meter with rev-match helper, +shift grade feedback, and launch progress. """ import json @@ -17,14 +18,33 @@ GREEN = rl.Color(46, 204, 113, 220) YELLOW = rl.Color(241, 196, 15, 220) RED = rl.Color(231, 76, 60, 220) +ORANGE = rl.Color(230, 126, 34, 220) CYAN = rl.Color(52, 152, 219, 220) WHITE = rl.Color(255, 255, 255, 220) GRAY = rl.Color(150, 150, 150, 200) BG_COLOR = rl.Color(0, 0, 0, 160) +# RPM zones for BRZ (7500 redline) +RPM_REDLINE = 7500 +RPM_ECONOMY_MAX = 2500 +RPM_POWER_MIN = 4000 +RPM_DANGER_MIN = 6500 + +# 2024 BRZ gear ratios for rev-match calculation +BRZ_GEAR_RATIOS = {1: 3.626, 2: 2.188, 3: 1.541, 4: 1.213, 5: 1.000, 6: 0.767} +BRZ_FINAL_DRIVE = 4.10 +BRZ_TIRE_CIRCUMFERENCE = 1.977 + + +def rpm_for_speed_and_gear(speed_ms: float, gear: int) -> float: + """Calculate expected RPM for a given speed and gear""" + if gear not in BRZ_GEAR_RATIOS or speed_ms <= 0: + return 0.0 + return (speed_ms * BRZ_FINAL_DRIVE * BRZ_GEAR_RATIOS[gear] * 60) / BRZ_TIRE_CIRCUMFERENCE + class ManualStatsWidget(Widget): - """Small widget showing live manual driving stats and shift suggestions""" + """Widget showing live manual driving stats, RPM meter, and feedback""" def __init__(self): super().__init__() @@ -32,6 +52,13 @@ def __init__(self): self._visible = False self._stats: dict = {} self._update_counter = 0 + # Shift grade flash state + self._last_shift_grade = 0 + self._shift_flash_frames = 0 + self._flash_grade = 0 # The grade to display during flash + # Track gear before clutch for rev-match display + self._gear_before_clutch = 0 + self._last_clutch_state = False def set_visible(self, visible: bool): self._visible = visible @@ -46,12 +73,14 @@ def _render(self, rect: rl.Rectangle): self._update_counter = 0 self._load_stats() - # Get live data from CarState (always available, doesn't need param) + # Get live data from CarState cs = ui_state.sm['carState'] if ui_state.sm.valid['carState'] else None + if not cs: + return - # Widget dimensions - w = 140 - h = 130 + # Widget dimensions - wider for RPM bar + w = 180 + h = 160 x = int(rect.x + rect.width - w - 10) y = int(rect.y + 10) @@ -63,39 +92,78 @@ def _render(self, rect: rl.Rectangle): px = x + 10 py = y + 8 - # Current gear from CarState (big) - always show this - gear = cs.gearActual if cs else 0 + # === RPM METER === + rpm = cs.engineRpm + self._draw_rpm_meter(px, py, w - 20, 35, rpm, cs) + py += 42 + + # === GEAR + SHIFT GRADE FLASH === + gear = cs.gearActual gear_text = str(gear) if gear > 0 else "N" - rl.draw_text_ex(font_bold, gear_text, rl.Vector2(px, py), 42, 0, WHITE) - # Shift suggestion next to gear (from param stats) + # Check for new shift - only trigger when shiftGrade goes from 0 to non-zero + if cs.shiftGrade > 0 and self._last_shift_grade == 0: + # New shift detected - start flash with this grade + self._shift_flash_frames = 150 # Flash for 2.5s at 60fps + self._flash_grade = cs.shiftGrade # Store the grade to display + # Track the raw shiftGrade value + self._last_shift_grade = cs.shiftGrade + + # Draw gear with flash color if recently shifted + if self._shift_flash_frames > 0: + self._shift_flash_frames -= 1 + if self._flash_grade == 1: + gear_color = GREEN + grade_text = "✓" + elif self._flash_grade == 2: + gear_color = YELLOW + grade_text = "~" + else: + gear_color = RED + grade_text = "✗" + rl.draw_text_ex(font_bold, gear_text, rl.Vector2(px, py), 38, 0, gear_color) + rl.draw_text_ex(font_bold, grade_text, rl.Vector2(px + 30, py + 5), 28, 0, gear_color) + else: + rl.draw_text_ex(font_bold, gear_text, rl.Vector2(px, py), 38, 0, WHITE) + + # Shift suggestion arrow suggestion = self._stats.get('shift_suggestion', 'ok') if suggestion == 'upshift': - rl.draw_text_ex(font_bold, "↑", rl.Vector2(px + 35, py + 5), 36, 0, GREEN) + rl.draw_text_ex(font_bold, "↑", rl.Vector2(px + 65, py + 5), 30, 0, GREEN) elif suggestion == 'downshift': - rl.draw_text_ex(font_bold, "↓", rl.Vector2(px + 35, py + 5), 36, 0, YELLOW) - - py += 48 + rl.draw_text_ex(font_bold, "↓", rl.Vector2(px + 65, py + 5), 30, 0, YELLOW) + + py += 42 + + # === LAUNCH FEEDBACK === + launches = self._stats.get('launches', 0) + good_launches = self._stats.get('good_launches', 0) + # Detect if currently launching (low speed, was stopped) + if cs.vEgo < 5.0 and cs.vEgo > 0.5 and not cs.clutchPressed: + rl.draw_text_ex(font, "LAUNCHING...", rl.Vector2(px, py), 18, 0, CYAN) + elif launches > 0: + pct = int(good_launches / launches * 100) if launches > 0 else 0 + color = GREEN if pct >= 75 else (YELLOW if pct >= 50 else GRAY) + rl.draw_text_ex(font, f"Launch: {good_launches}/{launches}", rl.Vector2(px, py), 18, 0, color) + else: + rl.draw_text_ex(font, "Launch: -", rl.Vector2(px, py), 18, 0, GRAY) + py += 22 - # Stats in smaller text - font_size = 20 - line_h = 24 + # === STATS ROW === + font_size = 17 - # Stalls + # Stalls & Lugs on same line stalls = self._stats.get('stalls', 0) - color = GREEN if stalls == 0 else (YELLOW if stalls <= 2 else RED) - rl.draw_text_ex(font, f"Stalls: {stalls}", rl.Vector2(px, py), font_size, 0, color) - py += line_h - - # Lugging indicator - use CarState.isLugging for real-time, param for count - is_lugging = cs.isLugging if cs else False lugs = self._stats.get('lugs', 0) + is_lugging = cs.isLugging + if is_lugging: rl.draw_text_ex(font, "LUGGING!", rl.Vector2(px, py), font_size, 0, RED) else: - color = GREEN if lugs == 0 else GRAY - rl.draw_text_ex(font, f"Lugs: {lugs}", rl.Vector2(px, py), font_size, 0, color) - py += line_h + stall_color = GREEN if stalls == 0 else RED + lug_color = GREEN if lugs == 0 else YELLOW + rl.draw_text_ex(font, f"S:{stalls}", rl.Vector2(px, py), font_size, 0, stall_color) + rl.draw_text_ex(font, f"L:{lugs}", rl.Vector2(px + 45, py), font_size, 0, lug_color) # Shift quality shifts = self._stats.get('shifts', 0) @@ -103,13 +171,62 @@ def _render(self, rect: rl.Rectangle): if shifts > 0: pct = int(good_shifts / shifts * 100) color = GREEN if pct >= 80 else (YELLOW if pct >= 50 else RED) - rl.draw_text_ex(font, f"Shifts: {pct}%", rl.Vector2(px, py), font_size, 0, color) + rl.draw_text_ex(font, f"Sh:{pct}%", rl.Vector2(px + 95, py), font_size, 0, color) + else: + rl.draw_text_ex(font, "Sh:-", rl.Vector2(px + 95, py), font_size, 0, GRAY) + + def _draw_rpm_meter(self, x: int, y: int, w: int, h: int, rpm: float, cs): + """Draw RPM bar with color zones and rev-match target""" + font = gui_app.font(FontWeight.MEDIUM) + + # Bar background + bar_h = 14 + bar_y = y + 18 + rl.draw_rectangle_rounded(rl.Rectangle(x, bar_y, w, bar_h), 0.3, 5, rl.Color(40, 40, 40, 200)) + + # Calculate fill width + rpm_pct = min(rpm / RPM_REDLINE, 1.0) + fill_w = int(w * rpm_pct) + + # Color based on RPM zone + if rpm < RPM_ECONOMY_MAX: + bar_color = GREEN + elif rpm < RPM_POWER_MIN: + bar_color = YELLOW + elif rpm < RPM_DANGER_MIN: + bar_color = ORANGE else: - rl.draw_text_ex(font, "Shifts: -", rl.Vector2(px, py), font_size, 0, GRAY) + bar_color = RED + + # Draw filled portion + if fill_w > 0: + rl.draw_rectangle_rounded(rl.Rectangle(x, bar_y, fill_w, bar_h), 0.3, 5, bar_color) + + # Track gear before clutch press for rev-match display + if not cs.clutchPressed and cs.gearActual > 0: + self._gear_before_clutch = cs.gearActual + + # Rev-match target line when clutch pressed (show target for downshift) + if cs.clutchPressed and self._gear_before_clutch > 1: + # Calculate target RPM for downshift to next lower gear + target_gear = self._gear_before_clutch - 1 + target_rpm = rpm_for_speed_and_gear(cs.vEgo, target_gear) + if 0 < target_rpm < RPM_REDLINE: + target_x = x + int(w * (target_rpm / RPM_REDLINE)) + # Draw target line + rl.draw_rectangle(target_x - 1, bar_y - 3, 3, bar_h + 6, CYAN) + # Draw small target RPM text + rl.draw_text_ex(font, f"{int(target_rpm)}", rl.Vector2(target_x - 15, bar_y - 14), 12, 0, CYAN) + + # RPM text + rpm_text = f"{int(rpm)}" + rl.draw_text_ex(font, rpm_text, rl.Vector2(x, y), 16, 0, WHITE) + rl.draw_text_ex(font, "rpm", rl.Vector2(x + 45, y + 2), 12, 0, GRAY) def _load_stats(self): """Load current session stats""" try: - self._stats = self._params.get("ManualDriveLiveStats") + data = self._params.get("ManualDriveLiveStats") + self._stats = data if data else {} except Exception: self._stats = {} From a3785e8136c2af2e3a19ed5d7e1587a4b6d59c7c Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Sun, 25 Jan 2026 00:52:20 -0800 Subject: [PATCH 11/82] tweaks --- opendbc_repo | 2 +- .../ui/mici/onroad/manual_stats_widget.py | 122 +++++++++++------- 2 files changed, 73 insertions(+), 51 deletions(-) diff --git a/opendbc_repo b/opendbc_repo index 4ab73ae3d4de31..ce6dc0eab68e8c 160000 --- a/opendbc_repo +++ b/opendbc_repo @@ -1 +1 @@ -Subproject commit 4ab73ae3d4de3195cf4b9848ceb54d51e9541d0d +Subproject commit ce6dc0eab68e8ce9e3f5bae9e5623c98f5193f8a diff --git a/selfdrive/ui/mici/onroad/manual_stats_widget.py b/selfdrive/ui/mici/onroad/manual_stats_widget.py index 4969be5d1d7162..914816cb0de6ba 100644 --- a/selfdrive/ui/mici/onroad/manual_stats_widget.py +++ b/selfdrive/ui/mici/onroad/manual_stats_widget.py @@ -9,6 +9,7 @@ import pyray as rl from openpilot.common.params import Params +from opendbc.car.common.filter_simple import FirstOrderFilter from openpilot.selfdrive.ui.ui_state import ui_state from openpilot.system.ui.lib.application import gui_app, FontWeight from openpilot.system.ui.widgets import Widget @@ -29,6 +30,7 @@ RPM_ECONOMY_MAX = 2500 RPM_POWER_MIN = 4000 RPM_DANGER_MIN = 6500 +RPM_TARGET_MIN_DISPLAY = 750 # Don't show upshift indicator below this RPM # 2024 BRZ gear ratios for rev-match calculation BRZ_GEAR_RATIOS = {1: 3.626, 2: 2.188, 3: 1.541, 4: 1.213, 5: 1.000, 6: 0.767} @@ -59,14 +61,10 @@ def __init__(self): # Track gear before clutch for rev-match display self._gear_before_clutch = 0 self._last_clutch_state = False - - def set_visible(self, visible: bool): - self._visible = visible + # Filtered RPM for smooth label display (0.1s time constant, ~60fps) + self._rpm_filter = FirstOrderFilter(0, 0.1, 1/60) def _render(self, rect: rl.Rectangle): - if not self._visible: - return - # Update stats every ~15 frames (0.25s at 60fps) self._update_counter += 1 if self._update_counter >= 15: @@ -74,28 +72,29 @@ def _render(self, rect: rl.Rectangle): self._load_stats() # Get live data from CarState - cs = ui_state.sm['carState'] if ui_state.sm.valid['carState'] else None + cs = ui_state.sm['carState']# if ui_state.sm.valid['carState'] else None if not cs: return - # Widget dimensions - wider for RPM bar - w = 180 - h = 160 - x = int(rect.x + rect.width - w - 10) - y = int(rect.y + 10) + # Widget dimensions - extend to bottom with same margin as top + margin = 10 + w = 250 + h = int(rect.height - 2 * margin) # Full height minus top and bottom margin + x = int(rect.x + rect.width - w - margin) + y = int(rect.y + margin) # Background - rl.draw_rectangle_rounded(rl.Rectangle(x, y, w, h), 0.1, 10, BG_COLOR) + rl.draw_rectangle_rounded(rl.Rectangle(x, y, w, h), 0.08, 10, BG_COLOR) font = gui_app.font(FontWeight.MEDIUM) font_bold = gui_app.font(FontWeight.BOLD) - px = x + 10 - py = y + 8 + px = x + 14 + py = y + 12 # === RPM METER === rpm = cs.engineRpm - self._draw_rpm_meter(px, py, w - 20, 35, rpm, cs) - py += 42 + self._draw_rpm_meter(px, py, w - 28, 50, rpm, cs) + py += 62 # === GEAR + SHIFT GRADE FLASH === gear = cs.gearActual @@ -121,36 +120,36 @@ def _render(self, rect: rl.Rectangle): else: gear_color = RED grade_text = "✗" - rl.draw_text_ex(font_bold, gear_text, rl.Vector2(px, py), 38, 0, gear_color) - rl.draw_text_ex(font_bold, grade_text, rl.Vector2(px + 30, py + 5), 28, 0, gear_color) + rl.draw_text_ex(font_bold, gear_text, rl.Vector2(px, py), 55, 0, gear_color) + rl.draw_text_ex(font_bold, grade_text, rl.Vector2(px + 42, py + 8), 40, 0, gear_color) else: - rl.draw_text_ex(font_bold, gear_text, rl.Vector2(px, py), 38, 0, WHITE) + rl.draw_text_ex(font_bold, gear_text, rl.Vector2(px, py), 55, 0, WHITE) # Shift suggestion arrow suggestion = self._stats.get('shift_suggestion', 'ok') if suggestion == 'upshift': - rl.draw_text_ex(font_bold, "↑", rl.Vector2(px + 65, py + 5), 30, 0, GREEN) + rl.draw_text_ex(font_bold, "↑", rl.Vector2(px + 95, py + 8), 43, 0, GREEN) elif suggestion == 'downshift': - rl.draw_text_ex(font_bold, "↓", rl.Vector2(px + 65, py + 5), 30, 0, YELLOW) + rl.draw_text_ex(font_bold, "↓", rl.Vector2(px + 95, py + 8), 43, 0, YELLOW) - py += 42 + py += 62 # === LAUNCH FEEDBACK === launches = self._stats.get('launches', 0) good_launches = self._stats.get('good_launches', 0) # Detect if currently launching (low speed, was stopped) if cs.vEgo < 5.0 and cs.vEgo > 0.5 and not cs.clutchPressed: - rl.draw_text_ex(font, "LAUNCHING...", rl.Vector2(px, py), 18, 0, CYAN) + rl.draw_text_ex(font, "LAUNCHING...", rl.Vector2(px, py), 26, 0, CYAN) elif launches > 0: pct = int(good_launches / launches * 100) if launches > 0 else 0 color = GREEN if pct >= 75 else (YELLOW if pct >= 50 else GRAY) - rl.draw_text_ex(font, f"Launch: {good_launches}/{launches}", rl.Vector2(px, py), 18, 0, color) + rl.draw_text_ex(font, f"Launch: {good_launches}/{launches}", rl.Vector2(px, py), 26, 0, color) else: - rl.draw_text_ex(font, "Launch: -", rl.Vector2(px, py), 18, 0, GRAY) - py += 22 + rl.draw_text_ex(font, "Launch: -", rl.Vector2(px, py), 26, 0, GRAY) + py += 34 # === STATS ROW === - font_size = 17 + font_size = 24 # Stalls & Lugs on same line stalls = self._stats.get('stalls', 0) @@ -163,7 +162,7 @@ def _render(self, rect: rl.Rectangle): stall_color = GREEN if stalls == 0 else RED lug_color = GREEN if lugs == 0 else YELLOW rl.draw_text_ex(font, f"S:{stalls}", rl.Vector2(px, py), font_size, 0, stall_color) - rl.draw_text_ex(font, f"L:{lugs}", rl.Vector2(px + 45, py), font_size, 0, lug_color) + rl.draw_text_ex(font, f"L:{lugs}", rl.Vector2(px + 65, py), font_size, 0, lug_color) # Shift quality shifts = self._stats.get('shifts', 0) @@ -171,17 +170,17 @@ def _render(self, rect: rl.Rectangle): if shifts > 0: pct = int(good_shifts / shifts * 100) color = GREEN if pct >= 80 else (YELLOW if pct >= 50 else RED) - rl.draw_text_ex(font, f"Sh:{pct}%", rl.Vector2(px + 95, py), font_size, 0, color) + rl.draw_text_ex(font, f"Sh:{pct}%", rl.Vector2(px + 135, py), font_size, 0, color) else: - rl.draw_text_ex(font, "Sh:-", rl.Vector2(px + 95, py), font_size, 0, GRAY) + rl.draw_text_ex(font, "Sh:-", rl.Vector2(px + 135, py), font_size, 0, GRAY) def _draw_rpm_meter(self, x: int, y: int, w: int, h: int, rpm: float, cs): """Draw RPM bar with color zones and rev-match target""" font = gui_app.font(FontWeight.MEDIUM) - # Bar background - bar_h = 14 - bar_y = y + 18 + # Bar background (pushed down for bigger RPM text) + bar_h = 20 + bar_y = y + 32 rl.draw_rectangle_rounded(rl.Rectangle(x, bar_y, w, bar_h), 0.3, 5, rl.Color(40, 40, 40, 200)) # Calculate fill width @@ -206,22 +205,45 @@ def _draw_rpm_meter(self, x: int, y: int, w: int, h: int, rpm: float, cs): if not cs.clutchPressed and cs.gearActual > 0: self._gear_before_clutch = cs.gearActual - # Rev-match target line when clutch pressed (show target for downshift) - if cs.clutchPressed and self._gear_before_clutch > 1: - # Calculate target RPM for downshift to next lower gear - target_gear = self._gear_before_clutch - 1 - target_rpm = rpm_for_speed_and_gear(cs.vEgo, target_gear) - if 0 < target_rpm < RPM_REDLINE: - target_x = x + int(w * (target_rpm / RPM_REDLINE)) - # Draw target line - rl.draw_rectangle(target_x - 1, bar_y - 3, 3, bar_h + 6, CYAN) - # Draw small target RPM text - rl.draw_text_ex(font, f"{int(target_rpm)}", rl.Vector2(target_x - 15, bar_y - 14), 12, 0, CYAN) - - # RPM text - rpm_text = f"{int(rpm)}" - rl.draw_text_ex(font, rpm_text, rl.Vector2(x, y), 16, 0, WHITE) - rl.draw_text_ex(font, "rpm", rl.Vector2(x + 45, y + 2), 12, 0, GRAY) + # Rev-match target lines when clutch pressed + if cs.clutchPressed and self._gear_before_clutch > 0: + # Calculate both targets first + down_rpm = 0 + up_rpm = 0 + if self._gear_before_clutch > 1: + down_rpm = rpm_for_speed_and_gear(cs.vEgo, self._gear_before_clutch - 1) + if self._gear_before_clutch < 6: + up_rpm = rpm_for_speed_and_gear(cs.vEgo, self._gear_before_clutch + 1) + + # Downshift target - cyan if safe, red if over redline + if down_rpm >= RPM_REDLINE: + # Over redline - show red warning clipped to right side + down_x = x + w + rl.draw_rectangle(down_x - 4, bar_y - 5, 4, bar_h + 10, RED) + down_text = f"{int(down_rpm)}!" + down_tw = rl.measure_text_ex(font, down_text, 20, 0).x + rl.draw_text_ex(font, down_text, rl.Vector2(down_x - down_tw / 2, bar_y + bar_h + 3), 20, 0, RED) + elif down_rpm > RPM_TARGET_MIN_DISPLAY: + # Safe downshift target (cyan) + down_x = x + int(w * (down_rpm / RPM_REDLINE)) + rl.draw_rectangle(down_x - 2, bar_y - 5, 4, bar_h + 10, CYAN) + down_text = f"{int(down_rpm)}" + down_tw = rl.measure_text_ex(font, down_text, 20, 0).x + rl.draw_text_ex(font, down_text, rl.Vector2(down_x - down_tw / 2, bar_y + bar_h + 3), 20, 0, CYAN) + + # Upshift target (white) - only show if above minimum display threshold + if up_rpm > RPM_TARGET_MIN_DISPLAY and up_rpm < RPM_REDLINE: + up_x = x + int(w * (up_rpm / RPM_REDLINE)) + rl.draw_rectangle(up_x - 2, bar_y - 5, 4, bar_h + 10, WHITE) + up_text = f"{int(up_rpm)}" + up_tw = rl.measure_text_ex(font, up_text, 20, 0).x + rl.draw_text_ex(font, up_text, rl.Vector2(up_x - up_tw / 2, bar_y + bar_h + 3), 20, 0, WHITE) + + # RPM text (filtered for smooth display, rounded to nearest 10) + self._rpm_filter.update(rpm) + rpm_text = f"{int(round(self._rpm_filter.x / 10) * 10)}" + rl.draw_text_ex(font, rpm_text, rl.Vector2(x, y), 28, 0, WHITE) + rl.draw_text_ex(font, "rpm", rl.Vector2(x + 70, y + 5), 20, 0, GRAY) def _load_stats(self): """Load current session stats""" From d86b4353e8430a8863e03f40a302a6df5c88d008 Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Sun, 25 Jan 2026 01:01:35 -0800 Subject: [PATCH 12/82] show rev matchers when shift suggesstion --- .../ui/mici/onroad/manual_stats_widget.py | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/selfdrive/ui/mici/onroad/manual_stats_widget.py b/selfdrive/ui/mici/onroad/manual_stats_widget.py index 914816cb0de6ba..01803a3d351e38 100644 --- a/selfdrive/ui/mici/onroad/manual_stats_widget.py +++ b/selfdrive/ui/mici/onroad/manual_stats_widget.py @@ -205,8 +205,10 @@ def _draw_rpm_meter(self, x: int, y: int, w: int, h: int, rpm: float, cs): if not cs.clutchPressed and cs.gearActual > 0: self._gear_before_clutch = cs.gearActual - # Rev-match target lines when clutch pressed - if cs.clutchPressed and self._gear_before_clutch > 0: + # Rev-match target lines when clutch pressed OR shift suggestion showing + suggestion = self._stats.get('shift_suggestion', 'ok') + show_rev_targets = (cs.clutchPressed or suggestion != 'ok') and self._gear_before_clutch > 0 + if show_rev_targets: # Calculate both targets first down_rpm = 0 up_rpm = 0 @@ -220,24 +222,18 @@ def _draw_rpm_meter(self, x: int, y: int, w: int, h: int, rpm: float, cs): # Over redline - show red warning clipped to right side down_x = x + w rl.draw_rectangle(down_x - 4, bar_y - 5, 4, bar_h + 10, RED) - down_text = f"{int(down_rpm)}!" - down_tw = rl.measure_text_ex(font, down_text, 20, 0).x - rl.draw_text_ex(font, down_text, rl.Vector2(down_x - down_tw / 2, bar_y + bar_h + 3), 20, 0, RED) + rl.draw_text_ex(font, f"{int(round(down_rpm / 10) * 10)}!", rl.Vector2(down_x - 45, bar_y + bar_h + 3), 20, 0, RED) elif down_rpm > RPM_TARGET_MIN_DISPLAY: # Safe downshift target (cyan) down_x = x + int(w * (down_rpm / RPM_REDLINE)) rl.draw_rectangle(down_x - 2, bar_y - 5, 4, bar_h + 10, CYAN) - down_text = f"{int(down_rpm)}" - down_tw = rl.measure_text_ex(font, down_text, 20, 0).x - rl.draw_text_ex(font, down_text, rl.Vector2(down_x - down_tw / 2, bar_y + bar_h + 3), 20, 0, CYAN) + rl.draw_text_ex(font, f"{int(round(down_rpm / 10) * 10)}", rl.Vector2(down_x - 20, bar_y + bar_h + 3), 20, 0, CYAN) # Upshift target (white) - only show if above minimum display threshold if up_rpm > RPM_TARGET_MIN_DISPLAY and up_rpm < RPM_REDLINE: up_x = x + int(w * (up_rpm / RPM_REDLINE)) rl.draw_rectangle(up_x - 2, bar_y - 5, 4, bar_h + 10, WHITE) - up_text = f"{int(up_rpm)}" - up_tw = rl.measure_text_ex(font, up_text, 20, 0).x - rl.draw_text_ex(font, up_text, rl.Vector2(up_x - up_tw / 2, bar_y + bar_h + 3), 20, 0, WHITE) + rl.draw_text_ex(font, f"{int(round(up_rpm / 10) * 10)}", rl.Vector2(up_x - 20, bar_y + bar_h + 3), 20, 0, WHITE) # RPM text (filtered for smooth display, rounded to nearest 10) self._rpm_filter.update(rpm) From 056fd36c157daf273c707dda02e2de205d2a72e7 Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Sun, 25 Jan 2026 01:05:59 -0800 Subject: [PATCH 13/82] darker when suggested --- .../ui/mici/onroad/manual_stats_widget.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/selfdrive/ui/mici/onroad/manual_stats_widget.py b/selfdrive/ui/mici/onroad/manual_stats_widget.py index 01803a3d351e38..42e6a7d0bb3fb7 100644 --- a/selfdrive/ui/mici/onroad/manual_stats_widget.py +++ b/selfdrive/ui/mici/onroad/manual_stats_widget.py @@ -209,6 +209,12 @@ def _draw_rpm_meter(self, x: int, y: int, w: int, h: int, rpm: float, cs): suggestion = self._stats.get('shift_suggestion', 'ok') show_rev_targets = (cs.clutchPressed or suggestion != 'ok') and self._gear_before_clutch > 0 if show_rev_targets: + # 65% opacity when showing due to suggestion only (not clutch) + alpha = 220 if cs.clutchPressed else 143 + cyan = rl.Color(CYAN.r, CYAN.g, CYAN.b, alpha) + red = rl.Color(RED.r, RED.g, RED.b, alpha) + white = rl.Color(WHITE.r, WHITE.g, WHITE.b, alpha) + # Calculate both targets first down_rpm = 0 up_rpm = 0 @@ -221,19 +227,19 @@ def _draw_rpm_meter(self, x: int, y: int, w: int, h: int, rpm: float, cs): if down_rpm >= RPM_REDLINE: # Over redline - show red warning clipped to right side down_x = x + w - rl.draw_rectangle(down_x - 4, bar_y - 5, 4, bar_h + 10, RED) - rl.draw_text_ex(font, f"{int(round(down_rpm / 10) * 10)}!", rl.Vector2(down_x - 45, bar_y + bar_h + 3), 20, 0, RED) + rl.draw_rectangle(down_x - 4, bar_y - 5, 4, bar_h + 10, red) + rl.draw_text_ex(font, f"{int(round(down_rpm / 10) * 10)}!", rl.Vector2(down_x - 45, bar_y + bar_h + 3), 20, 0, red) elif down_rpm > RPM_TARGET_MIN_DISPLAY: # Safe downshift target (cyan) down_x = x + int(w * (down_rpm / RPM_REDLINE)) - rl.draw_rectangle(down_x - 2, bar_y - 5, 4, bar_h + 10, CYAN) - rl.draw_text_ex(font, f"{int(round(down_rpm / 10) * 10)}", rl.Vector2(down_x - 20, bar_y + bar_h + 3), 20, 0, CYAN) + rl.draw_rectangle(down_x - 2, bar_y - 5, 4, bar_h + 10, cyan) + rl.draw_text_ex(font, f"{int(round(down_rpm / 10) * 10)}", rl.Vector2(down_x - 20, bar_y + bar_h + 3), 20, 0, cyan) # Upshift target (white) - only show if above minimum display threshold if up_rpm > RPM_TARGET_MIN_DISPLAY and up_rpm < RPM_REDLINE: up_x = x + int(w * (up_rpm / RPM_REDLINE)) - rl.draw_rectangle(up_x - 2, bar_y - 5, 4, bar_h + 10, WHITE) - rl.draw_text_ex(font, f"{int(round(up_rpm / 10) * 10)}", rl.Vector2(up_x - 20, bar_y + bar_h + 3), 20, 0, WHITE) + rl.draw_rectangle(up_x - 2, bar_y - 5, 4, bar_h + 10, white) + rl.draw_text_ex(font, f"{int(round(up_rpm / 10) * 10)}", rl.Vector2(up_x - 20, bar_y + bar_h + 3), 20, 0, white) # RPM text (filtered for smooth display, rounded to nearest 10) self._rpm_filter.update(rpm) From 33456fd11b9c27125d42e1b2804a7e5d85eec09e Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Sun, 25 Jan 2026 01:14:49 -0800 Subject: [PATCH 14/82] show more gears and gear label --- .../ui/mici/onroad/manual_stats_widget.py | 63 +++++++++++-------- 1 file changed, 37 insertions(+), 26 deletions(-) diff --git a/selfdrive/ui/mici/onroad/manual_stats_widget.py b/selfdrive/ui/mici/onroad/manual_stats_widget.py index 42e6a7d0bb3fb7..c2ae69a8b52954 100644 --- a/selfdrive/ui/mici/onroad/manual_stats_widget.py +++ b/selfdrive/ui/mici/onroad/manual_stats_widget.py @@ -214,32 +214,43 @@ def _draw_rpm_meter(self, x: int, y: int, w: int, h: int, rpm: float, cs): cyan = rl.Color(CYAN.r, CYAN.g, CYAN.b, alpha) red = rl.Color(RED.r, RED.g, RED.b, alpha) white = rl.Color(WHITE.r, WHITE.g, WHITE.b, alpha) - - # Calculate both targets first - down_rpm = 0 - up_rpm = 0 - if self._gear_before_clutch > 1: - down_rpm = rpm_for_speed_and_gear(cs.vEgo, self._gear_before_clutch - 1) - if self._gear_before_clutch < 6: - up_rpm = rpm_for_speed_and_gear(cs.vEgo, self._gear_before_clutch + 1) - - # Downshift target - cyan if safe, red if over redline - if down_rpm >= RPM_REDLINE: - # Over redline - show red warning clipped to right side - down_x = x + w - rl.draw_rectangle(down_x - 4, bar_y - 5, 4, bar_h + 10, red) - rl.draw_text_ex(font, f"{int(round(down_rpm / 10) * 10)}!", rl.Vector2(down_x - 45, bar_y + bar_h + 3), 20, 0, red) - elif down_rpm > RPM_TARGET_MIN_DISPLAY: - # Safe downshift target (cyan) - down_x = x + int(w * (down_rpm / RPM_REDLINE)) - rl.draw_rectangle(down_x - 2, bar_y - 5, 4, bar_h + 10, cyan) - rl.draw_text_ex(font, f"{int(round(down_rpm / 10) * 10)}", rl.Vector2(down_x - 20, bar_y + bar_h + 3), 20, 0, cyan) - - # Upshift target (white) - only show if above minimum display threshold - if up_rpm > RPM_TARGET_MIN_DISPLAY and up_rpm < RPM_REDLINE: - up_x = x + int(w * (up_rpm / RPM_REDLINE)) - rl.draw_rectangle(up_x - 2, bar_y - 5, 4, bar_h + 10, white) - rl.draw_text_ex(font, f"{int(round(up_rpm / 10) * 10)}", rl.Vector2(up_x - 20, bar_y + bar_h + 3), 20, 0, white) + gray = rl.Color(GRAY.r, GRAY.g, GRAY.b, alpha) + + # Find lowest gear at redline (don't show gears below it) + lowest_redline_gear = 7 # None at redline + for gear in range(1, 7): + if rpm_for_speed_and_gear(cs.vEgo, gear) >= RPM_REDLINE: + lowest_redline_gear = gear + break + + # Show gears with gear numbers (2 adjacent on each side) + LUG_RPM = 1500 # Hide gears that would lug or be under idle + min_gear = max(1, self._gear_before_clutch - 2) + max_gear = min(6, self._gear_before_clutch + 2) + for gear in range(min_gear, max_gear + 1): + gear_rpm = rpm_for_speed_and_gear(cs.vEgo, gear) + if gear_rpm < LUG_RPM: + continue # Would lug or be under idle + if gear < lowest_redline_gear and lowest_redline_gear <= 6: + continue # Skip gears below the lowest redline gear + + # Choose color based on gear relative to current + if gear_rpm >= RPM_REDLINE: + # Over redline - red, clipped to right + gear_x = x + w + color = red + rl.draw_rectangle(gear_x - 4, bar_y - 5, 4, bar_h + 10, color) + rl.draw_text_ex(font, f"{gear}!", rl.Vector2(gear_x - 18, bar_y + bar_h + 3), 20, 0, color) + else: + gear_x = x + int(w * (gear_rpm / RPM_REDLINE)) + if gear == self._gear_before_clutch - 1: + color = cyan # Downshift target + elif gear == self._gear_before_clutch + 1: + color = white # Upshift target + else: + color = gray # Other gears + rl.draw_rectangle(gear_x - 2, bar_y - 5, 4, bar_h + 10, color) + rl.draw_text_ex(font, str(gear), rl.Vector2(gear_x - 5, bar_y + bar_h + 3), 20, 0, color) # RPM text (filtered for smooth display, rounded to nearest 10) self._rpm_filter.update(rpm) From 477e105221d7cbb2e8ad1f367cb7b57b74c7402b Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Sun, 25 Jan 2026 01:14:57 -0800 Subject: [PATCH 15/82] Revert "show more gears and gear label" This reverts commit 33456fd11b9c27125d42e1b2804a7e5d85eec09e. --- .../ui/mici/onroad/manual_stats_widget.py | 63 ++++++++----------- 1 file changed, 26 insertions(+), 37 deletions(-) diff --git a/selfdrive/ui/mici/onroad/manual_stats_widget.py b/selfdrive/ui/mici/onroad/manual_stats_widget.py index c2ae69a8b52954..42e6a7d0bb3fb7 100644 --- a/selfdrive/ui/mici/onroad/manual_stats_widget.py +++ b/selfdrive/ui/mici/onroad/manual_stats_widget.py @@ -214,43 +214,32 @@ def _draw_rpm_meter(self, x: int, y: int, w: int, h: int, rpm: float, cs): cyan = rl.Color(CYAN.r, CYAN.g, CYAN.b, alpha) red = rl.Color(RED.r, RED.g, RED.b, alpha) white = rl.Color(WHITE.r, WHITE.g, WHITE.b, alpha) - gray = rl.Color(GRAY.r, GRAY.g, GRAY.b, alpha) - - # Find lowest gear at redline (don't show gears below it) - lowest_redline_gear = 7 # None at redline - for gear in range(1, 7): - if rpm_for_speed_and_gear(cs.vEgo, gear) >= RPM_REDLINE: - lowest_redline_gear = gear - break - - # Show gears with gear numbers (2 adjacent on each side) - LUG_RPM = 1500 # Hide gears that would lug or be under idle - min_gear = max(1, self._gear_before_clutch - 2) - max_gear = min(6, self._gear_before_clutch + 2) - for gear in range(min_gear, max_gear + 1): - gear_rpm = rpm_for_speed_and_gear(cs.vEgo, gear) - if gear_rpm < LUG_RPM: - continue # Would lug or be under idle - if gear < lowest_redline_gear and lowest_redline_gear <= 6: - continue # Skip gears below the lowest redline gear - - # Choose color based on gear relative to current - if gear_rpm >= RPM_REDLINE: - # Over redline - red, clipped to right - gear_x = x + w - color = red - rl.draw_rectangle(gear_x - 4, bar_y - 5, 4, bar_h + 10, color) - rl.draw_text_ex(font, f"{gear}!", rl.Vector2(gear_x - 18, bar_y + bar_h + 3), 20, 0, color) - else: - gear_x = x + int(w * (gear_rpm / RPM_REDLINE)) - if gear == self._gear_before_clutch - 1: - color = cyan # Downshift target - elif gear == self._gear_before_clutch + 1: - color = white # Upshift target - else: - color = gray # Other gears - rl.draw_rectangle(gear_x - 2, bar_y - 5, 4, bar_h + 10, color) - rl.draw_text_ex(font, str(gear), rl.Vector2(gear_x - 5, bar_y + bar_h + 3), 20, 0, color) + + # Calculate both targets first + down_rpm = 0 + up_rpm = 0 + if self._gear_before_clutch > 1: + down_rpm = rpm_for_speed_and_gear(cs.vEgo, self._gear_before_clutch - 1) + if self._gear_before_clutch < 6: + up_rpm = rpm_for_speed_and_gear(cs.vEgo, self._gear_before_clutch + 1) + + # Downshift target - cyan if safe, red if over redline + if down_rpm >= RPM_REDLINE: + # Over redline - show red warning clipped to right side + down_x = x + w + rl.draw_rectangle(down_x - 4, bar_y - 5, 4, bar_h + 10, red) + rl.draw_text_ex(font, f"{int(round(down_rpm / 10) * 10)}!", rl.Vector2(down_x - 45, bar_y + bar_h + 3), 20, 0, red) + elif down_rpm > RPM_TARGET_MIN_DISPLAY: + # Safe downshift target (cyan) + down_x = x + int(w * (down_rpm / RPM_REDLINE)) + rl.draw_rectangle(down_x - 2, bar_y - 5, 4, bar_h + 10, cyan) + rl.draw_text_ex(font, f"{int(round(down_rpm / 10) * 10)}", rl.Vector2(down_x - 20, bar_y + bar_h + 3), 20, 0, cyan) + + # Upshift target (white) - only show if above minimum display threshold + if up_rpm > RPM_TARGET_MIN_DISPLAY and up_rpm < RPM_REDLINE: + up_x = x + int(w * (up_rpm / RPM_REDLINE)) + rl.draw_rectangle(up_x - 2, bar_y - 5, 4, bar_h + 10, white) + rl.draw_text_ex(font, f"{int(round(up_rpm / 10) * 10)}", rl.Vector2(up_x - 20, bar_y + bar_h + 3), 20, 0, white) # RPM text (filtered for smooth display, rounded to nearest 10) self._rpm_filter.update(rpm) From 056479707d82d23ad69cdc1c1910d1a9a1584272 Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Sat, 7 Feb 2026 22:09:57 -0800 Subject: [PATCH 16/82] clean up from cursor --- common/params_keys.h | 1 - selfdrive/ui/mici/layouts/main.py | 42 +++---- .../ui/mici/layouts/manual_drive_summary.py | 105 +++++++++--------- .../ui/mici/layouts/settings/manual_stats.py | 44 +++++--- .../ui/mici/onroad/manual_stats_widget.py | 7 +- 5 files changed, 102 insertions(+), 97 deletions(-) diff --git a/common/params_keys.h b/common/params_keys.h index bb51fbb1a48386..b793837eada390 100644 --- a/common/params_keys.h +++ b/common/params_keys.h @@ -85,7 +85,6 @@ inline static std::unordered_map keys = { {"LongitudinalManeuverMode", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION, BOOL}}, {"LongitudinalPersonality", {PERSISTENT, INT, std::to_string(static_cast(cereal::LongitudinalPersonality::STANDARD))}}, {"ManualDriveLiveStats", {CLEAR_ON_MANAGER_START, JSON}}, - {"ManualDriveLastSession", {PERSISTENT, JSON}}, {"ManualDriveStats", {PERSISTENT, JSON}}, {"NetworkMetered", {PERSISTENT, BOOL}}, {"ObdMultiplexingChanged", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, BOOL}}, diff --git a/selfdrive/ui/mici/layouts/main.py b/selfdrive/ui/mici/layouts/main.py index d0768fc76dff44..ca27592f2b1316 100644 --- a/selfdrive/ui/mici/layouts/main.py +++ b/selfdrive/ui/mici/layouts/main.py @@ -134,30 +134,24 @@ def _handle_transitions(self): self._prev_standstill = CS.standstill def _show_drive_summary_if_available(self): - """End manual stats session and show summary dialog if data exists""" - # Try to end the manual stats session - try: - from opendbc.car.subaru.manual_stats import get_tracker - tracker = get_tracker() - tracker.end_session() - except Exception: - pass - - # Show the summary dialog if there's session data - try: - data = self._params.get("ManualDriveLastSession") - if data: - session = json.loads(data) - # Only show if there's meaningful data (duration > 30s and some activity) - duration = session.get('duration', 0) - has_activity = (session.get('stall_count', 0) > 0 or - session.get('upshift_count', 0) > 0 or - session.get('launch_count', 0) > 0) - if duration > 30 and has_activity: - self._drive_summary_dialog = ManualDriveSummaryDialog() - gui_app.set_modal_overlay(self._drive_summary_dialog) - except Exception: - pass + """Show end-of-drive summary dialog if there's data worth showing. + All stats are saved by the card process -- UI just reads and displays.""" + data = self._params.get("ManualDriveStats") + if not data: + return + stats = data if isinstance(data, dict) else json.loads(data) + history = stats.get('session_history', []) + if not history: + return + + session = history[-1] + duration = session.get('duration', 0) + has_activity = (session.get('stalls', 0) > 0 or + session.get('upshifts', 0) > 0 or + session.get('launches', 0) > 0) + if duration > 30 and has_activity: + self._drive_summary_dialog = ManualDriveSummaryDialog() + gui_app.set_modal_overlay(self._drive_summary_dialog) def _set_mode_for_started(self, onroad_transition: bool = False): if ui_state.started: diff --git a/selfdrive/ui/mici/layouts/manual_drive_summary.py b/selfdrive/ui/mici/layouts/manual_drive_summary.py index 37119e1a87fe80..d264bdec8e7d44 100644 --- a/selfdrive/ui/mici/layouts/manual_drive_summary.py +++ b/selfdrive/ui/mici/layouts/manual_drive_summary.py @@ -71,36 +71,37 @@ def show_event(self): self._load_historical() def _load_session(self): - """Load the last session data from Params""" - try: - data = self._params.get("ManualDriveLastSession") - if data: - self._session_data = data if isinstance(data, dict) else json.loads(data) + """Load the last session data from session_history in ManualDriveStats""" + data = self._params.get("ManualDriveStats") + if data: + stats = data if isinstance(data, dict) else json.loads(data) + history = stats.get('session_history', []) + if history: + self._session_data = history[-1] self._calculate_grade() - except Exception: - self._session_data = None + return + self._session_data = None def _load_historical(self): """Load historical stats for comparison""" - try: - data = self._params.get("ManualDriveStats") - if data: - self._historical_data = data if isinstance(data, dict) else json.loads(data) - # Calculate average shift score from history - history = self._historical_data.get('session_history', []) - if history: - scores = [] - for s in history[-10:]: # Last 10 sessions - ups = s.get('upshifts', 0) - ups_good = s.get('upshifts_good', 0) - downs = s.get('downshifts', 0) - downs_good = s.get('downshifts_good', 0) - total = ups + downs - if total > 0: - scores.append((ups_good + downs_good) / total * 100) - if scores: - self._avg_shift_score = sum(scores) / len(scores) - except Exception: + data = self._params.get("ManualDriveStats") + if data: + self._historical_data = data if isinstance(data, dict) else json.loads(data) + # Calculate average shift score from history + history = self._historical_data.get('session_history', []) + if history: + scores = [] + for s in history[-10:]: # Last 10 sessions + ups = s.get('upshifts', 0) + ups_good = s.get('upshifts_good', 0) + downs = s.get('downshifts', 0) + downs_good = s.get('downshifts_good', 0) + total = ups + downs + if total > 0: + scores.append((ups_good + downs_good) / total * 100) + if scores: + self._avg_shift_score = sum(scores) / len(scores) + else: self._historical_data = None def _calculate_grade(self): @@ -112,19 +113,19 @@ def _calculate_grade(self): return # Calculate grade based on stalls, shifts, and launches - stalls = self._session_data.get('stall_count', 0) - lugs = self._session_data.get('lug_count', 0) + stalls = self._session_data.get('stalls', 0) + lugs = self._session_data.get('lugs', 0) # Shift quality - upshift_total = self._session_data.get('upshift_count', 0) - upshift_good = self._session_data.get('upshift_good', 0) - downshift_total = self._session_data.get('downshift_count', 0) - downshift_good = self._session_data.get('downshift_good', 0) + upshift_total = self._session_data.get('upshifts', 0) + upshift_good = self._session_data.get('upshifts_good', 0) + downshift_total = self._session_data.get('downshifts', 0) + downshift_good = self._session_data.get('downshifts_good', 0) # Launch quality - launch_total = self._session_data.get('launch_count', 0) - launch_good = self._session_data.get('launch_good', 0) - launch_stalled = self._session_data.get('launch_stalled', 0) + launch_total = self._session_data.get('launches', 0) + launch_good = self._session_data.get('launches_good', 0) + launch_stalled = self._session_data.get('launches_stalled', 0) # Calculate scores total_shifts = upshift_total + downshift_total @@ -169,16 +170,16 @@ def _get_encouragement_text(self) -> str: if not self._session_data: return "No data available for this drive." - stalls = self._session_data.get('stall_count', 0) - lugs = self._session_data.get('lug_count', 0) - launch_stalled = self._session_data.get('launch_stalled', 0) + stalls = self._session_data.get('stalls', 0) + lugs = self._session_data.get('lugs', 0) + launch_stalled = self._session_data.get('launches_stalled', 0) - upshift_good = self._session_data.get('upshift_good', 0) - upshift_total = self._session_data.get('upshift_count', 0) - downshift_good = self._session_data.get('downshift_good', 0) - downshift_total = self._session_data.get('downshift_count', 0) - launch_good = self._session_data.get('launch_good', 0) - launch_total = self._session_data.get('launch_count', 0) + upshift_good = self._session_data.get('upshifts_good', 0) + upshift_total = self._session_data.get('upshifts', 0) + downshift_good = self._session_data.get('downshifts_good', 0) + downshift_total = self._session_data.get('downshifts', 0) + launch_good = self._session_data.get('launches_good', 0) + launch_total = self._session_data.get('launches', 0) messages = [] @@ -304,8 +305,8 @@ def _render(self, rect: rl.Rectangle): card_y = y + 12 # Jackets section (stalls + lugs) - stalls = self._session_data.get('stall_count', 0) if self._session_data else 0 - lugs = self._session_data.get('lug_count', 0) if self._session_data else 0 + stalls = self._session_data.get('stalls', 0) if self._session_data else 0 + lugs = self._session_data.get('lugs', 0) if self._session_data else 0 jackets_text = "Jackets:" if (stalls > 0 or lugs > 0) else "No Jackets!" jackets_color = RED if stalls > 0 else (YELLOW if lugs > 0 else GREEN) rl.draw_text_ex(font_medium, jackets_text, rl.Vector2(card_x, card_y), 24, 0, jackets_color) @@ -319,12 +320,12 @@ def _render(self, rect: rl.Rectangle): rl.draw_text_ex(font_medium, "Waddle Stats:", rl.Vector2(card_x, card_y), 24, 0, WHITE) card_y += 30 - upshift_total = self._session_data.get('upshift_count', 0) if self._session_data else 0 - upshift_good = self._session_data.get('upshift_good', 0) if self._session_data else 0 - downshift_total = self._session_data.get('downshift_count', 0) if self._session_data else 0 - downshift_good = self._session_data.get('downshift_good', 0) if self._session_data else 0 - launch_total = self._session_data.get('launch_count', 0) if self._session_data else 0 - launch_good = self._session_data.get('launch_good', 0) if self._session_data else 0 + upshift_total = self._session_data.get('upshifts', 0) if self._session_data else 0 + upshift_good = self._session_data.get('upshifts_good', 0) if self._session_data else 0 + downshift_total = self._session_data.get('downshifts', 0) if self._session_data else 0 + downshift_good = self._session_data.get('downshifts_good', 0) if self._session_data else 0 + launch_total = self._session_data.get('launches', 0) if self._session_data else 0 + launch_good = self._session_data.get('launches_good', 0) if self._session_data else 0 if launch_total > 0: card_y = self._draw_mini_stat(card_x, card_y, w - 30, "Launches", f"{launch_good}/{launch_total}", launch_total, False, launch_good) diff --git a/selfdrive/ui/mici/layouts/settings/manual_stats.py b/selfdrive/ui/mici/layouts/settings/manual_stats.py index afc7d1e3c9abfd..d92dd3ca8502a2 100644 --- a/selfdrive/ui/mici/layouts/settings/manual_stats.py +++ b/selfdrive/ui/mici/layouts/settings/manual_stats.py @@ -42,14 +42,10 @@ def show_event(self): def _load_stats(self): """Load historical stats from Params""" - try: - data = self._params.get("ManualDriveStats") - if data: - # Params returns dict directly for JSON type - self._stats = data if isinstance(data, dict) else json.loads(data) - else: - self._stats = {} - except Exception: + data = self._params.get("ManualDriveStats") + if data: + self._stats = data if isinstance(data, dict) else json.loads(data) + else: self._stats = {} def _render(self, rect: rl.Rectangle): @@ -125,9 +121,15 @@ def _render(self, rect: rl.Rectangle): ]) y += 15 - # Trend card - recent_stalls = self._stats.get('recent_stall_rates', []) - recent_shifts = self._stats.get('recent_shift_scores', []) + # Trend card - derive from session_history + session_history = self._stats.get('session_history', []) + recent_sessions = session_history[-10:] + recent_stalls = [s.get('stalls', 0) for s in recent_sessions] + recent_shifts = [] + for s in recent_sessions: + total = s.get('upshifts', 0) + s.get('downshifts', 0) + good = s.get('upshifts_good', 0) + s.get('downshifts_good', 0) + recent_shifts.append(int(good / total * 100) if total > 0 else 100) trend_items = [] if len(recent_stalls) >= 2: @@ -580,8 +582,13 @@ def _get_overall_hand(self) -> tuple[str, rl.Color]: # Calculate overall score score = shift_pct - (stall_rate * 10) - # Recent improvement bonus - recent_scores = self._stats.get('recent_shift_scores', []) + # Recent improvement bonus - derive from session_history + session_history = self._stats.get('session_history', []) + recent_scores = [] + for s in session_history[-10:]: + total = s.get('upshifts', 0) + s.get('downshifts', 0) + good = s.get('upshifts_good', 0) + s.get('downshifts_good', 0) + recent_scores.append(int(good / total * 100) if total > 0 else 100) if len(recent_scores) >= 3: if recent_scores[-1] > recent_scores[0]: score += 5 # Bonus for improving @@ -613,8 +620,15 @@ def _get_encouragement(self) -> str: """Get encouragement based on overall progress""" total_drives = self._stats.get('total_drives', 0) total_stalls = self._stats.get('total_stalls', 0) - recent_stalls = self._stats.get('recent_stall_rates', []) - recent_scores = self._stats.get('recent_shift_scores', []) + # Derive recent trends from session_history + session_history = self._stats.get('session_history', []) + recent_sessions = session_history[-10:] + recent_stalls = [s.get('stalls', 0) for s in recent_sessions] + recent_scores = [] + for s in recent_sessions: + total = s.get('upshifts', 0) + s.get('downshifts', 0) + good = s.get('upshifts_good', 0) + s.get('downshifts_good', 0) + recent_scores.append(int(good / total * 100) if total > 0 else 100) if total_drives == 0: return "Start driving to see your stats! Time to earn your first waddle KP." diff --git a/selfdrive/ui/mici/onroad/manual_stats_widget.py b/selfdrive/ui/mici/onroad/manual_stats_widget.py index 42e6a7d0bb3fb7..e8aed06d9b2235 100644 --- a/selfdrive/ui/mici/onroad/manual_stats_widget.py +++ b/selfdrive/ui/mici/onroad/manual_stats_widget.py @@ -249,8 +249,5 @@ def _draw_rpm_meter(self, x: int, y: int, w: int, h: int, rpm: float, cs): def _load_stats(self): """Load current session stats""" - try: - data = self._params.get("ManualDriveLiveStats") - self._stats = data if data else {} - except Exception: - self._stats = {} + data = self._params.get("ManualDriveLiveStats") + self._stats = data if data else {} From 091f686a2bf73c771799c737381a0f38d12c948c Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Sat, 7 Feb 2026 22:12:18 -0800 Subject: [PATCH 17/82] fix launching --- selfdrive/ui/mici/onroad/manual_stats_widget.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/selfdrive/ui/mici/onroad/manual_stats_widget.py b/selfdrive/ui/mici/onroad/manual_stats_widget.py index e8aed06d9b2235..1629146376e0e9 100644 --- a/selfdrive/ui/mici/onroad/manual_stats_widget.py +++ b/selfdrive/ui/mici/onroad/manual_stats_widget.py @@ -137,8 +137,7 @@ def _render(self, rect: rl.Rectangle): # === LAUNCH FEEDBACK === launches = self._stats.get('launches', 0) good_launches = self._stats.get('good_launches', 0) - # Detect if currently launching (low speed, was stopped) - if cs.vEgo < 5.0 and cs.vEgo > 0.5 and not cs.clutchPressed: + if self._stats.get('is_launching', False): rl.draw_text_ex(font, "LAUNCHING...", rl.Vector2(px, py), 26, 0, CYAN) elif launches > 0: pct = int(good_launches / launches * 100) if launches > 0 else 0 From 8565338f208f3224b7d98ca11919d62529180098 Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Sat, 7 Feb 2026 22:39:38 -0800 Subject: [PATCH 18/82] aggregate by day not drive --- opendbc_repo | 2 +- .../ui/mici/layouts/settings/manual_stats.py | 153 +++++++++++------- 2 files changed, 95 insertions(+), 60 deletions(-) diff --git a/opendbc_repo b/opendbc_repo index ce6dc0eab68e8c..e26d58bc77d979 160000 --- a/opendbc_repo +++ b/opendbc_repo @@ -1 +1 @@ -Subproject commit ce6dc0eab68e8ce9e3f5bae9e5623c98f5193f8a +Subproject commit e26d58bc77d979486ff7251d56d6ce430d062b92 diff --git a/selfdrive/ui/mici/layouts/settings/manual_stats.py b/selfdrive/ui/mici/layouts/settings/manual_stats.py index d92dd3ca8502a2..2e5f7f81f01af1 100644 --- a/selfdrive/ui/mici/layouts/settings/manual_stats.py +++ b/selfdrive/ui/mici/layouts/settings/manual_stats.py @@ -4,6 +4,7 @@ Shows historical stats and trends for manual transmission driving. """ +import datetime import json import pyray as rl @@ -121,30 +122,32 @@ def _render(self, rect: rl.Rectangle): ]) y += 15 - # Trend card - derive from session_history + # Trend card - aggregate by day for consistency with charts session_history = self._stats.get('session_history', []) - recent_sessions = session_history[-10:] - recent_stalls = [s.get('stalls', 0) for s in recent_sessions] + recent_days = self._aggregate_by_day(session_history)[-10:] + num_days = len(recent_days) + + recent_stalls = [d.get('stalls', 0) for d in recent_days] recent_shifts = [] - for s in recent_sessions: - total = s.get('upshifts', 0) + s.get('downshifts', 0) - good = s.get('upshifts_good', 0) + s.get('downshifts_good', 0) + for d in recent_days: + total = d.get('upshifts', 0) + d.get('downshifts', 0) + good = d.get('upshifts_good', 0) + d.get('downshifts_good', 0) recent_shifts.append(int(good / total * 100) if total > 0 else 100) trend_items = [] if len(recent_stalls) >= 2: trend = self._calculate_trend(recent_stalls) trend_text, trend_color = self._trend_text(trend, lower_better=True) - trend_items.append(("Stall Trend", trend_text, trend_color)) + trend_items.append((f"Stall Trend (last {num_days}d)", trend_text, trend_color)) if len(recent_shifts) >= 2: trend = self._calculate_trend(recent_shifts) trend_text, trend_color = self._trend_text(trend, lower_better=False) - trend_items.append(("Shift Score Trend", trend_text, trend_color)) + trend_items.append((f"Shift Score Trend (last {num_days}d)", trend_text, trend_color)) if recent_shifts: avg_score = sum(recent_shifts) / len(recent_shifts) - trend_items.append(("Avg Shift Score (last 10)", f"{int(avg_score)}/100", self._score_color(avg_score))) + trend_items.append((f"Avg Shift Score (last {num_days}d)", f"{int(avg_score)}/100", self._score_color(avg_score))) if trend_items: y = self._draw_card(x, y, w, "Recent Trends", trend_items) @@ -218,9 +221,32 @@ def _draw_card(self, x: int, y: int, w: int, title: str, items: list) -> int: return y + def _aggregate_by_day(self, sessions: list) -> list: + """Aggregate sessions into per-day summaries, summing counts""" + import math + days: dict[str, dict] = {} # date_str -> aggregated dict + for s in sessions: + ts = s.get('timestamp', 0) + if not ts or not isinstance(ts, (int, float)) or math.isnan(ts) or ts <= 0: + continue + date_key = datetime.datetime.fromtimestamp(ts).strftime('%Y-%m-%d') + if date_key not in days: + days[date_key] = { + 'timestamp': ts, # Keep last timestamp for the day label + 'duration': 0, 'stalls': 0, 'lugs': 0, + 'upshifts': 0, 'upshifts_good': 0, + 'downshifts': 0, 'downshifts_good': 0, + 'launches': 0, 'launches_good': 0, + } + d = days[date_key] + d['timestamp'] = max(d['timestamp'], ts) + for k in ('duration', 'stalls', 'lugs', 'upshifts', 'upshifts_good', + 'downshifts', 'downshifts_good', 'launches', 'launches_good'): + d[k] = d.get(k, 0) + s.get(k, 0) + return list(days.values()) + def _draw_shift_chart(self, x: int, y: int, w: int, sessions: list) -> int: - """Draw a bar chart showing shift score history""" - import datetime + """Draw a bar chart showing shift score history (aggregated by day)""" font_bold = gui_app.font(FontWeight.BOLD) font_small = gui_app.font(FontWeight.ROMAN) @@ -245,30 +271,31 @@ def _draw_shift_chart(self, x: int, y: int, w: int, sessions: list) -> int: rl.draw_text_ex(font_small, "50", rl.Vector2(x + 15, chart_y + chart_inner_h // 2 - 5), 14, 0, GRAY) rl.draw_text_ex(font_small, "0", rl.Vector2(x + 22, chart_y + chart_inner_h - 5), 14, 0, GRAY) - display_sessions = sessions[-12:] if len(sessions) > 12 else sessions - if not display_sessions: + days = self._aggregate_by_day(sessions) + display_days = days[-12:] if len(days) > 12 else days + if not display_days: return y + chart_h bar_spacing = 4 - bar_w = max(8, (chart_w - bar_spacing * len(display_sessions)) // len(display_sessions)) + bar_w = max(8, (chart_w - bar_spacing * len(display_days)) // len(display_days)) - for i, session in enumerate(display_sessions): - ups = session.get('upshifts', 0) - ups_good = session.get('upshifts_good', 0) - downs = session.get('downshifts', 0) - downs_good = session.get('downshifts_good', 0) + for i, day in enumerate(display_days): + ups = day.get('upshifts', 0) + ups_good = day.get('upshifts_good', 0) + downs = day.get('downshifts', 0) + downs_good = day.get('downshifts_good', 0) total = ups + downs score = ((ups_good + downs_good) / total * 100) if total > 0 else 100 bar_h = int((score / 100) * chart_inner_h) bar_x = chart_x + i * (bar_w + bar_spacing) - bar_y = chart_y + chart_inner_h - bar_h + bar_y_top = chart_y + chart_inner_h - bar_h color = GREEN if score >= 80 else (YELLOW if score >= 50 else RED) - rl.draw_rectangle(int(bar_x), int(bar_y), int(bar_w), int(bar_h), color) + rl.draw_rectangle(int(bar_x), int(bar_y_top), int(bar_w), int(bar_h), color) # Day label - timestamp = session.get('timestamp', 0) + timestamp = day.get('timestamp', 0) if timestamp > 0: dt = datetime.datetime.fromtimestamp(timestamp) day_x = bar_x + bar_w // 2 - 4 @@ -281,8 +308,7 @@ def _draw_shift_chart(self, x: int, y: int, w: int, sessions: list) -> int: return y + chart_h def _draw_stalls_chart(self, x: int, y: int, w: int, sessions: list) -> int: - """Draw a bar chart showing stalls and lugs per session""" - import datetime + """Draw a bar chart showing stalls and lugs per day""" font_bold = gui_app.font(FontWeight.BOLD) font_small = gui_app.font(FontWeight.ROMAN) @@ -298,9 +324,11 @@ def _draw_stalls_chart(self, x: int, y: int, w: int, sessions: list) -> int: chart_w = w - 60 chart_inner_h = 70 + days = self._aggregate_by_day(sessions) + display_days = days[-12:] if len(days) > 12 else days + # Find max for scaling - display_sessions = sessions[-12:] if len(sessions) > 12 else sessions - max_issues = max((s.get('stalls', 0) + s.get('lugs', 0) for s in display_sessions), default=1) + max_issues = max((d.get('stalls', 0) + d.get('lugs', 0) for d in display_days), default=1) if display_days else 1 max_issues = max(max_issues, 5) # Min scale of 5 # Draw axis @@ -311,15 +339,15 @@ def _draw_stalls_chart(self, x: int, y: int, w: int, sessions: list) -> int: rl.draw_text_ex(font_small, str(max_issues), rl.Vector2(x + 15, chart_y - 5), 14, 0, GRAY) rl.draw_text_ex(font_small, "0", rl.Vector2(x + 22, chart_y + chart_inner_h - 5), 14, 0, GRAY) - if not display_sessions: + if not display_days: return y + chart_h bar_spacing = 4 - bar_w = max(8, (chart_w - bar_spacing * len(display_sessions)) // len(display_sessions)) + bar_w = max(8, (chart_w - bar_spacing * len(display_days)) // len(display_days)) - for i, session in enumerate(display_sessions): - stalls = session.get('stalls', 0) - lugs = session.get('lugs', 0) + for i, day in enumerate(display_days): + stalls = day.get('stalls', 0) + lugs = day.get('lugs', 0) bar_x = chart_x + i * (bar_w + bar_spacing) # Stacked bar: stalls (red) on bottom, lugs (orange) on top @@ -335,7 +363,7 @@ def _draw_stalls_chart(self, x: int, y: int, w: int, sessions: list) -> int: rl.draw_rectangle(int(bar_x), int(chart_y + chart_inner_h - lug_h - stall_h), int(bar_w), int(stall_h), RED) # Day label - timestamp = session.get('timestamp', 0) + timestamp = day.get('timestamp', 0) if timestamp > 0: dt = datetime.datetime.fromtimestamp(timestamp) day_x = bar_x + bar_w // 2 - 4 @@ -352,8 +380,7 @@ def _draw_stalls_chart(self, x: int, y: int, w: int, sessions: list) -> int: return y + chart_h def _draw_launch_chart(self, x: int, y: int, w: int, sessions: list) -> int: - """Draw a bar chart showing launch success rate""" - import datetime + """Draw a bar chart showing launch success rate per day""" font_bold = gui_app.font(FontWeight.BOLD) font_small = gui_app.font(FontWeight.ROMAN) @@ -377,30 +404,31 @@ def _draw_launch_chart(self, x: int, y: int, w: int, sessions: list) -> int: rl.draw_text_ex(font_small, "100%", rl.Vector2(x + 5, chart_y - 5), 14, 0, GRAY) rl.draw_text_ex(font_small, "0%", rl.Vector2(x + 15, chart_y + chart_inner_h - 5), 14, 0, GRAY) - display_sessions = sessions[-12:] if len(sessions) > 12 else sessions - if not display_sessions: + days = self._aggregate_by_day(sessions) + display_days = days[-12:] if len(days) > 12 else days + if not display_days: return y + chart_h bar_spacing = 4 - bar_w = max(8, (chart_w - bar_spacing * len(display_sessions)) // len(display_sessions)) + bar_w = max(8, (chart_w - bar_spacing * len(display_days)) // len(display_days)) - for i, session in enumerate(display_sessions): - launches = session.get('launches', 0) - launches_good = session.get('launches_good', 0) + for i, day in enumerate(display_days): + launches = day.get('launches', 0) + launches_good = day.get('launches_good', 0) bar_x = chart_x + i * (bar_w + bar_spacing) if launches > 0: pct = (launches_good / launches) * 100 bar_h = int((pct / 100) * chart_inner_h) - bar_y = chart_y + chart_inner_h - bar_h + bar_y_top = chart_y + chart_inner_h - bar_h color = GREEN if pct >= 80 else (YELLOW if pct >= 50 else RED) - rl.draw_rectangle(int(bar_x), int(bar_y), int(bar_w), int(bar_h), color) + rl.draw_rectangle(int(bar_x), int(bar_y_top), int(bar_w), int(bar_h), color) else: # No launches - draw thin gray bar rl.draw_rectangle(int(bar_x), int(chart_y + chart_inner_h - 2), int(bar_w), 2, GRAY) # Day label - timestamp = session.get('timestamp', 0) + timestamp = day.get('timestamp', 0) if timestamp > 0: dt = datetime.datetime.fromtimestamp(timestamp) day_x = bar_x + bar_w // 2 - 4 @@ -582,12 +610,13 @@ def _get_overall_hand(self) -> tuple[str, rl.Color]: # Calculate overall score score = shift_pct - (stall_rate * 10) - # Recent improvement bonus - derive from session_history + # Recent improvement bonus - aggregate by day session_history = self._stats.get('session_history', []) + recent_days = self._aggregate_by_day(session_history)[-10:] recent_scores = [] - for s in session_history[-10:]: - total = s.get('upshifts', 0) + s.get('downshifts', 0) - good = s.get('upshifts_good', 0) + s.get('downshifts_good', 0) + for d in recent_days: + total = d.get('upshifts', 0) + d.get('downshifts', 0) + good = d.get('upshifts_good', 0) + d.get('downshifts_good', 0) recent_scores.append(int(good / total * 100) if total > 0 else 100) if len(recent_scores) >= 3: if recent_scores[-1] > recent_scores[0]: @@ -620,20 +649,26 @@ def _get_encouragement(self) -> str: """Get encouragement based on overall progress""" total_drives = self._stats.get('total_drives', 0) total_stalls = self._stats.get('total_stalls', 0) - # Derive recent trends from session_history + # Aggregate by day for consistent messaging session_history = self._stats.get('session_history', []) - recent_sessions = session_history[-10:] - recent_stalls = [s.get('stalls', 0) for s in recent_sessions] + recent_days = self._aggregate_by_day(session_history)[-10:] + num_days = len(recent_days) + recent_stalls = [d.get('stalls', 0) for d in recent_days] recent_scores = [] - for s in recent_sessions: - total = s.get('upshifts', 0) + s.get('downshifts', 0) - good = s.get('upshifts_good', 0) + s.get('downshifts_good', 0) + for d in recent_days: + total = d.get('upshifts', 0) + d.get('downshifts', 0) + good = d.get('upshifts_good', 0) + d.get('downshifts_good', 0) recent_scores.append(int(good / total * 100) if total > 0 else 100) if total_drives == 0: return "Start driving to see your stats! Time to earn your first waddle KP." - stall_rate = total_stalls / total_drives if total_drives > 0 else 0 + if total_drives <= 2: + if total_stalls == 0: + return "No stalls yet! Waddle energy from day 1. Keep it up!" + return f"{total_stalls} stall{'s' if total_stalls > 1 else ''} so far - every waddle driver starts somewhere. QG!" + + stall_rate = total_stalls / total_drives # Check for improvement improving = False @@ -646,16 +681,16 @@ def _get_encouragement(self) -> str: if recent_avg == 0: # Check for crazy good performance if len(recent_scores) >= 3 and all(s >= 95 for s in recent_scores[-3:]): - return "3 drives 95%+ NO stalls?! Waddle is driving! Kacper threw his glasses!" + return f"Last {num_days}d: 95%+ shifts, NO stalls?! Waddle is driving! Kacper threw his glasses!" if improving: - return "No stalls AND improving? Waddle energy! QG to KP!" - return "No stalls recent - waddle game strong! Not SS, priest-approved!" + return f"Last {num_days}d: no stalls AND improving? Waddle energy! QG to KP!" + return f"Last {num_days}d: no stalls - waddle game strong! Not SS, priest-approved!" elif recent_avg < stall_rate: - return "Recent drives better than avg - shedding jackets, channeling waddle!" + return f"Last {num_days}d: better than avg - shedding jackets, channeling waddle!" if stall_rate < 0.5: if improving: - return "< 1 stall per 2 drives AND improving! Porch-worthy waddle progress!" + return f"< 1 stall per 2 drives AND improving (last {num_days}d)! Porch-worthy waddle progress!" return "< 1 stall per 2 drives - solid waddle vibes, not SS!" elif stall_rate < 1: return "~1 stall per drive - de-jacketing in progress!" From 993e9e22de7ec80e93dad306aabab3ad4e8d7de9 Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Sat, 7 Feb 2026 22:59:40 -0800 Subject: [PATCH 19/82] big mici ui --- .../ui/mici/onroad/manual_stats_widget.py | 77 ++++++++++--------- 1 file changed, 41 insertions(+), 36 deletions(-) diff --git a/selfdrive/ui/mici/onroad/manual_stats_widget.py b/selfdrive/ui/mici/onroad/manual_stats_widget.py index 1629146376e0e9..f3a8d8ae5e55a5 100644 --- a/selfdrive/ui/mici/onroad/manual_stats_widget.py +++ b/selfdrive/ui/mici/onroad/manual_stats_widget.py @@ -76,27 +76,27 @@ def _render(self, rect: rl.Rectangle): if not cs: return - # Widget dimensions - extend to bottom with same margin as top + # Widget dimensions - full width with equal margins margin = 10 - w = 250 - h = int(rect.height - 2 * margin) # Full height minus top and bottom margin - x = int(rect.x + rect.width - w - margin) + w = int(rect.width - 2 * margin) + h = int(rect.height - 2 * margin) + x = int(rect.x + margin) y = int(rect.y + margin) # Background - rl.draw_rectangle_rounded(rl.Rectangle(x, y, w, h), 0.08, 10, BG_COLOR) + rl.draw_rectangle_rounded(rl.Rectangle(x, y, w, h), 0.2, 10, BG_COLOR) font = gui_app.font(FontWeight.MEDIUM) font_bold = gui_app.font(FontWeight.BOLD) - px = x + 14 - py = y + 12 + px = x + 16 + py = y + 2 - # === RPM METER === + # === RPM METER (top, full width) === rpm = cs.engineRpm - self._draw_rpm_meter(px, py, w - 28, 50, rpm, cs) - py += 62 + self._draw_rpm_meter(px, py, w - 32, 60, rpm, cs) + py += 64 - # === GEAR + SHIFT GRADE FLASH === + # === GEAR (left) + RPM NUMBER (right) on same line === gear = cs.gearActual gear_text = str(gear) if gear > 0 else "N" @@ -120,35 +120,43 @@ def _render(self, rect: rl.Rectangle): else: gear_color = RED grade_text = "✗" - rl.draw_text_ex(font_bold, gear_text, rl.Vector2(px, py), 55, 0, gear_color) - rl.draw_text_ex(font_bold, grade_text, rl.Vector2(px + 42, py + 8), 40, 0, gear_color) + rl.draw_text_ex(font_bold, gear_text, rl.Vector2(px, py), 66, 0, gear_color) + rl.draw_text_ex(font_bold, grade_text, rl.Vector2(px + 50, py + 10), 48, 0, gear_color) else: - rl.draw_text_ex(font_bold, gear_text, rl.Vector2(px, py), 55, 0, WHITE) + rl.draw_text_ex(font_bold, gear_text, rl.Vector2(px, py), 66, 0, WHITE) # Shift suggestion arrow suggestion = self._stats.get('shift_suggestion', 'ok') if suggestion == 'upshift': - rl.draw_text_ex(font_bold, "↑", rl.Vector2(px + 95, py + 8), 43, 0, GREEN) + rl.draw_text_ex(font_bold, "↑", rl.Vector2(px + 115, py + 10), 52, 0, GREEN) elif suggestion == 'downshift': - rl.draw_text_ex(font_bold, "↓", rl.Vector2(px + 95, py + 8), 43, 0, YELLOW) + rl.draw_text_ex(font_bold, "↓", rl.Vector2(px + 115, py + 10), 52, 0, YELLOW) - py += 62 + # RPM number (right-aligned, same line as gear) + rpm_text = f"{int(round(self._rpm_filter.x / 10) * 10)}" + rpm_width = rl.measure_text_ex(font_bold, rpm_text, 44, 0).x + rpm_label_width = rl.measure_text_ex(font, "rpm", 22, 0).x + rpm_right = x + w - 16 + rl.draw_text_ex(font_bold, rpm_text, rl.Vector2(rpm_right - rpm_width - rpm_label_width - 22, py + 22), 44, 0, WHITE) + rl.draw_text_ex(font, "rpm", rl.Vector2(rpm_right - rpm_label_width, py + 42), 22, 0, GRAY) + + py += 68 # === LAUNCH FEEDBACK === launches = self._stats.get('launches', 0) good_launches = self._stats.get('good_launches', 0) if self._stats.get('is_launching', False): - rl.draw_text_ex(font, "LAUNCHING...", rl.Vector2(px, py), 26, 0, CYAN) + rl.draw_text_ex(font, "LAUNCHING...", rl.Vector2(px, py), 31, 0, CYAN) elif launches > 0: pct = int(good_launches / launches * 100) if launches > 0 else 0 color = GREEN if pct >= 75 else (YELLOW if pct >= 50 else GRAY) - rl.draw_text_ex(font, f"Launch: {good_launches}/{launches}", rl.Vector2(px, py), 26, 0, color) + rl.draw_text_ex(font, f"Launch: {good_launches}/{launches}", rl.Vector2(px, py), 31, 0, color) else: - rl.draw_text_ex(font, "Launch: -", rl.Vector2(px, py), 26, 0, GRAY) - py += 34 + rl.draw_text_ex(font, "Launch: -", rl.Vector2(px, py), 31, 0, GRAY) + py += 36 # === STATS ROW === - font_size = 24 + font_size = 29 # Stalls & Lugs on same line stalls = self._stats.get('stalls', 0) @@ -161,7 +169,7 @@ def _render(self, rect: rl.Rectangle): stall_color = GREEN if stalls == 0 else RED lug_color = GREEN if lugs == 0 else YELLOW rl.draw_text_ex(font, f"S:{stalls}", rl.Vector2(px, py), font_size, 0, stall_color) - rl.draw_text_ex(font, f"L:{lugs}", rl.Vector2(px + 65, py), font_size, 0, lug_color) + rl.draw_text_ex(font, f"L:{lugs}", rl.Vector2(px + 78, py), font_size, 0, lug_color) # Shift quality shifts = self._stats.get('shifts', 0) @@ -169,18 +177,18 @@ def _render(self, rect: rl.Rectangle): if shifts > 0: pct = int(good_shifts / shifts * 100) color = GREEN if pct >= 80 else (YELLOW if pct >= 50 else RED) - rl.draw_text_ex(font, f"Sh:{pct}%", rl.Vector2(px + 135, py), font_size, 0, color) + rl.draw_text_ex(font, f"Sh:{pct}%", rl.Vector2(px + 162, py), font_size, 0, color) else: - rl.draw_text_ex(font, "Sh:-", rl.Vector2(px + 135, py), font_size, 0, GRAY) + rl.draw_text_ex(font, "Sh:-", rl.Vector2(px + 162, py), font_size, 0, GRAY) def _draw_rpm_meter(self, x: int, y: int, w: int, h: int, rpm: float, cs): """Draw RPM bar with color zones and rev-match target""" font = gui_app.font(FontWeight.MEDIUM) - # Bar background (pushed down for bigger RPM text) - bar_h = 20 - bar_y = y + 32 - rl.draw_rectangle_rounded(rl.Rectangle(x, bar_y, w, bar_h), 0.3, 5, rl.Color(40, 40, 40, 200)) + # Bar at top, taller + bar_h = 56 + bar_y = y + 4 + rl.draw_rectangle_rounded(rl.Rectangle(x, bar_y, w, bar_h), 0.2, 5, rl.Color(40, 40, 40, 200)) # Calculate fill width rpm_pct = min(rpm / RPM_REDLINE, 1.0) @@ -227,24 +235,21 @@ def _draw_rpm_meter(self, x: int, y: int, w: int, h: int, rpm: float, cs): # Over redline - show red warning clipped to right side down_x = x + w rl.draw_rectangle(down_x - 4, bar_y - 5, 4, bar_h + 10, red) - rl.draw_text_ex(font, f"{int(round(down_rpm / 10) * 10)}!", rl.Vector2(down_x - 45, bar_y + bar_h + 3), 20, 0, red) + rl.draw_text_ex(font, f"{int(round(down_rpm / 10) * 10)}!", rl.Vector2(down_x - 54, bar_y + bar_h + 4), 24, 0, red) elif down_rpm > RPM_TARGET_MIN_DISPLAY: # Safe downshift target (cyan) down_x = x + int(w * (down_rpm / RPM_REDLINE)) rl.draw_rectangle(down_x - 2, bar_y - 5, 4, bar_h + 10, cyan) - rl.draw_text_ex(font, f"{int(round(down_rpm / 10) * 10)}", rl.Vector2(down_x - 20, bar_y + bar_h + 3), 20, 0, cyan) + rl.draw_text_ex(font, f"{int(round(down_rpm / 10) * 10)}", rl.Vector2(down_x - 24, bar_y + bar_h + 4), 24, 0, cyan) # Upshift target (white) - only show if above minimum display threshold if up_rpm > RPM_TARGET_MIN_DISPLAY and up_rpm < RPM_REDLINE: up_x = x + int(w * (up_rpm / RPM_REDLINE)) rl.draw_rectangle(up_x - 2, bar_y - 5, 4, bar_h + 10, white) - rl.draw_text_ex(font, f"{int(round(up_rpm / 10) * 10)}", rl.Vector2(up_x - 20, bar_y + bar_h + 3), 20, 0, white) + rl.draw_text_ex(font, f"{int(round(up_rpm / 10) * 10)}", rl.Vector2(up_x - 24, bar_y + bar_h + 4), 24, 0, white) - # RPM text (filtered for smooth display, rounded to nearest 10) + # Update RPM filter (text drawn in main render next to gear) self._rpm_filter.update(rpm) - rpm_text = f"{int(round(self._rpm_filter.x / 10) * 10)}" - rl.draw_text_ex(font, rpm_text, rl.Vector2(x, y), 28, 0, WHITE) - rl.draw_text_ex(font, "rpm", rl.Vector2(x + 70, y + 5), 20, 0, GRAY) def _load_stats(self): """Load current session stats""" From bf0870d6993f46451cd9cc3c2b7a8eb8cf18b0f8 Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Sat, 7 Feb 2026 23:14:56 -0800 Subject: [PATCH 20/82] randomize drive summary texts --- selfdrive/ui/mici/layouts/main.py | 6 +- .../ui/mici/layouts/manual_drive_summary.py | 117 ++++++++++++------ 2 files changed, 82 insertions(+), 41 deletions(-) diff --git a/selfdrive/ui/mici/layouts/main.py b/selfdrive/ui/mici/layouts/main.py index ca27592f2b1316..365a769320cf28 100644 --- a/selfdrive/ui/mici/layouts/main.py +++ b/selfdrive/ui/mici/layouts/main.py @@ -36,9 +36,6 @@ def __init__(self): self._onroad_time_delay: float | None = None self._setup = False - # Manual drive summary dialog - self._drive_summary_dialog: ManualDriveSummaryDialog | None = None - # Initialize widgets self._home_layout = MiciHomeLayout() self._alerts_layout = MiciOffroadAlerts() @@ -150,8 +147,7 @@ def _show_drive_summary_if_available(self): session.get('upshifts', 0) > 0 or session.get('launches', 0) > 0) if duration > 30 and has_activity: - self._drive_summary_dialog = ManualDriveSummaryDialog() - gui_app.set_modal_overlay(self._drive_summary_dialog) + gui_app.set_modal_overlay(ManualDriveSummaryDialog()) def _set_mode_for_started(self, onroad_transition: bool = False): if ui_state.started: diff --git a/selfdrive/ui/mici/layouts/manual_drive_summary.py b/selfdrive/ui/mici/layouts/manual_drive_summary.py index d264bdec8e7d44..d7f853d04d6fbf 100644 --- a/selfdrive/ui/mici/layouts/manual_drive_summary.py +++ b/selfdrive/ui/mici/layouts/manual_drive_summary.py @@ -7,6 +7,7 @@ """ import json +import random import pyray as rl from typing import Optional, Callable @@ -62,14 +63,12 @@ def __init__(self, dismiss_callback: Optional[Callable] = None): # Load data immediately since show_event may not be called for modals self._load_session() self._load_historical() + # Pick random texts once for this instance + self._header_text, self._header_color = self._pick_header() + self._encouragement_text = self._pick_encouragement() # Set back callback to dismiss modal self.set_back_callback(lambda: gui_app.set_modal_overlay(None)) - def show_event(self): - super().show_event() - self._load_session() - self._load_historical() - def _load_session(self): """Load the last session data from session_history in ManualDriveStats""" data = self._params.get("ManualDriveStats") @@ -156,17 +155,34 @@ def _calculate_grade(self): self._card_rank = "10" self._overall_grade = "poor" - def _get_header_text(self) -> tuple[str, rl.Color]: - """Get header text and color based on grade""" + def _pick_header(self) -> tuple[str, rl.Color]: if self._overall_grade == "good": - return "Waddle Driver!", GREEN + return random.choice([ + "Waddle Driver!", + "KP Earned!", + "Porch-worthy!", + "CCR Energy!", + "Priest-approved!", + "Pure Waddle!", + ]), GREEN elif self._overall_grade == "ok": - return "Decent Drive", YELLOW + return random.choice([ + "Decent Drive", + "Getting There!", + "Not SS... Yet", + "Shedding Jackets", + "Almost Waddle", + ]), YELLOW else: - return "Jackets...", RED - - def _get_encouragement_text(self) -> str: - """Get encouragement or criticism text based on performance""" + return random.choice([ + "Jackets...", + "Huge Oof", + "SS Vibes", + "Full Jackets!", + "Jacketed!", + ]), RED + + def _pick_encouragement(self) -> str: if not self._session_data: return "No data available for this drive." @@ -191,48 +207,77 @@ def _get_encouragement_text(self) -> str: perfect_launches = launch_total > 0 and launch_good == launch_total if self._card_rank == "A" and stalls == 0 and lugs == 0 and perfect_shifts and perfect_launches: - messages.append("PERFECT! Waddle is driving! Kacper threw his glasses!") + messages.append(random.choice([ + "PERFECT! Waddle is driving! Kacper threw his glasses!", + "FLAWLESS! Even Kacper couldn't believe it!", + "LEGENDARY! Full waddle, zero jackets, KP maxed!", + ])) elif self._card_rank == "A": - messages.append("Aces! Porch-worthy waddle, KP earned!") + messages.append(random.choice([ + "Aces! Porch-worthy waddle, KP earned!", + "Aces! CCR material right here!", + "Aces! Waddle would be proud!", + ])) elif self._card_rank == "K": - messages.append("Kings! Waddle energy, CCM vibes!") + messages.append(random.choice([ + "Kings! Waddle energy, CCM vibes!", + "Kings! Solid drive, almost porch-worthy!", + "Kings! Not SS, definitely QG!", + ])) if stalls == 0 and launch_stalled == 0: - messages.append("No stalls!") + messages.append(random.choice(["No stalls!", "Zero stalls, clean!", "Stall-free!"])) if perfect_shifts: - messages.append("Perfect shifts - priest-approved!") + messages.append(random.choice([ + "Perfect shifts - priest-approved!", + "Every shift was butter!", + "Flawless shifting, pure waddle!", + ])) elif upshift_total > 0 and upshift_good == upshift_total: - messages.append("Perfect upshifts!") + messages.append(random.choice(["Perfect upshifts!", "Upshifts on point!", "Clean upshifts!"])) if downshift_total > 0 and downshift_good >= downshift_total * 0.8: - messages.append("Great rev matching!") + messages.append(random.choice(["Great rev matching!", "Rev matching on point!", "Heel-toe vibes!"])) if perfect_launches: - messages.append("Flawless launches!") + messages.append(random.choice(["Flawless launches!", "Every launch was smooth!", "Launch game maxed!"])) elif launch_total > 0 and launch_good >= launch_total * 0.8: - messages.append("Smooth launches!") + messages.append(random.choice(["Smooth launches!", "Launches looking clean!", "Good clutch control!"])) if not messages: - messages.append("Keep channeling waddle!") + messages.append(random.choice(["Keep channeling waddle!", "Waddle energy maintained!", "Stay on this path!"])) elif self._overall_grade == "ok": if self._card_rank == "Q": - messages.append("Queens - almost there!") + messages.append(random.choice([ + "Queens - almost there!", + "Queens - one step from waddle!", + "Queens - so close to KP!", + ])) else: - messages.append("Jacks - improving, not SS!") + messages.append(random.choice([ + "Jacks - improving, not SS!", + "Jacks - shedding jackets slowly!", + "Jacks - waddle is within reach!", + ])) if stalls > 0: - messages.append(f"Only {stalls} stall{'s' if stalls > 1 else ''} - shedding jackets!") + messages.append(f"Only {stalls} stall{'s' if stalls > 1 else ''} - {random.choice(['shedding jackets!', 'getting better!', 'less than before?'])}") if lugs > 0: - messages.append(f"Watch RPMs - {lugs} lug{'s' if lugs > 1 else ''}.") + messages.append(f"{random.choice(['Watch RPMs', 'Easy on the low RPMs'])} - {lugs} lug{'s' if lugs > 1 else ''}.") if upshift_total > 0 and upshift_good < upshift_total: - messages.append("Smoother upshifts needed.") + messages.append(random.choice(["Smoother upshifts needed.", "Upshifts could be cleaner.", "Work on those upshifts!"])) else: # poor - jackets - messages.append("Jacketed! Huge oof. SS vibes!") + messages.append(random.choice([ + "Jacketed! Huge oof. SS vibes!", + "Full jackets! CCR this is not.", + "Oof. Jacket city. QG needed!", + "Jacketed hard. Waddle disapproves.", + ])) if stalls > 2: - messages.append(f"{stalls} stalls - more gas, slower clutch!") + messages.append(f"{stalls} stalls - {random.choice(['more gas, slower clutch!', 'find that bite point!', 'easy on the clutch!'])}") if launch_stalled > 0: - messages.append(f"{launch_stalled} stalled launch{'es' if launch_stalled > 1 else ''} - find bite point!") + messages.append(f"{launch_stalled} stalled launch{'es' if launch_stalled > 1 else ''} - {random.choice(['find bite point!', 'more revs before release!', 'hold clutch longer!'])}") if lugs > 3: - messages.append(f"Lugging {lugs}x - downshift sooner!") + messages.append(f"Lugging {lugs}x - {random.choice(['downshift sooner!', 'drop a gear!', 'RPMs too low!'])}") if not messages[1:]: - messages.append("Even the best got jacketed at first. QG!") + messages.append(random.choice(["Even the best got jacketed at first. QG!", "Keep practicing, waddle awaits!", "Every driver starts here. KP is coming!"])) return " ".join(messages) @@ -246,7 +291,7 @@ def _measure_content_height(self) -> int: h += 75 # Shift score bar h += 195 # Stats card # Encouragement text (estimate) - encouragement = self._get_encouragement_text() + encouragement = self._encouragement_text wrapped = wrap_text(font_roman, encouragement, 22, 500) h += len(wrapped) * 28 + 20 return h @@ -273,7 +318,7 @@ def _render(self, rect: rl.Rectangle): rl.draw_rectangle_rounded(rl.Rectangle(x, y, w, top_card_h), 0.02, 10, BG_CARD) # Header - header_text, header_color = self._get_header_text() + header_text, header_color = self._header_text, self._header_color rl.draw_text_ex(font_bold, header_text, rl.Vector2(x + 15, y + 12), 44, 0, header_color) y += 58 @@ -338,7 +383,7 @@ def _render(self, rect: rl.Rectangle): y += 200 # Encouragement/criticism text - encouragement = self._get_encouragement_text() + encouragement = self._encouragement_text wrapped = wrap_text(font_roman, encouragement, 22, w) for line in wrapped: rl.draw_text_ex(font_roman, line, rl.Vector2(x, y), 22, 0, LIGHT_GRAY) From bb45e4f4cb76c24784136b356d83585ac7a94ff2 Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Sat, 7 Feb 2026 23:19:52 -0800 Subject: [PATCH 21/82] clean up summary --- .../ui/mici/layouts/manual_drive_summary.py | 126 +++++------------- 1 file changed, 37 insertions(+), 89 deletions(-) diff --git a/selfdrive/ui/mici/layouts/manual_drive_summary.py b/selfdrive/ui/mici/layouts/manual_drive_summary.py index d7f853d04d6fbf..5c5bd1ecee5d7a 100644 --- a/selfdrive/ui/mici/layouts/manual_drive_summary.py +++ b/selfdrive/ui/mici/layouts/manual_drive_summary.py @@ -9,10 +9,10 @@ import json import random import pyray as rl -from typing import Optional, Callable +from typing import Optional from openpilot.common.params import Params -from openpilot.system.ui.lib.application import gui_app, FontWeight, FONT_SCALE +from openpilot.system.ui.lib.application import gui_app, FontWeight from openpilot.system.ui.lib.scroll_panel2 import GuiScrollPanel2 from openpilot.system.ui.lib.wrap_text import wrap_text from openpilot.system.ui.widgets import NavWidget @@ -22,11 +22,9 @@ GREEN = rl.Color(46, 204, 113, 255) YELLOW = rl.Color(241, 196, 15, 255) RED = rl.Color(231, 76, 60, 255) -ORANGE = rl.Color(230, 126, 34, 255) GRAY = rl.Color(150, 150, 150, 255) LIGHT_GRAY = rl.Color(200, 200, 200, 255) WHITE = rl.Color(255, 255, 255, 255) -BG_COLOR = rl.Color(30, 30, 30, 245) BG_CARD = rl.Color(45, 45, 45, 255) # Poker hand names @@ -50,58 +48,50 @@ class ManualDriveSummaryDialog(NavWidget): """Modal dialog showing end-of-drive manual transmission stats""" - def __init__(self, dismiss_callback: Optional[Callable] = None): + def __init__(self): super().__init__() - self._params = Params() self._scroll_panel = GuiScrollPanel2(horizontal=False) self._session_data: Optional[dict] = None - self._historical_data: Optional[dict] = None self._overall_grade: str = "good" # good, ok, poor self._card_rank: str = "10" # Poker card rank: 10, J, Q, K, A self._shift_score: float = 0.0 self._avg_shift_score: float = 0.0 - # Load data immediately since show_event may not be called for modals - self._load_session() - self._load_historical() + + # Load all data from one param read + self._load_data() + # Pick random texts once for this instance self._header_text, self._header_color = self._pick_header() self._encouragement_text = self._pick_encouragement() - # Set back callback to dismiss modal + self.set_back_callback(lambda: gui_app.set_modal_overlay(None)) - def _load_session(self): - """Load the last session data from session_history in ManualDriveStats""" - data = self._params.get("ManualDriveStats") - if data: - stats = data if isinstance(data, dict) else json.loads(data) - history = stats.get('session_history', []) - if history: - self._session_data = history[-1] - self._calculate_grade() - return - self._session_data = None - - def _load_historical(self): - """Load historical stats for comparison""" - data = self._params.get("ManualDriveStats") - if data: - self._historical_data = data if isinstance(data, dict) else json.loads(data) - # Calculate average shift score from history - history = self._historical_data.get('session_history', []) - if history: - scores = [] - for s in history[-10:]: # Last 10 sessions - ups = s.get('upshifts', 0) - ups_good = s.get('upshifts_good', 0) - downs = s.get('downshifts', 0) - downs_good = s.get('downshifts_good', 0) - total = ups + downs - if total > 0: - scores.append((ups_good + downs_good) / total * 100) - if scores: - self._avg_shift_score = sum(scores) / len(scores) - else: - self._historical_data = None + def _load_data(self): + """Load session and historical data from ManualDriveStats (single read)""" + data = Params().get("ManualDriveStats") + if not data: + return + + stats = json.loads(data) + history = stats.get('session_history', []) + + # Last session + if history: + self._session_data = history[-1] + self._calculate_grade() + + # Average shift score from recent history + scores = [] + for s in history[-10:]: + ups = s.get('upshifts', 0) + ups_good = s.get('upshifts_good', 0) + downs = s.get('downshifts', 0) + downs_good = s.get('downshifts_good', 0) + total = ups + downs + if total > 0: + scores.append((ups_good + downs_good) / total * 100) + if scores: + self._avg_shift_score = sum(scores) / len(scores) def _calculate_grade(self): """Calculate overall grade based on session performance""" @@ -291,8 +281,7 @@ def _measure_content_height(self) -> int: h += 75 # Shift score bar h += 195 # Stats card # Encouragement text (estimate) - encouragement = self._encouragement_text - wrapped = wrap_text(font_roman, encouragement, 22, 500) + wrapped = wrap_text(font_roman, self._encouragement_text, 22, 500) h += len(wrapped) * 28 + 20 return h @@ -318,8 +307,7 @@ def _render(self, rect: rl.Rectangle): rl.draw_rectangle_rounded(rl.Rectangle(x, y, w, top_card_h), 0.02, 10, BG_CARD) # Header - header_text, header_color = self._header_text, self._header_color - rl.draw_text_ex(font_bold, header_text, rl.Vector2(x + 15, y + 12), 44, 0, header_color) + rl.draw_text_ex(font_bold, self._header_text, rl.Vector2(x + 15, y + 12), 44, 0, self._header_color) y += 58 # Card rank display - poker hand style with subtitle @@ -383,8 +371,7 @@ def _render(self, rect: rl.Rectangle): y += 200 # Encouragement/criticism text - encouragement = self._encouragement_text - wrapped = wrap_text(font_roman, encouragement, 22, w) + wrapped = wrap_text(font_roman, self._encouragement_text, 22, w) for line in wrapped: rl.draw_text_ex(font_roman, line, rl.Vector2(x, y), 22, 0, LIGHT_GRAY) y += 28 @@ -464,42 +451,3 @@ def _draw_mini_stat(self, x: int, y: int, w: int, label: str, value, target, low rl.draw_text_ex(font_roman, value_str, rl.Vector2(x + w - value_width, y), font_size, 0, color) return y + 26 - - def _draw_stat_section(self, x: int, y: int, w: int, label: str, value, target=None, - current=None, lower_better=False) -> int: - """Draw a stat row with label and value, colored based on performance""" - font = gui_app.font(FontWeight.MEDIUM) - font_size = 28 - - # Determine color based on target - if target is not None: - if lower_better: - if value == 0: - color = GREEN - elif value <= 2: - color = YELLOW - else: - color = RED - else: - if current is not None: - ratio = current / target if target > 0 else 1 - if ratio >= 0.8: - color = GREEN - elif ratio >= 0.5: - color = YELLOW - else: - color = RED - else: - color = LIGHT_GRAY - else: - color = LIGHT_GRAY - - # Draw label - rl.draw_text_ex(font, label, rl.Vector2(x, y), font_size, 0, LIGHT_GRAY) - - # Draw value (right-aligned) - value_str = str(value) - value_width = rl.measure_text_ex(font, value_str, font_size, 0).x - rl.draw_text_ex(font, value_str, rl.Vector2(x + w - value_width, y), font_size, 0, color) - - return y + 38 From 727a1c30c0787166d316f6221be79e3d6b596a6e Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Sat, 7 Feb 2026 23:40:52 -0800 Subject: [PATCH 22/82] more random wx --- selfdrive/ui/mici/layouts/main.py | 3 +- .../ui/mici/layouts/manual_drive_summary.py | 9 +- .../ui/mici/layouts/settings/manual_stats.py | 180 ++++++++++++++---- 3 files changed, 154 insertions(+), 38 deletions(-) diff --git a/selfdrive/ui/mici/layouts/main.py b/selfdrive/ui/mici/layouts/main.py index 365a769320cf28..b308e70c2fdd15 100644 --- a/selfdrive/ui/mici/layouts/main.py +++ b/selfdrive/ui/mici/layouts/main.py @@ -1,4 +1,3 @@ -import json import pyray as rl from enum import IntEnum import cereal.messaging as messaging @@ -136,7 +135,7 @@ def _show_drive_summary_if_available(self): data = self._params.get("ManualDriveStats") if not data: return - stats = data if isinstance(data, dict) else json.loads(data) + stats = data history = stats.get('session_history', []) if not history: return diff --git a/selfdrive/ui/mici/layouts/manual_drive_summary.py b/selfdrive/ui/mici/layouts/manual_drive_summary.py index 5c5bd1ecee5d7a..4f5535fc47677d 100644 --- a/selfdrive/ui/mici/layouts/manual_drive_summary.py +++ b/selfdrive/ui/mici/layouts/manual_drive_summary.py @@ -6,7 +6,6 @@ Poker hand themed with waddle/jacket references. """ -import json import random import pyray as rl from typing import Optional @@ -72,7 +71,7 @@ def _load_data(self): if not data: return - stats = json.loads(data) + stats = data history = stats.get('session_history', []) # Last session @@ -201,18 +200,21 @@ def _pick_encouragement(self) -> str: "PERFECT! Waddle is driving! Kacper threw his glasses!", "FLAWLESS! Even Kacper couldn't believe it!", "LEGENDARY! Full waddle, zero jackets, KP maxed!", + "PERFECT! Weixing just shed a tear of joy. Kirby is star-spinning.", ])) elif self._card_rank == "A": messages.append(random.choice([ "Aces! Porch-worthy waddle, KP earned!", "Aces! CCR material right here!", "Aces! Waddle would be proud!", + "Aces! Weixing raised an eyebrow, in a good way. Kirby did a little twirl.", ])) elif self._card_rank == "K": messages.append(random.choice([ "Kings! Waddle energy, CCM vibes!", "Kings! Solid drive, almost porch-worthy!", "Kings! Not SS, definitely QG!", + "Kings! Weixing didn't complain. For Weixing, that's a compliment. Kirby is chilling.", ])) if stalls == 0 and launch_stalled == 0: messages.append(random.choice(["No stalls!", "Zero stalls, clean!", "Stall-free!"])) @@ -239,12 +241,14 @@ def _pick_encouragement(self) -> str: "Queens - almost there!", "Queens - one step from waddle!", "Queens - so close to KP!", + "Queens - Weixing checked his watch. Kirby yawned.", ])) else: messages.append(random.choice([ "Jacks - improving, not SS!", "Jacks - shedding jackets slowly!", "Jacks - waddle is within reach!", + "Jacks - Weixing pinched the bridge of his nose. Kirby deflated.", ])) if stalls > 0: messages.append(f"Only {stalls} stall{'s' if stalls > 1 else ''} - {random.choice(['shedding jackets!', 'getting better!', 'less than before?'])}") @@ -259,6 +263,7 @@ def _pick_encouragement(self) -> str: "Full jackets! CCR this is not.", "Oof. Jacket city. QG needed!", "Jacketed hard. Waddle disapproves.", + "Weixing pretends he doesn't know you. Kirby swallowed the car whole.", ])) if stalls > 2: messages.append(f"{stalls} stalls - {random.choice(['more gas, slower clutch!', 'find that bite point!', 'easy on the clutch!'])}") diff --git a/selfdrive/ui/mici/layouts/settings/manual_stats.py b/selfdrive/ui/mici/layouts/settings/manual_stats.py index 2e5f7f81f01af1..700c1601f6c22f 100644 --- a/selfdrive/ui/mici/layouts/settings/manual_stats.py +++ b/selfdrive/ui/mici/layouts/settings/manual_stats.py @@ -5,7 +5,7 @@ """ import datetime -import json +import random import pyray as rl from openpilot.common.params import Params @@ -34,6 +34,9 @@ def __init__(self, back_callback): self._params = Params() self._scroll_panel = GuiScrollPanel2(horizontal=False) self._stats: dict = {} + self._hand_text: str = "" + self._hand_color: rl.Color = GRAY + self._encouragement_text: str = "" self.set_back_callback(back_callback) def show_event(self): @@ -42,12 +45,15 @@ def show_event(self): self._load_stats() def _load_stats(self): - """Load historical stats from Params""" + """Load historical stats from Params and cache random text picks""" data = self._params.get("ManualDriveStats") if data: - self._stats = data if isinstance(data, dict) else json.loads(data) + self._stats = data else: self._stats = {} + # Pick random texts once per page visit (not every frame) + self._hand_text, self._hand_color = self._get_overall_hand() + self._encouragement_text = self._get_encouragement() def _render(self, rect: rl.Rectangle): content_height = self._measure_content_height(rect) @@ -81,9 +87,8 @@ def _render(self, rect: rl.Rectangle): return # Overall hand rating - hand_rating, hand_color = self._get_overall_hand() y = self._draw_card(x, y, w, "Your Hand", [ - ("Overall Rating", hand_rating, hand_color), + ("Overall Rating", self._hand_text, self._hand_color), ("Total Drives", str(self._stats.get('total_drives', 0)), WHITE), ("Total Drive Time", self._format_time(self._stats.get('total_drive_time', 0)), WHITE), ("Total Stalls", str(self._stats.get('total_stalls', 0)), self._stall_color(self._stats.get('total_stalls', 0))), @@ -172,8 +177,7 @@ def _render(self, rect: rl.Rectangle): # Encouragement based on progress (with text wrapping) y += 10 - encouragement = self._get_encouragement() - wrapped_lines = wrap_text(font_roman, encouragement, 24, w - 10) + wrapped_lines = wrap_text(font_roman, self._encouragement_text, 24, w - 10) for line in wrapped_lines: rl.draw_text_ex(font_roman, line, rl.Vector2(x, y), 24, 0, LIGHT_GRAY) y += 30 @@ -588,11 +592,11 @@ def _trend_text(self, trend: float, lower_better: bool) -> tuple[str, rl.Color]: if lower_better: if trend < 0: return "Improving!", GREEN - return "Getting worse", RED + return random.choice(["Getting worse", "Getting worse - Weixing is shaking his head. Kirby turned around."]), RED else: if trend > 0: return "Improving!", GREEN - return "Getting worse", RED + return random.choice(["Getting worse", "Getting worse - Weixing is shaking his head. Kirby turned around."]), RED def _get_overall_hand(self) -> tuple[str, rl.Color]: """Calculate overall poker hand rating based on all stats""" @@ -623,27 +627,86 @@ def _get_overall_hand(self) -> tuple[str, rl.Color]: score += 5 # Bonus for improving if score >= 98 and stall_rate == 0: - return "Royal Flush - Waddle is driving! Kacper threw his glasses!", GREEN + return random.choice([ + "Royal Flush - Waddle is driving! Kacper threw his glasses!", + "Royal Flush - Perfection! Pure waddle energy!", + "Royal Flush - Legendary! KP maxed out!", + "Royal Flush - CCR material! Waddle certified!", + "Royal Flush - Elite! Priest-approved waddle!", + "Royal Flush - Weixing just shed a tear of joy. Kirby is star-spinning.", + ]), GREEN elif score >= 95 and stall_rate == 0: - return "Royal Flush - Porch-worthy waddle! KP earned!", GREEN + return random.choice([ + "Royal Flush - Porch-worthy waddle! KP earned!", + "Royal Flush - Top-tier driving, almost flawless!", + "Royal Flush - So close to perfection! Waddle approved!", + "Royal Flush - KP is proud, keep this up!", + "Royal Flush - Premium waddle, just shy of legendary!", + "Royal Flush - Weixing put your photo on his fridge. Kirby gave you a star.", + ]), GREEN elif score >= 90: - return "Straight Flush - Elite waddle, CCM vibes!", GREEN + return random.choice([ + "Straight Flush - Elite waddle, CCM vibes!", + "Straight Flush - Near-perfect, porch is calling!", + "Straight Flush - Waddle royalty!", + "Straight Flush - Weixing raised an eyebrow, in a good way. Kirby did a little twirl.", + ]), GREEN elif score >= 85: - return "Four of a Kind - Priest-approved waddle!", GREEN + return random.choice([ + "Four of a Kind - Priest-approved waddle!", + "Four of a Kind - Strong waddle game!", + "Four of a Kind - CCR energy building!", + "Four of a Kind - Weixing almost smiled. Kirby perked up.", + ]), GREEN elif score >= 80: - return "Full House - Solid waddle, not SS!", GREEN + return random.choice([ + "Full House - Solid waddle, not SS!", + "Full House - Consistent waddle vibes!", + "Full House - QG territory!", + "Full House - Weixing didn't complain. For Weixing, that's a compliment. Kirby is chilling.", + ]), GREEN elif score >= 70: - return "Flush - Good waddle, almost KP", YELLOW + return random.choice([ + "Flush - Good waddle, almost KP", + "Flush - Getting there, waddle incoming!", + "Flush - Shedding jackets nicely!", + "Flush - Weixing looked up from his phone briefly. Kirby yawned.", + ]), YELLOW elif score >= 60: - return "Straight - Improving, not SS yet", YELLOW + return random.choice([ + "Straight - Improving, not SS yet", + "Straight - Progress! Keep pushing!", + "Straight - Jacket count dropping!", + "Straight - Weixing checked his watch. Kirby fell asleep.", + ]), YELLOW elif score >= 50: - return "Three of a Kind - Getting there, shake off jackets", YELLOW + return random.choice([ + "Three of a Kind - Getting there, shake off jackets", + "Three of a Kind - Waddle is within reach!", + "Three of a Kind - Keep at it, less jackets soon!", + "Three of a Kind - Weixing pinched the bridge of his nose. Kirby deflated.", + ]), YELLOW elif score >= 40: - return "Two Pair - Jackets territory", YELLOW + return random.choice([ + "Two Pair - Jackets territory", + "Two Pair - Room to grow, QG!", + "Two Pair - Still shedding jackets", + "Two Pair - Weixing closed his laptop and stared out the window. Kirby popped.", + ]), YELLOW elif score >= 30: - return "One Pair - Jacketed, huge oof", RED + return random.choice([ + "One Pair - Jacketed, huge oof", + "One Pair - Jacket city, but improving?", + "One Pair - SS vibes, keep practicing!", + "One Pair - Weixing blocked your number. Kirby ate your clutch.", + ]), RED else: - return "High Card - SS! Full jackets!", RED + return random.choice([ + "High Card - SS! Full jackets!", + "High Card - Jacketed hard! QG needed!", + "High Card - Waddle disapproves. Keep going!", + "High Card - Weixing pretends he doesn't know you. Kirby swallowed the car whole.", + ]), RED def _get_encouragement(self) -> str: """Get encouragement based on overall progress""" @@ -661,12 +724,24 @@ def _get_encouragement(self) -> str: recent_scores.append(int(good / total * 100) if total > 0 else 100) if total_drives == 0: - return "Start driving to see your stats! Time to earn your first waddle KP." + return random.choice([ + "Start driving to see your stats! Time to earn your first waddle KP.", + "No drives yet! Get out there and start your waddle journey!", + "Empty stats - the porch awaits your first drive!", + ]) if total_drives <= 2: if total_stalls == 0: - return "No stalls yet! Waddle energy from day 1. Keep it up!" - return f"{total_stalls} stall{'s' if total_stalls > 1 else ''} so far - every waddle driver starts somewhere. QG!" + return random.choice([ + "No stalls yet! Waddle energy from day 1. Keep it up!", + "Zero stalls early on! Natural waddle talent?!", + "Clean start! Priest-approved from the jump!", + ]) + return random.choice([ + f"{total_stalls} stall{'s' if total_stalls > 1 else ''} so far - every waddle driver starts somewhere. QG!", + f"{total_stalls} stall{'s' if total_stalls > 1 else ''} early on - totally normal, waddle is coming!", + f"{total_stalls} stall{'s' if total_stalls > 1 else ''} - shedding jackets already. Keep going!", + ]) stall_rate = total_stalls / total_drives @@ -681,18 +756,55 @@ def _get_encouragement(self) -> str: if recent_avg == 0: # Check for crazy good performance if len(recent_scores) >= 3 and all(s >= 95 for s in recent_scores[-3:]): - return f"Last {num_days}d: 95%+ shifts, NO stalls?! Waddle is driving! Kacper threw his glasses!" + return random.choice([ + f"Last {num_days}d: 95%+ shifts, NO stalls?! Waddle is driving! Kacper threw his glasses!", + f"Last {num_days}d: near-perfect shifts, zero stalls! Legendary waddle!", + f"Last {num_days}d: flawless! Porch-worthy, priest-approved, KP maxed!", + ]) if improving: - return f"Last {num_days}d: no stalls AND improving? Waddle energy! QG to KP!" - return f"Last {num_days}d: no stalls - waddle game strong! Not SS, priest-approved!" + return random.choice([ + f"Last {num_days}d: no stalls AND improving? Waddle energy! QG to KP!", + f"Last {num_days}d: stall-free and trending up! CCR energy!", + f"Last {num_days}d: zero stalls, scores climbing! Porch incoming!", + ]) + return random.choice([ + f"Last {num_days}d: no stalls - waddle game strong! Not SS, priest-approved!", + f"Last {num_days}d: stall-free! Solid waddle vibes!", + f"Last {num_days}d: clean driving, no jackets! Keep it up!", + ]) elif recent_avg < stall_rate: - return f"Last {num_days}d: better than avg - shedding jackets, channeling waddle!" - - if stall_rate < 0.5: + return random.choice([ + f"Last {num_days}d: better than avg - shedding jackets, channeling waddle!", + f"Last {num_days}d: fewer stalls than usual! De-jacketing in progress!", + f"Last {num_days}d: improving! Waddle is within reach!", + ]) + + if total_stalls == 0: + return random.choice([ + "Zero stalls overall! Waddle game is immaculate!", + "Not a single stall! Priest-approved driving!", + "Stall-free career! Pure waddle energy!", + ]) + + drives_per_stall = round(total_drives / total_stalls) + + if stall_rate < 1: if improving: - return f"< 1 stall per 2 drives AND improving (last {num_days}d)! Porch-worthy waddle progress!" - return "< 1 stall per 2 drives - solid waddle vibes, not SS!" - elif stall_rate < 1: - return "~1 stall per drive - de-jacketing in progress!" + return random.choice([ + f"1 stall every {drives_per_stall} drives AND improving (last {num_days}d)! Porch-worthy waddle progress!", + f"1 stall every {drives_per_stall} drives AND getting better (last {num_days}d)! CCR material!", + f"1 stall every {drives_per_stall} drives AND trending up (last {num_days}d)! KP earned!", + ]) + return random.choice([ + f"1 stall every {drives_per_stall} drives - solid waddle vibes, not SS!", + f"1 stall every {drives_per_stall} drives - consistent waddle energy!", + f"1 stall every {drives_per_stall} drives - jacket count staying low!", + ]) else: - return "Keep at it! Even the best got jacketed at first. QG to KP!" + stalls_per_drive = round(total_stalls / total_drives) + return random.choice([ + f"About {stalls_per_drive} stall{'s' if stalls_per_drive > 1 else ''} every drive - keep at it! QG to KP!", + f"About {stalls_per_drive} stall{'s' if stalls_per_drive > 1 else ''} every drive - still jacketed, but every drive is practice!", + f"About {stalls_per_drive} stall{'s' if stalls_per_drive > 1 else ''} every drive - jackets happen! The porch is waiting!", + f"About {stalls_per_drive} stall{'s' if stalls_per_drive > 1 else ''} every drive - Weixing left the room. Kirby followed him out.", + ]) From 4e0bcddae930caea9e15a4d17ce616e3a1ac90ea Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Sat, 7 Feb 2026 23:43:48 -0800 Subject: [PATCH 23/82] not sure how i feel about this change, could revert --- .../ui/mici/layouts/settings/manual_stats.py | 208 +++++++++++++++++- 1 file changed, 204 insertions(+), 4 deletions(-) diff --git a/selfdrive/ui/mici/layouts/settings/manual_stats.py b/selfdrive/ui/mici/layouts/settings/manual_stats.py index 700c1601f6c22f..d8292f3c15dbfd 100644 --- a/selfdrive/ui/mici/layouts/settings/manual_stats.py +++ b/selfdrive/ui/mici/layouts/settings/manual_stats.py @@ -37,6 +37,7 @@ def __init__(self, back_callback): self._hand_text: str = "" self._hand_color: rl.Color = GRAY self._encouragement_text: str = "" + self._section_comments: dict[str, tuple[str, rl.Color]] = {} self.set_back_callback(back_callback) def show_event(self): @@ -54,6 +55,19 @@ def _load_stats(self): # Pick random texts once per page visit (not every frame) self._hand_text, self._hand_color = self._get_overall_hand() self._encouragement_text = self._get_encouragement() + self._section_comments = self._pick_section_comments() + + def _draw_comment(self, x: int, y: int, w: int, key: str) -> int: + """Draw a section comment if one exists. Returns updated y.""" + if key not in self._section_comments: + return y + text, color = self._section_comments[key] + font_roman = gui_app.font(FontWeight.ROMAN) + wrapped = wrap_text(font_roman, text, 18, w) + for line in wrapped: + rl.draw_text_ex(font_roman, line, rl.Vector2(x, y), 18, 0, color) + y += 22 + return y + 5 def _render(self, rect: rl.Rectangle): content_height = self._measure_content_height(rect) @@ -111,6 +125,7 @@ def _render(self, rect: rl.Rectangle): ("Total Downshifts", str(total_down), WHITE), ("Good Downshifts", f"{down_good} ({down_pct})", self._pct_color(down_good, total_down)), ]) + y = self._draw_comment(x, y, w, 'shifts') y += 15 # Launch quality card @@ -125,6 +140,7 @@ def _render(self, rect: rl.Rectangle): ("Good Launches", f"{good_launches} ({launch_pct})", self._pct_color(good_launches, total_launches)), ("Stalled Launches", str(stalled_launches), RED if stalled_launches > 0 else GREEN), ]) + y = self._draw_comment(x, y, w, 'launches') y += 15 # Trend card - aggregate by day for consistency with charts @@ -163,16 +179,20 @@ def _render(self, rect: rl.Rectangle): gear_jerks = self._stats.get('gear_shift_jerk_totals', {}) if gear_counts and any(gear_counts.values()): y = self._draw_gear_chart(x, y, w, gear_counts, gear_jerks) + y = self._draw_comment(x, y, w, 'gears') y += 15 # Session history charts session_history = self._stats.get('session_history', []) if session_history: y = self._draw_shift_chart(x, y, w, session_history) + y = self._draw_comment(x, y, w, 'shift_chart') y += 15 y = self._draw_stalls_chart(x, y, w, session_history) + y = self._draw_comment(x, y, w, 'stalls_chart') y += 15 y = self._draw_launch_chart(x, y, w, session_history) + y = self._draw_comment(x, y, w, 'launch_chart') y += 15 # Encouragement based on progress (with text wrapping) @@ -520,23 +540,37 @@ def _measure_content_height(self, rect: rl.Rectangle) -> int: if not self._stats or self._stats.get('total_drives', 0) == 0: return y + 40 + comment_h = 27 # height per section comment line + # Overview card (now has 5 items with hand rating, +60 for potential wrapped lines) y += 50 + 5 * 38 + 60 + 15 - # Shift card + # Shift card + comment y += 50 + 4 * 38 + 15 - # Launch card + if 'shifts' in self._section_comments: + y += comment_h + # Launch card + comment y += 50 + 3 * 38 + 15 + if 'launches' in self._section_comments: + y += comment_h # Trend card (estimate) y += 50 + 3 * 38 + 15 - # Gear chart + # Gear chart + comment if self._stats.get('gear_shift_counts'): y += 180 + 15 + if 'gears' in self._section_comments: + y += comment_h - # Charts (3 charts) + # Charts (3 charts) + comments if self._stats.get('session_history'): y += 200 + 15 # Shift score chart + if 'shift_chart' in self._section_comments: + y += comment_h y += 180 + 15 # Stalls/lugs chart + if 'stalls_chart' in self._section_comments: + y += comment_h y += 180 + 15 # Launch chart + if 'launch_chart' in self._section_comments: + y += comment_h # Encouragement (estimate 2-3 lines wrapped) y += 100 @@ -708,6 +742,172 @@ def _get_overall_hand(self) -> tuple[str, rl.Color]: "High Card - Weixing pretends he doesn't know you. Kirby swallowed the car whole.", ]), RED + def _pick_section_comments(self) -> dict[str, tuple[str, 'rl.Color']]: + """Pick a contextual comment for each section based on the data""" + comments: dict[str, tuple[str, rl.Color]] = {} + + # Shift quality + total_up = self._stats.get('total_upshifts', 0) + total_down = self._stats.get('total_downshifts', 0) + up_good = self._stats.get('upshifts_good', 0) + down_good = self._stats.get('downshifts_good', 0) + total_shifts = total_up + total_down + if total_shifts > 0: + shift_pct = (up_good + down_good) / total_shifts * 100 + if shift_pct >= 90: + comments['shifts'] = random.choice([ + "Butter smooth! Weixing almost smiled.", + "Shifts are dialed. Kirby did a little twirl.", + "Priest-approved shifting right here.", + ]), GREEN + elif shift_pct >= 70: + comments['shifts'] = random.choice([ + "Solid shifts, room to polish.", + "Getting cleaner. Kirby is watching.", + "Not bad! Weixing looked up briefly.", + ]), YELLOW + else: + comments['shifts'] = random.choice([ + "Those shifts need some love.", + "Weixing felt that from across the room.", + "Kirby is concerned about your synchros.", + ]), RED + + # Launch quality + total_launches = self._stats.get('total_launches', 0) + good_launches = self._stats.get('launches_good', 0) + stalled_launches = self._stats.get('launches_stalled', 0) + if total_launches > 0: + launch_pct = good_launches / total_launches * 100 + if launch_pct >= 90: + comments['launches'] = random.choice([ + "Smooth off the line! Clutch control on point.", + "Launch game is strong. Kirby approves.", + "Clean launches. The bite point is your friend.", + ]), GREEN + elif launch_pct >= 70: + comments['launches'] = random.choice([ + "Launches are OK. Find that bite point more consistently.", + "Getting there! A little more clutch feel needed.", + "Decent launches, some room to grow.", + ]), YELLOW + else: + comments['launches'] = random.choice([ + "Launches need work. Easy on the clutch!", + "Kirby is bracing for impact every launch.", + "More revs, slower clutch release. You'll get it.", + ]), RED + if stalled_launches > 2: + comments['launches'] = random.choice([ + f"{stalled_launches} stalled launches - find that bite point!", + f"{stalled_launches} stalled launches - Kirby is hiding the keys.", + f"{stalled_launches} stalled launches - more gas before you release!", + ]), RED + + # Gear chart + gear_counts = self._stats.get('gear_shift_counts', {}) + gear_jerks = self._stats.get('gear_shift_jerk_totals', {}) + if gear_counts and any(gear_counts.values()): + worst_gear, worst_score = None, 101 + best_gear, best_score = None, -1 + for gear in range(1, 7): + count = gear_counts.get(gear, gear_counts.get(str(gear), 0)) + jerk = gear_jerks.get(gear, gear_jerks.get(str(gear), 0.0)) + if count > 0: + smoothness = max(0, min(100, 100 - (jerk / count * 20))) + if smoothness < worst_score: + worst_gear, worst_score = gear, smoothness + if smoothness > best_score: + best_gear, best_score = gear, smoothness + if worst_gear and best_gear and worst_gear != best_gear: + comments['gears'] = random.choice([ + f"Gear {best_gear} is your smoothest. Gear {worst_gear} needs practice.", + f"Cleanest into gear {best_gear}. Gear {worst_gear} is your weak spot.", + f"Gear {worst_gear} is where the jackets live. Gear {best_gear} is waddle territory.", + ]), YELLOW + + # Shift score chart + session_history = self._stats.get('session_history', []) + days = self._aggregate_by_day(session_history) + if len(days) >= 3: + recent = days[-3:] + scores = [] + for d in recent: + t = d.get('upshifts', 0) + d.get('downshifts', 0) + g = d.get('upshifts_good', 0) + d.get('downshifts_good', 0) + scores.append(int(g / t * 100) if t > 0 else 100) + avg = sum(scores) / len(scores) + if avg >= 85: + comments['shift_chart'] = random.choice([ + "Recent shifts looking clean!", + "Shift scores are up. Waddle energy.", + "Consistency is showing. Kirby is pleased.", + ]), GREEN + elif scores[-1] > scores[0]: + comments['shift_chart'] = random.choice([ + "Trending up! Keep this momentum.", + "Scores climbing. Weixing might notice soon.", + "Getting better day by day.", + ]), YELLOW + else: + comments['shift_chart'] = random.choice([ + "Shift scores dipping. Focus up!", + "Weixing is watching these numbers drop.", + "Time to tighten up those shifts.", + ]), RED + + # Stalls chart + if len(days) >= 3: + recent_stalls = [d.get('stalls', 0) for d in days[-3:]] + if all(s == 0 for s in recent_stalls): + comments['stalls_chart'] = random.choice([ + "Stall-free streak! Don't break it.", + "Zero stalls lately. Weixing is watching... approvingly.", + "Clean streak. Kirby is relaxed.", + ]), GREEN + elif recent_stalls[-1] < recent_stalls[0]: + comments['stalls_chart'] = random.choice([ + "Stalls trending down. Shedding jackets!", + "Fewer stalls recently. Progress!", + "The jacket count is dropping. Keep going.", + ]), YELLOW + elif recent_stalls[-1] > recent_stalls[0]: + comments['stalls_chart'] = random.choice([ + "Stalls creeping up. Deep breath, find the bite point.", + "More stalls lately. Weixing noticed.", + "Jacket count rising. Kirby is concerned.", + ]), RED + + # Launch chart + if len(days) >= 3: + recent_launch_pcts = [] + for d in days[-3:]: + l_total = d.get('launches', 0) + l_good = d.get('launches_good', 0) + if l_total > 0: + recent_launch_pcts.append(l_good / l_total * 100) + if len(recent_launch_pcts) >= 2: + if all(p >= 80 for p in recent_launch_pcts): + comments['launch_chart'] = random.choice([ + "Launches looking consistent!", + "Smooth off the line, day after day.", + "Kirby trusts your launches now.", + ]), GREEN + elif recent_launch_pcts[-1] > recent_launch_pcts[0]: + comments['launch_chart'] = random.choice([ + "Launch success trending up!", + "Getting smoother off the line.", + "Clutch control improving. Waddle incoming.", + ]), YELLOW + elif recent_launch_pcts[-1] < recent_launch_pcts[0]: + comments['launch_chart'] = random.choice([ + "Launches getting rougher. Easy on the clutch!", + "Launch success dipping. Find that bite point.", + "Kirby is bracing again.", + ]), RED + + return comments + def _get_encouragement(self) -> str: """Get encouragement based on overall progress""" total_drives = self._stats.get('total_drives', 0) From b7f7ecabe1dba92139c0fc5f48ba07edaf76c714 Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Sun, 22 Feb 2026 07:25:03 -0800 Subject: [PATCH 24/82] fix from merge --- selfdrive/ui/mici/layouts/manual_drive_summary.py | 2 +- selfdrive/ui/mici/layouts/settings/manual_stats.py | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/selfdrive/ui/mici/layouts/manual_drive_summary.py b/selfdrive/ui/mici/layouts/manual_drive_summary.py index 4f5535fc47677d..deadfce4914276 100644 --- a/selfdrive/ui/mici/layouts/manual_drive_summary.py +++ b/selfdrive/ui/mici/layouts/manual_drive_summary.py @@ -14,7 +14,7 @@ from openpilot.system.ui.lib.application import gui_app, FontWeight from openpilot.system.ui.lib.scroll_panel2 import GuiScrollPanel2 from openpilot.system.ui.lib.wrap_text import wrap_text -from openpilot.system.ui.widgets import NavWidget +from openpilot.system.ui.widgets.nav_widget import NavWidget # Colors diff --git a/selfdrive/ui/mici/layouts/settings/manual_stats.py b/selfdrive/ui/mici/layouts/settings/manual_stats.py index d8292f3c15dbfd..d1cc1017e119e7 100644 --- a/selfdrive/ui/mici/layouts/settings/manual_stats.py +++ b/selfdrive/ui/mici/layouts/settings/manual_stats.py @@ -12,7 +12,8 @@ from openpilot.system.ui.lib.application import gui_app, FontWeight, FONT_SCALE from openpilot.system.ui.lib.scroll_panel2 import GuiScrollPanel2 from openpilot.system.ui.lib.wrap_text import wrap_text -from openpilot.system.ui.widgets import Widget, NavWidget +from openpilot.system.ui.widgets import Widget +from openpilot.system.ui.widgets.nav_widget import NavWidget from openpilot.selfdrive.ui.mici.layouts.manual_drive_summary import ManualDriveSummaryDialog @@ -29,7 +30,7 @@ class ManualStatsLayout(NavWidget): """Settings page showing historical manual driving stats""" - def __init__(self, back_callback): + def __init__(self): super().__init__() self._params = Params() self._scroll_panel = GuiScrollPanel2(horizontal=False) @@ -38,7 +39,7 @@ def __init__(self, back_callback): self._hand_color: rl.Color = GRAY self._encouragement_text: str = "" self._section_comments: dict[str, tuple[str, rl.Color]] = {} - self.set_back_callback(back_callback) + self.set_back_callback(gui_app.pop_widget) def show_event(self): super().show_event() From efc97746bd172ae077b7d6d98ab02d26926af1c3 Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Sun, 8 Mar 2026 05:23:46 -0700 Subject: [PATCH 25/82] glowtime --- tools/underglow/BLUETOOTH_SETUP.md | 135 +++++++++++++++ tools/underglow/sp105e.py | 270 +++++++++++++++++++++++++++++ 2 files changed, 405 insertions(+) create mode 100644 tools/underglow/BLUETOOTH_SETUP.md create mode 100644 tools/underglow/sp105e.py diff --git a/tools/underglow/BLUETOOTH_SETUP.md b/tools/underglow/BLUETOOTH_SETUP.md new file mode 100644 index 00000000000000..006320e0d9fc38 --- /dev/null +++ b/tools/underglow/BLUETOOTH_SETUP.md @@ -0,0 +1,135 @@ +# Bluetooth on comma four (SDM845 / WCN3990) + +## Status: Working (March 2026) + +## Kernel Changes (in agnos-builder) + +### Branch: `bluetooth-support` in agnos-builder + +### 1. Defconfig (`agnos-kernel-sdm845/arch/arm64/configs/tici_defconfig`) +``` +CONFIG_BT=y +CONFIG_BT_BREDR=y +CONFIG_BT_RFCOMM=y +CONFIG_BT_LE=y +CONFIG_BT_HCIUART=y +CONFIG_BT_HCIUART_H4=y +CONFIG_BT_HCIUART_QCA=y +CONFIG_MSM_BT_POWER=y +CONFIG_BTFM_SLIM=y +CONFIG_BTFM_SLIM_WCN3990=y +``` + +### 2. Device Tree (`agnos-kernel-sdm845/arch/arm64/boot/dts/qcom/comma_common.dtsi`) +Added BT UART (SE6 at 0x898000, GPIOs 45-48): +```dts +&qupv3_se6_4uart { + status = "ok"; +}; +``` + +## Userspace Init Sequence + +### Prerequisites (apt) +- `bluez` (provides hciattach, hciconfig, bluetoothctl) +- `rfkill` + +### Init Steps (in order) +1. Power on BT chip via btpower ioctl: + ```python + import fcntl, os + fd = os.open('/dev/btpower', os.O_RDWR) + fcntl.ioctl(fd, 0xbfad, 1) # BT_CMD_PWR_CTRL = 0xbfad + os.close(fd) + ``` +2. `rfkill unblock bluetooth` +3. `hciattach -s 115200 /dev/ttyHS0 qualcomm 115200 flow` +4. `hciconfig hci0 up` + +### Key Gotchas +- ttyHS0 is SE6 (0x898000) = BT UART. Before DTS change, ttyHS0 was GPS UART (0x88c000) — returned all zeros +- `hciattach any` creates hci0 but `hci0 up` fails with EBUSY. Must use `qualcomm` type with `-s 115200` +- bluez `qualcomm` init generates garbled firmware filename ("201 PF_ BUI.bin") from WCN3990 version string — but chip works without firmware file +- BT firmware partition (sde5, vfat) has files at `/image/crbtfw21.tlv` etc — not needed for basic BLE +- Root fs is read-only in most places; `/data/` is writable + +## Hardware Details +- Chip: WCN3990 (Qualcomm, integrated WiFi+BT) +- BT version: 4.2 (LMP 0x08, sub 0x02be) +- Firmware string: "Release 10.0201 PF=WCN3990" +- BD Address: partially populated (00:00:00:00:5A:AD) +- BT UART: QUPv3 SE6 4-wire UART at 0x898000 + +## LowGlow Underglow Controller +- Controller: **SP105E** (not SP110E as initially assumed) +- BLE device name: `SP105E` +- MAC seen: `BA:AB:05:04:02:BD` +- Protocol: SP110E-compatible (same BLE service 0xFFE0, characteristic 0xFFE1) +- Can set: static color, brightness, mode (1-120 presets), speed, on/off +- Cannot address individual LEDs over BT (controller limitation) +- Python library: `sp110e` (pip) or raw `bleak` + +## Remaining TODO +1. **UI setup button** - install bluez+rfkill, run init sequence, confirm slider pattern +2. **SP105E control script** - Python script using bleak to connect to SP105E and send commands +3. **CarState reactive colors** - Map vEgo/steeringAngleDeg/brakePressed/etc to SP105E color commands +4. **Bake into AGNOS** - Add bluez+rfkill to agnos-builder system image so they persist across reboots + +## SP105E BLE Protocol (Reverse-Engineered March 2026) +- Service: 0xFFE0, Write characteristic: 0xFFE1 (read/write-without-response/write/notify) +- SP105E does NOT have 0xFFE2 init char (SP110E does) — no init needed +- Also has battery service 0x180F char 0x2A19 (read/notify) +- **Packet format: `38 [D1] [D2] [D3] [CMD] 83`** (6 bytes, 38/83 framing) +- **Color byte order: GRB (not RGB!)** + +### Confirmed Commands +| Command | Format | Notes | +|---------|--------|-------| +| SET_COLOR | `38 GG RR BB 1E 83` | GRB byte order! | +| POWER_TOGGLE | `38 00 00 00 AA 83` | Toggle only, 0xAB does nothing | +| BRIGHTNESS | `38 BB 00 00 2A 83` | 0-255, higher=brighter | +| SET_MODE | `38 MM 00 00 2C 83` | Mode number in D1 (01, 05, 0A, etc.) | + +### Pattern Modes (CMD byte directly, D1-D3 ignored) +| CMD | Description | +|-----|-------------| +| 0x03 | Rainbow animation (blue/red/green flowing) | +| 0x05 | Rainbow pattern 1 | +| 0x06 | Rainbow pattern 2 | +| 0x07 | Breathing: fade through colors (slow) | +| 0x08-0x0B | Breathing variations | +| 0x0D | Color cycle: yellow→orange→red, no fade | +| 0x0E | Same as 0x0D but very slow | +| 0x0F | Fast flowing rainbow | +| 0x10 | Same as 0x0F | + +### Other findings +- `0x28` also affects brightness (inverse: higher=dimmer) +- `0xAB` (OFF) does nothing +- `0x03` as speed command didn't work — triggers pattern instead +- Device must be ON for commands to work; color cmd alone doesn't turn it on +- `0x1C`-`0x29` (except 0x28) had no visible effect +- SP110E gist (partial overlap): https://gist.github.com/mbullington/37957501a07ad065b67d4e8d39bfe012 + +## Color Ideas for CarState Mapping +- **Startup** (park→drive): rainbow chase → settle to base color +- **Speed** (vEgo): blue(0)→purple(30mph)→pink/red(60+mph), brightness scales with speed +- **Braking** (brakePressed/brake): deep red, brightness = brake pressure, flash on hard brake (aEgo < -3) +- **Acceleration** (gasPressed + aEgo): orange→red fire gradient +- **Steering** (steeringAngleDeg): color shifts left=blue/purple, right=orange/amber +- **Blinker** (leftBlinker/rightBlinker): amber pulse at ~1.5Hz +- **openpilot engaged** (cruiseState.enabled): comma green (0,255,100) +- **Reverse** (gearShifter==reverse): white glow +- **Parked/idle** (standstill): slow breathing pulse +- Note: engineRpm is DEPRECATED in car.capnp, use vEgo/aEgo instead + +## Quick Reference Commands +- Scan: `adb shell "timeout 10 hcitool -i hci0 lescan 2>&1 | grep -v '(unknown)' | sort -u -k2"` +- Full init (after kernel+DTS already flashed): + ``` + apt-get update && apt-get install -y bluez rfkill + python3 -c "import fcntl,os; fd=os.open('/dev/btpower',os.O_RDWR); fcntl.ioctl(fd,0xbfad,1); os.close(fd)" + rfkill unblock bluetooth + hciattach -s 115200 /dev/ttyHS0 qualcomm 115200 flow + hciconfig hci0 up + ``` diff --git a/tools/underglow/sp105e.py b/tools/underglow/sp105e.py new file mode 100644 index 00000000000000..105ad9fe2fc0b2 --- /dev/null +++ b/tools/underglow/sp105e.py @@ -0,0 +1,270 @@ +#!/usr/bin/env python3 +""" +SP105E BLE LED controller for LowGlow underglow kit. + +Protocol (reverse-engineered March 2026): + Packet format: 38 [D1] [D2] [D3] [CMD] 83 + Color byte order: GRB (not RGB!) + +Confirmed commands: + 0x1E = Set color: 38 RR GG BB 1E 83 (after setting order=2 for RGB) + 0xAA = Power toggle: 38 00 00 00 AA 83 (toggle only, 0xAB does nothing) + 0x2C = Set mode: 38 MM 00 00 2C 83 (mode number in D1) + 0x2A = Brightness: 38 BB 00 00 2A 83 (higher = brighter) + 0x3C = Color order: 38 NN 00 00 3C 83 (0=GRB, 1=GBR, 2=RGB, 3=BGR, 4=RBG, 5=BRG) + 0x2D = Pixel count?: 38 NN 00 00 2D 83 (causes brief off/on, might set LED count) + +Pattern modes (as CMD byte directly, D1-D3 ignored): + 0x03 = rainbow animation (blue/red/green flowing) + 0x05 = rainbow pattern 1 + 0x06 = rainbow pattern 2 + 0x07 = breathing: fade through colors (red->blue->yellow etc), slow + 0x08-0x0B = breathing variations (similar to 0x07) + 0x0D = color cycle: yellow->orange->red, no fade between colors + 0x0E = same as 0x0D but very slow + 0x0F = fast flowing rainbow + 0x10 = same as 0x0F + +Notes: + - Send 38 02 00 00 3C 83 on connect to set RGB order + - Sending color (0x1E) stops any active pattern and goes to static + - Device must be ON for commands to work + - 0xAA is a toggle (on->off, off->on), not absolute + - 0xAB does nothing + - Speed command not found yet + - bleak needs: source /etc/profile && python3 +""" +import argparse +import asyncio +import sys +from bleak import BleakScanner, BleakClient + +CHAR = "0000ffe1-0000-1000-8000-00805f9b34fb" + + +def packet(d1: int, d2: int, d3: int, cmd: int) -> bytes: + return bytes([0x38, d1, d2, d3, cmd, 0x83]) + + +def color_packet(r: int, g: int, b: int) -> bytes: + """Color packet. Assumes RGB order has been set via set_color_order(2).""" + return packet(r, g, b, 0x1E) + + +async def find_sp105e(timeout=10): + scanner = BleakScanner() + await scanner.start() + await asyncio.sleep(timeout) + await scanner.stop() + for d in scanner.discovered_devices: + if d.name and "SP" in d.name: + return d + return None + + +async def connect(): + dev = await find_sp105e() + if not dev: + print("SP105E not found") + sys.exit(1) + print(f"Found {dev.address}") + client = BleakClient(dev.address, timeout=20) + await client.connect() + print("Connected.") + return client + + +async def send(client, data: bytes): + await client.write_gatt_char(CHAR, data, response=False) + + +# --- High-level commands --- + +async def set_color(client, r, g, b): + await send(client, color_packet(r, g, b)) + + +async def power_toggle(client): + await send(client, packet(0, 0, 0, 0xAA)) + + +async def set_brightness(client, val): + """Set brightness. 0-255, higher = brighter.""" + await send(client, packet(val, 0, 0, 0x2A)) + + +async def set_mode(client, mode): + """Set animation mode via 0x2C with mode number in D1.""" + await send(client, packet(mode, 0, 0, 0x2C)) + + +async def set_pattern(client, pattern): + """Set pattern directly via CMD byte (0x03, 0x05-0x10, etc.).""" + await send(client, packet(0, 0, 0, pattern)) + + +# --- CLI --- + +async def cmd_color(args): + client = await connect() + await set_color(client, args.r, args.g, args.b) + print(f"Color set to ({args.r}, {args.g}, {args.b})") + await client.disconnect() + + +async def cmd_toggle(args): + client = await connect() + await power_toggle(client) + print("Power toggled") + await client.disconnect() + + +async def cmd_bright(args): + client = await connect() + await set_brightness(client, args.value) + print(f"Brightness set to {args.value}") + await client.disconnect() + + +async def cmd_mode(args): + client = await connect() + await set_mode(client, args.mode) + print(f"Mode set to {args.mode}") + await client.disconnect() + + +async def cmd_pattern(args): + client = await connect() + await set_pattern(client, args.pattern) + print(f"Pattern set to 0x{args.pattern:02X}") + await client.disconnect() + + +async def cmd_demo(args): + client = await connect() + colors = [ + (255, 0, 0, "red"), + (0, 255, 0, "green"), + (0, 0, 255, "blue"), + (255, 255, 0, "yellow"), + (0, 255, 100, "comma green"), + (255, 0, 255, "magenta"), + (255, 128, 0, "orange"), + (255, 255, 255, "white"), + ] + for r, g, b, name in colors: + print(f" {name} ({r},{g},{b})") + await set_color(client, r, g, b) + await asyncio.sleep(1.5) + print("Demo done.") + await client.disconnect() + + +async def cmd_interactive(args): + client = await connect() + print("\nCommands:") + print(" color R G B set static color") + print(" bright N brightness 0-255") + print(" toggle power on/off") + print(" mode N set mode (decimal, via 0x2C)") + print(" pattern HH set pattern (hex CMD byte)") + print(" raw HH HH ... send raw hex bytes") + print(" demo color cycle") + print(" quit\n") + + while True: + try: + line = input("sp105e> ").strip() + except (EOFError, KeyboardInterrupt): + break + if not line: + continue + parts = line.split() + c = parts[0].lower() + try: + if c == "color" and len(parts) == 4: + await set_color(client, int(parts[1]), int(parts[2]), int(parts[3])) + elif c in ("bright", "brightness") and len(parts) == 2: + await set_brightness(client, int(parts[1])) + elif c == "toggle": + await power_toggle(client) + elif c == "mode" and len(parts) == 2: + await set_mode(client, int(parts[1])) + elif c == "pattern" and len(parts) == 2: + await set_pattern(client, int(parts[1], 16)) + elif c == "raw": + data = bytes([int(x, 16) for x in parts[1:]]) + await send(client, data) + print(f" sent: {data.hex()}") + elif c == "demo": + colors = [ + (255, 0, 0, "red"), (0, 255, 0, "green"), (0, 0, 255, "blue"), + (255, 255, 0, "yellow"), (0, 255, 100, "comma green"), + ] + for r, g, b, name in colors: + print(f" {name}") + await set_color(client, r, g, b) + await asyncio.sleep(1.5) + elif c in ("quit", "exit", "q"): + break + else: + print("Unknown command.") + except Exception as e: + print(f"Error: {e}") + + await client.disconnect() + print("Bye.") + + +async def cmd_scan(args): + print("Scanning 10s...") + devices = await BleakScanner.discover(timeout=10, return_adv=True) + for addr, (dev, adv) in sorted(devices.items(), key=lambda x: x[1][1].rssi, reverse=True): + if dev.name: + print(f" {dev.address} {dev.name} rssi={adv.rssi}") + + +def main(): + parser = argparse.ArgumentParser(description="SP105E BLE LED controller") + sub = parser.add_subparsers(dest="command") + + p_color = sub.add_parser("color", help="Set color (RGB 0-255)") + p_color.add_argument("r", type=int) + p_color.add_argument("g", type=int) + p_color.add_argument("b", type=int) + + sub.add_parser("toggle", help="Toggle power on/off") + sub.add_parser("demo", help="Color cycle demo") + sub.add_parser("interactive", help="Interactive REPL") + sub.add_parser("scan", help="Scan for BLE devices") + + p_bright = sub.add_parser("bright", help="Set brightness (0-255)") + p_bright.add_argument("value", type=int) + + p_mode = sub.add_parser("mode", help="Set mode (decimal, via 0x2C)") + p_mode.add_argument("mode", type=int) + + p_pattern = sub.add_parser("pattern", help="Set pattern (hex CMD byte)") + p_pattern.add_argument("pattern", type=lambda x: int(x, 16)) + + args = parser.parse_args() + + commands = { + "color": cmd_color, + "toggle": cmd_toggle, + "bright": cmd_bright, + "mode": cmd_mode, + "pattern": cmd_pattern, + "demo": cmd_demo, + "interactive": cmd_interactive, + "scan": cmd_scan, + } + + if not args.command: + args.command = "interactive" + + asyncio.run(commands[args.command](args)) + + +if __name__ == "__main__": + main() From 228a87da6033bf0a5af9ef2fa4f44889af747b7e Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Sun, 8 Mar 2026 05:26:42 -0700 Subject: [PATCH 26/82] int enum --- tools/underglow/BLUETOOTH_SETUP.md | 20 ++++-- tools/underglow/sp105e.py | 112 +++++++++++++++++++---------- 2 files changed, 92 insertions(+), 40 deletions(-) diff --git a/tools/underglow/BLUETOOTH_SETUP.md b/tools/underglow/BLUETOOTH_SETUP.md index 006320e0d9fc38..772d97fac06f4b 100644 --- a/tools/underglow/BLUETOOTH_SETUP.md +++ b/tools/underglow/BLUETOOTH_SETUP.md @@ -85,10 +85,22 @@ Added BT UART (SE6 at 0x898000, GPIOs 45-48): ### Confirmed Commands | Command | Format | Notes | |---------|--------|-------| -| SET_COLOR | `38 GG RR BB 1E 83` | GRB byte order! | +| SET_COLOR | `38 RR GG BB 1E 83` | After setting RGB order (0x3C=2) | | POWER_TOGGLE | `38 00 00 00 AA 83` | Toggle only, 0xAB does nothing | | BRIGHTNESS | `38 BB 00 00 2A 83` | 0-255, higher=brighter | | SET_MODE | `38 MM 00 00 2C 83` | Mode number in D1 (01, 05, 0A, etc.) | +| COLOR_ORDER | `38 NN 00 00 3C 83` | 0=GRB 1=GBR **2=RGB** 3=BGR 4=RBG 5=BRG | +| PIXEL_COUNT? | `38 NN 00 00 2D 83` | Causes brief off/on, might set LED count | + +### Color Order Map (0x3C) +| Value | D1 | D2 | D3 | Name | +|-------|----|----|-----|------| +| 0 | G | R | B | GRB (default) | +| 1 | G | B | R | GBR | +| **2** | **R** | **G** | **B** | **RGB** (use this!) | +| 3 | B | G | R | BGR | +| 4 | R | B | G | RBG | +| 5 | B | R | G | BRG | ### Pattern Modes (CMD byte directly, D1-D3 ignored) | CMD | Description | @@ -104,11 +116,11 @@ Added BT UART (SE6 at 0x898000, GPIOs 45-48): | 0x10 | Same as 0x0F | ### Other findings -- `0x28` also affects brightness (inverse: higher=dimmer) +- Sending color (0x1E) stops any active pattern → returns to static - `0xAB` (OFF) does nothing -- `0x03` as speed command didn't work — triggers pattern instead +- Speed command not found yet (patterns auto-cycle between effects) - Device must be ON for commands to work; color cmd alone doesn't turn it on -- `0x1C`-`0x29` (except 0x28) had no visible effect +- `0x28` also affects brightness (inverse: higher=dimmer) - SP110E gist (partial overlap): https://gist.github.com/mbullington/37957501a07ad065b67d4e8d39bfe012 ## Color Ideas for CarState Mapping diff --git a/tools/underglow/sp105e.py b/tools/underglow/sp105e.py index 105ad9fe2fc0b2..642adbc7fa5c55 100644 --- a/tools/underglow/sp105e.py +++ b/tools/underglow/sp105e.py @@ -4,51 +4,76 @@ Protocol (reverse-engineered March 2026): Packet format: 38 [D1] [D2] [D3] [CMD] 83 - Color byte order: GRB (not RGB!) + Send COLOR_ORDER=RGB on connect, then use standard RGB values. Confirmed commands: - 0x1E = Set color: 38 RR GG BB 1E 83 (after setting order=2 for RGB) - 0xAA = Power toggle: 38 00 00 00 AA 83 (toggle only, 0xAB does nothing) - 0x2C = Set mode: 38 MM 00 00 2C 83 (mode number in D1) - 0x2A = Brightness: 38 BB 00 00 2A 83 (higher = brighter) - 0x3C = Color order: 38 NN 00 00 3C 83 (0=GRB, 1=GBR, 2=RGB, 3=BGR, 4=RBG, 5=BRG) - 0x2D = Pixel count?: 38 NN 00 00 2D 83 (causes brief off/on, might set LED count) + SET_COLOR: 38 RR GG BB 1E 83 (after setting RGB order) + POWER_TOGGLE: 38 00 00 00 AA 83 (toggle only, 0xAB does nothing) + SET_MODE: 38 MM 00 00 2C 83 (mode number in D1) + SET_BRIGHTNESS: 38 BB 00 00 2A 83 (higher = brighter) + COLOR_ORDER: 38 NN 00 00 3C 83 (0=GRB, 1=GBR, 2=RGB, 3=BGR, 4=RBG, 5=BRG) Pattern modes (as CMD byte directly, D1-D3 ignored): - 0x03 = rainbow animation (blue/red/green flowing) - 0x05 = rainbow pattern 1 - 0x06 = rainbow pattern 2 - 0x07 = breathing: fade through colors (red->blue->yellow etc), slow - 0x08-0x0B = breathing variations (similar to 0x07) - 0x0D = color cycle: yellow->orange->red, no fade between colors - 0x0E = same as 0x0D but very slow - 0x0F = fast flowing rainbow - 0x10 = same as 0x0F + See Pattern enum below. Notes: - - Send 38 02 00 00 3C 83 on connect to set RGB order - - Sending color (0x1E) stops any active pattern and goes to static + - Sending SET_COLOR stops any active pattern and goes to static - Device must be ON for commands to work - 0xAA is a toggle (on->off, off->on), not absolute - - 0xAB does nothing - Speed command not found yet - - bleak needs: source /etc/profile && python3 """ import argparse import asyncio import sys +from enum import IntEnum from bleak import BleakScanner, BleakClient CHAR = "0000ffe1-0000-1000-8000-00805f9b34fb" +PACKET_START = 0x38 +PACKET_END = 0x83 + + +class Command(IntEnum): + SET_COLOR = 0x1E + POWER_TOGGLE = 0xAA + SET_BRIGHTNESS = 0x2A + SET_MODE = 0x2C + COLOR_ORDER = 0x3C + PIXEL_COUNT = 0x2D # unconfirmed, causes brief off/on + + +class ColorOrder(IntEnum): + GRB = 0 # default + GBR = 1 + RGB = 2 + BGR = 3 + RBG = 4 + BRG = 5 + + +class Pattern(IntEnum): + RAINBOW_FLOW = 0x03 + RAINBOW_1 = 0x05 + RAINBOW_2 = 0x06 + BREATHING = 0x07 # fade through colors, slow + BREATHING_2 = 0x08 + BREATHING_3 = 0x09 + BREATHING_4 = 0x0A + BREATHING_5 = 0x0B + COLOR_CYCLE = 0x0D # yellow->orange->red, no fade + COLOR_CYCLE_SLOW = 0x0E + RAINBOW_FAST = 0x0F + RAINBOW_FAST_2 = 0x10 + def packet(d1: int, d2: int, d3: int, cmd: int) -> bytes: - return bytes([0x38, d1, d2, d3, cmd, 0x83]) + return bytes([PACKET_START, d1, d2, d3, cmd, PACKET_END]) def color_packet(r: int, g: int, b: int) -> bytes: - """Color packet. Assumes RGB order has been set via set_color_order(2).""" - return packet(r, g, b, 0x1E) + """Color packet. Assumes RGB order has been set via set_color_order().""" + return packet(r, g, b, Command.SET_COLOR) async def find_sp105e(timeout=10): @@ -70,7 +95,8 @@ async def connect(): print(f"Found {dev.address}") client = BleakClient(dev.address, timeout=20) await client.connect() - print("Connected.") + await set_color_order(client, ColorOrder.RGB) + print("Connected (RGB order set).") return client @@ -85,24 +111,29 @@ async def set_color(client, r, g, b): async def power_toggle(client): - await send(client, packet(0, 0, 0, 0xAA)) + await send(client, packet(0, 0, 0, Command.POWER_TOGGLE)) async def set_brightness(client, val): """Set brightness. 0-255, higher = brighter.""" - await send(client, packet(val, 0, 0, 0x2A)) + await send(client, packet(val, 0, 0, Command.SET_BRIGHTNESS)) async def set_mode(client, mode): - """Set animation mode via 0x2C with mode number in D1.""" - await send(client, packet(mode, 0, 0, 0x2C)) + """Set animation mode via SET_MODE with mode number in D1.""" + await send(client, packet(mode, 0, 0, Command.SET_MODE)) async def set_pattern(client, pattern): - """Set pattern directly via CMD byte (0x03, 0x05-0x10, etc.).""" + """Set pattern directly via CMD byte.""" await send(client, packet(0, 0, 0, pattern)) +async def set_color_order(client, order=ColorOrder.RGB): + """Set color byte order.""" + await send(client, packet(order, 0, 0, Command.COLOR_ORDER)) + + # --- CLI --- async def cmd_color(args): @@ -136,7 +167,7 @@ async def cmd_mode(args): async def cmd_pattern(args): client = await connect() await set_pattern(client, args.pattern) - print(f"Pattern set to 0x{args.pattern:02X}") + print(f"Pattern set to {args.pattern.name}") await client.disconnect() @@ -166,11 +197,12 @@ async def cmd_interactive(args): print(" color R G B set static color") print(" bright N brightness 0-255") print(" toggle power on/off") - print(" mode N set mode (decimal, via 0x2C)") - print(" pattern HH set pattern (hex CMD byte)") + print(" mode N set mode (decimal, via SET_MODE)") + print(" pattern NAME set pattern (e.g. BREATHING, RAINBOW_FLOW)") print(" raw HH HH ... send raw hex bytes") print(" demo color cycle") print(" quit\n") + print(f" Available patterns: {', '.join(p.name for p in Pattern)}\n") while True: try: @@ -191,7 +223,14 @@ async def cmd_interactive(args): elif c == "mode" and len(parts) == 2: await set_mode(client, int(parts[1])) elif c == "pattern" and len(parts) == 2: - await set_pattern(client, int(parts[1], 16)) + name = parts[1].upper() + try: + p = Pattern[name] + except KeyError: + print(f" Unknown pattern. Options: {', '.join(p.name for p in Pattern)}") + continue + await set_pattern(client, p) + print(f" {p.name}") elif c == "raw": data = bytes([int(x, 16) for x in parts[1:]]) await send(client, data) @@ -241,11 +280,12 @@ def main(): p_bright = sub.add_parser("bright", help="Set brightness (0-255)") p_bright.add_argument("value", type=int) - p_mode = sub.add_parser("mode", help="Set mode (decimal, via 0x2C)") + p_mode = sub.add_parser("mode", help="Set mode (decimal)") p_mode.add_argument("mode", type=int) - p_pattern = sub.add_parser("pattern", help="Set pattern (hex CMD byte)") - p_pattern.add_argument("pattern", type=lambda x: int(x, 16)) + p_pattern = sub.add_parser("pattern", help="Set pattern by name") + p_pattern.add_argument("pattern", type=lambda x: Pattern[x.upper()], + choices=list(Pattern), metavar="PATTERN") args = parser.parse_args() From 77abba377550834eba177b2a0009618bca6c081f Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Sun, 8 Mar 2026 05:40:50 -0700 Subject: [PATCH 27/82] faster discovery --- tools/underglow/sp105e.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/tools/underglow/sp105e.py b/tools/underglow/sp105e.py index 642adbc7fa5c55..9dd15f8478d1f0 100644 --- a/tools/underglow/sp105e.py +++ b/tools/underglow/sp105e.py @@ -79,11 +79,13 @@ def color_packet(r: int, g: int, b: int) -> bytes: async def find_sp105e(timeout=10): scanner = BleakScanner() await scanner.start() - await asyncio.sleep(timeout) + for _ in range(timeout * 10): + await asyncio.sleep(0.1) + for d in scanner.discovered_devices: + if d.name and "SP" in d.name: + await scanner.stop() + return d await scanner.stop() - for d in scanner.discovered_devices: - if d.name and "SP" in d.name: - return d return None From 7b6d2153c4c5dada4d0c471a699b34888a2ccc90 Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Sun, 8 Mar 2026 07:17:07 -0700 Subject: [PATCH 28/82] update docs and demo mode --- tools/underglow/BLUETOOTH_SETUP.md | 37 ++++++--- tools/underglow/sp105e.py | 129 +++++++++++++++++++---------- 2 files changed, 112 insertions(+), 54 deletions(-) diff --git a/tools/underglow/BLUETOOTH_SETUP.md b/tools/underglow/BLUETOOTH_SETUP.md index 772d97fac06f4b..7f427292cfdf2a 100644 --- a/tools/underglow/BLUETOOTH_SETUP.md +++ b/tools/underglow/BLUETOOTH_SETUP.md @@ -65,39 +65,40 @@ Added BT UART (SE6 at 0x898000, GPIOs 45-48): - BLE device name: `SP105E` - MAC seen: `BA:AB:05:04:02:BD` - Protocol: SP110E-compatible (same BLE service 0xFFE0, characteristic 0xFFE1) -- Can set: static color, brightness, mode (1-120 presets), speed, on/off -- Cannot address individual LEDs over BT (controller limitation) +- Can set: static color, brightness (relative), mode (1-120 presets), on/off. Speed not found yet +- Controller addresses LEDs individually for built-in patterns (rainbow flow etc.), but no per-LED BLE command found yet. May require longer payloads — only 6-byte packets tested so far - Python library: `sp110e` (pip) or raw `bleak` ## Remaining TODO -1. **UI setup button** - install bluez+rfkill, run init sequence, confirm slider pattern -2. **SP105E control script** - Python script using bleak to connect to SP105E and send commands +1. **Speed command** - Not found yet, need focused testing with patterns running +2. **Absolute brightness** - Only relative up/down exists, may need software tracking 3. **CarState reactive colors** - Map vEgo/steeringAngleDeg/brakePressed/etc to SP105E color commands 4. **Bake into AGNOS** - Add bluez+rfkill to agnos-builder system image so they persist across reboots +5. **App "apply configuration" recovery sequence** - Reverse-engineer what the app sends to recover from soft-brick ## SP105E BLE Protocol (Reverse-Engineered March 2026) - Service: 0xFFE0, Write characteristic: 0xFFE1 (read/write-without-response/write/notify) - SP105E does NOT have 0xFFE2 init char (SP110E does) — no init needed - Also has battery service 0x180F char 0x2A19 (read/notify) - **Packet format: `38 [D1] [D2] [D3] [CMD] 83`** (6 bytes, 38/83 framing) -- **Color byte order: GRB (not RGB!)** +- **Color byte order: GRB (factory default) — persists to flash. Script sets GRB on connect to ensure known state** ### Confirmed Commands | Command | Format | Notes | |---------|--------|-------| -| SET_COLOR | `38 RR GG BB 1E 83` | After setting RGB order (0x3C=2) | +| SET_COLOR | `38 GG RR BB 1E 83` | GRB wire order (set on connect), API takes RGB | | POWER_TOGGLE | `38 00 00 00 AA 83` | Toggle only, 0xAB does nothing | -| BRIGHTNESS | `38 BB 00 00 2A 83` | 0-255, higher=brighter | +| BRIGHT_UP | `38 SS 00 00 2A 83` | Relative step brighter, S=step size (1-16) | +| BRIGHT_DOWN | `38 SS 00 00 28 83` | Relative step dimmer, S=step size (1-8) | | SET_MODE | `38 MM 00 00 2C 83` | Mode number in D1 (01, 05, 0A, etc.) | -| COLOR_ORDER | `38 NN 00 00 3C 83` | 0=GRB 1=GBR **2=RGB** 3=BGR 4=RBG 5=BRG | -| PIXEL_COUNT? | `38 NN 00 00 2D 83` | Causes brief off/on, might set LED count | +| COLOR_ORDER | `38 NN 00 00 3C 83` | 0=GRB 1=GBR 2=RGB 3=BGR 4=RBG 5=BRG. Persists to flash! | ### Color Order Map (0x3C) | Value | D1 | D2 | D3 | Name | |-------|----|----|-----|------| | 0 | G | R | B | GRB (default) | | 1 | G | B | R | GBR | -| **2** | **R** | **G** | **B** | **RGB** (use this!) | +| 2 | R | G | B | RGB | | 3 | B | G | R | BGR | | 4 | R | B | G | RBG | | 5 | B | R | G | BRG | @@ -120,7 +121,21 @@ Added BT UART (SE6 at 0x898000, GPIOs 45-48): - `0xAB` (OFF) does nothing - Speed command not found yet (patterns auto-cycle between effects) - Device must be ON for commands to work; color cmd alone doesn't turn it on -- `0x28` also affects brightness (inverse: higher=dimmer) +- Brightness is RELATIVE not absolute: + - `0x2A` = step brighter, D1=step size (usable range 1-16, caps around 16) + - `0x28` = step dimmer, D1=step size (usable range 1-8, caps around 8) + - Both are one-directional per command — 0x2A only goes up, 0x28 only goes down + - ~6-7 visible brightness levels total, cannot dim to fully off + - No absolute brightness command found (entire CMD range 0x01-0xFE swept) + - Must track brightness level in software for absolute control +- BLE read-back: FFE1 direct read returns 128 bytes of zeros. Notify subscription also returns nothing. No way to read device state over BLE +- Battery service (0x180F/0x2A19) returns 0 (not useful) +- Color order (0x3C) persists to flash across power cycles — script sets GRB (0) on connect +- **DANGEROUS commands** (soft-brick, ignores all commands after): + - `0x1C` — sets bright white, unresponsive + - `0x2D` — brief off/on, may wedge device state + - **Recovery without power cycle**: App "apply configuration" (change color order to RGB then back to GRB + apply) recovers the device. Sending 0x3C alone does NOT work — app sends a config bundle that reinitializes the controller. Exact recovery sequence unknown. + - LowGlow LED app config options: controller type (LowGlow V1), IC model (OG Kit vs Standard Kit), color order - SP110E gist (partial overlap): https://gist.github.com/mbullington/37957501a07ad065b67d4e8d39bfe012 ## Color Ideas for CarState Mapping diff --git a/tools/underglow/sp105e.py b/tools/underglow/sp105e.py index 9dd15f8478d1f0..f43634ef610d21 100644 --- a/tools/underglow/sp105e.py +++ b/tools/underglow/sp105e.py @@ -4,13 +4,14 @@ Protocol (reverse-engineered March 2026): Packet format: 38 [D1] [D2] [D3] [CMD] 83 - Send COLOR_ORDER=RGB on connect, then use standard RGB values. + Sets GRB color order (factory default) on connect. API accepts RGB. Confirmed commands: - SET_COLOR: 38 RR GG BB 1E 83 (after setting RGB order) + SET_COLOR: 38 GG RR BB 1E 83 (GRB wire order, API takes RGB) POWER_TOGGLE: 38 00 00 00 AA 83 (toggle only, 0xAB does nothing) SET_MODE: 38 MM 00 00 2C 83 (mode number in D1) - SET_BRIGHTNESS: 38 BB 00 00 2A 83 (higher = brighter) + BRIGHT_UP: 38 SS 00 00 2A 83 (relative step up, S=step size 1-16) + BRIGHT_DOWN: 38 SS 00 00 28 83 (relative step down, S=step size 1-8) COLOR_ORDER: 38 NN 00 00 3C 83 (0=GRB, 1=GBR, 2=RGB, 3=BGR, 4=RBG, 5=BRG) Pattern modes (as CMD byte directly, D1-D3 ignored): @@ -21,6 +22,7 @@ - Device must be ON for commands to work - 0xAA is a toggle (on->off, off->on), not absolute - Speed command not found yet + - Color order (0x3C) persists to flash — script sets GRB on connect """ import argparse import asyncio @@ -37,10 +39,13 @@ class Command(IntEnum): SET_COLOR = 0x1E POWER_TOGGLE = 0xAA - SET_BRIGHTNESS = 0x2A + BRIGHT_UP = 0x2A # relative: step brighter, D1=step size (1-16) + BRIGHT_DOWN = 0x28 # relative: step dimmer, D1=step size (1-8) SET_MODE = 0x2C COLOR_ORDER = 0x3C - PIXEL_COUNT = 0x2D # unconfirmed, causes brief off/on + # DANGEROUS — do not send, will soft-brick (requires power cycle): + # 0x1C — bright white, ignores all commands after + # 0x2D — brief off/on, may wedge state class ColorOrder(IntEnum): @@ -72,8 +77,8 @@ def packet(d1: int, d2: int, d3: int, cmd: int) -> bytes: def color_packet(r: int, g: int, b: int) -> bytes: - """Color packet. Assumes RGB order has been set via set_color_order().""" - return packet(r, g, b, Command.SET_COLOR) + """Color packet. Swaps to GRB wire order so callers use standard RGB.""" + return packet(g, r, b, Command.SET_COLOR) async def find_sp105e(timeout=10): @@ -97,8 +102,9 @@ async def connect(): print(f"Found {dev.address}") client = BleakClient(dev.address, timeout=20) await client.connect() - await set_color_order(client, ColorOrder.RGB) - print("Connected (RGB order set).") + # Always set GRB (factory default) on connect to ensure known state + await send(client, packet(ColorOrder.GRB, 0, 0, Command.COLOR_ORDER)) + print(f"Connected to {dev.address} (GRB order set)") return client @@ -117,8 +123,13 @@ async def power_toggle(client): async def set_brightness(client, val): - """Set brightness. 0-255, higher = brighter.""" - await send(client, packet(val, 0, 0, Command.SET_BRIGHTNESS)) + """Step brightness up. Relative, not absolute. Step size 1-16.""" + await send(client, packet(val, 0, 0, Command.BRIGHT_UP)) + + +async def dim(client, val): + """Step brightness down. Relative, not absolute. Step size 1-8.""" + await send(client, packet(val, 0, 0, Command.BRIGHT_DOWN)) async def set_mode(client, mode): @@ -131,8 +142,8 @@ async def set_pattern(client, pattern): await send(client, packet(0, 0, 0, pattern)) -async def set_color_order(client, order=ColorOrder.RGB): - """Set color byte order.""" +async def set_color_order(client, order=ColorOrder.GRB): + """Set color byte order. WARNING: persists to flash! Leave as GRB (default).""" await send(client, packet(order, 0, 0, Command.COLOR_ORDER)) @@ -154,8 +165,17 @@ async def cmd_toggle(args): async def cmd_bright(args): client = await connect() - await set_brightness(client, args.value) - print(f"Brightness set to {args.value}") + steps = abs(args.value) + if args.value >= 0: + for _ in range(steps): + await set_brightness(client, 8) + await asyncio.sleep(0.1) + print(f"Brightness up {steps} steps") + else: + for _ in range(steps): + await dim(client, 8) + await asyncio.sleep(0.1) + print(f"Brightness down {steps} steps") await client.disconnect() @@ -173,31 +193,53 @@ async def cmd_pattern(args): await client.disconnect() +async def run_demo(client): + """HSV color cycle → brightness ramp → repeat. Ctrl+C to stop.""" + import colorsys + # Max brightness + for _ in range(10): + await set_brightness(client, 16) + await asyncio.sleep(0.05) + + print("Demo: colors → brightness → colors. Ctrl+C to stop.") + try: + while True: + print(" color cycle...") + for step in range(300): + hue = step / 300.0 + r, g, b = colorsys.hsv_to_rgb(hue, 1.0, 1.0) + await set_color(client, int(r * 255), int(g * 255), int(b * 255)) + await asyncio.sleep(0.01) + + print(" brightness ramp...") + await set_color(client, 255, 0, 0) + await asyncio.sleep(0.1) + for _ in range(6): + await dim(client, 1) + await asyncio.sleep(0.05) + for _ in range(6): + await set_brightness(client, 1) + await asyncio.sleep(0.05) + except KeyboardInterrupt: + pass + # Restore full bright + for _ in range(10): + await set_brightness(client, 16) + await asyncio.sleep(0.05) + print("\n demo done.") + + async def cmd_demo(args): client = await connect() - colors = [ - (255, 0, 0, "red"), - (0, 255, 0, "green"), - (0, 0, 255, "blue"), - (255, 255, 0, "yellow"), - (0, 255, 100, "comma green"), - (255, 0, 255, "magenta"), - (255, 128, 0, "orange"), - (255, 255, 255, "white"), - ] - for r, g, b, name in colors: - print(f" {name} ({r},{g},{b})") - await set_color(client, r, g, b) - await asyncio.sleep(1.5) - print("Demo done.") + await run_demo(client) await client.disconnect() async def cmd_interactive(args): client = await connect() print("\nCommands:") - print(" color R G B set static color") - print(" bright N brightness 0-255") + print(" color R G B set static color (RGB 0-255)") + print(" bright N step brighter (N steps, use -N to dim)") print(" toggle power on/off") print(" mode N set mode (decimal, via SET_MODE)") print(" pattern NAME set pattern (e.g. BREATHING, RAINBOW_FLOW)") @@ -219,7 +261,15 @@ async def cmd_interactive(args): if c == "color" and len(parts) == 4: await set_color(client, int(parts[1]), int(parts[2]), int(parts[3])) elif c in ("bright", "brightness") and len(parts) == 2: - await set_brightness(client, int(parts[1])) + val = int(parts[1]) + if val >= 0: + for _ in range(val): + await set_brightness(client, 8) + await asyncio.sleep(0.1) + else: + for _ in range(-val): + await dim(client, 8) + await asyncio.sleep(0.1) elif c == "toggle": await power_toggle(client) elif c == "mode" and len(parts) == 2: @@ -238,14 +288,7 @@ async def cmd_interactive(args): await send(client, data) print(f" sent: {data.hex()}") elif c == "demo": - colors = [ - (255, 0, 0, "red"), (0, 255, 0, "green"), (0, 0, 255, "blue"), - (255, 255, 0, "yellow"), (0, 255, 100, "comma green"), - ] - for r, g, b, name in colors: - print(f" {name}") - await set_color(client, r, g, b) - await asyncio.sleep(1.5) + await run_demo(client) elif c in ("quit", "exit", "q"): break else: @@ -279,8 +322,8 @@ def main(): sub.add_parser("interactive", help="Interactive REPL") sub.add_parser("scan", help="Scan for BLE devices") - p_bright = sub.add_parser("bright", help="Set brightness (0-255)") - p_bright.add_argument("value", type=int) + p_bright = sub.add_parser("bright", help="Step brightness (positive=up, negative=down)") + p_bright.add_argument("value", type=int, help="number of steps (negative to dim)") p_mode = sub.add_parser("mode", help="Set mode (decimal)") p_mode.add_argument("mode", type=int) From 0934ccdce81922d0e87a57d20bc5c4f07762ba36 Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Sun, 8 Mar 2026 07:32:37 -0700 Subject: [PATCH 29/82] glowd! --- selfdrive/glowd/glowd.py | 277 +++++++++++++++++++++++++++++ system/manager/process_config.py | 1 + tools/underglow/BLUETOOTH_SETUP.md | 2 +- tools/underglow/sp105e.py | 42 +++-- 4 files changed, 309 insertions(+), 13 deletions(-) create mode 100644 selfdrive/glowd/glowd.py diff --git a/selfdrive/glowd/glowd.py b/selfdrive/glowd/glowd.py new file mode 100644 index 00000000000000..cd3c3887b14bc4 --- /dev/null +++ b/selfdrive/glowd/glowd.py @@ -0,0 +1,277 @@ +#!/usr/bin/env python3 +""" +glowd — SP105E underglow controller daemon. + +Runs only_onroad. On start: connects BLE + powers on LEDs. +On SIGTERM (manager kill at ignition off): powers off LEDs + disconnects. +Maps CarState to underglow colors reactively. + +Color mapping: + - RPM → hue (green idle → yellow → amber → purple at redline) + - Braking → orange, intensity scales with decel + - Gas → warm amber blended with RPM color + - Downshift → brief purple flash + - Blinker → amber pulse + - Standstill → slow breathing pulse + - Reverse → white + +California-legal: no red or blue, especially on the front. +Safe colors: green, yellow, amber, orange, purple, white, pink. +""" +import asyncio +import colorsys +import math +import signal +import time + +import cereal.messaging as messaging +from openpilot.common.realtime import Ratekeeper + +from openpilot.tools.underglow.sp105e import ( + connect, set_color, set_brightness, dim, power_toggle, send, packet, Command, +) + +DEBUG = True + +# --- Color palette (California-legal: no red, no blue) --- +COLOR_IDLE = (0, 180, 60) # green at idle +COLOR_BRAKE = (255, 40, 0) # deep orange-red (more orange than red) +COLOR_BRAKE_HARD = (255, 80, 0) # bright orange on hard brake +COLOR_GAS = (255, 140, 0) # warm amber +COLOR_REVERSE = (255, 255, 255) # white +COLOR_BLINKER = (255, 160, 0) # amber +COLOR_DOWNSHIFT = (180, 0, 255) # purple flash +COLOR_STANDSTILL = (0, 200, 80) # soft green for breathing + +# RPM thresholds +RPM_MIN = 800 +RPM_MAX = 7000 + +# Timing +UPDATE_HZ = 20 +BLINKER_HZ = 1.5 +DOWNSHIFT_FLASH_DURATION = 0.4 +BRIGHT_INIT_STEPS = 10 + +# Gear debounce: ignore gearActual == 0 briefly (neutral between shifts) +GEAR_ZERO_DEBOUNCE_S = 0.5 + + +def rpm_to_color(rpm: float) -> tuple[int, int, int]: + """Map RPM to hue: green(idle) → yellow → amber → purple(redline). + Avoids pure red (0.0) and blue (0.66).""" + t = max(0.0, min(1.0, (rpm - RPM_MIN) / (RPM_MAX - RPM_MIN))) + if t < 0.7: + hue = 0.33 - (0.33 - 0.08) * (t / 0.7) + else: + hue = 0.08 + (0.78 - 0.08) * ((t - 0.7) / 0.3) + r, g, b = colorsys.hsv_to_rgb(hue, 1.0, 1.0) + return int(r * 255), int(g * 255), int(b * 255) + + +def breathing_brightness(t: float, period: float = 3.0) -> float: + """Sinusoidal breathing: 0.3 → 1.0 → 0.3.""" + phase = (t % period) / period + return 0.3 + 0.7 * (0.5 + 0.5 * math.sin(2 * math.pi * phase - math.pi / 2)) + + +def scale_color(color: tuple[int, int, int], brightness: float) -> tuple[int, int, int]: + return (int(color[0] * brightness), int(color[1] * brightness), int(color[2] * brightness)) + + +class GlowController: + def __init__(self): + self.last_valid_gear = 0 + self.gear_zero_since = 0.0 + self.effective_gear = 0 + self._prev_effective_gear = 0 + + self.downshift_until = 0.0 + self.last_blinker_toggle = 0.0 + self.blinker_on = True + self.last_color = (0, 0, 0) + self.standstill_start = 0.0 + self.was_standstill = False + + def _update_gear(self, raw_gear: int, now: float) -> int: + """Debounce gearActual: hold last valid gear when it drops to 0 briefly.""" + if raw_gear > 0: + self.last_valid_gear = raw_gear + self.gear_zero_since = 0.0 + self.effective_gear = raw_gear + else: + if self.gear_zero_since == 0.0: + self.gear_zero_since = now + if now - self.gear_zero_since < GEAR_ZERO_DEBOUNCE_S: + self.effective_gear = self.last_valid_gear + else: + self.effective_gear = 0 + return self.effective_gear + + def compute_color(self, sm) -> tuple[int, int, int]: + cs = sm['carState'] + now = time.monotonic() + + rpm = cs.engineRpm + brake = cs.brakePressed + gas = cs.gasPressed + standstill = cs.standstill + left_blinker = cs.leftBlinker + right_blinker = cs.rightBlinker + raw_gear = cs.gearActual + + gear = self._update_gear(raw_gear, now) + prev_gear = self._prev_effective_gear + + # --- Priority 1: Downshift flash --- + if gear > 0 and prev_gear > 0 and gear < prev_gear: + self.downshift_until = now + DOWNSHIFT_FLASH_DURATION + if DEBUG: + print(f"glowd: DOWNSHIFT {prev_gear} → {gear}") + self._prev_effective_gear = gear + + if now < self.downshift_until: + return COLOR_DOWNSHIFT + + # --- Priority 2: Braking --- + if brake: + a_ego = cs.aEgo + if a_ego < -3.0: + return COLOR_BRAKE_HARD + else: + intensity = min(1.0, max(0.5, abs(a_ego) / 4.0)) + return scale_color(COLOR_BRAKE, intensity) + + # --- Priority 3: Blinker amber pulse --- + if left_blinker or right_blinker: + period = 1.0 / BLINKER_HZ + if now - self.last_blinker_toggle >= period / 2: + self.blinker_on = not self.blinker_on + self.last_blinker_toggle = now + if self.blinker_on: + return COLOR_BLINKER + + # --- Priority 4: Reverse --- + if str(cs.gearShifter) == 'reverse': + return COLOR_REVERSE + + # --- Priority 5: Standstill breathing --- + if standstill: + if not self.was_standstill: + self.standstill_start = now + self.was_standstill = True + elapsed = now - self.standstill_start + if elapsed > 2.0: + bright = breathing_brightness(elapsed - 2.0, period=3.0) + return scale_color(COLOR_STANDSTILL, bright) + else: + self.was_standstill = False + + # --- Priority 6: Gas pressed (warm amber overlay) --- + if gas and rpm > RPM_MIN: + rpm_color = rpm_to_color(rpm) + return ( + (rpm_color[0] + COLOR_GAS[0]) // 2, + (rpm_color[1] + COLOR_GAS[1]) // 2, + (rpm_color[2] + COLOR_GAS[2]) // 2, + ) + + # --- Priority 7: RPM-based color --- + return rpm_to_color(rpm) + + +async def ble_connect(): + """Connect to SP105E, power on, max brightness. Returns client or None.""" + print("glowd: connecting to SP105E...") + client = await connect(exit_on_fail=False) + if client is None: + return None + # Toggle on + max brightness + await power_toggle(client) + await asyncio.sleep(0.3) + for _ in range(BRIGHT_INIT_STEPS): + await set_brightness(client, 16) + await asyncio.sleep(0.03) + print("glowd: connected, LEDs on, brightness maxed") + return client + + +async def ble_shutdown(client): + """Power off LEDs and disconnect.""" + if client is not None: + try: + await power_toggle(client) + await asyncio.sleep(0.1) + await client.disconnect() + print("glowd: LEDs off, disconnected") + except Exception as e: + print(f"glowd: shutdown BLE error: {e}") + + +async def glowd_thread(): + do_exit = False + client = None + + def signal_handler(signum, frame): + nonlocal do_exit + print(f"glowd: caught signal {signum}, exiting") + do_exit = True + + signal.signal(signal.SIGTERM, signal_handler) + signal.signal(signal.SIGINT, signal_handler) + + client = await ble_connect() + + sm = messaging.SubMaster(['carState'], poll='carState') + ctrl = GlowController() + rk = Ratekeeper(UPDATE_HZ) + last_reconnect_attempt = 0.0 + + print(f"glowd: running at {UPDATE_HZ}Hz") + + while not do_exit: + sm.update(timeout=100) + + # If disconnected, try to reconnect every 5s + if client is None: + now = time.monotonic() + if now - last_reconnect_attempt > 5.0: + last_reconnect_attempt = now + print("glowd: attempting reconnect...") + client = await ble_connect() + rk.keep_time() + continue + + if sm.updated['carState']: + color = ctrl.compute_color(sm) + + if color != ctrl.last_color: + if DEBUG: + cs = sm['carState'] + print(f"glowd: RPM={cs.engineRpm:.0f} gear={cs.gearActual} → RGB{color}") + + try: + await set_color(client, *color) + except Exception as e: + print(f"glowd: BLE error: {e}") + try: + await client.disconnect() + except Exception: + pass + client = None + continue + + ctrl.last_color = color + + rk.keep_time() + + # Clean shutdown: power off LEDs + await ble_shutdown(client) + + +def main(): + asyncio.run(glowd_thread()) + + +if __name__ == "__main__": + main() diff --git a/system/manager/process_config.py b/system/manager/process_config.py index 0b99183193e6e4..71a5d5a22dd84e 100644 --- a/system/manager/process_config.py +++ b/system/manager/process_config.py @@ -107,6 +107,7 @@ def and_(*fns): PythonProcess("uploader", "system.loggerd.uploader", always_run), PythonProcess("statsd", "system.statsd", always_run), PythonProcess("feedbackd", "selfdrive.ui.feedback.feedbackd", only_onroad), + PythonProcess("glowd", "selfdrive.glowd.glowd", only_onroad, enabled=TICI), # debug procs NativeProcess("bridge", "cereal/messaging", ["./bridge"], notcar), diff --git a/tools/underglow/BLUETOOTH_SETUP.md b/tools/underglow/BLUETOOTH_SETUP.md index 7f427292cfdf2a..d94c5014c19c10 100644 --- a/tools/underglow/BLUETOOTH_SETUP.md +++ b/tools/underglow/BLUETOOTH_SETUP.md @@ -148,7 +148,7 @@ Added BT UART (SE6 at 0x898000, GPIOs 45-48): - **openpilot engaged** (cruiseState.enabled): comma green (0,255,100) - **Reverse** (gearShifter==reverse): white glow - **Parked/idle** (standstill): slow breathing pulse -- Note: engineRpm is DEPRECATED in car.capnp, use vEgo/aEgo instead +- engineRpm available on brzpilot fork (un-deprecated), also gearActual, shiftGrade, clutchPressed ## Quick Reference Commands - Scan: `adb shell "timeout 10 hcitool -i hci0 lescan 2>&1 | grep -v '(unknown)' | sort -u -k2"` diff --git a/tools/underglow/sp105e.py b/tools/underglow/sp105e.py index f43634ef610d21..88453a7032706f 100644 --- a/tools/underglow/sp105e.py +++ b/tools/underglow/sp105e.py @@ -32,6 +32,9 @@ CHAR = "0000ffe1-0000-1000-8000-00805f9b34fb" +CONNECT_RETRIES = 3 +SCAN_TIMEOUT = 8 + PACKET_START = 0x38 PACKET_END = 0x83 @@ -81,7 +84,8 @@ def color_packet(r: int, g: int, b: int) -> bytes: return packet(g, r, b, Command.SET_COLOR) -async def find_sp105e(timeout=10): +async def find_sp105e(timeout=SCAN_TIMEOUT): + """Scan for SP105E by name. Returns BLEDevice or None.""" scanner = BleakScanner() await scanner.start() for _ in range(timeout * 10): @@ -94,18 +98,32 @@ async def find_sp105e(timeout=10): return None -async def connect(): - dev = await find_sp105e() - if not dev: - print("SP105E not found") +async def connect(retries=CONNECT_RETRIES, exit_on_fail=True): + """Connect to SP105E with retries. Returns BleakClient.""" + for attempt in range(1, retries + 1): + try: + dev = await find_sp105e() + if not dev: + print(f"SP105E not found (attempt {attempt}/{retries})") + if attempt < retries: + await asyncio.sleep(2) + continue + print(f"Found {dev.address}") + client = BleakClient(dev.address, timeout=20) + await client.connect() + # Always set GRB (factory default) on connect to ensure known state + await send(client, packet(ColorOrder.GRB, 0, 0, Command.COLOR_ORDER)) + print(f"Connected to {dev.address} (GRB order set)") + return client + except Exception as e: + print(f"Connect failed (attempt {attempt}/{retries}): {e}") + if attempt < retries: + await asyncio.sleep(2) + + if exit_on_fail: + print("SP105E: all connection attempts failed") sys.exit(1) - print(f"Found {dev.address}") - client = BleakClient(dev.address, timeout=20) - await client.connect() - # Always set GRB (factory default) on connect to ensure known state - await send(client, packet(ColorOrder.GRB, 0, 0, Command.COLOR_ORDER)) - print(f"Connected to {dev.address} (GRB order set)") - return client + return None async def send(client, data: bytes): From 6ac99fae8e60cbaa12102b2d116c9bb46a7cdd22 Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Sun, 8 Mar 2026 08:03:18 -0700 Subject: [PATCH 30/82] we're getting somewhere --- selfdrive/glowd/glowd.py | 13 +- tools/underglow/BLUETOOTH_SETUP.md | 16 +++ tools/underglow/sp105e.py | 198 ++++++++++++++++++++++------- 3 files changed, 174 insertions(+), 53 deletions(-) diff --git a/selfdrive/glowd/glowd.py b/selfdrive/glowd/glowd.py index cd3c3887b14bc4..8589fb65a207f3 100644 --- a/selfdrive/glowd/glowd.py +++ b/selfdrive/glowd/glowd.py @@ -28,7 +28,7 @@ from openpilot.common.realtime import Ratekeeper from openpilot.tools.underglow.sp105e import ( - connect, set_color, set_brightness, dim, power_toggle, send, packet, Command, + connect, set_color, set_brightness, power_on, power_off, BRIGHTNESS_MAX, ) DEBUG = True @@ -186,12 +186,8 @@ async def ble_connect(): client = await connect(exit_on_fail=False) if client is None: return None - # Toggle on + max brightness - await power_toggle(client) - await asyncio.sleep(0.3) - for _ in range(BRIGHT_INIT_STEPS): - await set_brightness(client, 16) - await asyncio.sleep(0.03) + await power_on(client) + await set_brightness(client, BRIGHTNESS_MAX) print("glowd: connected, LEDs on, brightness maxed") return client @@ -200,8 +196,7 @@ async def ble_shutdown(client): """Power off LEDs and disconnect.""" if client is not None: try: - await power_toggle(client) - await asyncio.sleep(0.1) + await power_off(client) await client.disconnect() print("glowd: LEDs off, disconnected") except Exception as e: diff --git a/tools/underglow/BLUETOOTH_SETUP.md b/tools/underglow/BLUETOOTH_SETUP.md index d94c5014c19c10..505da55a286c4e 100644 --- a/tools/underglow/BLUETOOTH_SETUP.md +++ b/tools/underglow/BLUETOOTH_SETUP.md @@ -91,8 +91,24 @@ Added BT UART (SE6 at 0x898000, GPIOs 45-48): | BRIGHT_UP | `38 SS 00 00 2A 83` | Relative step brighter, S=step size (1-16) | | BRIGHT_DOWN | `38 SS 00 00 28 83` | Relative step dimmer, S=step size (1-8) | | SET_MODE | `38 MM 00 00 2C 83` | Mode number in D1 (01, 05, 0A, etc.) | +| GET_STATE | `38 00 00 00 10 83` | Triggers notify with 8-byte state (see below) | | COLOR_ORDER | `38 NN 00 00 3C 83` | 0=GRB 1=GBR 2=RGB 3=BGR 4=RBG 5=BRG. Persists to flash! | +### State Response (GET_STATE 0x10, via notify on FFE1) +8 bytes returned via BLE notify after sending GET_STATE. Also fires automatically on POWER_TOGGLE. +``` +Byte 0: Power state (1=ON, 0=OFF) +Byte 1: 0xC9 (brightness? — doesn't change with BRIGHT_UP/DOWN, may be stored config) +Byte 2: 0x06 (mode/pattern? — doesn't change with SET_MODE/pattern, may be stored config) +Byte 3: 0x06 (speed? — untested) +Byte 4: 0x03 (unknown) +Byte 5: 0x00 (color order? — matches GRB=0) +Byte 6: 0x02 (unknown) +Byte 7: 0x58 (88 — LED count?) +``` +Only byte 0 (power) changes at runtime. Other bytes appear to be flash config. +Power state enables deterministic on/off: read state, toggle only if needed. + ### Color Order Map (0x3C) | Value | D1 | D2 | D3 | Name | |-------|----|----|-----|------| diff --git a/tools/underglow/sp105e.py b/tools/underglow/sp105e.py index 88453a7032706f..8eaad45161db6a 100644 --- a/tools/underglow/sp105e.py +++ b/tools/underglow/sp105e.py @@ -45,6 +45,7 @@ class Command(IntEnum): BRIGHT_UP = 0x2A # relative: step brighter, D1=step size (1-16) BRIGHT_DOWN = 0x28 # relative: step dimmer, D1=step size (1-8) SET_MODE = 0x2C + GET_STATE = 0x10 # triggers notify with 8-byte state response COLOR_ORDER = 0x3C # DANGEROUS — do not send, will soft-brick (requires power cycle): # 0x1C — bright white, ignores all commands after @@ -130,6 +131,35 @@ async def send(client, data: bytes): await client.write_gatt_char(CHAR, data, response=False) +# --- State reading --- + +async def get_state(client) -> bytes | None: + """Send GET_STATE (0x10) and return 8-byte notify response. + Returns None on timeout. Byte 0: 1=on, 0=off.""" + result = None + event = asyncio.Event() + + def on_notify(sender, data): + nonlocal result + result = data + event.set() + + await client.start_notify(CHAR, on_notify) + await send(client, packet(0, 0, 0, Command.GET_STATE)) + try: + await asyncio.wait_for(event.wait(), timeout=2.0) + except asyncio.TimeoutError: + pass + await client.stop_notify(CHAR) + return result + + +async def is_on(client) -> bool: + """Returns True if LEDs are on.""" + state = await get_state(client) + return state is not None and state[0] == 1 + + # --- High-level commands --- async def set_color(client, r, g, b): @@ -140,14 +170,59 @@ async def power_toggle(client): await send(client, packet(0, 0, 0, Command.POWER_TOGGLE)) -async def set_brightness(client, val): - """Step brightness up. Relative, not absolute. Step size 1-16.""" - await send(client, packet(val, 0, 0, Command.BRIGHT_UP)) +async def power_on(client): + """Turn on if off. No-op if already on.""" + if not await is_on(client): + await power_toggle(client) + + +async def power_off(client): + """Turn off if on. No-op if already off.""" + if await is_on(client): + await power_toggle(client) + + +BRIGHTNESS_MAX = 6 +BRIGHTNESS_MIN = 0 + + +async def get_brightness(client) -> int | None: + """Read current brightness level (0-6). Returns None on error.""" + state = await get_state(client) + if state is not None and len(state) >= 4: + return state[3] + return None + + +async def set_brightness(client, level: int): + """Set absolute brightness (0-6). Reads current level and steps to target.""" + level = max(BRIGHTNESS_MIN, min(BRIGHTNESS_MAX, level)) + current = await get_brightness(client) + if current is None: + # Can't read state, just step up to max as fallback + for _ in range(BRIGHTNESS_MAX): + await send(client, packet(1, 0, 0, Command.BRIGHT_UP)) + await asyncio.sleep(0.05) + return + diff = level - current + if diff > 0: + for _ in range(diff): + await send(client, packet(1, 0, 0, Command.BRIGHT_UP)) + await asyncio.sleep(0.05) + elif diff < 0: + for _ in range(-diff): + await send(client, packet(1, 0, 0, Command.BRIGHT_DOWN)) + await asyncio.sleep(0.05) + +async def brightness_step_up(client, step=1): + """Step brightness up. Relative. Step size 1-16.""" + await send(client, packet(step, 0, 0, Command.BRIGHT_UP)) -async def dim(client, val): - """Step brightness down. Relative, not absolute. Step size 1-8.""" - await send(client, packet(val, 0, 0, Command.BRIGHT_DOWN)) + +async def brightness_step_down(client, step=1): + """Step brightness down. Relative. Step size 1-8.""" + await send(client, packet(step, 0, 0, Command.BRIGHT_DOWN)) async def set_mode(client, mode): @@ -181,19 +256,40 @@ async def cmd_toggle(args): await client.disconnect() -async def cmd_bright(args): +async def cmd_on(args): client = await connect() - steps = abs(args.value) - if args.value >= 0: - for _ in range(steps): - await set_brightness(client, 8) - await asyncio.sleep(0.1) - print(f"Brightness up {steps} steps") + await power_on(client) + print("ON") + await client.disconnect() + + +async def cmd_off(args): + client = await connect() + await power_off(client) + print("OFF") + await client.disconnect() + + +async def cmd_state(args): + client = await connect() + state = await get_state(client) + if state is None: + print("No response") else: - for _ in range(steps): - await dim(client, 8) - await asyncio.sleep(0.1) - print(f"Brightness down {steps} steps") + print(f"Power: {'ON' if state[0] == 1 else 'OFF'}") + print(f"Brightness: {state[3]}/{BRIGHTNESS_MAX}") + print(f"Mode: {state[1]} ({'static' if state[1] == 0xC9 else 'pattern'})") + print(f"Color order: {state[5]} ({ColorOrder(state[5]).name})") + print(f"Raw: {' '.join(f'{b:02x}' for b in state)}") + await client.disconnect() + + +async def cmd_bright(args): + client = await connect() + current = await get_brightness(client) + await set_brightness(client, args.value) + after = await get_brightness(client) + print(f"Brightness: {current} → {after} (range 0-{BRIGHTNESS_MAX})") await client.disconnect() @@ -214,10 +310,8 @@ async def cmd_pattern(args): async def run_demo(client): """HSV color cycle → brightness ramp → repeat. Ctrl+C to stop.""" import colorsys - # Max brightness - for _ in range(10): - await set_brightness(client, 16) - await asyncio.sleep(0.05) + await power_on(client) + await set_brightness(client, BRIGHTNESS_MAX) print("Demo: colors → brightness → colors. Ctrl+C to stop.") try: @@ -232,18 +326,15 @@ async def run_demo(client): print(" brightness ramp...") await set_color(client, 255, 0, 0) await asyncio.sleep(0.1) - for _ in range(6): - await dim(client, 1) - await asyncio.sleep(0.05) - for _ in range(6): - await set_brightness(client, 1) - await asyncio.sleep(0.05) + for level in range(BRIGHTNESS_MAX, -1, -1): + await set_brightness(client, level) + await asyncio.sleep(0.15) + for level in range(BRIGHTNESS_MAX + 1): + await set_brightness(client, level) + await asyncio.sleep(0.15) except KeyboardInterrupt: pass - # Restore full bright - for _ in range(10): - await set_brightness(client, 16) - await asyncio.sleep(0.05) + await set_brightness(client, BRIGHTNESS_MAX) print("\n demo done.") @@ -257,8 +348,9 @@ async def cmd_interactive(args): client = await connect() print("\nCommands:") print(" color R G B set static color (RGB 0-255)") - print(" bright N step brighter (N steps, use -N to dim)") - print(" toggle power on/off") + print(" bright N set brightness (0-6)") + print(" on / off / toggle power control") + print(" state read device state") print(" mode N set mode (decimal, via SET_MODE)") print(" pattern NAME set pattern (e.g. BREATHING, RAINBOW_FLOW)") print(" raw HH HH ... send raw hex bytes") @@ -279,17 +371,29 @@ async def cmd_interactive(args): if c == "color" and len(parts) == 4: await set_color(client, int(parts[1]), int(parts[2]), int(parts[3])) elif c in ("bright", "brightness") and len(parts) == 2: - val = int(parts[1]) - if val >= 0: - for _ in range(val): - await set_brightness(client, 8) - await asyncio.sleep(0.1) - else: - for _ in range(-val): - await dim(client, 8) - await asyncio.sleep(0.1) + level = int(parts[1]) + current = await get_brightness(client) + await set_brightness(client, level) + after = await get_brightness(client) + print(f" Brightness: {current} → {after}") + elif c == "on": + await power_on(client) + print(" ON") + elif c == "off": + await power_off(client) + print(" OFF") elif c == "toggle": await power_toggle(client) + elif c == "state": + state = await get_state(client) + if state is None: + print(" No response") + else: + print(f" Power: {'ON' if state[0] == 1 else 'OFF'}") + print(f" Brightness: {state[3]}/{BRIGHTNESS_MAX}") + print(f" Mode: {state[1]} ({'static' if state[1] == 0xC9 else 'pattern'})") + print(f" Color order: {state[5]} ({ColorOrder(state[5]).name})") + print(f" Raw: {' '.join(f'{b:02x}' for b in state)}") elif c == "mode" and len(parts) == 2: await set_mode(client, int(parts[1])) elif c == "pattern" and len(parts) == 2: @@ -336,12 +440,15 @@ def main(): p_color.add_argument("b", type=int) sub.add_parser("toggle", help="Toggle power on/off") + sub.add_parser("on", help="Turn on (no-op if already on)") + sub.add_parser("off", help="Turn off (no-op if already off)") + sub.add_parser("state", help="Read device state") sub.add_parser("demo", help="Color cycle demo") sub.add_parser("interactive", help="Interactive REPL") sub.add_parser("scan", help="Scan for BLE devices") - p_bright = sub.add_parser("bright", help="Step brightness (positive=up, negative=down)") - p_bright.add_argument("value", type=int, help="number of steps (negative to dim)") + p_bright = sub.add_parser("bright", help="Set brightness (0-6)") + p_bright.add_argument("value", type=int, help="brightness level 0-6") p_mode = sub.add_parser("mode", help="Set mode (decimal)") p_mode.add_argument("mode", type=int) @@ -355,6 +462,9 @@ def main(): commands = { "color": cmd_color, "toggle": cmd_toggle, + "on": cmd_on, + "off": cmd_off, + "state": cmd_state, "bright": cmd_bright, "mode": cmd_mode, "pattern": cmd_pattern, From 7e317120c7ebebf8ec97ad9d40abc421796b15e0 Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Sun, 8 Mar 2026 08:09:04 -0700 Subject: [PATCH 31/82] safe bt bringup --- selfdrive/glowd/glowd.py | 61 ++++++++++++++++++++++++- tools/underglow/BLUETOOTH_SETUP.md | 73 ++++++++++++++++-------------- 2 files changed, 100 insertions(+), 34 deletions(-) diff --git a/selfdrive/glowd/glowd.py b/selfdrive/glowd/glowd.py index 8589fb65a207f3..fde28e62698fc4 100644 --- a/selfdrive/glowd/glowd.py +++ b/selfdrive/glowd/glowd.py @@ -20,8 +20,11 @@ """ import asyncio import colorsys +import fcntl import math +import os import signal +import subprocess import time import cereal.messaging as messaging @@ -180,8 +183,64 @@ def compute_color(self, sm) -> tuple[int, int, int]: return rpm_to_color(rpm) +def bt_init(): + """Initialize BT adapter on comma four (WCN3990). Requires sudo (passwordless on AGNOS). + Safe to call multiple times — kills stale hciattach first. + Sleeps between steps since BT stack can be slow to come up on boot.""" + try: + # Kill any stale hciattach + subprocess.run(["sudo", "killall", "hciattach"], capture_output=True) + time.sleep(1) + + # Power on BT chip via btpower ioctl + fd = os.open('/dev/btpower', os.O_RDWR) + fcntl.ioctl(fd, 0xbfad, 1) # BT_CMD_PWR_CTRL + os.close(fd) + time.sleep(1) + + # Unblock bluetooth + subprocess.run(["sudo", "rfkill", "unblock", "bluetooth"], check=True, capture_output=True) + time.sleep(0.5) + + # Attach UART — prints firmware errors but works fine without firmware + result = subprocess.run( + ["sudo", "hciattach", "-s", "115200", "/dev/ttyHS0", "qualcomm", "115200", "flow"], + capture_output=True, timeout=15, + ) + if result.returncode != 0: + print(f"glowd: hciattach stderr: {result.stderr.decode().strip()}") + time.sleep(1) + + # Bring interface up + subprocess.run(["sudo", "hciconfig", "hci0", "up"], check=True, capture_output=True) + time.sleep(0.5) + + # Verify it's actually up + if not bt_is_up(): + print("glowd: BT init completed but hci0 not up") + return False + + print("glowd: BT adapter initialized") + return True + except Exception as e: + print(f"glowd: BT init failed: {e}") + return False + + +def bt_is_up() -> bool: + """Check if hci0 is up.""" + result = subprocess.run(["sudo", "hciconfig", "hci0"], capture_output=True) + return b"UP RUNNING" in result.stdout + + async def ble_connect(): - """Connect to SP105E, power on, max brightness. Returns client or None.""" + """Initialize BT, connect to SP105E, power on, max brightness. Returns client or None.""" + # Always reinit BT stack to ensure clean state + print("glowd: initializing BT adapter...") + if not bt_init(): + return None + await asyncio.sleep(1) + print("glowd: connecting to SP105E...") client = await connect(exit_on_fail=False) if client is None: diff --git a/tools/underglow/BLUETOOTH_SETUP.md b/tools/underglow/BLUETOOTH_SETUP.md index 505da55a286c4e..0b97de5855cc76 100644 --- a/tools/underglow/BLUETOOTH_SETUP.md +++ b/tools/underglow/BLUETOOTH_SETUP.md @@ -70,11 +70,11 @@ Added BT UART (SE6 at 0x898000, GPIOs 45-48): - Python library: `sp110e` (pip) or raw `bleak` ## Remaining TODO -1. **Speed command** - Not found yet, need focused testing with patterns running -2. **Absolute brightness** - Only relative up/down exists, may need software tracking -3. **CarState reactive colors** - Map vEgo/steeringAngleDeg/brakePressed/etc to SP105E color commands -4. **Bake into AGNOS** - Add bluez+rfkill to agnos-builder system image so they persist across reboots -5. **App "apply configuration" recovery sequence** - Reverse-engineer what the app sends to recover from soft-brick +1. **Speed command** - Not found yet (0x24/0x26 don't work), need more testing +2. **Map remaining state bytes** - Bytes 2, 4, 6, 7 still unknown +3. **Bake into AGNOS** - Add bluez+rfkill to agnos-builder system image so they persist across reboots +4. **App "apply configuration" recovery sequence** - Reverse-engineer what the app sends to recover from soft-brick +5. **Test glowd on device while driving** ## SP105E BLE Protocol (Reverse-Engineered March 2026) - Service: 0xFFE0, Write characteristic: 0xFFE1 (read/write-without-response/write/notify) @@ -97,17 +97,17 @@ Added BT UART (SE6 at 0x898000, GPIOs 45-48): ### State Response (GET_STATE 0x10, via notify on FFE1) 8 bytes returned via BLE notify after sending GET_STATE. Also fires automatically on POWER_TOGGLE. ``` -Byte 0: Power state (1=ON, 0=OFF) -Byte 1: 0xC9 (brightness? — doesn't change with BRIGHT_UP/DOWN, may be stored config) -Byte 2: 0x06 (mode/pattern? — doesn't change with SET_MODE/pattern, may be stored config) -Byte 3: 0x06 (speed? — untested) +Byte 0: Power (1=ON, 0=OFF) — confirmed with visual correlation +Byte 1: Mode (SET_MODE value: 0xC9=201=static color, 1-120+=patterns) +Byte 2: 0x06 (unknown — never changed) +Byte 3: Brightness (0=min, 6=max, 7 levels) — confirmed stepping 0→1→2→3→4→5→6 Byte 4: 0x03 (unknown) -Byte 5: 0x00 (color order? — matches GRB=0) +Byte 5: Color order (0=GRB, 1=GBR, 2=RGB, etc.) — confirmed Byte 6: 0x02 (unknown) Byte 7: 0x58 (88 — LED count?) ``` -Only byte 0 (power) changes at runtime. Other bytes appear to be flash config. -Power state enables deterministic on/off: read state, toggle only if needed. +Power + brightness + mode + color order are live-readable. +Enables: deterministic on/off (`power_on`/`power_off`), absolute brightness (`set_brightness(0-6)`). ### Color Order Map (0x3C) | Value | D1 | D2 | D3 | Name | @@ -134,19 +134,15 @@ Power state enables deterministic on/off: read state, toggle only if needed. ### Other findings - Sending color (0x1E) stops any active pattern → returns to static +- SET_COLOR does NOT change power state — byte 0 stays the same, but LEDs visually respond even when "off" - `0xAB` (OFF) does nothing -- Speed command not found yet (patterns auto-cycle between effects) -- Device must be ON for commands to work; color cmd alone doesn't turn it on -- Brightness is RELATIVE not absolute: - - `0x2A` = step brighter, D1=step size (usable range 1-16, caps around 16) - - `0x28` = step dimmer, D1=step size (usable range 1-8, caps around 8) - - Both are one-directional per command — 0x2A only goes up, 0x28 only goes down - - ~6-7 visible brightness levels total, cannot dim to fully off - - No absolute brightness command found (entire CMD range 0x01-0xFE swept) - - Must track brightness level in software for absolute control -- BLE read-back: FFE1 direct read returns 128 bytes of zeros. Notify subscription also returns nothing. No way to read device state over BLE +- Speed command not found yet (0x24/0x26 don't work, patterns auto-cycle between effects) +- Brightness: 7 levels (0-6), commands are relative (0x2A up, 0x28 down) but absolute control via state read + stepping +- Pattern commands (0x03, 0x07, etc as CMD byte) don't update state byte 1 — only SET_MODE (0x2C) does +- BLE direct read: FFE1 returns 128 bytes of zeros. State only readable via notify after GET_STATE (0x10) or POWER_TOGGLE (0xAA) - Battery service (0x180F/0x2A19) returns 0 (not useful) - Color order (0x3C) persists to flash across power cycles — script sets GRB (0) on connect +- Don't rapid-fire toggles — 0.3s gap between toggles can drop one - **DANGEROUS commands** (soft-brick, ignores all commands after): - `0x1C` — sets bright white, unresponsive - `0x2D` — brief off/on, may wedge device state @@ -154,17 +150,28 @@ Power state enables deterministic on/off: read state, toggle only if needed. - LowGlow LED app config options: controller type (LowGlow V1), IC model (OG Kit vs Standard Kit), color order - SP110E gist (partial overlap): https://gist.github.com/mbullington/37957501a07ad065b67d4e8d39bfe012 -## Color Ideas for CarState Mapping -- **Startup** (park→drive): rainbow chase → settle to base color -- **Speed** (vEgo): blue(0)→purple(30mph)→pink/red(60+mph), brightness scales with speed -- **Braking** (brakePressed/brake): deep red, brightness = brake pressure, flash on hard brake (aEgo < -3) -- **Acceleration** (gasPressed + aEgo): orange→red fire gradient -- **Steering** (steeringAngleDeg): color shifts left=blue/purple, right=orange/amber -- **Blinker** (leftBlinker/rightBlinker): amber pulse at ~1.5Hz -- **openpilot engaged** (cruiseState.enabled): comma green (0,255,100) -- **Reverse** (gearShifter==reverse): white glow -- **Parked/idle** (standstill): slow breathing pulse -- engineRpm available on brzpilot fork (un-deprecated), also gearActual, shiftGrade, clutchPressed +## glowd — Underglow Daemon +- Location: `selfdrive/glowd/glowd.py` +- Registered in `system/manager/process_config.py` as `only_onroad`, `enabled=TICI` +- Initializes BT adapter on start (btpower, rfkill, hciattach, hciconfig) +- Uses `power_on()`/`power_off()` for deterministic on/off with ignition +- SIGTERM handler (from manager) cleanly powers off LEDs on ignition off +- Auto-reconnects every 5s on BLE failure +- 20Hz update loop reading CarState + +### Color Mapping (California-legal: no red or blue) +Priority order: +1. **Downshift** (gearActual decreases): purple flash 0.4s — gear debounced 0.5s to ignore neutral +2. **Braking** (brakePressed): deep orange, bright orange on hard brake (aEgo < -3) +3. **Blinker** (leftBlinker/rightBlinker): amber pulse at 1.5Hz +4. **Reverse** (gearShifter==reverse): white +5. **Standstill** (>2s): slow green breathing +6. **Gas** (gasPressed): RPM color blended toward warm amber +7. **RPM** (default): green(800rpm) → yellow → amber → purple(7000rpm) + +### CarState fields used (brzpilot fork) +engineRpm, gearActual, shiftGrade, clutchPressed, brakePressed, gasPressed, +aEgo, vEgo, standstill, leftBlinker, rightBlinker, gearShifter ## Quick Reference Commands - Scan: `adb shell "timeout 10 hcitool -i hci0 lescan 2>&1 | grep -v '(unknown)' | sort -u -k2"` From 6ca15434d8ca8c8c3924464290bcf1139c16339f Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Sun, 8 Mar 2026 08:32:17 -0700 Subject: [PATCH 32/82] exe --- tools/underglow/sp105e.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 tools/underglow/sp105e.py diff --git a/tools/underglow/sp105e.py b/tools/underglow/sp105e.py old mode 100644 new mode 100755 From a677dbc77bc8ffa1d29500c7c3d329ce81b1c9a9 Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Sun, 8 Mar 2026 08:36:30 -0700 Subject: [PATCH 33/82] fix lagging and init --- selfdrive/glowd/glowd.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/selfdrive/glowd/glowd.py b/selfdrive/glowd/glowd.py index fde28e62698fc4..4062323263a0b0 100644 --- a/selfdrive/glowd/glowd.py +++ b/selfdrive/glowd/glowd.py @@ -20,9 +20,7 @@ """ import asyncio import colorsys -import fcntl import math -import os import signal import subprocess import time @@ -192,10 +190,10 @@ def bt_init(): subprocess.run(["sudo", "killall", "hciattach"], capture_output=True) time.sleep(1) - # Power on BT chip via btpower ioctl - fd = os.open('/dev/btpower', os.O_RDWR) - fcntl.ioctl(fd, 0xbfad, 1) # BT_CMD_PWR_CTRL - os.close(fd) + # Power on BT chip via btpower ioctl (needs root) + subprocess.run(["sudo", "python3", "-c", + "import fcntl,os; fd=os.open('/dev/btpower',os.O_RDWR); fcntl.ioctl(fd,0xbfad,1); os.close(fd)"], + check=True, capture_output=True) time.sleep(1) # Unblock bluetooth @@ -284,7 +282,7 @@ def signal_handler(signum, frame): print(f"glowd: running at {UPDATE_HZ}Hz") while not do_exit: - sm.update(timeout=100) + sm.update(0) # If disconnected, try to reconnect every 5s if client is None: From 7c3390918d98d0a8ece567a4ac46122e0b116d3c Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Sun, 8 Mar 2026 09:10:58 -0700 Subject: [PATCH 34/82] glowd: UI integration, chill mode, and bug fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - add GlowStatus param: glowd reports connection state (connected/connecting/disconnected) - add GlowMode param: "chill glow" toggle skips reactive effects (brake/blinker/downshift), RPM color only - status dot on home screen (green=connected, yellow=connecting, gray=off) - chill glow toggle in settings - fix RPM hue going blue at redline (RGB blend orange→purple) - fix blinker starting on wrong phase - simplify brake to full intensity --- common/params_keys.h | 2 + selfdrive/glowd/glowd.py | 61 ++++++++++++++----- selfdrive/ui/mici/layouts/home.py | 29 +++++++++ selfdrive/ui/mici/layouts/settings/toggles.py | 3 + 4 files changed, 79 insertions(+), 16 deletions(-) diff --git a/common/params_keys.h b/common/params_keys.h index b793837eada390..5d691953cf75a9 100644 --- a/common/params_keys.h +++ b/common/params_keys.h @@ -49,6 +49,8 @@ inline static std::unordered_map keys = { {"GithubSshKeys", {PERSISTENT, STRING}}, {"GithubUsername", {PERSISTENT, STRING}}, {"GitRemote", {PERSISTENT, STRING}}, + {"GlowMode", {PERSISTENT, BOOL}}, + {"GlowStatus", {CLEAR_ON_MANAGER_START, STRING}}, {"GsmApn", {PERSISTENT, STRING}}, {"GsmMetered", {PERSISTENT, BOOL, "1"}}, {"GsmRoaming", {PERSISTENT, BOOL}}, diff --git a/selfdrive/glowd/glowd.py b/selfdrive/glowd/glowd.py index 4062323263a0b0..b0b3f3092d8972 100644 --- a/selfdrive/glowd/glowd.py +++ b/selfdrive/glowd/glowd.py @@ -26,6 +26,7 @@ import time import cereal.messaging as messaging +from openpilot.common.params import Params from openpilot.common.realtime import Ratekeeper from openpilot.tools.underglow.sp105e import ( @@ -59,15 +60,22 @@ def rpm_to_color(rpm: float) -> tuple[int, int, int]: - """Map RPM to hue: green(idle) → yellow → amber → purple(redline). - Avoids pure red (0.0) and blue (0.66).""" + """Map RPM to color: green(idle) → yellow → amber → purple(redline). + Avoids pure red and blue. Low range uses HSV, high range blends RGB.""" t = max(0.0, min(1.0, (rpm - RPM_MIN) / (RPM_MAX - RPM_MIN))) if t < 0.7: + # green (0.33) → orange (0.08) in HSV hue = 0.33 - (0.33 - 0.08) * (t / 0.7) + r, g, b = colorsys.hsv_to_rgb(hue, 1.0, 1.0) + return int(r * 255), int(g * 255), int(b * 255) else: - hue = 0.08 + (0.78 - 0.08) * ((t - 0.7) / 0.3) - r, g, b = colorsys.hsv_to_rgb(hue, 1.0, 1.0) - return int(r * 255), int(g * 255), int(b * 255) + # orange → purple via RGB blend (avoids blue in HSV path) + frac = (t - 0.7) / 0.3 + return ( + int(255 + (COLOR_DOWNSHIFT[0] - 255) * frac), + int(122 * (1 - frac)), + int(COLOR_DOWNSHIFT[2] * frac), + ) def breathing_brightness(t: float, period: float = 3.0) -> float: @@ -89,7 +97,7 @@ def __init__(self): self.downshift_until = 0.0 self.last_blinker_toggle = 0.0 - self.blinker_on = True + self.blinker_on = False self.last_color = (0, 0, 0) self.standstill_start = 0.0 self.was_standstill = False @@ -109,7 +117,7 @@ def _update_gear(self, raw_gear: int, now: float) -> int: self.effective_gear = 0 return self.effective_gear - def compute_color(self, sm) -> tuple[int, int, int]: + def compute_color(self, sm, chill_mode: bool = False) -> tuple[int, int, int]: cs = sm['carState'] now = time.monotonic() @@ -124,6 +132,15 @@ def compute_color(self, sm) -> tuple[int, int, int]: gear = self._update_gear(raw_gear, now) prev_gear = self._prev_effective_gear + # Chill mode: RPM color only, no reactive effects + if chill_mode: + self._prev_effective_gear = gear + if not self.was_standstill and standstill: + self.was_standstill = True + elif not standstill: + self.was_standstill = False + return rpm_to_color(rpm) + # --- Priority 1: Downshift flash --- if gear > 0 and prev_gear > 0 and gear < prev_gear: self.downshift_until = now + DOWNSHIFT_FLASH_DURATION @@ -136,12 +153,9 @@ def compute_color(self, sm) -> tuple[int, int, int]: # --- Priority 2: Braking --- if brake: - a_ego = cs.aEgo - if a_ego < -3.0: + if cs.aEgo < -3.0: return COLOR_BRAKE_HARD - else: - intensity = min(1.0, max(0.5, abs(a_ego) / 4.0)) - return scale_color(COLOR_BRAKE, intensity) + return COLOR_BRAKE # --- Priority 3: Blinker amber pulse --- if left_blinker or right_blinker: @@ -272,35 +286,48 @@ def signal_handler(signum, frame): signal.signal(signal.SIGTERM, signal_handler) signal.signal(signal.SIGINT, signal_handler) + params = Params() + params.put_nonblocking("GlowStatus", "connecting") + chill_mode = params.get_bool("GlowMode") + last_param_read = 0.0 + client = await ble_connect() + params.put_nonblocking("GlowStatus", "connected" if client else "disconnected") sm = messaging.SubMaster(['carState'], poll='carState') ctrl = GlowController() rk = Ratekeeper(UPDATE_HZ) last_reconnect_attempt = 0.0 - print(f"glowd: running at {UPDATE_HZ}Hz") + print(f"glowd: running at {UPDATE_HZ}Hz, chill={chill_mode}") while not do_exit: sm.update(0) + # Refresh params every 5s + now = time.monotonic() + if now - last_param_read > 5.0: + chill_mode = params.get_bool("GlowMode") + last_param_read = now + # If disconnected, try to reconnect every 5s if client is None: - now = time.monotonic() if now - last_reconnect_attempt > 5.0: last_reconnect_attempt = now print("glowd: attempting reconnect...") + params.put_nonblocking("GlowStatus", "connecting") client = await ble_connect() + params.put_nonblocking("GlowStatus", "connected" if client else "disconnected") rk.keep_time() continue if sm.updated['carState']: - color = ctrl.compute_color(sm) + color = ctrl.compute_color(sm, chill_mode) if color != ctrl.last_color: if DEBUG: cs = sm['carState'] - print(f"glowd: RPM={cs.engineRpm:.0f} gear={cs.gearActual} → RGB{color}") + print(f"glowd: RPM={cs.engineRpm:.0f} gear={cs.gearActual} chill={chill_mode} → RGB{color}") try: await set_color(client, *color) @@ -311,6 +338,7 @@ def signal_handler(signum, frame): except Exception: pass client = None + params.put_nonblocking("GlowStatus", "disconnected") continue ctrl.last_color = color @@ -318,6 +346,7 @@ def signal_handler(signum, frame): rk.keep_time() # Clean shutdown: power off LEDs + params.put_nonblocking("GlowStatus", "disconnected") await ble_shutdown(client) diff --git a/selfdrive/ui/mici/layouts/home.py b/selfdrive/ui/mici/layouts/home.py index da5b0ac5e058c2..20ed8acbb32eb5 100644 --- a/selfdrive/ui/mici/layouts/home.py +++ b/selfdrive/ui/mici/layouts/home.py @@ -28,6 +28,30 @@ } +class GlowStatusIcon(Widget): + """Small colored circle indicating glowd connection status.""" + SIZE = 16 + + def __init__(self): + super().__init__() + self.set_rect(rl.Rectangle(0, 0, self.SIZE, self.SIZE)) + self._color = rl.Color(128, 128, 128, 180) + self.set_enabled(False) + + def set_status(self, status: str | None): + if status == "connected": + self._color = rl.Color(0, 200, 80, 230) + elif status == "connecting": + self._color = rl.Color(255, 200, 0, 230) + else: + self._color = rl.Color(128, 128, 128, 180) + + def _render(self, _): + cx = int(self._rect.x + self.SIZE / 2) + cy = int(self._rect.y + self.SIZE / 2) + rl.draw_circle(cx, cy, self.SIZE // 2, self._color) + + class NetworkIcon(Widget): def __init__(self): super().__init__() @@ -95,12 +119,14 @@ def __init__(self): self._experimental_icon = IconWidget("icons_mici/experimental_mode.png", (48, 48)) self._mic_icon = IconWidget("icons_mici/microphone.png", (32, 46)) + self._glow_icon = GlowStatusIcon() self._status_bar_layout = HBoxLayout([ IconWidget("icons_mici/settings.png", (48, 48), opacity=0.9), NetworkIcon(), self._experimental_icon, self._mic_icon, + self._glow_icon, ], spacing=18) self._openpilot_label = UnifiedLabel("openpilot", font_size=96, font_weight=FontWeight.DISPLAY, max_width=480, wrap_text=False) @@ -117,6 +143,9 @@ def show_event(self): def _update_params(self): self._experimental_mode = ui_state.params.get_bool("ExperimentalMode") + glow_status = ui_state.params.get("GlowStatus") + self._glow_icon.set_status(glow_status) + self._glow_icon.set_visible(glow_status is not None) def _update_state(self): if self.is_pressed and not self._is_pressed_prev: diff --git a/selfdrive/ui/mici/layouts/settings/toggles.py b/selfdrive/ui/mici/layouts/settings/toggles.py index acb502fda0ae72..7f8132c2123587 100644 --- a/selfdrive/ui/mici/layouts/settings/toggles.py +++ b/selfdrive/ui/mici/layouts/settings/toggles.py @@ -20,6 +20,7 @@ def __init__(self): always_on_dm_toggle = BigParamControl("always-on driver monitor", "AlwaysOnDM") record_front = BigParamControl("record & upload driver camera", "RecordFront", toggle_callback=restart_needed_callback) record_mic = BigParamControl("record & upload mic audio", "RecordAudio", toggle_callback=restart_needed_callback) + glow_toggle = BigParamControl("chill glow", "GlowMode") enable_openpilot = BigParamControl("enable openpilot", "OpenpilotEnabledToggle", toggle_callback=restart_needed_callback) self._scroller.add_widgets([ @@ -30,6 +31,7 @@ def __init__(self): always_on_dm_toggle, record_front, record_mic, + glow_toggle, enable_openpilot, ]) @@ -41,6 +43,7 @@ def __init__(self): ("AlwaysOnDM", always_on_dm_toggle), ("RecordFront", record_front), ("RecordAudio", record_mic), + ("GlowMode", glow_toggle), ("OpenpilotEnabledToggle", enable_openpilot), ) From 9e99bb72e24d5327347b521370f17888bf184e81 Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Sun, 8 Mar 2026 09:40:45 -0700 Subject: [PATCH 35/82] power cycle to reset it --- selfdrive/glowd/glowd.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/selfdrive/glowd/glowd.py b/selfdrive/glowd/glowd.py index b0b3f3092d8972..45f8a55586cc7d 100644 --- a/selfdrive/glowd/glowd.py +++ b/selfdrive/glowd/glowd.py @@ -204,11 +204,15 @@ def bt_init(): subprocess.run(["sudo", "killall", "hciattach"], capture_output=True) time.sleep(1) - # Power on BT chip via btpower ioctl (needs root) + # Power cycle BT chip via btpower ioctl (off then on — fixes stale state on retries) + subprocess.run(["sudo", "python3", "-c", + "import fcntl,os; fd=os.open('/dev/btpower',os.O_RDWR); fcntl.ioctl(fd,0xbfad,0); os.close(fd)"], + capture_output=True) + time.sleep(1) subprocess.run(["sudo", "python3", "-c", "import fcntl,os; fd=os.open('/dev/btpower',os.O_RDWR); fcntl.ioctl(fd,0xbfad,1); os.close(fd)"], check=True, capture_output=True) - time.sleep(1) + time.sleep(2) # Unblock bluetooth subprocess.run(["sudo", "rfkill", "unblock", "bluetooth"], check=True, capture_output=True) From 425e23fef82df0619eac492656971d365d441cdc Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Sun, 8 Mar 2026 09:41:28 -0700 Subject: [PATCH 36/82] bump --- opendbc_repo | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/opendbc_repo b/opendbc_repo index 579645f71406cb..3a062adc4a2049 160000 --- a/opendbc_repo +++ b/opendbc_repo @@ -1 +1 @@ -Subproject commit 579645f71406cba2ae6b97b5979ddbe219f0ec5b +Subproject commit 3a062adc4a20497fd8da14f409d6b8063ca22de9 From 70107fb8cc05d065ad6eb72a9370a299091ea322 Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Sun, 8 Mar 2026 12:30:29 -0700 Subject: [PATCH 37/82] bt bring up moved to agnos --- selfdrive/glowd/glowd.py | 75 +++++-------------------------- selfdrive/ui/mici/layouts/home.py | 25 ++++++++--- 2 files changed, 30 insertions(+), 70 deletions(-) diff --git a/selfdrive/glowd/glowd.py b/selfdrive/glowd/glowd.py index 45f8a55586cc7d..162883dee31df1 100644 --- a/selfdrive/glowd/glowd.py +++ b/selfdrive/glowd/glowd.py @@ -29,9 +29,7 @@ from openpilot.common.params import Params from openpilot.common.realtime import Ratekeeper -from openpilot.tools.underglow.sp105e import ( - connect, set_color, set_brightness, power_on, power_off, BRIGHTNESS_MAX, -) +from openpilot.tools.underglow import sp105e DEBUG = True @@ -195,74 +193,25 @@ def compute_color(self, sm, chill_mode: bool = False) -> tuple[int, int, int]: return rpm_to_color(rpm) -def bt_init(): - """Initialize BT adapter on comma four (WCN3990). Requires sudo (passwordless on AGNOS). - Safe to call multiple times — kills stale hciattach first. - Sleeps between steps since BT stack can be slow to come up on boot.""" - try: - # Kill any stale hciattach - subprocess.run(["sudo", "killall", "hciattach"], capture_output=True) - time.sleep(1) - - # Power cycle BT chip via btpower ioctl (off then on — fixes stale state on retries) - subprocess.run(["sudo", "python3", "-c", - "import fcntl,os; fd=os.open('/dev/btpower',os.O_RDWR); fcntl.ioctl(fd,0xbfad,0); os.close(fd)"], - capture_output=True) - time.sleep(1) - subprocess.run(["sudo", "python3", "-c", - "import fcntl,os; fd=os.open('/dev/btpower',os.O_RDWR); fcntl.ioctl(fd,0xbfad,1); os.close(fd)"], - check=True, capture_output=True) - time.sleep(2) - - # Unblock bluetooth - subprocess.run(["sudo", "rfkill", "unblock", "bluetooth"], check=True, capture_output=True) - time.sleep(0.5) - - # Attach UART — prints firmware errors but works fine without firmware - result = subprocess.run( - ["sudo", "hciattach", "-s", "115200", "/dev/ttyHS0", "qualcomm", "115200", "flow"], - capture_output=True, timeout=15, - ) - if result.returncode != 0: - print(f"glowd: hciattach stderr: {result.stderr.decode().strip()}") - time.sleep(1) - - # Bring interface up - subprocess.run(["sudo", "hciconfig", "hci0", "up"], check=True, capture_output=True) - time.sleep(0.5) - - # Verify it's actually up - if not bt_is_up(): - print("glowd: BT init completed but hci0 not up") - return False - - print("glowd: BT adapter initialized") - return True - except Exception as e: - print(f"glowd: BT init failed: {e}") - return False - - -def bt_is_up() -> bool: - """Check if hci0 is up.""" +def bt_is_ready() -> bool: + """Check if BT stack is up (managed by bluetooth.service in AGNOS).""" result = subprocess.run(["sudo", "hciconfig", "hci0"], capture_output=True) return b"UP RUNNING" in result.stdout async def ble_connect(): - """Initialize BT, connect to SP105E, power on, max brightness. Returns client or None.""" - # Always reinit BT stack to ensure clean state - print("glowd: initializing BT adapter...") - if not bt_init(): + """Connect to SP105E, power on, max brightness. Returns client or None. + Assumes BT stack is already up (bluetooth.service in AGNOS).""" + if not bt_is_ready(): + print("glowd: hci0 not up (waiting for bluetooth.service)") return None - await asyncio.sleep(1) print("glowd: connecting to SP105E...") - client = await connect(exit_on_fail=False) + client = await sp105e.connect(exit_on_fail=False) if client is None: return None - await power_on(client) - await set_brightness(client, BRIGHTNESS_MAX) + await sp105e.power_on(client) + await sp105e.set_brightness(client, sp105e.BRIGHTNESS_MAX) print("glowd: connected, LEDs on, brightness maxed") return client @@ -271,7 +220,7 @@ async def ble_shutdown(client): """Power off LEDs and disconnect.""" if client is not None: try: - await power_off(client) + await sp105e.power_off(client) await client.disconnect() print("glowd: LEDs off, disconnected") except Exception as e: @@ -334,7 +283,7 @@ def signal_handler(signum, frame): print(f"glowd: RPM={cs.engineRpm:.0f} gear={cs.gearActual} chill={chill_mode} → RGB{color}") try: - await set_color(client, *color) + await sp105e.set_color(client, *color) except Exception as e: print(f"glowd: BLE error: {e}") try: diff --git a/selfdrive/ui/mici/layouts/home.py b/selfdrive/ui/mici/layouts/home.py index 20ed8acbb32eb5..3effb4db6cc656 100644 --- a/selfdrive/ui/mici/layouts/home.py +++ b/selfdrive/ui/mici/layouts/home.py @@ -29,27 +29,38 @@ class GlowStatusIcon(Widget): - """Small colored circle indicating glowd connection status.""" - SIZE = 16 + """LED-style icon indicating glowd connection status.""" + SIZE = 48 def __init__(self): super().__init__() self.set_rect(rl.Rectangle(0, 0, self.SIZE, self.SIZE)) - self._color = rl.Color(128, 128, 128, 180) + self._color = rl.Color(200, 40, 40, 230) + self._glow = rl.Color(200, 40, 40, 60) self.set_enabled(False) def set_status(self, status: str | None): if status == "connected": - self._color = rl.Color(0, 200, 80, 230) + self._color = rl.Color(0, 200, 80, 240) + self._glow = rl.Color(0, 200, 80, 60) elif status == "connecting": - self._color = rl.Color(255, 200, 0, 230) + self._color = rl.Color(255, 200, 0, 240) + self._glow = rl.Color(255, 200, 0, 50) else: - self._color = rl.Color(128, 128, 128, 180) + self._color = rl.Color(200, 40, 40, 230) + self._glow = rl.Color(200, 40, 40, 50) def _render(self, _): cx = int(self._rect.x + self.SIZE / 2) cy = int(self._rect.y + self.SIZE / 2) - rl.draw_circle(cx, cy, self.SIZE // 2, self._color) + # Outer glow + rl.draw_circle(cx, cy, self.SIZE // 2, self._glow) + # Bulb body + rl.draw_circle(cx, cy - 3, 12, self._color) + # Base/stem + rl.draw_rectangle(cx - 6, cy + 8, 12, 8, self._color) + # Highlight reflection + rl.draw_circle(cx - 3, cy - 7, 3, rl.Color(255, 255, 255, 80)) class NetworkIcon(Widget): From c9939be2b3703da8d8c0c674ad3f4c088ad4a859 Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Sun, 8 Mar 2026 13:11:56 -0700 Subject: [PATCH 38/82] fix settings --- selfdrive/ui/mici/layouts/settings/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/selfdrive/ui/mici/layouts/settings/settings.py b/selfdrive/ui/mici/layouts/settings/settings.py index 0c1f8aa2f7592e..76579a6de75582 100644 --- a/selfdrive/ui/mici/layouts/settings/settings.py +++ b/selfdrive/ui/mici/layouts/settings/settings.py @@ -41,7 +41,7 @@ def __init__(self): firehose_btn.set_click_callback(lambda: gui_app.push_widget(firehose_panel)) manual_stats_panel = ManualStatsLayout() - manual_stats_btn = SettingsBigButton("MT stats", "", "icons_mici/wheel.png") + manual_stats_btn = SettingsBigButton("MT stats", "", gui_app.texture("icons_mici/wheel.png", 64, 64)) manual_stats_btn.set_click_callback(lambda: gui_app.push_widget(manual_stats_panel)) self._scroller.add_widgets([ From 33afab803e44f55c4fcf86c2891a13b5e3973c69 Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Sun, 8 Mar 2026 13:28:48 -0700 Subject: [PATCH 39/82] glow status --- common/params_keys.h | 2 +- selfdrive/glowd/glowd.py | 17 +++++++----- selfdrive/ui/mici/layouts/home.py | 26 +++++++++---------- .../ui/mici/onroad/augmented_road_view.py | 20 ++++++++++++++ 4 files changed, 44 insertions(+), 21 deletions(-) diff --git a/common/params_keys.h b/common/params_keys.h index 5d691953cf75a9..e9a635831ec8eb 100644 --- a/common/params_keys.h +++ b/common/params_keys.h @@ -50,7 +50,7 @@ inline static std::unordered_map keys = { {"GithubUsername", {PERSISTENT, STRING}}, {"GitRemote", {PERSISTENT, STRING}}, {"GlowMode", {PERSISTENT, BOOL}}, - {"GlowStatus", {CLEAR_ON_MANAGER_START, STRING}}, + {"GlowStatus", {CLEAR_ON_MANAGER_START, JSON}}, {"GsmApn", {PERSISTENT, STRING}}, {"GsmMetered", {PERSISTENT, BOOL, "1"}}, {"GsmRoaming", {PERSISTENT, BOOL}}, diff --git a/selfdrive/glowd/glowd.py b/selfdrive/glowd/glowd.py index 162883dee31df1..60dad2185a0d5f 100644 --- a/selfdrive/glowd/glowd.py +++ b/selfdrive/glowd/glowd.py @@ -193,6 +193,10 @@ def compute_color(self, sm, chill_mode: bool = False) -> tuple[int, int, int]: return rpm_to_color(rpm) +def _put_glow_status(params, status: str, color: tuple[int, int, int] = (0, 0, 0)): + params.put_nonblocking("GlowStatus", {"status": status, "color": list(color)}) + + def bt_is_ready() -> bool: """Check if BT stack is up (managed by bluetooth.service in AGNOS).""" result = subprocess.run(["sudo", "hciconfig", "hci0"], capture_output=True) @@ -240,12 +244,12 @@ def signal_handler(signum, frame): signal.signal(signal.SIGINT, signal_handler) params = Params() - params.put_nonblocking("GlowStatus", "connecting") + _put_glow_status(params, "connecting") chill_mode = params.get_bool("GlowMode") last_param_read = 0.0 client = await ble_connect() - params.put_nonblocking("GlowStatus", "connected" if client else "disconnected") + _put_glow_status(params, "connected" if client else "disconnected") sm = messaging.SubMaster(['carState'], poll='carState') ctrl = GlowController() @@ -268,9 +272,9 @@ def signal_handler(signum, frame): if now - last_reconnect_attempt > 5.0: last_reconnect_attempt = now print("glowd: attempting reconnect...") - params.put_nonblocking("GlowStatus", "connecting") + _put_glow_status(params, "connecting") client = await ble_connect() - params.put_nonblocking("GlowStatus", "connected" if client else "disconnected") + _put_glow_status(params, "connected" if client else "disconnected") rk.keep_time() continue @@ -291,15 +295,16 @@ def signal_handler(signum, frame): except Exception: pass client = None - params.put_nonblocking("GlowStatus", "disconnected") + _put_glow_status(params, "disconnected", ctrl.last_color) continue ctrl.last_color = color + _put_glow_status(params, "connected", color) rk.keep_time() # Clean shutdown: power off LEDs - params.put_nonblocking("GlowStatus", "disconnected") + _put_glow_status(params, "disconnected", ctrl.last_color) await ble_shutdown(client) diff --git a/selfdrive/ui/mici/layouts/home.py b/selfdrive/ui/mici/layouts/home.py index 3effb4db6cc656..8928bf8a620ff5 100644 --- a/selfdrive/ui/mici/layouts/home.py +++ b/selfdrive/ui/mici/layouts/home.py @@ -35,20 +35,20 @@ class GlowStatusIcon(Widget): def __init__(self): super().__init__() self.set_rect(rl.Rectangle(0, 0, self.SIZE, self.SIZE)) - self._color = rl.Color(200, 40, 40, 230) - self._glow = rl.Color(200, 40, 40, 60) - self.set_enabled(False) - - def set_status(self, status: str | None): - if status == "connected": - self._color = rl.Color(0, 200, 80, 240) - self._glow = rl.Color(0, 200, 80, 60) - elif status == "connecting": + self._color = rl.Color(120, 120, 120, 160) + self._glow = rl.Color(120, 120, 120, 40) + + def set_glow_state(self, glow_state: dict | None): + if glow_state is None or glow_state.get("status") not in ("connected", "connecting"): + self._color = rl.Color(120, 120, 120, 160) + self._glow = rl.Color(120, 120, 120, 40) + elif glow_state["status"] == "connecting": self._color = rl.Color(255, 200, 0, 240) self._glow = rl.Color(255, 200, 0, 50) else: - self._color = rl.Color(200, 40, 40, 230) - self._glow = rl.Color(200, 40, 40, 50) + r, g, b = glow_state.get("color", [0, 200, 80]) + self._color = rl.Color(r, g, b, 240) + self._glow = rl.Color(r, g, b, 60) def _render(self, _): cx = int(self._rect.x + self.SIZE / 2) @@ -154,9 +154,7 @@ def show_event(self): def _update_params(self): self._experimental_mode = ui_state.params.get_bool("ExperimentalMode") - glow_status = ui_state.params.get("GlowStatus") - self._glow_icon.set_status(glow_status) - self._glow_icon.set_visible(glow_status is not None) + self._glow_icon.set_glow_state(ui_state.params.get("GlowStatus") or {}) def _update_state(self): if self.is_pressed and not self._is_pressed_prev: diff --git a/selfdrive/ui/mici/onroad/augmented_road_view.py b/selfdrive/ui/mici/onroad/augmented_road_view.py index c55ab59c66bc57..99823fb250672f 100644 --- a/selfdrive/ui/mici/onroad/augmented_road_view.py +++ b/selfdrive/ui/mici/onroad/augmented_road_view.py @@ -162,6 +162,10 @@ def __init__(self, bookmark_callback=None, stream_type: VisionStreamType = Visio self._fade_texture = gui_app.texture("icons_mici/onroad/onroad_fade.png") + # Glow color indicator + self._glow_color = rl.Color(120, 120, 120, 160) + self._glow_glow = rl.Color(120, 120, 120, 40) + # Manual stats widget for MT cars self._manual_stats_widget = ManualStatsWidget() @@ -175,6 +179,16 @@ def is_swiping_left(self) -> bool: def _update_state(self): super()._update_state() + # update glow color from param + state = ui_state.params.get("GlowStatus") or {} + if state.get("status") == "connected" and state.get("color"): + r, g, b = state["color"] + self._glow_color = rl.Color(r, g, b, 220) + self._glow_glow = rl.Color(r, g, b, 60) + else: + self._glow_color = rl.Color(120, 120, 120, 160) + self._glow_glow = rl.Color(120, 120, 120, 40) + # update offroad label if ui_state.panda_type == log.PandaState.PandaType.unknown: self._offroad_label.set_text("system booting") @@ -236,6 +250,12 @@ def _render(self, _): self._alert_renderer.render(self._content_rect) self._hud_renderer.render(self._content_rect) + # Glow color dot (top right) + glow_cx = int(self._content_rect.x + self._content_rect.width - 30) + glow_cy = int(self._content_rect.y + 30) + rl.draw_circle(glow_cx, glow_cy, 16, self._glow_glow) + rl.draw_circle(glow_cx, glow_cy, 8, self._glow_color) + # Draw fake rounded border rl.draw_rectangle_rounded_lines_ex(self._content_rect, 0.2 * 1.02, 10, 50, rl.BLACK) From a7088633113f130a1410192d6838ff80f95bce32 Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Sun, 8 Mar 2026 13:34:26 -0700 Subject: [PATCH 40/82] temp fix --- SConstruct | 21 ++++----------------- 1 file changed, 4 insertions(+), 17 deletions(-) diff --git a/SConstruct b/SConstruct index 18bf040cf07746..3f974f09c169d6 100644 --- a/SConstruct +++ b/SConstruct @@ -4,6 +4,7 @@ import sys import sysconfig import platform import shlex +import importlib import numpy as np import SCons.Errors @@ -38,23 +39,9 @@ assert arch in [ "Darwin", # macOS arm64 (x86 not supported) ] -if arch != "larch64": - import bzip2 - import capnproto - import eigen - import ffmpeg as ffmpeg_pkg - import libjpeg - import libyuv - import ncurses - import python3_dev - import zeromq - import zstd - pkgs = [bzip2, capnproto, eigen, ffmpeg_pkg, libjpeg, libyuv, ncurses, zeromq, zstd] - py_include = python3_dev.INCLUDE_DIR -else: - # TODO: remove when AGNOS has our new vendor pkgs - pkgs = [] - py_include = sysconfig.get_paths()['include'] +pkg_names = ['bzip2', 'capnproto', 'eigen', 'ffmpeg', 'libjpeg', 'libyuv', 'ncurses', 'zeromq', 'zstd'] +pkgs = [importlib.import_module(name) for name in pkg_names] +py_include = importlib.import_module('python3_dev').INCLUDE_DIR env = Environment( ENV={ From f5a965c15e1ebb0fd438bb6488e28ec6aa6690b5 Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Sun, 8 Mar 2026 14:49:33 -0700 Subject: [PATCH 41/82] temp agnos 17 --- launch_env.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/launch_env.sh b/launch_env.sh index 314366f429ae41..097f5fbe948a82 100755 --- a/launch_env.sh +++ b/launch_env.sh @@ -16,7 +16,7 @@ export VECLIB_MAXIMUM_THREADS=1 export QCOM_PRIORITY=12 if [ -z "$AGNOS_VERSION" ]; then - export AGNOS_VERSION="16" + export AGNOS_VERSION="17" fi export STAGING_ROOT="/data/safe_staging" From 6b7fe8effdad1c6e1e9ab6598c452438b004537f Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Sun, 8 Mar 2026 15:57:54 -0700 Subject: [PATCH 42/82] fix leak --- selfdrive/ui/mici/onroad/augmented_road_view.py | 4 ++-- system/sensord/sensord.py | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/selfdrive/ui/mici/onroad/augmented_road_view.py b/selfdrive/ui/mici/onroad/augmented_road_view.py index 99823fb250672f..2d7fe8ec19ebb4 100644 --- a/selfdrive/ui/mici/onroad/augmented_road_view.py +++ b/selfdrive/ui/mici/onroad/augmented_road_view.py @@ -250,9 +250,9 @@ def _render(self, _): self._alert_renderer.render(self._content_rect) self._hud_renderer.render(self._content_rect) - # Glow color dot (top right) + # Glow color dot (bottom right) glow_cx = int(self._content_rect.x + self._content_rect.width - 30) - glow_cy = int(self._content_rect.y + 30) + glow_cy = int(self._content_rect.y + self._content_rect.height - 30) rl.draw_circle(glow_cx, glow_cy, 16, self._glow_glow) rl.draw_circle(glow_cx, glow_cy, 8, self._glow_color) diff --git a/system/sensord/sensord.py b/system/sensord/sensord.py index 62908c6f1853b5..bfcabed61cd7fc 100755 --- a/system/sensord/sensord.py +++ b/system/sensord/sensord.py @@ -1,4 +1,5 @@ #!/usr/bin/env python3 +import gc import os import time import ctypes @@ -91,6 +92,7 @@ def polling_loop(sensor: Sensor, service: str, event: threading.Event) -> None: def main() -> None: config_realtime_process([1, ], 1) + gc.enable() sensors_cfg = [ (LSM6DS3_Accel(I2C_BUS_IMU), "accelerometer", True), From 4180b35f75cdb6be7e5b377e8dc1c50c14295979 Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Sun, 8 Mar 2026 16:21:10 -0700 Subject: [PATCH 43/82] old pycapnp fixes it --- system/sensord/sensord.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/system/sensord/sensord.py b/system/sensord/sensord.py index bfcabed61cd7fc..62908c6f1853b5 100755 --- a/system/sensord/sensord.py +++ b/system/sensord/sensord.py @@ -1,5 +1,4 @@ #!/usr/bin/env python3 -import gc import os import time import ctypes @@ -92,7 +91,6 @@ def polling_loop(sensor: Sensor, service: str, event: threading.Event) -> None: def main() -> None: config_realtime_process([1, ], 1) - gc.enable() sensors_cfg = [ (LSM6DS3_Accel(I2C_BUS_IMU), "accelerometer", True), From da9af7a10be4ee0e8efdaebb8211009d09e6c097 Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Sun, 8 Mar 2026 16:24:58 -0700 Subject: [PATCH 44/82] fix nav stack --- selfdrive/ui/mici/layouts/main.py | 2 +- selfdrive/ui/mici/layouts/manual_drive_summary.py | 2 +- selfdrive/ui/mici/layouts/settings/manual_stats.py | 2 +- selfdrive/ui/mici/layouts/settings/toggles.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/selfdrive/ui/mici/layouts/main.py b/selfdrive/ui/mici/layouts/main.py index af867abd8b07c8..e780e1bb49f3e2 100644 --- a/selfdrive/ui/mici/layouts/main.py +++ b/selfdrive/ui/mici/layouts/main.py @@ -124,7 +124,7 @@ def _show_drive_summary_if_available(self): session.get('upshifts', 0) > 0 or session.get('launches', 0) > 0) if duration > 30 and has_activity: - gui_app.set_modal_overlay(ManualDriveSummaryDialog()) + gui_app.push_widget(ManualDriveSummaryDialog()) def _on_interactive_timeout(self): # Don't pop if onboarding diff --git a/selfdrive/ui/mici/layouts/manual_drive_summary.py b/selfdrive/ui/mici/layouts/manual_drive_summary.py index deadfce4914276..86bc7c36ecd9a6 100644 --- a/selfdrive/ui/mici/layouts/manual_drive_summary.py +++ b/selfdrive/ui/mici/layouts/manual_drive_summary.py @@ -63,7 +63,7 @@ def __init__(self): self._header_text, self._header_color = self._pick_header() self._encouragement_text = self._pick_encouragement() - self.set_back_callback(lambda: gui_app.set_modal_overlay(None)) + self.set_back_callback(lambda: gui_app.pop_widget()) def _load_data(self): """Load session and historical data from ManualDriveStats (single read)""" diff --git a/selfdrive/ui/mici/layouts/settings/manual_stats.py b/selfdrive/ui/mici/layouts/settings/manual_stats.py index d1cc1017e119e7..7f714a12117d2d 100644 --- a/selfdrive/ui/mici/layouts/settings/manual_stats.py +++ b/selfdrive/ui/mici/layouts/settings/manual_stats.py @@ -93,7 +93,7 @@ def _render(self, rect: rl.Rectangle): rl.draw_rectangle_rounded(btn_rect, 0.3, 10, btn_color) rl.draw_text_ex(font_medium, "View Last Drive Summary", rl.Vector2(x + 20, y + 18), 26, 0, WHITE) if rl.is_mouse_button_released(rl.MouseButton.MOUSE_BUTTON_LEFT) and rl.check_collision_point_rec(rl.get_mouse_position(), btn_rect): - gui_app.set_modal_overlay(ManualDriveSummaryDialog()) + gui_app.push_widget(ManualDriveSummaryDialog()) y += btn_h + 25 if not self._stats or self._stats.get('total_drives', 0) == 0: diff --git a/selfdrive/ui/mici/layouts/settings/toggles.py b/selfdrive/ui/mici/layouts/settings/toggles.py index 7f8132c2123587..37dac547b4e7dc 100644 --- a/selfdrive/ui/mici/layouts/settings/toggles.py +++ b/selfdrive/ui/mici/layouts/settings/toggles.py @@ -24,6 +24,7 @@ def __init__(self): enable_openpilot = BigParamControl("enable openpilot", "OpenpilotEnabledToggle", toggle_callback=restart_needed_callback) self._scroller.add_widgets([ + glow_toggle, self._personality_toggle, self._experimental_btn, is_metric_toggle, @@ -31,7 +32,6 @@ def __init__(self): always_on_dm_toggle, record_front, record_mic, - glow_toggle, enable_openpilot, ]) From 23b4a6a53131c2a54de1a3e681e416256d38932e Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Sun, 8 Mar 2026 16:25:29 -0700 Subject: [PATCH 45/82] bigger --- selfdrive/ui/mici/onroad/augmented_road_view.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/selfdrive/ui/mici/onroad/augmented_road_view.py b/selfdrive/ui/mici/onroad/augmented_road_view.py index 2d7fe8ec19ebb4..4fbe5fee04fb75 100644 --- a/selfdrive/ui/mici/onroad/augmented_road_view.py +++ b/selfdrive/ui/mici/onroad/augmented_road_view.py @@ -250,12 +250,6 @@ def _render(self, _): self._alert_renderer.render(self._content_rect) self._hud_renderer.render(self._content_rect) - # Glow color dot (bottom right) - glow_cx = int(self._content_rect.x + self._content_rect.width - 30) - glow_cy = int(self._content_rect.y + self._content_rect.height - 30) - rl.draw_circle(glow_cx, glow_cy, 16, self._glow_glow) - rl.draw_circle(glow_cx, glow_cy, 8, self._glow_color) - # Draw fake rounded border rl.draw_rectangle_rounded_lines_ex(self._content_rect, 0.2 * 1.02, 10, 50, rl.BLACK) @@ -271,6 +265,12 @@ def _render(self, _): self._manual_stats_widget.set_visible(is_manual and ui_state.started) self._manual_stats_widget.render(self._content_rect) + # Glow color dot (bottom right, after MT stats overlay) + glow_cx = int(self._content_rect.x + self._content_rect.width - 50) + glow_cy = int(self._content_rect.y + self._content_rect.height - 50) + rl.draw_circle(glow_cx, glow_cy, 48, self._glow_glow) + rl.draw_circle(glow_cx, glow_cy, 24, self._glow_color) + self._bookmark_icon.render(self.rect) # Draw darkened background and text if not onroad From 4dfdf56488d1f85b0cf692f0e6af65fe709cc567 Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Sun, 8 Mar 2026 16:36:18 -0700 Subject: [PATCH 46/82] start simple + smooth --- selfdrive/glowd/glowd.py | 113 ++++-------------- .../ui/mici/onroad/augmented_road_view.py | 4 +- 2 files changed, 24 insertions(+), 93 deletions(-) diff --git a/selfdrive/glowd/glowd.py b/selfdrive/glowd/glowd.py index 60dad2185a0d5f..50866af449cbf0 100644 --- a/selfdrive/glowd/glowd.py +++ b/selfdrive/glowd/glowd.py @@ -26,6 +26,7 @@ import time import cereal.messaging as messaging +from openpilot.common.filter_simple import FirstOrderFilter from openpilot.common.params import Params from openpilot.common.realtime import Ratekeeper @@ -33,14 +34,8 @@ DEBUG = True -# --- Color palette (California-legal: no red, no blue) --- -COLOR_IDLE = (0, 180, 60) # green at idle -COLOR_BRAKE = (255, 40, 0) # deep orange-red (more orange than red) -COLOR_BRAKE_HARD = (255, 80, 0) # bright orange on hard brake -COLOR_GAS = (255, 140, 0) # warm amber +# --- Color palette --- COLOR_REVERSE = (255, 255, 255) # white -COLOR_BLINKER = (255, 160, 0) # amber -COLOR_DOWNSHIFT = (180, 0, 255) # purple flash COLOR_STANDSTILL = (0, 200, 80) # soft green for breathing # RPM thresholds @@ -49,13 +44,8 @@ # Timing UPDATE_HZ = 20 -BLINKER_HZ = 1.5 -DOWNSHIFT_FLASH_DURATION = 0.4 BRIGHT_INIT_STEPS = 10 -# Gear debounce: ignore gearActual == 0 briefly (neutral between shifts) -GEAR_ZERO_DEBOUNCE_S = 0.5 - def rpm_to_color(rpm: float) -> tuple[int, int, int]: """Map RPM to color: green(idle) → yellow → amber → purple(redline). @@ -70,9 +60,9 @@ def rpm_to_color(rpm: float) -> tuple[int, int, int]: # orange → purple via RGB blend (avoids blue in HSV path) frac = (t - 0.7) / 0.3 return ( - int(255 + (COLOR_DOWNSHIFT[0] - 255) * frac), + int(255 + (180 - 255) * frac), int(122 * (1 - frac)), - int(COLOR_DOWNSHIFT[2] * frac), + int(255 * frac), ) @@ -88,87 +78,37 @@ def scale_color(color: tuple[int, int, int], brightness: float) -> tuple[int, in class GlowController: def __init__(self): - self.last_valid_gear = 0 - self.gear_zero_since = 0.0 - self.effective_gear = 0 - self._prev_effective_gear = 0 - - self.downshift_until = 0.0 - self.last_blinker_toggle = 0.0 - self.blinker_on = False self.last_color = (0, 0, 0) self.standstill_start = 0.0 self.was_standstill = False - def _update_gear(self, raw_gear: int, now: float) -> int: - """Debounce gearActual: hold last valid gear when it drops to 0 briefly.""" - if raw_gear > 0: - self.last_valid_gear = raw_gear - self.gear_zero_since = 0.0 - self.effective_gear = raw_gear - else: - if self.gear_zero_since == 0.0: - self.gear_zero_since = now - if now - self.gear_zero_since < GEAR_ZERO_DEBOUNCE_S: - self.effective_gear = self.last_valid_gear - else: - self.effective_gear = 0 - return self.effective_gear + # HSV smoothing filters + dt = 1.0 / UPDATE_HZ + self._h_filter = FirstOrderFilter(0.0, 0.1, dt) + self._s_filter = FirstOrderFilter(0.0, 0.1, dt) + self._v_filter = FirstOrderFilter(0.0, 0.1, dt) + + def _smooth_color(self, color: tuple[int, int, int]) -> tuple[int, int, int]: + """Filter RGB through HSV space for smooth transitions.""" + h, s, v = colorsys.rgb_to_hsv(color[0] / 255, color[1] / 255, color[2] / 255) + h = self._h_filter.update(h) + s = self._s_filter.update(s) + v = self._v_filter.update(v) + r, g, b = colorsys.hsv_to_rgb(h, s, v) + return int(r * 255), int(g * 255), int(b * 255) def compute_color(self, sm, chill_mode: bool = False) -> tuple[int, int, int]: cs = sm['carState'] now = time.monotonic() rpm = cs.engineRpm - brake = cs.brakePressed - gas = cs.gasPressed standstill = cs.standstill - left_blinker = cs.leftBlinker - right_blinker = cs.rightBlinker - raw_gear = cs.gearActual - gear = self._update_gear(raw_gear, now) - prev_gear = self._prev_effective_gear - - # Chill mode: RPM color only, no reactive effects - if chill_mode: - self._prev_effective_gear = gear - if not self.was_standstill and standstill: - self.was_standstill = True - elif not standstill: - self.was_standstill = False - return rpm_to_color(rpm) - - # --- Priority 1: Downshift flash --- - if gear > 0 and prev_gear > 0 and gear < prev_gear: - self.downshift_until = now + DOWNSHIFT_FLASH_DURATION - if DEBUG: - print(f"glowd: DOWNSHIFT {prev_gear} → {gear}") - self._prev_effective_gear = gear - - if now < self.downshift_until: - return COLOR_DOWNSHIFT - - # --- Priority 2: Braking --- - if brake: - if cs.aEgo < -3.0: - return COLOR_BRAKE_HARD - return COLOR_BRAKE - - # --- Priority 3: Blinker amber pulse --- - if left_blinker or right_blinker: - period = 1.0 / BLINKER_HZ - if now - self.last_blinker_toggle >= period / 2: - self.blinker_on = not self.blinker_on - self.last_blinker_toggle = now - if self.blinker_on: - return COLOR_BLINKER - - # --- Priority 4: Reverse --- + # Reverse if str(cs.gearShifter) == 'reverse': return COLOR_REVERSE - # --- Priority 5: Standstill breathing --- + # Standstill breathing if standstill: if not self.was_standstill: self.standstill_start = now @@ -180,16 +120,7 @@ def compute_color(self, sm, chill_mode: bool = False) -> tuple[int, int, int]: else: self.was_standstill = False - # --- Priority 6: Gas pressed (warm amber overlay) --- - if gas and rpm > RPM_MIN: - rpm_color = rpm_to_color(rpm) - return ( - (rpm_color[0] + COLOR_GAS[0]) // 2, - (rpm_color[1] + COLOR_GAS[1]) // 2, - (rpm_color[2] + COLOR_GAS[2]) // 2, - ) - - # --- Priority 7: RPM-based color --- + # RPM-based color return rpm_to_color(rpm) @@ -279,7 +210,7 @@ def signal_handler(signum, frame): continue if sm.updated['carState']: - color = ctrl.compute_color(sm, chill_mode) + color = ctrl._smooth_color(ctrl.compute_color(sm, chill_mode)) if color != ctrl.last_color: if DEBUG: diff --git a/selfdrive/ui/mici/onroad/augmented_road_view.py b/selfdrive/ui/mici/onroad/augmented_road_view.py index 4fbe5fee04fb75..b33826e71a719a 100644 --- a/selfdrive/ui/mici/onroad/augmented_road_view.py +++ b/selfdrive/ui/mici/onroad/augmented_road_view.py @@ -268,8 +268,8 @@ def _render(self, _): # Glow color dot (bottom right, after MT stats overlay) glow_cx = int(self._content_rect.x + self._content_rect.width - 50) glow_cy = int(self._content_rect.y + self._content_rect.height - 50) - rl.draw_circle(glow_cx, glow_cy, 48, self._glow_glow) - rl.draw_circle(glow_cx, glow_cy, 24, self._glow_color) + rl.draw_circle(glow_cx, glow_cy, 32, self._glow_glow) + rl.draw_circle(glow_cx, glow_cy, 16, self._glow_color) self._bookmark_icon.render(self.rect) From 397691b5472730214e75339f24c6ffe32d4781fb Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Sun, 8 Mar 2026 16:44:42 -0700 Subject: [PATCH 47/82] brake --- selfdrive/glowd/glowd.py | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/selfdrive/glowd/glowd.py b/selfdrive/glowd/glowd.py index 50866af449cbf0..e82f108a9ee14d 100644 --- a/selfdrive/glowd/glowd.py +++ b/selfdrive/glowd/glowd.py @@ -81,6 +81,8 @@ def __init__(self): self.last_color = (0, 0, 0) self.standstill_start = 0.0 self.was_standstill = False + self._brake_dark_until = 0.0 + self._was_brake = False # HSV smoothing filters dt = 1.0 / UPDATE_HZ @@ -103,25 +105,35 @@ def compute_color(self, sm, chill_mode: bool = False) -> tuple[int, int, int]: rpm = cs.engineRpm standstill = cs.standstill + # TODO: use sp105e.set_brightness instead of scaling RGB + brightness = 1.0 if standstill or str(cs.gearShifter) == 'reverse' else 0.7 + + # Brake rising edge: go dark for 0.6s + if cs.brakePressed and not self._was_brake: + self._brake_dark_until = now + 0.6 + self._was_brake = cs.brakePressed + if now < self._brake_dark_until: + return (128, 0, 0) # Reverse if str(cs.gearShifter) == 'reverse': - return COLOR_REVERSE + return scale_color(COLOR_REVERSE, brightness) - # Standstill breathing + # Standstill: slow rainbow cycle if standstill: if not self.was_standstill: self.standstill_start = now self.was_standstill = True elapsed = now - self.standstill_start if elapsed > 2.0: - bright = breathing_brightness(elapsed - 2.0, period=3.0) - return scale_color(COLOR_STANDSTILL, bright) + hue = ((elapsed - 2.0) / 8.0) % 1.0 # full cycle every 8s + r, g, b = colorsys.hsv_to_rgb(hue, 1.0, 1.0) + return scale_color((int(r * 255), int(g * 255), int(b * 255)), brightness) else: self.was_standstill = False # RPM-based color - return rpm_to_color(rpm) + return scale_color(rpm_to_color(rpm), brightness) def _put_glow_status(params, status: str, color: tuple[int, int, int] = (0, 0, 0)): From e340a67c1932c310f060f0b7ddc0c528cc248fbe Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Sun, 8 Mar 2026 16:48:33 -0700 Subject: [PATCH 48/82] more simple changes --- selfdrive/glowd/glowd.py | 39 +++++++++++++++++++++------------------ 1 file changed, 21 insertions(+), 18 deletions(-) diff --git a/selfdrive/glowd/glowd.py b/selfdrive/glowd/glowd.py index e82f108a9ee14d..934406878bcb76 100644 --- a/selfdrive/glowd/glowd.py +++ b/selfdrive/glowd/glowd.py @@ -80,9 +80,10 @@ class GlowController: def __init__(self): self.last_color = (0, 0, 0) self.standstill_start = 0.0 - self.was_standstill = False - self._brake_dark_until = 0.0 - self._was_brake = False + self.prev_standstill = False + self._rainbow_until = 0.0 + self._brake_pressed_t = 0.0 + self._prev_brake = False # HSV smoothing filters dt = 1.0 / UPDATE_HZ @@ -108,29 +109,31 @@ def compute_color(self, sm, chill_mode: bool = False) -> tuple[int, int, int]: # TODO: use sp105e.set_brightness instead of scaling RGB brightness = 1.0 if standstill or str(cs.gearShifter) == 'reverse' else 0.7 - # Brake rising edge: go dark for 0.6s - if cs.brakePressed and not self._was_brake: - self._brake_dark_until = now + 0.6 - self._was_brake = cs.brakePressed - if now < self._brake_dark_until: + # Brake rising edge: dark red for 0.4s + if cs.brakePressed and not self._prev_brake: + self._brake_pressed_t = now + self._prev_brake = cs.brakePressed + if now - self._brake_pressed_t < 0.4: return (128, 0, 0) # Reverse if str(cs.gearShifter) == 'reverse': return scale_color(COLOR_REVERSE, brightness) - # Standstill: slow rainbow cycle + # Standstill: slow rainbow cycle (continues 2.5s after leaving) if standstill: - if not self.was_standstill: + if not self.prev_standstill: self.standstill_start = now - self.was_standstill = True - elapsed = now - self.standstill_start - if elapsed > 2.0: - hue = ((elapsed - 2.0) / 8.0) % 1.0 # full cycle every 8s - r, g, b = colorsys.hsv_to_rgb(hue, 1.0, 1.0) - return scale_color((int(r * 255), int(g * 255), int(b * 255)), brightness) - else: - self.was_standstill = False + self.prev_standstill = True + elif self.prev_standstill: + self.prev_standstill = False + self._rainbow_until = now + 2.5 + + elapsed = now - self.standstill_start + if (standstill and elapsed > 2.0) or now < self._rainbow_until: + hue = ((elapsed - 2.0) / 8.0) % 1.0 # full cycle every 8s + r, g, b = colorsys.hsv_to_rgb(hue, 1.0, 1.0) + return scale_color((int(r * 255), int(g * 255), int(b * 255)), brightness) # RPM-based color return scale_color(rpm_to_color(rpm), brightness) From 47a8e8cff5222551115f838c8f4efb9b3278d232 Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Sun, 8 Mar 2026 17:01:36 -0700 Subject: [PATCH 49/82] state machine --- selfdrive/glowd/glowd.py | 115 ++++++++++++++++++++++++++++----------- 1 file changed, 82 insertions(+), 33 deletions(-) diff --git a/selfdrive/glowd/glowd.py b/selfdrive/glowd/glowd.py index 934406878bcb76..cc40015220f450 100644 --- a/selfdrive/glowd/glowd.py +++ b/selfdrive/glowd/glowd.py @@ -24,6 +24,7 @@ import signal import subprocess import time +from enum import IntEnum, IntFlag import cereal.messaging as messaging from openpilot.common.filter_simple import FirstOrderFilter @@ -36,7 +37,6 @@ # --- Color palette --- COLOR_REVERSE = (255, 255, 255) # white -COLOR_STANDSTILL = (0, 200, 80) # soft green for breathing # RPM thresholds RPM_MIN = 800 @@ -44,7 +44,20 @@ # Timing UPDATE_HZ = 20 -BRIGHT_INIT_STEPS = 10 +BRAKE_FLASH_S = 0.4 +RAINBOW_HOLDOVER_S = 2.5 +RAINBOW_DELAY_S = 1.5 +RAINBOW_PERIOD_S = 8.0 + + +class GlowState(IntEnum): + DRIVING = 0 # RPM-based color + STANDSTILL = 1 # rainbow cycle + + +class GlowMod(IntFlag): + BRAKE = 1 + REVERSE = 2 def rpm_to_color(rpm: float) -> tuple[int, int, int]: @@ -79,11 +92,12 @@ def scale_color(color: tuple[int, int, int], brightness: float) -> tuple[int, in class GlowController: def __init__(self): self.last_color = (0, 0, 0) - self.standstill_start = 0.0 - self.prev_standstill = False - self._rainbow_until = 0.0 - self._brake_pressed_t = 0.0 + self.state = GlowState.STANDSTILL + self._state_t = time.monotonic() + self._standstill_start = time.monotonic() + self._mods = GlowMod(0) self._prev_brake = False + self._brake_pressed_t = 0.0 # HSV smoothing filters dt = 1.0 / UPDATE_HZ @@ -91,7 +105,12 @@ def __init__(self): self._s_filter = FirstOrderFilter(0.0, 0.1, dt) self._v_filter = FirstOrderFilter(0.0, 0.1, dt) - def _smooth_color(self, color: tuple[int, int, int]) -> tuple[int, int, int]: + def _set_state(self, state: GlowState): + if self.state != state: + self.state = state + self._state_t = time.monotonic() + + def smooth_color(self, color: tuple[int, int, int]) -> tuple[int, int, int]: """Filter RGB through HSV space for smooth transitions.""" h, s, v = colorsys.rgb_to_hsv(color[0] / 255, color[1] / 255, color[2] / 255) h = self._h_filter.update(h) @@ -100,43 +119,72 @@ def _smooth_color(self, color: tuple[int, int, int]) -> tuple[int, int, int]: r, g, b = colorsys.hsv_to_rgb(h, s, v) return int(r * 255), int(g * 255), int(b * 255) - def compute_color(self, sm, chill_mode: bool = False) -> tuple[int, int, int]: + def _rainbow_color(self) -> tuple[int, int, int]: + elapsed = time.monotonic() - self._standstill_start - RAINBOW_DELAY_S + hue = (elapsed / RAINBOW_PERIOD_S) % 1.0 + r, g, b = colorsys.hsv_to_rgb(hue, 1.0, 1.0) + return int(r * 255), int(g * 255), int(b * 255) + + def update(self, sm): cs = sm['carState'] now = time.monotonic() - rpm = cs.engineRpm - standstill = cs.standstill - # TODO: use sp105e.set_brightness instead of scaling RGB - brightness = 1.0 if standstill or str(cs.gearShifter) == 'reverse' else 0.7 + # Base state transitions + if self.state == GlowState.DRIVING: + if cs.standstill: + self._standstill_start = now + self._set_state(GlowState.STANDSTILL) + elif self.state == GlowState.STANDSTILL: + if not cs.standstill and now - self._state_t > RAINBOW_HOLDOVER_S: + self._set_state(GlowState.DRIVING) - # Brake rising edge: dark red for 0.4s + # Update modifiers if cs.brakePressed and not self._prev_brake: self._brake_pressed_t = now self._prev_brake = cs.brakePressed - if now - self._brake_pressed_t < 0.4: - return (128, 0, 0) - # Reverse + if now - self._brake_pressed_t < BRAKE_FLASH_S: + self._mods |= GlowMod.BRAKE + else: + self._mods &= ~GlowMod.BRAKE + if str(cs.gearShifter) == 'reverse': - return scale_color(COLOR_REVERSE, brightness) + self._mods |= GlowMod.REVERSE + else: + self._mods &= ~GlowMod.REVERSE - # Standstill: slow rainbow cycle (continues 2.5s after leaving) - if standstill: - if not self.prev_standstill: - self.standstill_start = now - self.prev_standstill = True - elif self.prev_standstill: - self.prev_standstill = False - self._rainbow_until = now + 2.5 + def get_color(self, sm) -> tuple[int, int, int]: + cs = sm['carState'] + now = time.monotonic() + + # Base color from state + if self.state == GlowState.STANDSTILL: + standstill_elapsed = now - self._standstill_start + if standstill_elapsed > RAINBOW_DELAY_S or not cs.standstill: + base = self._rainbow_color() + else: + base = rpm_to_color(cs.engineRpm) + else: + base = rpm_to_color(cs.engineRpm) - elapsed = now - self.standstill_start - if (standstill and elapsed > 2.0) or now < self._rainbow_until: - hue = ((elapsed - 2.0) / 8.0) % 1.0 # full cycle every 8s - r, g, b = colorsys.hsv_to_rgb(hue, 1.0, 1.0) - return scale_color((int(r * 255), int(g * 255), int(b * 255)), brightness) + # TODO: use sp105e.set_brightness instead of scaling RGB + brightness = 1.0 if self.state == GlowState.STANDSTILL else 0.7 + color = scale_color(base, brightness) + + # Modifier: reverse — pulse between normal color and white at 1Hz + if self._mods & GlowMod.REVERSE: + blend = 0.5 + 0.5 * math.sin(2 * math.pi * now) + color = ( + int(color[0] + (255 - color[0]) * blend), + int(color[1] + (255 - color[1]) * blend), + int(color[2] + (255 - color[2]) * blend), + ) + + # Modifier: brake — dark red flash on rising edge (highest priority) + if self._mods & GlowMod.BRAKE: + return (128, 0, 0) - # RPM-based color - return scale_color(rpm_to_color(rpm), brightness) + return color def _put_glow_status(params, status: str, color: tuple[int, int, int] = (0, 0, 0)): @@ -225,7 +273,8 @@ def signal_handler(signum, frame): continue if sm.updated['carState']: - color = ctrl._smooth_color(ctrl.compute_color(sm, chill_mode)) + ctrl.update(sm) + color = ctrl.smooth_color(ctrl.get_color(sm)) if color != ctrl.last_color: if DEBUG: From 2cb06fb98006f7791b17f9354032ba1e2a9e8070 Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Sun, 8 Mar 2026 17:07:23 -0700 Subject: [PATCH 50/82] no reverse yet --- selfdrive/glowd/glowd.py | 34 +++++----------------------------- 1 file changed, 5 insertions(+), 29 deletions(-) diff --git a/selfdrive/glowd/glowd.py b/selfdrive/glowd/glowd.py index cc40015220f450..b18154b6fbe6a3 100644 --- a/selfdrive/glowd/glowd.py +++ b/selfdrive/glowd/glowd.py @@ -20,7 +20,6 @@ """ import asyncio import colorsys -import math import signal import subprocess import time @@ -35,16 +34,13 @@ DEBUG = True -# --- Color palette --- -COLOR_REVERSE = (255, 255, 255) # white - # RPM thresholds RPM_MIN = 800 RPM_MAX = 7000 # Timing UPDATE_HZ = 20 -BRAKE_FLASH_S = 0.4 +BRAKE_FLASH_S = 0.6 RAINBOW_HOLDOVER_S = 2.5 RAINBOW_DELAY_S = 1.5 RAINBOW_PERIOD_S = 8.0 @@ -57,7 +53,6 @@ class GlowState(IntEnum): class GlowMod(IntFlag): BRAKE = 1 - REVERSE = 2 def rpm_to_color(rpm: float) -> tuple[int, int, int]: @@ -79,12 +74,6 @@ def rpm_to_color(rpm: float) -> tuple[int, int, int]: ) -def breathing_brightness(t: float, period: float = 3.0) -> float: - """Sinusoidal breathing: 0.3 → 1.0 → 0.3.""" - phase = (t % period) / period - return 0.3 + 0.7 * (0.5 + 0.5 * math.sin(2 * math.pi * phase - math.pi / 2)) - - def scale_color(color: tuple[int, int, int], brightness: float) -> tuple[int, int, int]: return (int(color[0] * brightness), int(color[1] * brightness), int(color[2] * brightness)) @@ -101,9 +90,9 @@ def __init__(self): # HSV smoothing filters dt = 1.0 / UPDATE_HZ - self._h_filter = FirstOrderFilter(0.0, 0.1, dt) - self._s_filter = FirstOrderFilter(0.0, 0.1, dt) - self._v_filter = FirstOrderFilter(0.0, 0.1, dt) + self._h_filter = FirstOrderFilter(0.0, 0.15, dt) + self._s_filter = FirstOrderFilter(0.0, 0.15, dt) + self._v_filter = FirstOrderFilter(0.0, 0.15, dt) def _set_state(self, state: GlowState): if self.state != state: @@ -148,10 +137,6 @@ def update(self, sm): else: self._mods &= ~GlowMod.BRAKE - if str(cs.gearShifter) == 'reverse': - self._mods |= GlowMod.REVERSE - else: - self._mods &= ~GlowMod.REVERSE def get_color(self, sm) -> tuple[int, int, int]: cs = sm['carState'] @@ -171,16 +156,7 @@ def get_color(self, sm) -> tuple[int, int, int]: brightness = 1.0 if self.state == GlowState.STANDSTILL else 0.7 color = scale_color(base, brightness) - # Modifier: reverse — pulse between normal color and white at 1Hz - if self._mods & GlowMod.REVERSE: - blend = 0.5 + 0.5 * math.sin(2 * math.pi * now) - color = ( - int(color[0] + (255 - color[0]) * blend), - int(color[1] + (255 - color[1]) * blend), - int(color[2] + (255 - color[2]) * blend), - ) - - # Modifier: brake — dark red flash on rising edge (highest priority) + # Modifier: brake — dark red flash on rising edge if self._mods & GlowMod.BRAKE: return (128, 0, 0) From f1a5b0f7f437c03b33f8378c33e3cb408d4693b7 Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Sun, 8 Mar 2026 17:12:10 -0700 Subject: [PATCH 51/82] speed up rrainbow on exit ss --- selfdrive/glowd/glowd.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/selfdrive/glowd/glowd.py b/selfdrive/glowd/glowd.py index b18154b6fbe6a3..870635164231d9 100644 --- a/selfdrive/glowd/glowd.py +++ b/selfdrive/glowd/glowd.py @@ -25,6 +25,8 @@ import time from enum import IntEnum, IntFlag +import numpy as np + import cereal.messaging as messaging from openpilot.common.filter_simple import FirstOrderFilter from openpilot.common.params import Params @@ -108,9 +110,10 @@ def smooth_color(self, color: tuple[int, int, int]) -> tuple[int, int, int]: r, g, b = colorsys.hsv_to_rgb(h, s, v) return int(r * 255), int(g * 255), int(b * 255) - def _rainbow_color(self) -> tuple[int, int, int]: + def _rainbow_color(self, v_ego: float) -> tuple[int, int, int]: + speed_mult = np.interp(v_ego, [0.0, 5.0], [1.0, 2.0]) elapsed = time.monotonic() - self._standstill_start - RAINBOW_DELAY_S - hue = (elapsed / RAINBOW_PERIOD_S) % 1.0 + hue = (elapsed * speed_mult / RAINBOW_PERIOD_S) % 1.0 r, g, b = colorsys.hsv_to_rgb(hue, 1.0, 1.0) return int(r * 255), int(g * 255), int(b * 255) @@ -146,7 +149,7 @@ def get_color(self, sm) -> tuple[int, int, int]: if self.state == GlowState.STANDSTILL: standstill_elapsed = now - self._standstill_start if standstill_elapsed > RAINBOW_DELAY_S or not cs.standstill: - base = self._rainbow_color() + base = self._rainbow_color(cs.vEgo) else: base = rpm_to_color(cs.engineRpm) else: From 2e12a42e9a09b9a246b9ff4a04c1799ea0d90c37 Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Sun, 8 Mar 2026 17:14:46 -0700 Subject: [PATCH 52/82] chill mode --- selfdrive/glowd/glowd.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/selfdrive/glowd/glowd.py b/selfdrive/glowd/glowd.py index 870635164231d9..809dbe6738d559 100644 --- a/selfdrive/glowd/glowd.py +++ b/selfdrive/glowd/glowd.py @@ -42,7 +42,7 @@ # Timing UPDATE_HZ = 20 -BRAKE_FLASH_S = 0.6 +BRAKE_FLASH_S = 1.2 RAINBOW_HOLDOVER_S = 2.5 RAINBOW_DELAY_S = 1.5 RAINBOW_PERIOD_S = 8.0 @@ -117,10 +117,15 @@ def _rainbow_color(self, v_ego: float) -> tuple[int, int, int]: r, g, b = colorsys.hsv_to_rgb(hue, 1.0, 1.0) return int(r * 255), int(g * 255), int(b * 255) - def update(self, sm): + def update(self, sm, chill: bool): cs = sm['carState'] now = time.monotonic() + if chill: + self._set_state(GlowState.DRIVING) + self._mods = GlowMod(0) + return + # Base state transitions if self.state == GlowState.DRIVING: if cs.standstill: @@ -140,7 +145,6 @@ def update(self, sm): else: self._mods &= ~GlowMod.BRAKE - def get_color(self, sm) -> tuple[int, int, int]: cs = sm['carState'] now = time.monotonic() @@ -236,7 +240,7 @@ def signal_handler(signum, frame): # Refresh params every 5s now = time.monotonic() - if now - last_param_read > 5.0: + if now - last_param_read > 2.5: chill_mode = params.get_bool("GlowMode") last_param_read = now @@ -252,7 +256,7 @@ def signal_handler(signum, frame): continue if sm.updated['carState']: - ctrl.update(sm) + ctrl.update(sm, chill_mode) color = ctrl.smooth_color(ctrl.get_color(sm)) if color != ctrl.last_color: From e2a2b8a1aa0e260fc6492d95fd83bc37560df9ce Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Sun, 8 Mar 2026 17:21:22 -0700 Subject: [PATCH 53/82] brighter above 4500, fade in animation --- selfdrive/glowd/glowd.py | 54 +++++++++++++++++++++++++--------------- 1 file changed, 34 insertions(+), 20 deletions(-) diff --git a/selfdrive/glowd/glowd.py b/selfdrive/glowd/glowd.py index 809dbe6738d559..70260e8498015d 100644 --- a/selfdrive/glowd/glowd.py +++ b/selfdrive/glowd/glowd.py @@ -38,7 +38,8 @@ # RPM thresholds RPM_MIN = 800 -RPM_MAX = 7000 +RPM_COLOR_MAX = 4500 +RPM_BRIGHTNESS_MAX = 7000 # Timing UPDATE_HZ = 20 @@ -60,7 +61,7 @@ class GlowMod(IntFlag): def rpm_to_color(rpm: float) -> tuple[int, int, int]: """Map RPM to color: green(idle) → yellow → amber → purple(redline). Avoids pure red and blue. Low range uses HSV, high range blends RGB.""" - t = max(0.0, min(1.0, (rpm - RPM_MIN) / (RPM_MAX - RPM_MIN))) + t = max(0.0, min(1.0, (rpm - RPM_MIN) / (RPM_COLOR_MAX - RPM_MIN))) if t < 0.7: # green (0.33) → orange (0.08) in HSV hue = 0.33 - (0.33 - 0.08) * (t / 0.7) @@ -76,13 +77,15 @@ def rpm_to_color(rpm: float) -> tuple[int, int, int]: ) -def scale_color(color: tuple[int, int, int], brightness: float) -> tuple[int, int, int]: - return (int(color[0] * brightness), int(color[1] * brightness), int(color[2] * brightness)) +def rpm_to_brightness(rpm: float) -> int: + """70% (level 4) normally, ramp to 100% (level 6) above 4000 RPM.""" + return int(np.interp(rpm, [RPM_COLOR_MAX, RPM_BRIGHTNESS_MAX], [4, sp105e.BRIGHTNESS_MAX])) class GlowController: def __init__(self): self.last_color = (0, 0, 0) + self.last_brightness = -1 self.state = GlowState.STANDSTILL self._state_t = time.monotonic() self._standstill_start = time.monotonic() @@ -145,7 +148,7 @@ def update(self, sm, chill: bool): else: self._mods &= ~GlowMod.BRAKE - def get_color(self, sm) -> tuple[int, int, int]: + def get_color(self, sm) -> tuple[tuple[int, int, int], int]: cs = sm['carState'] now = time.monotonic() @@ -153,21 +156,19 @@ def get_color(self, sm) -> tuple[int, int, int]: if self.state == GlowState.STANDSTILL: standstill_elapsed = now - self._standstill_start if standstill_elapsed > RAINBOW_DELAY_S or not cs.standstill: - base = self._rainbow_color(cs.vEgo) + color = self._rainbow_color(cs.vEgo) else: - base = rpm_to_color(cs.engineRpm) + color = rpm_to_color(cs.engineRpm) + brightness = rpm_to_brightness(cs.engineRpm) else: - base = rpm_to_color(cs.engineRpm) - - # TODO: use sp105e.set_brightness instead of scaling RGB - brightness = 1.0 if self.state == GlowState.STANDSTILL else 0.7 - color = scale_color(base, brightness) + color = rpm_to_color(cs.engineRpm) + brightness = rpm_to_brightness(cs.engineRpm) # Modifier: brake — dark red flash on rising edge if self._mods & GlowMod.BRAKE: - return (128, 0, 0) + return (128, 0, 0), brightness - return color + return color, brightness def _put_glow_status(params, status: str, color: tuple[int, int, int] = (0, 0, 0)): @@ -181,7 +182,7 @@ def bt_is_ready() -> bool: async def ble_connect(): - """Connect to SP105E, power on, max brightness. Returns client or None. + """Connect to SP105E, power on, sweep brightness. Returns client or None. Assumes BT stack is already up (bluetooth.service in AGNOS).""" if not bt_is_ready(): print("glowd: hci0 not up (waiting for bluetooth.service)") @@ -192,8 +193,17 @@ async def ble_connect(): if client is None: return None await sp105e.power_on(client) - await sp105e.set_brightness(client, sp105e.BRIGHTNESS_MAX) - print("glowd: connected, LEDs on, brightness maxed") + + # Startup sweep: min → max → 70% + await sp105e.set_brightness(client, sp105e.BRIGHTNESS_MIN) + for level in range(sp105e.BRIGHTNESS_MIN, sp105e.BRIGHTNESS_MAX + 1): + await sp105e.set_brightness(client, level) + await asyncio.sleep(0.2) + for level in range(sp105e.BRIGHTNESS_MAX, 3, -1): + await sp105e.set_brightness(client, level) + await asyncio.sleep(0.2) + + print("glowd: connected, LEDs on, startup sweep done") return client @@ -257,15 +267,18 @@ def signal_handler(signum, frame): if sm.updated['carState']: ctrl.update(sm, chill_mode) - color = ctrl.smooth_color(ctrl.get_color(sm)) + raw_color, brightness = ctrl.get_color(sm) + color = ctrl.smooth_color(raw_color) - if color != ctrl.last_color: + if color != ctrl.last_color or brightness != ctrl.last_brightness: if DEBUG: cs = sm['carState'] - print(f"glowd: RPM={cs.engineRpm:.0f} gear={cs.gearActual} chill={chill_mode} → RGB{color}") + print(f"glowd: RPM={cs.engineRpm:.0f} brightness={brightness} chill={chill_mode} → RGB{color}") try: await sp105e.set_color(client, *color) + if brightness != ctrl.last_brightness: + await sp105e.set_brightness(client, brightness) except Exception as e: print(f"glowd: BLE error: {e}") try: @@ -277,6 +290,7 @@ def signal_handler(signum, frame): continue ctrl.last_color = color + ctrl.last_brightness = brightness _put_glow_status(params, "connected", color) rk.keep_time() From 1181777c38f6a164d334d93ae5ffbf839235078d Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Sun, 8 Mar 2026 17:24:21 -0700 Subject: [PATCH 54/82] tweak brake --- selfdrive/glowd/glowd.py | 14 +++++++------- selfdrive/ui/mici/onroad/augmented_road_view.py | 7 +++++-- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/selfdrive/glowd/glowd.py b/selfdrive/glowd/glowd.py index 70260e8498015d..8f224e8d7cdea7 100644 --- a/selfdrive/glowd/glowd.py +++ b/selfdrive/glowd/glowd.py @@ -38,12 +38,12 @@ # RPM thresholds RPM_MIN = 800 -RPM_COLOR_MAX = 4500 +RPM_COLOR_MAX = 5500 RPM_BRIGHTNESS_MAX = 7000 # Timing UPDATE_HZ = 20 -BRAKE_FLASH_S = 1.2 +BRAKE_FLASH_S = 0.8 RAINBOW_HOLDOVER_S = 2.5 RAINBOW_DELAY_S = 1.5 RAINBOW_PERIOD_S = 8.0 @@ -171,8 +171,8 @@ def get_color(self, sm) -> tuple[tuple[int, int, int], int]: return color, brightness -def _put_glow_status(params, status: str, color: tuple[int, int, int] = (0, 0, 0)): - params.put_nonblocking("GlowStatus", {"status": status, "color": list(color)}) +def _put_glow_status(params, status: str, color: tuple[int, int, int] = (0, 0, 0), brightness: int = 0): + params.put_nonblocking("GlowStatus", {"status": status, "color": list(color), "brightness": brightness}) def bt_is_ready() -> bool: @@ -286,17 +286,17 @@ def signal_handler(signum, frame): except Exception: pass client = None - _put_glow_status(params, "disconnected", ctrl.last_color) + _put_glow_status(params, "disconnected", ctrl.last_color, ctrl.last_brightness) continue ctrl.last_color = color ctrl.last_brightness = brightness - _put_glow_status(params, "connected", color) + _put_glow_status(params, "connected", color, brightness) rk.keep_time() # Clean shutdown: power off LEDs - _put_glow_status(params, "disconnected", ctrl.last_color) + _put_glow_status(params, "disconnected", ctrl.last_color, ctrl.last_brightness) await ble_shutdown(client) diff --git a/selfdrive/ui/mici/onroad/augmented_road_view.py b/selfdrive/ui/mici/onroad/augmented_road_view.py index b33826e71a719a..5bcdfcd4e4a425 100644 --- a/selfdrive/ui/mici/onroad/augmented_road_view.py +++ b/selfdrive/ui/mici/onroad/augmented_road_view.py @@ -183,8 +183,11 @@ def _update_state(self): state = ui_state.params.get("GlowStatus") or {} if state.get("status") == "connected" and state.get("color"): r, g, b = state["color"] - self._glow_color = rl.Color(r, g, b, 220) - self._glow_glow = rl.Color(r, g, b, 60) + bright = state.get("brightness", 6) + alpha = int(np.interp(bright + 1, [0, 7], [100, 220])) + glow_alpha = int(np.interp(bright + 1, [0, 7], [20, 60])) + self._glow_color = rl.Color(r, g, b, alpha) + self._glow_glow = rl.Color(r, g, b, glow_alpha) else: self._glow_color = rl.Color(120, 120, 120, 160) self._glow_glow = rl.Color(120, 120, 120, 40) From f8f1e681a07458e88dbbe93c653bed280fc8147b Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Sun, 8 Mar 2026 17:25:52 -0700 Subject: [PATCH 55/82] try high filters --- selfdrive/glowd/glowd.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/selfdrive/glowd/glowd.py b/selfdrive/glowd/glowd.py index 8f224e8d7cdea7..a8eaa1f0f5e43f 100644 --- a/selfdrive/glowd/glowd.py +++ b/selfdrive/glowd/glowd.py @@ -95,9 +95,9 @@ def __init__(self): # HSV smoothing filters dt = 1.0 / UPDATE_HZ - self._h_filter = FirstOrderFilter(0.0, 0.15, dt) - self._s_filter = FirstOrderFilter(0.0, 0.15, dt) - self._v_filter = FirstOrderFilter(0.0, 0.15, dt) + self._h_filter = FirstOrderFilter(0.0, 0.5, dt) + self._s_filter = FirstOrderFilter(0.0, 0.5, dt) + self._v_filter = FirstOrderFilter(0.0, 0.5, dt) def _set_state(self, state: GlowState): if self.state != state: From 7d6bd8ad7e5153431dbeba372097f7d5c4efe38b Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Sun, 8 Mar 2026 17:32:16 -0700 Subject: [PATCH 56/82] standstill only --- common/params_keys.h | 1 + selfdrive/glowd/glowd.py | 13 +++++++++++++ selfdrive/ui/mici/layouts/home.py | 5 ++--- selfdrive/ui/mici/layouts/settings/toggles.py | 3 +++ 4 files changed, 19 insertions(+), 3 deletions(-) diff --git a/common/params_keys.h b/common/params_keys.h index e9a635831ec8eb..44f07decdca10f 100644 --- a/common/params_keys.h +++ b/common/params_keys.h @@ -50,6 +50,7 @@ inline static std::unordered_map keys = { {"GithubUsername", {PERSISTENT, STRING}}, {"GitRemote", {PERSISTENT, STRING}}, {"GlowMode", {PERSISTENT, BOOL}}, + {"GlowStandstillOnly", {PERSISTENT, BOOL, "1"}}, {"GlowStatus", {CLEAR_ON_MANAGER_START, JSON}}, {"GsmApn", {PERSISTENT, STRING}}, {"GsmMetered", {PERSISTENT, BOOL, "1"}}, diff --git a/selfdrive/glowd/glowd.py b/selfdrive/glowd/glowd.py index a8eaa1f0f5e43f..ab0ce720f19fa9 100644 --- a/selfdrive/glowd/glowd.py +++ b/selfdrive/glowd/glowd.py @@ -233,6 +233,7 @@ def signal_handler(signum, frame): params = Params() _put_glow_status(params, "connecting") chill_mode = params.get_bool("GlowMode") + standstill_only = params.get_bool("GlowStandstillOnly") last_param_read = 0.0 client = await ble_connect() @@ -252,6 +253,7 @@ def signal_handler(signum, frame): now = time.monotonic() if now - last_param_read > 2.5: chill_mode = params.get_bool("GlowMode") + standstill_only = params.get_bool("GlowStandstillOnly") last_param_read = now # If disconnected, try to reconnect every 5s @@ -267,6 +269,17 @@ def signal_handler(signum, frame): if sm.updated['carState']: ctrl.update(sm, chill_mode) + + if standstill_only and ctrl.state == GlowState.DRIVING: + if ctrl.last_color != (0, 0, 0): + await sp105e.power_off(client) + ctrl.last_color = (0, 0, 0) + _put_glow_status(params, "connected") + rk.keep_time() + continue + elif standstill_only and ctrl.last_color == (0, 0, 0): + await sp105e.power_on(client) + raw_color, brightness = ctrl.get_color(sm) color = ctrl.smooth_color(raw_color) diff --git a/selfdrive/ui/mici/layouts/home.py b/selfdrive/ui/mici/layouts/home.py index 8928bf8a620ff5..5147c93331c7d8 100644 --- a/selfdrive/ui/mici/layouts/home.py +++ b/selfdrive/ui/mici/layouts/home.py @@ -46,9 +46,8 @@ def set_glow_state(self, glow_state: dict | None): self._color = rl.Color(255, 200, 0, 240) self._glow = rl.Color(255, 200, 0, 50) else: - r, g, b = glow_state.get("color", [0, 200, 80]) - self._color = rl.Color(r, g, b, 240) - self._glow = rl.Color(r, g, b, 60) + self._color = rl.Color(0, 200, 80, 240) + self._glow = rl.Color(0, 200, 80, 60) def _render(self, _): cx = int(self._rect.x + self.SIZE / 2) diff --git a/selfdrive/ui/mici/layouts/settings/toggles.py b/selfdrive/ui/mici/layouts/settings/toggles.py index 37dac547b4e7dc..f64792ea8a13e4 100644 --- a/selfdrive/ui/mici/layouts/settings/toggles.py +++ b/selfdrive/ui/mici/layouts/settings/toggles.py @@ -21,10 +21,12 @@ def __init__(self): record_front = BigParamControl("record & upload driver camera", "RecordFront", toggle_callback=restart_needed_callback) record_mic = BigParamControl("record & upload mic audio", "RecordAudio", toggle_callback=restart_needed_callback) glow_toggle = BigParamControl("chill glow", "GlowMode") + glow_standstill_toggle = BigParamControl("standstill only glow", "GlowStandstillOnly") enable_openpilot = BigParamControl("enable openpilot", "OpenpilotEnabledToggle", toggle_callback=restart_needed_callback) self._scroller.add_widgets([ glow_toggle, + glow_standstill_toggle, self._personality_toggle, self._experimental_btn, is_metric_toggle, @@ -44,6 +46,7 @@ def __init__(self): ("RecordFront", record_front), ("RecordAudio", record_mic), ("GlowMode", glow_toggle), + ("GlowStandstillOnly", glow_standstill_toggle), ("OpenpilotEnabledToggle", enable_openpilot), ) From 0b8bc964108e72444dfd72f76f111b4eff432bb9 Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Mon, 9 Mar 2026 10:55:42 -0700 Subject: [PATCH 57/82] fix smoothing not taking shortest path! --- selfdrive/glowd/glowd.py | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/selfdrive/glowd/glowd.py b/selfdrive/glowd/glowd.py index ab0ce720f19fa9..444d04215e9b2a 100644 --- a/selfdrive/glowd/glowd.py +++ b/selfdrive/glowd/glowd.py @@ -20,6 +20,7 @@ """ import asyncio import colorsys +import math import signal import subprocess import time @@ -95,7 +96,8 @@ def __init__(self): # HSV smoothing filters dt = 1.0 / UPDATE_HZ - self._h_filter = FirstOrderFilter(0.0, 0.5, dt) + self._hx_filter = FirstOrderFilter(0.0, 0.5, dt) # cos(hue) + self._hy_filter = FirstOrderFilter(0.0, 0.5, dt) # sin(hue) self._s_filter = FirstOrderFilter(0.0, 0.5, dt) self._v_filter = FirstOrderFilter(0.0, 0.5, dt) @@ -105,9 +107,13 @@ def _set_state(self, state: GlowState): self._state_t = time.monotonic() def smooth_color(self, color: tuple[int, int, int]) -> tuple[int, int, int]: - """Filter RGB through HSV space for smooth transitions.""" + """Filter RGB through HSV space. Hue filtered in cartesian (cos/sin) + to handle circular wrapping generically via atan2.""" h, s, v = colorsys.rgb_to_hsv(color[0] / 255, color[1] / 255, color[2] / 255) - h = self._h_filter.update(h) + angle = 2 * math.pi * h + hx = self._hx_filter.update(math.cos(angle)) + hy = self._hy_filter.update(math.sin(angle)) + h = math.atan2(hy, hx) / (2 * math.pi) % 1.0 s = self._s_filter.update(s) v = self._v_filter.update(v) r, g, b = colorsys.hsv_to_rgb(h, s, v) @@ -194,14 +200,16 @@ async def ble_connect(): return None await sp105e.power_on(client) - # Startup sweep: min → max → 70% - await sp105e.set_brightness(client, sp105e.BRIGHTNESS_MIN) + # Startup sweep: min → max → min → 70% (like RPM gauge self-test) for level in range(sp105e.BRIGHTNESS_MIN, sp105e.BRIGHTNESS_MAX + 1): await sp105e.set_brightness(client, level) - await asyncio.sleep(0.2) - for level in range(sp105e.BRIGHTNESS_MAX, 3, -1): + await asyncio.sleep(0.15) + for level in range(sp105e.BRIGHTNESS_MAX, sp105e.BRIGHTNESS_MIN - 1, -1): await sp105e.set_brightness(client, level) - await asyncio.sleep(0.2) + await asyncio.sleep(0.15) + for level in range(sp105e.BRIGHTNESS_MIN, 5): + await sp105e.set_brightness(client, level) + await asyncio.sleep(0.15) print("glowd: connected, LEDs on, startup sweep done") return client From e2053203e6ecd625af3d68d346bb437dacde8692 Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Mon, 9 Mar 2026 14:44:34 -0700 Subject: [PATCH 58/82] check vego --- selfdrive/glowd/glowd.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/selfdrive/glowd/glowd.py b/selfdrive/glowd/glowd.py index 444d04215e9b2a..0d3acc420d9e25 100644 --- a/selfdrive/glowd/glowd.py +++ b/selfdrive/glowd/glowd.py @@ -137,11 +137,11 @@ def update(self, sm, chill: bool): # Base state transitions if self.state == GlowState.DRIVING: - if cs.standstill: + if cs.vEgo < 1: self._standstill_start = now self._set_state(GlowState.STANDSTILL) elif self.state == GlowState.STANDSTILL: - if not cs.standstill and now - self._state_t > RAINBOW_HOLDOVER_S: + if cs.vEgo >= 1 and now - self._state_t > RAINBOW_HOLDOVER_S: self._set_state(GlowState.DRIVING) # Update modifiers @@ -161,7 +161,7 @@ def get_color(self, sm) -> tuple[tuple[int, int, int], int]: # Base color from state if self.state == GlowState.STANDSTILL: standstill_elapsed = now - self._standstill_start - if standstill_elapsed > RAINBOW_DELAY_S or not cs.standstill: + if standstill_elapsed > RAINBOW_DELAY_S or cs.vEgo >= 1: color = self._rainbow_color(cs.vEgo) else: color = rpm_to_color(cs.engineRpm) From 71b00871e3c25ad3c0834ad0adb143dc08195d9d Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Mon, 9 Mar 2026 15:21:22 -0700 Subject: [PATCH 59/82] fix connect --- tools/underglow/sp105e.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tools/underglow/sp105e.py b/tools/underglow/sp105e.py index 8eaad45161db6a..ca06f067f6201b 100755 --- a/tools/underglow/sp105e.py +++ b/tools/underglow/sp105e.py @@ -110,6 +110,8 @@ async def connect(retries=CONNECT_RETRIES, exit_on_fail=True): await asyncio.sleep(2) continue print(f"Found {dev.address}") + # Clear any stale BlueZ connection from a crashed process + await BleakClient(dev.address).disconnect() client = BleakClient(dev.address, timeout=20) await client.connect() # Always set GRB (factory default) on connect to ensure known state From 1f1c88b80b5af47cb0683de85535251316565b47 Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Mon, 9 Mar 2026 15:24:57 -0700 Subject: [PATCH 60/82] fix connect for real --- tools/underglow/sp105e.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tools/underglow/sp105e.py b/tools/underglow/sp105e.py index ca06f067f6201b..c5c61b5edaeea8 100755 --- a/tools/underglow/sp105e.py +++ b/tools/underglow/sp105e.py @@ -26,6 +26,7 @@ """ import argparse import asyncio +import subprocess import sys from enum import IntEnum from bleak import BleakScanner, BleakClient @@ -111,7 +112,7 @@ async def connect(retries=CONNECT_RETRIES, exit_on_fail=True): continue print(f"Found {dev.address}") # Clear any stale BlueZ connection from a crashed process - await BleakClient(dev.address).disconnect() + subprocess.run(["bluetoothctl", "disconnect", dev.address], capture_output=True, timeout=5) client = BleakClient(dev.address, timeout=20) await client.connect() # Always set GRB (factory default) on connect to ensure known state From d383090f78295ef2f711ea144a9e15d62f3ac615 Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Tue, 10 Mar 2026 00:55:47 -0700 Subject: [PATCH 61/82] rm start sweep --- selfdrive/glowd/glowd.py | 23 ++++++----------------- 1 file changed, 6 insertions(+), 17 deletions(-) diff --git a/selfdrive/glowd/glowd.py b/selfdrive/glowd/glowd.py index 0d3acc420d9e25..eac30bb019e449 100644 --- a/selfdrive/glowd/glowd.py +++ b/selfdrive/glowd/glowd.py @@ -96,10 +96,10 @@ def __init__(self): # HSV smoothing filters dt = 1.0 / UPDATE_HZ - self._hx_filter = FirstOrderFilter(0.0, 0.5, dt) # cos(hue) - self._hy_filter = FirstOrderFilter(0.0, 0.5, dt) # sin(hue) - self._s_filter = FirstOrderFilter(0.0, 0.5, dt) - self._v_filter = FirstOrderFilter(0.0, 0.5, dt) + self._hx_filter = FirstOrderFilter(0.0, 0.5, dt, initialized=False) # cos(hue) + self._hy_filter = FirstOrderFilter(0.0, 0.5, dt, initialized=False) # sin(hue) + self._s_filter = FirstOrderFilter(0.0, 0.5, dt, initialized=False) + self._v_filter = FirstOrderFilter(0.0, 0.5, dt, initialized=False) def _set_state(self, state: GlowState): if self.state != state: @@ -199,19 +199,8 @@ async def ble_connect(): if client is None: return None await sp105e.power_on(client) - - # Startup sweep: min → max → min → 70% (like RPM gauge self-test) - for level in range(sp105e.BRIGHTNESS_MIN, sp105e.BRIGHTNESS_MAX + 1): - await sp105e.set_brightness(client, level) - await asyncio.sleep(0.15) - for level in range(sp105e.BRIGHTNESS_MAX, sp105e.BRIGHTNESS_MIN - 1, -1): - await sp105e.set_brightness(client, level) - await asyncio.sleep(0.15) - for level in range(sp105e.BRIGHTNESS_MIN, 5): - await sp105e.set_brightness(client, level) - await asyncio.sleep(0.15) - - print("glowd: connected, LEDs on, startup sweep done") + await sp105e.set_brightness(client, 4) + print("glowd: connected, LEDs on") return client From 58f89663b962c817c0afa00fcf397882efc9180c Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Tue, 10 Mar 2026 00:59:08 -0700 Subject: [PATCH 62/82] fix demo --- tools/underglow/sp105e.py | 34 ++++++++++++++++++++++------------ 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/tools/underglow/sp105e.py b/tools/underglow/sp105e.py index c5c61b5edaeea8..c19676adc6750a 100755 --- a/tools/underglow/sp105e.py +++ b/tools/underglow/sp105e.py @@ -130,8 +130,8 @@ async def connect(retries=CONNECT_RETRIES, exit_on_fail=True): return None -async def send(client, data: bytes): - await client.write_gatt_char(CHAR, data, response=False) +async def send(client, data: bytes, response=False): + await client.write_gatt_char(CHAR, data, response=response) # --- State reading --- @@ -152,7 +152,7 @@ def on_notify(sender, data): try: await asyncio.wait_for(event.wait(), timeout=2.0) except asyncio.TimeoutError: - pass + print("sp105e: get_state timeout") await client.stop_notify(CHAR) return result @@ -170,18 +170,28 @@ async def set_color(client, r, g, b): async def power_toggle(client): - await send(client, packet(0, 0, 0, Command.POWER_TOGGLE)) + await send(client, packet(0, 0, 0, Command.POWER_TOGGLE), response=True) async def power_on(client): """Turn on if off. No-op if already on.""" - if not await is_on(client): + state = await get_state(client) + if state is None: + print("WARNING: power_on state read failed, skipping") + elif state[0] == 1: + print("WARNING: power_on called but already on") + else: await power_toggle(client) async def power_off(client): """Turn off if on. No-op if already off.""" - if await is_on(client): + state = await get_state(client) + if state is None: + print("WARNING: power_off state read failed, skipping") + elif state[0] != 1: + print("WARNING: power_off called but already off") + else: await power_toggle(client) @@ -329,12 +339,12 @@ async def run_demo(client): print(" brightness ramp...") await set_color(client, 255, 0, 0) await asyncio.sleep(0.1) - for level in range(BRIGHTNESS_MAX, -1, -1): - await set_brightness(client, level) - await asyncio.sleep(0.15) - for level in range(BRIGHTNESS_MAX + 1): - await set_brightness(client, level) - await asyncio.sleep(0.15) + for _ in range(BRIGHTNESS_MAX): + await brightness_step_down(client) + await asyncio.sleep(0.05) + for _ in range(BRIGHTNESS_MAX): + await brightness_step_up(client) + await asyncio.sleep(0.05) except KeyboardInterrupt: pass await set_brightness(client, BRIGHTNESS_MAX) From 6e53c6981539e846fe597418bd04948f9dbe1ca2 Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Tue, 10 Mar 2026 01:09:34 -0700 Subject: [PATCH 63/82] fix state machine --- selfdrive/glowd/glowd.py | 46 ++++++++++++++++++++++------------------ 1 file changed, 25 insertions(+), 21 deletions(-) diff --git a/selfdrive/glowd/glowd.py b/selfdrive/glowd/glowd.py index eac30bb019e449..4ab38050b9fe92 100644 --- a/selfdrive/glowd/glowd.py +++ b/selfdrive/glowd/glowd.py @@ -88,8 +88,8 @@ def __init__(self): self.last_color = (0, 0, 0) self.last_brightness = -1 self.state = GlowState.STANDSTILL - self._state_t = time.monotonic() - self._standstill_start = time.monotonic() + self._standstill_start: float | None = None + self._moving_start: float | None = None self._mods = GlowMod(0) self._prev_brake = False self._brake_pressed_t = 0.0 @@ -101,11 +101,6 @@ def __init__(self): self._s_filter = FirstOrderFilter(0.0, 0.5, dt, initialized=False) self._v_filter = FirstOrderFilter(0.0, 0.5, dt, initialized=False) - def _set_state(self, state: GlowState): - if self.state != state: - self.state = state - self._state_t = time.monotonic() - def smooth_color(self, color: tuple[int, int, int]) -> tuple[int, int, int]: """Filter RGB through HSV space. Hue filtered in cartesian (cos/sin) to handle circular wrapping generically via atan2.""" @@ -131,18 +126,33 @@ def update(self, sm, chill: bool): now = time.monotonic() if chill: - self._set_state(GlowState.DRIVING) + self.state = GlowState.DRIVING self._mods = GlowMod(0) return # Base state transitions if self.state == GlowState.DRIVING: - if cs.vEgo < 1: - self._standstill_start = now - self._set_state(GlowState.STANDSTILL) + if cs.vEgo < 1 and cs.engineRpm < 1500: + if self._standstill_start is None: + self._standstill_start = now + elif now - self._standstill_start > RAINBOW_DELAY_S: + self._standstill_start = None + self.state = GlowState.STANDSTILL + else: + self._standstill_start = None + elif self.state == GlowState.STANDSTILL: - if cs.vEgo >= 1 and now - self._state_t > RAINBOW_HOLDOVER_S: - self._set_state(GlowState.DRIVING) + if cs.engineRpm >= 1500: + self._moving_start = None + self.state = GlowState.DRIVING + elif cs.vEgo >= 1: + if self._moving_start is None: + self._moving_start = now + elif now - self._moving_start > RAINBOW_HOLDOVER_S: + self._moving_start = None + self.state = GlowState.DRIVING + else: + self._moving_start = None # Update modifiers if cs.brakePressed and not self._prev_brake: @@ -156,19 +166,13 @@ def update(self, sm, chill: bool): def get_color(self, sm) -> tuple[tuple[int, int, int], int]: cs = sm['carState'] - now = time.monotonic() # Base color from state if self.state == GlowState.STANDSTILL: - standstill_elapsed = now - self._standstill_start - if standstill_elapsed > RAINBOW_DELAY_S or cs.vEgo >= 1: - color = self._rainbow_color(cs.vEgo) - else: - color = rpm_to_color(cs.engineRpm) - brightness = rpm_to_brightness(cs.engineRpm) + color = self._rainbow_color(cs.vEgo) else: color = rpm_to_color(cs.engineRpm) - brightness = rpm_to_brightness(cs.engineRpm) + brightness = rpm_to_brightness(cs.engineRpm) # Modifier: brake — dark red flash on rising edge if self._mods & GlowMod.BRAKE: From 462529b3a57f825fa0dabdeeb82d6939895abf4b Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Tue, 10 Mar 2026 01:41:54 -0700 Subject: [PATCH 64/82] make power more reliable --- selfdrive/glowd/glowd.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/selfdrive/glowd/glowd.py b/selfdrive/glowd/glowd.py index 4ab38050b9fe92..5842342dfdeffc 100644 --- a/selfdrive/glowd/glowd.py +++ b/selfdrive/glowd/glowd.py @@ -43,7 +43,7 @@ RPM_BRIGHTNESS_MAX = 7000 # Timing -UPDATE_HZ = 20 +UPDATE_HZ = 15 BRAKE_FLASH_S = 0.8 RAINBOW_HOLDOVER_S = 2.5 RAINBOW_DELAY_S = 1.5 @@ -116,8 +116,7 @@ def smooth_color(self, color: tuple[int, int, int]) -> tuple[int, int, int]: def _rainbow_color(self, v_ego: float) -> tuple[int, int, int]: speed_mult = np.interp(v_ego, [0.0, 5.0], [1.0, 2.0]) - elapsed = time.monotonic() - self._standstill_start - RAINBOW_DELAY_S - hue = (elapsed * speed_mult / RAINBOW_PERIOD_S) % 1.0 + hue = (time.monotonic() * speed_mult / RAINBOW_PERIOD_S) % 1.0 r, g, b = colorsys.hsv_to_rgb(hue, 1.0, 1.0) return int(r * 255), int(g * 255), int(b * 255) @@ -202,8 +201,9 @@ async def ble_connect(): client = await sp105e.connect(exit_on_fail=False) if client is None: return None + await asyncio.sleep(0.5) await sp105e.power_on(client) - await sp105e.set_brightness(client, 4) + await asyncio.sleep(0.5) print("glowd: connected, LEDs on") return client @@ -212,6 +212,8 @@ async def ble_shutdown(client): """Power off LEDs and disconnect.""" if client is not None: try: + await sp105e.set_brightness(client, sp105e.BRIGHTNESS_MIN) + await asyncio.sleep(0.5) await sp105e.power_off(client) await client.disconnect() print("glowd: LEDs off, disconnected") From d7db1eb903f3d09d39cb7704c7c77cbe5b18b577 Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Tue, 10 Mar 2026 02:11:04 -0700 Subject: [PATCH 65/82] no rpm brightness --- selfdrive/glowd/glowd.py | 49 +++++++----------- tools/underglow/sp105e.py | 101 +++++++++++++++++--------------------- 2 files changed, 64 insertions(+), 86 deletions(-) diff --git a/selfdrive/glowd/glowd.py b/selfdrive/glowd/glowd.py index 5842342dfdeffc..9e711e97864f24 100644 --- a/selfdrive/glowd/glowd.py +++ b/selfdrive/glowd/glowd.py @@ -40,7 +40,6 @@ # RPM thresholds RPM_MIN = 800 RPM_COLOR_MAX = 5500 -RPM_BRIGHTNESS_MAX = 7000 # Timing UPDATE_HZ = 15 @@ -78,15 +77,10 @@ def rpm_to_color(rpm: float) -> tuple[int, int, int]: ) -def rpm_to_brightness(rpm: float) -> int: - """70% (level 4) normally, ramp to 100% (level 6) above 4000 RPM.""" - return int(np.interp(rpm, [RPM_COLOR_MAX, RPM_BRIGHTNESS_MAX], [4, sp105e.BRIGHTNESS_MAX])) - class GlowController: def __init__(self): self.last_color = (0, 0, 0) - self.last_brightness = -1 self.state = GlowState.STANDSTILL self._standstill_start: float | None = None self._moving_start: float | None = None @@ -163,25 +157,21 @@ def update(self, sm, chill: bool): else: self._mods &= ~GlowMod.BRAKE - def get_color(self, sm) -> tuple[tuple[int, int, int], int]: + def get_color(self, sm) -> tuple[int, int, int]: cs = sm['carState'] - # Base color from state - if self.state == GlowState.STANDSTILL: - color = self._rainbow_color(cs.vEgo) - else: - color = rpm_to_color(cs.engineRpm) - brightness = rpm_to_brightness(cs.engineRpm) - # Modifier: brake — dark red flash on rising edge if self._mods & GlowMod.BRAKE: - return (128, 0, 0), brightness + return (128, 0, 0) - return color, brightness + # Base color from state + if self.state == GlowState.STANDSTILL: + return self._rainbow_color(cs.vEgo) + return rpm_to_color(cs.engineRpm) -def _put_glow_status(params, status: str, color: tuple[int, int, int] = (0, 0, 0), brightness: int = 0): - params.put_nonblocking("GlowStatus", {"status": status, "color": list(color), "brightness": brightness}) +def _put_glow_status(params, status: str, color: tuple[int, int, int] = (0, 0, 0)): + params.put_nonblocking("GlowStatus", {"status": status, "color": list(color)}) def bt_is_ready() -> bool: @@ -202,7 +192,7 @@ async def ble_connect(): if client is None: return None await asyncio.sleep(0.5) - await sp105e.power_on(client) + await sp105e.set_power(client, on=True) await asyncio.sleep(0.5) print("glowd: connected, LEDs on") return client @@ -214,7 +204,7 @@ async def ble_shutdown(client): try: await sp105e.set_brightness(client, sp105e.BRIGHTNESS_MIN) await asyncio.sleep(0.5) - await sp105e.power_off(client) + await sp105e.set_power(client, on=False) await client.disconnect() print("glowd: LEDs off, disconnected") except Exception as e: @@ -275,26 +265,24 @@ def signal_handler(signum, frame): if standstill_only and ctrl.state == GlowState.DRIVING: if ctrl.last_color != (0, 0, 0): - await sp105e.power_off(client) + await sp105e.set_power(client, on=False) ctrl.last_color = (0, 0, 0) _put_glow_status(params, "connected") rk.keep_time() continue elif standstill_only and ctrl.last_color == (0, 0, 0): - await sp105e.power_on(client) + await sp105e.set_power(client, on=True) - raw_color, brightness = ctrl.get_color(sm) + raw_color = ctrl.get_color(sm) color = ctrl.smooth_color(raw_color) - if color != ctrl.last_color or brightness != ctrl.last_brightness: + if color != ctrl.last_color: if DEBUG: cs = sm['carState'] - print(f"glowd: RPM={cs.engineRpm:.0f} brightness={brightness} chill={chill_mode} → RGB{color}") + print(f"glowd: RPM={cs.engineRpm:.0f} chill={chill_mode} → RGB{color}") try: await sp105e.set_color(client, *color) - if brightness != ctrl.last_brightness: - await sp105e.set_brightness(client, brightness) except Exception as e: print(f"glowd: BLE error: {e}") try: @@ -302,17 +290,16 @@ def signal_handler(signum, frame): except Exception: pass client = None - _put_glow_status(params, "disconnected", ctrl.last_color, ctrl.last_brightness) + _put_glow_status(params, "disconnected", ctrl.last_color) continue ctrl.last_color = color - ctrl.last_brightness = brightness - _put_glow_status(params, "connected", color, brightness) + _put_glow_status(params, "connected", color) rk.keep_time() # Clean shutdown: power off LEDs - _put_glow_status(params, "disconnected", ctrl.last_color, ctrl.last_brightness) + _put_glow_status(params, "disconnected", ctrl.last_color) await ble_shutdown(client) diff --git a/tools/underglow/sp105e.py b/tools/underglow/sp105e.py index c19676adc6750a..712a8fa1fd1cd3 100755 --- a/tools/underglow/sp105e.py +++ b/tools/underglow/sp105e.py @@ -136,25 +136,29 @@ async def send(client, data: bytes, response=False): # --- State reading --- -async def get_state(client) -> bytes | None: +async def get_state(client, retries: int = 2) -> bytes | None: """Send GET_STATE (0x10) and return 8-byte notify response. Returns None on timeout. Byte 0: 1=on, 0=off.""" - result = None - event = asyncio.Event() + for attempt in range(retries): + result = None + event = asyncio.Event() - def on_notify(sender, data): - nonlocal result - result = data - event.set() + def on_notify(sender, data): + nonlocal result + result = data + event.set() - await client.start_notify(CHAR, on_notify) - await send(client, packet(0, 0, 0, Command.GET_STATE)) - try: - await asyncio.wait_for(event.wait(), timeout=2.0) - except asyncio.TimeoutError: - print("sp105e: get_state timeout") - await client.stop_notify(CHAR) - return result + await client.start_notify(CHAR, on_notify) + await send(client, packet(0, 0, 0, Command.GET_STATE)) + try: + await asyncio.wait_for(event.wait(), timeout=2.0) + except asyncio.TimeoutError: + print(f"sp105e: get_state timeout (attempt {attempt + 1}/{retries})") + await client.stop_notify(CHAR) + if result is not None: + return result + await asyncio.sleep(0.5) + return None async def is_on(client) -> bool: @@ -166,33 +170,28 @@ async def is_on(client) -> bool: # --- High-level commands --- async def set_color(client, r, g, b): - await send(client, color_packet(r, g, b)) + await send(client, color_packet(r, g, b), response=True) async def power_toggle(client): await send(client, packet(0, 0, 0, Command.POWER_TOGGLE), response=True) -async def power_on(client): - """Turn on if off. No-op if already on.""" - state = await get_state(client) - if state is None: - print("WARNING: power_on state read failed, skipping") - elif state[0] == 1: - print("WARNING: power_on called but already on") - else: - await power_toggle(client) - - -async def power_off(client): - """Turn off if on. No-op if already off.""" - state = await get_state(client) - if state is None: - print("WARNING: power_off state read failed, skipping") - elif state[0] != 1: - print("WARNING: power_off called but already off") - else: +async def set_power(client, on: bool, retries: int = 2): + """Set power state. No-op if already in desired state.""" + target = 1 if on else 0 + label = "on" if on else "off" + for attempt in range(retries): + state = await get_state(client) + if state is None: + print(f"WARNING: power_{label} state read failed (attempt {attempt + 1}/{retries})") + await asyncio.sleep(0.5) + continue + if state[0] == target: + return await power_toggle(client) + return + print(f"WARNING: power_{label} failed after retries") BRIGHTNESS_MAX = 6 @@ -212,30 +211,25 @@ async def set_brightness(client, level: int): level = max(BRIGHTNESS_MIN, min(BRIGHTNESS_MAX, level)) current = await get_brightness(client) if current is None: - # Can't read state, just step up to max as fallback - for _ in range(BRIGHTNESS_MAX): - await send(client, packet(1, 0, 0, Command.BRIGHT_UP)) - await asyncio.sleep(0.05) + print("WARNING: set_brightness can't read current level, skipping") return diff = level - current if diff > 0: for _ in range(diff): - await send(client, packet(1, 0, 0, Command.BRIGHT_UP)) - await asyncio.sleep(0.05) + await brightness_step_up(client) elif diff < 0: for _ in range(-diff): - await send(client, packet(1, 0, 0, Command.BRIGHT_DOWN)) - await asyncio.sleep(0.05) + await brightness_step_down(client) async def brightness_step_up(client, step=1): """Step brightness up. Relative. Step size 1-16.""" - await send(client, packet(step, 0, 0, Command.BRIGHT_UP)) + await send(client, packet(step, 0, 0, Command.BRIGHT_UP), response=True) async def brightness_step_down(client, step=1): """Step brightness down. Relative. Step size 1-8.""" - await send(client, packet(step, 0, 0, Command.BRIGHT_DOWN)) + await send(client, packet(step, 0, 0, Command.BRIGHT_DOWN), response=True) async def set_mode(client, mode): @@ -271,14 +265,14 @@ async def cmd_toggle(args): async def cmd_on(args): client = await connect() - await power_on(client) + await set_power(client, on=True) print("ON") await client.disconnect() async def cmd_off(args): client = await connect() - await power_off(client) + await set_power(client, on=False) print("OFF") await client.disconnect() @@ -323,28 +317,25 @@ async def cmd_pattern(args): async def run_demo(client): """HSV color cycle → brightness ramp → repeat. Ctrl+C to stop.""" import colorsys - await power_on(client) + await set_power(client, on=True) await set_brightness(client, BRIGHTNESS_MAX) print("Demo: colors → brightness → colors. Ctrl+C to stop.") try: while True: print(" color cycle...") - for step in range(300): - hue = step / 300.0 + for step in range(200): + hue = step / 200.0 r, g, b = colorsys.hsv_to_rgb(hue, 1.0, 1.0) await set_color(client, int(r * 255), int(g * 255), int(b * 255)) - await asyncio.sleep(0.01) print(" brightness ramp...") await set_color(client, 255, 0, 0) await asyncio.sleep(0.1) for _ in range(BRIGHTNESS_MAX): await brightness_step_down(client) - await asyncio.sleep(0.05) for _ in range(BRIGHTNESS_MAX): await brightness_step_up(client) - await asyncio.sleep(0.05) except KeyboardInterrupt: pass await set_brightness(client, BRIGHTNESS_MAX) @@ -390,10 +381,10 @@ async def cmd_interactive(args): after = await get_brightness(client) print(f" Brightness: {current} → {after}") elif c == "on": - await power_on(client) + await set_power(client, on=True) print(" ON") elif c == "off": - await power_off(client) + await set_power(client, on=False) print(" OFF") elif c == "toggle": await power_toggle(client) From af797055434870ac91fddf8daee7d3da0cdd872e Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Tue, 10 Mar 2026 02:20:55 -0700 Subject: [PATCH 66/82] more fix bright --- selfdrive/glowd/glowd.py | 3 +++ selfdrive/ui/mici/onroad/augmented_road_view.py | 7 ++----- tools/underglow/sp105e.py | 2 ++ 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/selfdrive/glowd/glowd.py b/selfdrive/glowd/glowd.py index 9e711e97864f24..217ed9df714b08 100644 --- a/selfdrive/glowd/glowd.py +++ b/selfdrive/glowd/glowd.py @@ -41,6 +41,8 @@ RPM_MIN = 800 RPM_COLOR_MAX = 5500 +DRIVING_BRIGHTNESS = 3 # ~43%, level 0-6 + # Timing UPDATE_HZ = 15 BRAKE_FLASH_S = 0.8 @@ -194,6 +196,7 @@ async def ble_connect(): await asyncio.sleep(0.5) await sp105e.set_power(client, on=True) await asyncio.sleep(0.5) + await sp105e.set_brightness(client, DRIVING_BRIGHTNESS) print("glowd: connected, LEDs on") return client diff --git a/selfdrive/ui/mici/onroad/augmented_road_view.py b/selfdrive/ui/mici/onroad/augmented_road_view.py index 5bcdfcd4e4a425..ea4f3797d4ccb2 100644 --- a/selfdrive/ui/mici/onroad/augmented_road_view.py +++ b/selfdrive/ui/mici/onroad/augmented_road_view.py @@ -183,11 +183,8 @@ def _update_state(self): state = ui_state.params.get("GlowStatus") or {} if state.get("status") == "connected" and state.get("color"): r, g, b = state["color"] - bright = state.get("brightness", 6) - alpha = int(np.interp(bright + 1, [0, 7], [100, 220])) - glow_alpha = int(np.interp(bright + 1, [0, 7], [20, 60])) - self._glow_color = rl.Color(r, g, b, alpha) - self._glow_glow = rl.Color(r, g, b, glow_alpha) + self._glow_color = rl.Color(r, g, b, 200) + self._glow_glow = rl.Color(r, g, b, 50) else: self._glow_color = rl.Color(120, 120, 120, 160) self._glow_glow = rl.Color(120, 120, 120, 40) diff --git a/tools/underglow/sp105e.py b/tools/underglow/sp105e.py index 712a8fa1fd1cd3..65d25b9c1294f3 100755 --- a/tools/underglow/sp105e.py +++ b/tools/underglow/sp105e.py @@ -224,11 +224,13 @@ async def set_brightness(client, level: int): async def brightness_step_up(client, step=1): """Step brightness up. Relative. Step size 1-16.""" + await asyncio.sleep(0.05) await send(client, packet(step, 0, 0, Command.BRIGHT_UP), response=True) async def brightness_step_down(client, step=1): """Step brightness down. Relative. Step size 1-8.""" + await asyncio.sleep(0.05) await send(client, packet(step, 0, 0, Command.BRIGHT_DOWN), response=True) From 4072101532802a3e7442db7bd2c5bf9623d727f9 Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Tue, 10 Mar 2026 03:26:57 -0700 Subject: [PATCH 67/82] new docs --- tools/underglow/sp105e.py | 136 ++++++++++++++++++++------------------ 1 file changed, 70 insertions(+), 66 deletions(-) diff --git a/tools/underglow/sp105e.py b/tools/underglow/sp105e.py index 65d25b9c1294f3..36a2c43310ba68 100755 --- a/tools/underglow/sp105e.py +++ b/tools/underglow/sp105e.py @@ -8,21 +8,53 @@ Confirmed commands: SET_COLOR: 38 GG RR BB 1E 83 (GRB wire order, API takes RGB) - POWER_TOGGLE: 38 00 00 00 AA 83 (toggle only, 0xAB does nothing) - SET_MODE: 38 MM 00 00 2C 83 (mode number in D1) - BRIGHT_UP: 38 SS 00 00 2A 83 (relative step up, S=step size 1-16) - BRIGHT_DOWN: 38 SS 00 00 28 83 (relative step down, S=step size 1-8) + POWER_TOGGLE: 38 00 00 00 AA 83 (toggle only, 0xAB does nothing on SP105E) + SET_MODE: 38 MM 00 00 2C 83 (mode 0-202, sets pattern/animation) + BRIGHT_UP: 38 00 00 00 2A 83 (relative +1, all data bytes ignored) + BRIGHT_DOWN: 38 00 00 00 28 83 (relative -1, all data bytes ignored) + Brightness: 7 levels (0-6), clamps at both ends (no wrap) COLOR_ORDER: 38 NN 00 00 3C 83 (0=GRB, 1=GBR, 2=RGB, 3=BGR, 4=RBG, 5=BRG) -Pattern modes (as CMD byte directly, D1-D3 ignored): - See Pattern enum below. +Modes (via SET_MODE 0x2C with mode number in D1): + - Modes 0-202 accepted (state byte 1 reflects mode number) + - Mode 0xC9 (201) = static color (also set implicitly by SET_COLOR) + - Modes 0xFE-0xFF wrap to mode 1 + - Mode 1 = rainbow flow (confirmed visually) + - Speed command not found yet (0x03 doesn't work, unlike SP110E) + +GET_STATE response (8 bytes via notify on 0xFFE1): + Byte 0: power (1=on, 0=off) + Byte 1: mode number (0xC9=static, 0x01-0xCA=patterns) + Byte 2: unknown (typically 5-6) + Byte 3: brightness (0-6) + Byte 4: unknown (0x03) + Byte 5: color order (0=GRB, etc.) + Byte 6: unknown (0x02) + Byte 7: 0x58 (88) — likely pixel count + +SP110E vs SP105E differences: + - SP110E has no packet framing (no 0x38/0x83), SP105E requires it + - SP110E: 0xAA=on, 0xAB=off. SP105E: 0xAA=toggle, 0xAB=no-op + - SP110E: 0x2A=absolute brightness (D1=level). SP105E: 0x2A=relative +1 (D1 ignored) + - SP110E: 0x03=speed. SP105E: 0x03 does nothing useful + - SP110E: 12-byte state (includes color, white, pixel count). SP105E: 8-byte state + - SP110E: 122 modes. SP105E: 202 modes Notes: - - Sending SET_COLOR stops any active pattern and goes to static + - Sending SET_COLOR stops any active pattern and sets mode to 0xC9 (static) - Device must be ON for commands to work - 0xAA is a toggle (on->off, off->on), not absolute - - Speed command not found yet - Color order (0x3C) persists to flash — script sets GRB on connect + +BLE timing (measured on comma four): + - SET_COLOR at ~33Hz sustained with response=True (no sleep needed) + - Brightness steps: 100ms sleep before each required (~50% drop rate without) + - GET_STATE round-trip: 150-400ms (start_notify → send → wait → stop_notify) + - GET_STATE unreliable if interleaved between brightness steps — verify only after all steps done + - After connect: 0.5s sleep before first command + - After rapid color writes: 0.5s sleep before GET_STATE works + - response=True on all commands prevents BLE write buffer buildup + - Stale BlueZ connections after kill -9: `bluetoothctl disconnect` clears them """ import argparse import asyncio @@ -43,14 +75,14 @@ class Command(IntEnum): SET_COLOR = 0x1E POWER_TOGGLE = 0xAA - BRIGHT_UP = 0x2A # relative: step brighter, D1=step size (1-16) - BRIGHT_DOWN = 0x28 # relative: step dimmer, D1=step size (1-8) + BRIGHT_UP = 0x2A # relative +1, all data bytes ignored, clamps at 6 + BRIGHT_DOWN = 0x28 # relative -1, all data bytes ignored, clamps at 0 SET_MODE = 0x2C GET_STATE = 0x10 # triggers notify with 8-byte state response COLOR_ORDER = 0x3C - # DANGEROUS — do not send, will soft-brick (requires power cycle): - # 0x1C — bright white, ignores all commands after - # 0x2D — brief off/on, may wedge state + # DANGEROUS on SP105E — do not send (requires power cycle to recover): + # 0x1C — SP110E: set IC model. SP105E: bright white, ignores all commands after + # 0x2D — SP110E: set pixel count. SP105E: brief off/on, may wedge state class ColorOrder(IntEnum): @@ -62,19 +94,8 @@ class ColorOrder(IntEnum): BRG = 5 -class Pattern(IntEnum): - RAINBOW_FLOW = 0x03 - RAINBOW_1 = 0x05 - RAINBOW_2 = 0x06 - BREATHING = 0x07 # fade through colors, slow - BREATHING_2 = 0x08 - BREATHING_3 = 0x09 - BREATHING_4 = 0x0A - BREATHING_5 = 0x0B - COLOR_CYCLE = 0x0D # yellow->orange->red, no fade - COLOR_CYCLE_SLOW = 0x0E - RAINBOW_FAST = 0x0F - RAINBOW_FAST_2 = 0x10 +MODE_STATIC = 0xC9 # set implicitly by SET_COLOR +MODE_MAX = 202 # modes 0-202 accepted, 0xFE+ wraps def packet(d1: int, d2: int, d3: int, cmd: int) -> bytes: @@ -222,26 +243,21 @@ async def set_brightness(client, level: int): await brightness_step_down(client) -async def brightness_step_up(client, step=1): - """Step brightness up. Relative. Step size 1-16.""" - await asyncio.sleep(0.05) - await send(client, packet(step, 0, 0, Command.BRIGHT_UP), response=True) +async def brightness_step_up(client): + """Step brightness up by 1. All data bytes ignored by controller.""" + await asyncio.sleep(0.1) + await send(client, packet(0, 0, 0, Command.BRIGHT_UP), response=True) -async def brightness_step_down(client, step=1): - """Step brightness down. Relative. Step size 1-8.""" - await asyncio.sleep(0.05) - await send(client, packet(step, 0, 0, Command.BRIGHT_DOWN), response=True) +async def brightness_step_down(client): + """Step brightness down by 1. All data bytes ignored by controller.""" + await asyncio.sleep(0.1) + await send(client, packet(0, 0, 0, Command.BRIGHT_DOWN), response=True) async def set_mode(client, mode): - """Set animation mode via SET_MODE with mode number in D1.""" - await send(client, packet(mode, 0, 0, Command.SET_MODE)) - - -async def set_pattern(client, pattern): - """Set pattern directly via CMD byte.""" - await send(client, packet(0, 0, 0, pattern)) + """Set animation mode (0-202). Mode 0xC9=static (also set by SET_COLOR).""" + await send(client, packet(mode, 0, 0, Command.SET_MODE), response=True) async def set_color_order(client, order=ColorOrder.GRB): @@ -309,13 +325,6 @@ async def cmd_mode(args): await client.disconnect() -async def cmd_pattern(args): - client = await connect() - await set_pattern(client, args.pattern) - print(f"Pattern set to {args.pattern.name}") - await client.disconnect() - - async def run_demo(client): """HSV color cycle → brightness ramp → repeat. Ctrl+C to stop.""" import colorsys @@ -357,12 +366,11 @@ async def cmd_interactive(args): print(" bright N set brightness (0-6)") print(" on / off / toggle power control") print(" state read device state") - print(" mode N set mode (decimal, via SET_MODE)") - print(" pattern NAME set pattern (e.g. BREATHING, RAINBOW_FLOW)") + print(" mode N set mode (0-202, decimal or 0xNN hex)") + print(" cmd XX [D1 D2 D3] send command byte (hex), wraps in packet") print(" raw HH HH ... send raw hex bytes") print(" demo color cycle") print(" quit\n") - print(f" Available patterns: {', '.join(p.name for p in Pattern)}\n") while True: try: @@ -401,16 +409,17 @@ async def cmd_interactive(args): print(f" Color order: {state[5]} ({ColorOrder(state[5]).name})") print(f" Raw: {' '.join(f'{b:02x}' for b in state)}") elif c == "mode" and len(parts) == 2: - await set_mode(client, int(parts[1])) - elif c == "pattern" and len(parts) == 2: - name = parts[1].upper() - try: - p = Pattern[name] - except KeyError: - print(f" Unknown pattern. Options: {', '.join(p.name for p in Pattern)}") - continue - await set_pattern(client, p) - print(f" {p.name}") + val = parts[1] + mode = int(val, 16) if val.startswith("0x") else int(val) + await set_mode(client, mode) + print(f" mode {mode}") + elif c == "cmd" and len(parts) >= 2: + cmd = int(parts[1], 16) + d1 = int(parts[2], 16) if len(parts) > 2 else 0 + d2 = int(parts[3], 16) if len(parts) > 3 else 0 + d3 = int(parts[4], 16) if len(parts) > 4 else 0 + await send(client, packet(d1, d2, d3, cmd), response=True) + print(f" sent: {packet(d1, d2, d3, cmd).hex()}") elif c == "raw": data = bytes([int(x, 16) for x in parts[1:]]) await send(client, data) @@ -459,10 +468,6 @@ def main(): p_mode = sub.add_parser("mode", help="Set mode (decimal)") p_mode.add_argument("mode", type=int) - p_pattern = sub.add_parser("pattern", help="Set pattern by name") - p_pattern.add_argument("pattern", type=lambda x: Pattern[x.upper()], - choices=list(Pattern), metavar="PATTERN") - args = parser.parse_args() commands = { @@ -473,7 +478,6 @@ def main(): "state": cmd_state, "bright": cmd_bright, "mode": cmd_mode, - "pattern": cmd_pattern, "demo": cmd_demo, "interactive": cmd_interactive, "scan": cmd_scan, From 993111e3f19e51072b90ddb49ad4591b406c2fb2 Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Tue, 10 Mar 2026 03:40:48 -0700 Subject: [PATCH 68/82] new docs --- tools/underglow/sp105e.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tools/underglow/sp105e.py b/tools/underglow/sp105e.py index 36a2c43310ba68..924f384c831aeb 100755 --- a/tools/underglow/sp105e.py +++ b/tools/underglow/sp105e.py @@ -48,7 +48,7 @@ BLE timing (measured on comma four): - SET_COLOR at ~33Hz sustained with response=True (no sleep needed) - - Brightness steps: 100ms sleep before each required (~50% drop rate without) + - Brightness steps: 50ms sleep before each required (0ms drops ~50%) - GET_STATE round-trip: 150-400ms (start_notify → send → wait → stop_notify) - GET_STATE unreliable if interleaved between brightness steps — verify only after all steps done - After connect: 0.5s sleep before first command @@ -245,13 +245,13 @@ async def set_brightness(client, level: int): async def brightness_step_up(client): """Step brightness up by 1. All data bytes ignored by controller.""" - await asyncio.sleep(0.1) + await asyncio.sleep(0.05) await send(client, packet(0, 0, 0, Command.BRIGHT_UP), response=True) async def brightness_step_down(client): """Step brightness down by 1. All data bytes ignored by controller.""" - await asyncio.sleep(0.1) + await asyncio.sleep(0.05) await send(client, packet(0, 0, 0, Command.BRIGHT_DOWN), response=True) From 644b47da8c6377458604902c7a4aa72a5842ce57 Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Tue, 10 Mar 2026 04:02:17 -0700 Subject: [PATCH 69/82] further fix --- tools/underglow/sp105e.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tools/underglow/sp105e.py b/tools/underglow/sp105e.py index 924f384c831aeb..af89023d5b7293 100755 --- a/tools/underglow/sp105e.py +++ b/tools/underglow/sp105e.py @@ -160,6 +160,7 @@ async def send(client, data: bytes, response=False): async def get_state(client, retries: int = 2) -> bytes | None: """Send GET_STATE (0x10) and return 8-byte notify response. Returns None on timeout. Byte 0: 1=on, 0=off.""" + await asyncio.sleep(0.1) for attempt in range(retries): result = None event = asyncio.Event() @@ -245,13 +246,13 @@ async def set_brightness(client, level: int): async def brightness_step_up(client): """Step brightness up by 1. All data bytes ignored by controller.""" - await asyncio.sleep(0.05) + await asyncio.sleep(0.1) await send(client, packet(0, 0, 0, Command.BRIGHT_UP), response=True) async def brightness_step_down(client): """Step brightness down by 1. All data bytes ignored by controller.""" - await asyncio.sleep(0.05) + await asyncio.sleep(0.1) await send(client, packet(0, 0, 0, Command.BRIGHT_DOWN), response=True) From ecfc31b8944a10444726544ac0e8543d14f45fc7 Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Tue, 10 Mar 2026 04:40:33 -0700 Subject: [PATCH 70/82] safe rainbow --- selfdrive/glowd/glowd.py | 43 ++++++++++++++++++++++++++++------------ 1 file changed, 30 insertions(+), 13 deletions(-) diff --git a/selfdrive/glowd/glowd.py b/selfdrive/glowd/glowd.py index 217ed9df714b08..cc538132cd5f1f 100644 --- a/selfdrive/glowd/glowd.py +++ b/selfdrive/glowd/glowd.py @@ -26,8 +26,6 @@ import time from enum import IntEnum, IntFlag -import numpy as np - import cereal.messaging as messaging from openpilot.common.filter_simple import FirstOrderFilter from openpilot.common.params import Params @@ -49,11 +47,13 @@ RAINBOW_HOLDOVER_S = 2.5 RAINBOW_DELAY_S = 1.5 RAINBOW_PERIOD_S = 8.0 +FULL_RAINBOW_DELAY_S = 60.0 class GlowState(IntEnum): - DRIVING = 0 # RPM-based color - STANDSTILL = 1 # rainbow cycle + DRIVING = 0 # RPM-based color + STANDSTILL = 1 # safe rainbow (green ↔ amber/purple, filter-friendly) + STANDSTILL_FULL = 2 # full rainbow (after 1min standstill) class GlowMod(IntFlag): @@ -65,8 +65,8 @@ def rpm_to_color(rpm: float) -> tuple[int, int, int]: Avoids pure red and blue. Low range uses HSV, high range blends RGB.""" t = max(0.0, min(1.0, (rpm - RPM_MIN) / (RPM_COLOR_MAX - RPM_MIN))) if t < 0.7: - # green (0.33) → orange (0.08) in HSV - hue = 0.33 - (0.33 - 0.08) * (t / 0.7) + # green (0.33) → orange (0.08) in HSV, stays greener at low RPM + hue = 0.33 - (0.33 - 0.08) * (t / 0.7) ** 1.5 r, g, b = colorsys.hsv_to_rgb(hue, 1.0, 1.0) return int(r * 255), int(g * 255), int(b * 255) else: @@ -79,7 +79,6 @@ def rpm_to_color(rpm: float) -> tuple[int, int, int]: ) - class GlowController: def __init__(self): self.last_color = (0, 0, 0) @@ -110,9 +109,17 @@ def smooth_color(self, color: tuple[int, int, int]) -> tuple[int, int, int]: r, g, b = colorsys.hsv_to_rgb(h, s, v) return int(r * 255), int(g * 255), int(b * 255) - def _rainbow_color(self, v_ego: float) -> tuple[int, int, int]: - speed_mult = np.interp(v_ego, [0.0, 5.0], [1.0, 2.0]) - hue = (time.monotonic() * speed_mult / RAINBOW_PERIOD_S) % 1.0 + def _rainbow_safe_color(self) -> tuple[int, int, int]: + """Cycle green(0.33) ↔ yellow(0.14). Smooth enough for the HSV filter.""" + t = (time.monotonic() / RAINBOW_PERIOD_S) % 1.0 + # Ping-pong between green and yellow + hue = 0.14 + (0.33 - 0.14) * (0.5 + 0.5 * math.sin(2 * math.pi * t)) + r, g, b = colorsys.hsv_to_rgb(hue, 1.0, 1.0) + return int(r * 255), int(g * 255), int(b * 255) + + def _rainbow_full_color(self) -> tuple[int, int, int]: + """Full hue cycle. Only used after extended standstill.""" + hue = (time.monotonic() / RAINBOW_PERIOD_S) % 1.0 r, g, b = colorsys.hsv_to_rgb(hue, 1.0, 1.0) return int(r * 255), int(g * 255), int(b * 255) @@ -136,7 +143,7 @@ def update(self, sm, chill: bool): else: self._standstill_start = None - elif self.state == GlowState.STANDSTILL: + elif self.state in (GlowState.STANDSTILL, GlowState.STANDSTILL_FULL): if cs.engineRpm >= 1500: self._moving_start = None self.state = GlowState.DRIVING @@ -149,6 +156,14 @@ def update(self, sm, chill: bool): else: self._moving_start = None + # Upgrade to full rainbow after extended standstill + if self.state == GlowState.STANDSTILL: + if self._standstill_start is None: + self._standstill_start = now + elif now - self._standstill_start > FULL_RAINBOW_DELAY_S: + self._standstill_start = None + self.state = GlowState.STANDSTILL_FULL + # Update modifiers if cs.brakePressed and not self._prev_brake: self._brake_pressed_t = now @@ -168,7 +183,9 @@ def get_color(self, sm) -> tuple[int, int, int]: # Base color from state if self.state == GlowState.STANDSTILL: - return self._rainbow_color(cs.vEgo) + return self._rainbow_safe_color() + if self.state == GlowState.STANDSTILL_FULL: + return self._rainbow_full_color() return rpm_to_color(cs.engineRpm) @@ -282,7 +299,7 @@ def signal_handler(signum, frame): if color != ctrl.last_color: if DEBUG: cs = sm['carState'] - print(f"glowd: RPM={cs.engineRpm:.0f} chill={chill_mode} → RGB{color}") + print(f"glowd: state={ctrl.state.name} RPM={cs.engineRpm:.0f} v={cs.vEgo:.1f} brake={cs.brakePressed} chill={chill_mode} → RGB{color}") try: await sp105e.set_color(client, *color) From 19d48079f172feaa4b6337cd71a8a2891375461a Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Tue, 10 Mar 2026 04:49:00 -0700 Subject: [PATCH 71/82] retry brightness --- tools/underglow/sp105e.py | 34 +++++++++++++++++++++------------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/tools/underglow/sp105e.py b/tools/underglow/sp105e.py index af89023d5b7293..1db7e93cb6dc30 100755 --- a/tools/underglow/sp105e.py +++ b/tools/underglow/sp105e.py @@ -228,20 +228,28 @@ async def get_brightness(client) -> int | None: return None -async def set_brightness(client, level: int): - """Set absolute brightness (0-6). Reads current level and steps to target.""" +async def set_brightness(client, level: int, retries: int = 2): + """Set absolute brightness (0-6). Reads current level, steps to target, verifies.""" level = max(BRIGHTNESS_MIN, min(BRIGHTNESS_MAX, level)) - current = await get_brightness(client) - if current is None: - print("WARNING: set_brightness can't read current level, skipping") - return - diff = level - current - if diff > 0: - for _ in range(diff): - await brightness_step_up(client) - elif diff < 0: - for _ in range(-diff): - await brightness_step_down(client) + for attempt in range(retries): + current = await get_brightness(client) + if current is None: + print("WARNING: set_brightness can't read current level, skipping") + return + diff = level - current + if diff == 0: + return + if diff > 0: + for _ in range(diff): + await brightness_step_up(client) + else: + for _ in range(-diff): + await brightness_step_down(client) + # verify + actual = await get_brightness(client) + if actual == level: + return + print(f"set_brightness: attempt {attempt + 1} wanted {level}, got {actual}, retrying") async def brightness_step_up(client): From 1a07822fb54e6595b3b63c2ecc8c43b6b06634b1 Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Tue, 10 Mar 2026 19:35:28 -0700 Subject: [PATCH 72/82] bouncefilter --- selfdrive/glowd/glowd.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/selfdrive/glowd/glowd.py b/selfdrive/glowd/glowd.py index cc538132cd5f1f..344a9c2308359a 100644 --- a/selfdrive/glowd/glowd.py +++ b/selfdrive/glowd/glowd.py @@ -27,7 +27,7 @@ from enum import IntEnum, IntFlag import cereal.messaging as messaging -from openpilot.common.filter_simple import FirstOrderFilter +from openpilot.common.filter_simple import BounceFilter, FirstOrderFilter from openpilot.common.params import Params from openpilot.common.realtime import Ratekeeper @@ -43,7 +43,7 @@ # Timing UPDATE_HZ = 15 -BRAKE_FLASH_S = 0.8 +BRAKE_FLASH_S = 0.4 RAINBOW_HOLDOVER_S = 2.5 RAINBOW_DELAY_S = 1.5 RAINBOW_PERIOD_S = 8.0 @@ -89,6 +89,9 @@ def __init__(self): self._prev_brake = False self._brake_pressed_t = 0.0 + # RPM bounce filter — overshoots on rapid changes (downshifts), settles back + self._rpm_bounce = BounceFilter(RPM_MIN, 0.3, dt, initialized=False, bounce=3) + # HSV smoothing filters dt = 1.0 / UPDATE_HZ self._hx_filter = FirstOrderFilter(0.0, 0.5, dt, initialized=False) # cos(hue) @@ -186,7 +189,9 @@ def get_color(self, sm) -> tuple[int, int, int]: return self._rainbow_safe_color() if self.state == GlowState.STANDSTILL_FULL: return self._rainbow_full_color() - return rpm_to_color(cs.engineRpm) + # Bounce filter adds overshoot on rapid RPM changes (downshifts, rev matches) + effective_rpm = self._rpm_bounce.update(cs.engineRpm) + return rpm_to_color(effective_rpm) def _put_glow_status(params, status: str, color: tuple[int, int, int] = (0, 0, 0)): From 8b68854576b32f7405884924813935282f03e1f2 Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Tue, 10 Mar 2026 19:44:01 -0700 Subject: [PATCH 73/82] bouncefilter --- selfdrive/glowd/glowd.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/selfdrive/glowd/glowd.py b/selfdrive/glowd/glowd.py index 344a9c2308359a..d5d95576440617 100644 --- a/selfdrive/glowd/glowd.py +++ b/selfdrive/glowd/glowd.py @@ -89,11 +89,12 @@ def __init__(self): self._prev_brake = False self._brake_pressed_t = 0.0 + dt = 1.0 / UPDATE_HZ + # RPM bounce filter — overshoots on rapid changes (downshifts), settles back self._rpm_bounce = BounceFilter(RPM_MIN, 0.3, dt, initialized=False, bounce=3) # HSV smoothing filters - dt = 1.0 / UPDATE_HZ self._hx_filter = FirstOrderFilter(0.0, 0.5, dt, initialized=False) # cos(hue) self._hy_filter = FirstOrderFilter(0.0, 0.5, dt, initialized=False) # sin(hue) self._s_filter = FirstOrderFilter(0.0, 0.5, dt, initialized=False) From 7c8afab791a15f0ac0430617961a5f4cef81e9b5 Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Tue, 10 Mar 2026 19:53:28 -0700 Subject: [PATCH 74/82] bouncefilter --- selfdrive/glowd/glowd.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/selfdrive/glowd/glowd.py b/selfdrive/glowd/glowd.py index d5d95576440617..4ccbc90a415a29 100644 --- a/selfdrive/glowd/glowd.py +++ b/selfdrive/glowd/glowd.py @@ -303,6 +303,11 @@ def signal_handler(signum, frame): color = ctrl.smooth_color(raw_color) if color != ctrl.last_color: + # Skip BLE write if we're behind — catch up on next frame + if rk.remaining < -0.1: + rk.keep_time() + continue + if DEBUG: cs = sm['carState'] print(f"glowd: state={ctrl.state.name} RPM={cs.engineRpm:.0f} v={cs.vEgo:.1f} brake={cs.brakePressed} chill={chill_mode} → RGB{color}") From 77acefe1c2d5243b8711859b121e0f173f11041d Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Tue, 10 Mar 2026 19:53:58 -0700 Subject: [PATCH 75/82] Revert "bouncefilter" This reverts commit 7c8afab791a15f0ac0430617961a5f4cef81e9b5. --- selfdrive/glowd/glowd.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/selfdrive/glowd/glowd.py b/selfdrive/glowd/glowd.py index 4ccbc90a415a29..d5d95576440617 100644 --- a/selfdrive/glowd/glowd.py +++ b/selfdrive/glowd/glowd.py @@ -303,11 +303,6 @@ def signal_handler(signum, frame): color = ctrl.smooth_color(raw_color) if color != ctrl.last_color: - # Skip BLE write if we're behind — catch up on next frame - if rk.remaining < -0.1: - rk.keep_time() - continue - if DEBUG: cs = sm['carState'] print(f"glowd: state={ctrl.state.name} RPM={cs.engineRpm:.0f} v={cs.vEgo:.1f} brake={cs.brakePressed} chill={chill_mode} → RGB{color}") From 56c8cc5222d0b9b9e0eb7552bf9397a7eb0c4b4b Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Wed, 11 Mar 2026 02:17:38 -0700 Subject: [PATCH 76/82] brighter --- selfdrive/glowd/glowd.py | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/selfdrive/glowd/glowd.py b/selfdrive/glowd/glowd.py index d5d95576440617..e3358c198caf5f 100644 --- a/selfdrive/glowd/glowd.py +++ b/selfdrive/glowd/glowd.py @@ -2,21 +2,19 @@ """ glowd — SP105E underglow controller daemon. -Runs only_onroad. On start: connects BLE + powers on LEDs. -On SIGTERM (manager kill at ignition off): powers off LEDs + disconnects. -Maps CarState to underglow colors reactively. +Runs only_onroad. On start: connects BLE, powers on LEDs, sets brightness. +On SIGTERM (manager kill at ignition off): dims to min, powers off, disconnects. Color mapping: - RPM → hue (green idle → yellow → amber → purple at redline) - - Braking → orange, intensity scales with decel - - Gas → warm amber blended with RPM color - - Downshift → brief purple flash - - Blinker → amber pulse - - Standstill → slow breathing pulse - - Reverse → white + - RPM rate-of-change → bounce filter overshoots color on downshifts/rev matches + - Braking → brief red flash (0.4s) on press + - Standstill → safe rainbow (green ↔ yellow, filter-friendly) + - Standstill 60s+ → full hue rainbow +All colors smoothed through HSV filter (cos/sin for hue wrapping). +BLE writes skip frames when lagging to prevent queue snowball. California-legal: no red or blue, especially on the front. -Safe colors: green, yellow, amber, orange, purple, white, pink. """ import asyncio import colorsys @@ -39,7 +37,7 @@ RPM_MIN = 800 RPM_COLOR_MAX = 5500 -DRIVING_BRIGHTNESS = 3 # ~43%, level 0-6 +DRIVING_BRIGHTNESS = 4 # ~57%, level 0-6 # Timing UPDATE_HZ = 15 From 0de7448c93a18cdfa9acb4df54342679550f4777 Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Wed, 11 Mar 2026 02:27:23 -0700 Subject: [PATCH 77/82] brightness toggle --- common/params_keys.h | 1 + selfdrive/glowd/glowd.py | 16 +++++++------- selfdrive/ui/mici/layouts/settings/toggles.py | 4 +++- selfdrive/ui/mici/widgets/button.py | 21 +++++++++++++++++++ 4 files changed, 33 insertions(+), 9 deletions(-) diff --git a/common/params_keys.h b/common/params_keys.h index 44f07decdca10f..899fe2624d5f6b 100644 --- a/common/params_keys.h +++ b/common/params_keys.h @@ -49,6 +49,7 @@ inline static std::unordered_map keys = { {"GithubSshKeys", {PERSISTENT, STRING}}, {"GithubUsername", {PERSISTENT, STRING}}, {"GitRemote", {PERSISTENT, STRING}}, + {"GlowBrightness", {PERSISTENT, INT, "4"}}, {"GlowMode", {PERSISTENT, BOOL}}, {"GlowStandstillOnly", {PERSISTENT, BOOL, "1"}}, {"GlowStatus", {CLEAR_ON_MANAGER_START, JSON}}, diff --git a/selfdrive/glowd/glowd.py b/selfdrive/glowd/glowd.py index e3358c198caf5f..f3721ad361f017 100644 --- a/selfdrive/glowd/glowd.py +++ b/selfdrive/glowd/glowd.py @@ -37,7 +37,7 @@ RPM_MIN = 800 RPM_COLOR_MAX = 5500 -DRIVING_BRIGHTNESS = 4 # ~57%, level 0-6 +DEFAULT_BRIGHTNESS = 4 # ~57%, level 0-6 # Timing UPDATE_HZ = 15 @@ -203,9 +203,8 @@ def bt_is_ready() -> bool: return b"UP RUNNING" in result.stdout -async def ble_connect(): - """Connect to SP105E, power on, sweep brightness. Returns client or None. - Assumes BT stack is already up (bluetooth.service in AGNOS).""" +async def ble_connect(brightness: int = DEFAULT_BRIGHTNESS): + """Connect to SP105E, power on, set brightness. Returns client or None.""" if not bt_is_ready(): print("glowd: hci0 not up (waiting for bluetooth.service)") return None @@ -217,8 +216,8 @@ async def ble_connect(): await asyncio.sleep(0.5) await sp105e.set_power(client, on=True) await asyncio.sleep(0.5) - await sp105e.set_brightness(client, DRIVING_BRIGHTNESS) - print("glowd: connected, LEDs on") + await sp105e.set_brightness(client, brightness) + print(f"glowd: connected, LEDs on, brightness={brightness}") return client @@ -251,9 +250,10 @@ def signal_handler(signum, frame): _put_glow_status(params, "connecting") chill_mode = params.get_bool("GlowMode") standstill_only = params.get_bool("GlowStandstillOnly") + brightness = params.get("GlowBrightness") or DEFAULT_BRIGHTNESS last_param_read = 0.0 - client = await ble_connect() + client = await ble_connect(brightness) _put_glow_status(params, "connected" if client else "disconnected") sm = messaging.SubMaster(['carState'], poll='carState') @@ -279,7 +279,7 @@ def signal_handler(signum, frame): last_reconnect_attempt = now print("glowd: attempting reconnect...") _put_glow_status(params, "connecting") - client = await ble_connect() + client = await ble_connect(brightness) _put_glow_status(params, "connected" if client else "disconnected") rk.keep_time() continue diff --git a/selfdrive/ui/mici/layouts/settings/toggles.py b/selfdrive/ui/mici/layouts/settings/toggles.py index f64792ea8a13e4..d556a7e317abe0 100644 --- a/selfdrive/ui/mici/layouts/settings/toggles.py +++ b/selfdrive/ui/mici/layouts/settings/toggles.py @@ -1,7 +1,7 @@ from cereal import log from openpilot.system.ui.widgets.scroller import NavScroller -from openpilot.selfdrive.ui.mici.widgets.button import BigParamControl, BigMultiParamToggle +from openpilot.selfdrive.ui.mici.widgets.button import BigParamControl, BigMultiParamToggle, BigParamCycler from openpilot.system.ui.lib.application import gui_app from openpilot.selfdrive.ui.layouts.settings.common import restart_needed_callback from openpilot.selfdrive.ui.ui_state import ui_state @@ -22,11 +22,13 @@ def __init__(self): record_mic = BigParamControl("record & upload mic audio", "RecordAudio", toggle_callback=restart_needed_callback) glow_toggle = BigParamControl("chill glow", "GlowMode") glow_standstill_toggle = BigParamControl("standstill only glow", "GlowStandstillOnly") + glow_brightness = BigParamCycler("glow brightness", "GlowBrightness", 6, callback=restart_needed_callback) enable_openpilot = BigParamControl("enable openpilot", "OpenpilotEnabledToggle", toggle_callback=restart_needed_callback) self._scroller.add_widgets([ glow_toggle, glow_standstill_toggle, + glow_brightness, self._personality_toggle, self._experimental_btn, is_metric_toggle, diff --git a/selfdrive/ui/mici/widgets/button.py b/selfdrive/ui/mici/widgets/button.py index 9724a18192f8f2..53becb2a0efc9f 100644 --- a/selfdrive/ui/mici/widgets/button.py +++ b/selfdrive/ui/mici/widgets/button.py @@ -368,6 +368,27 @@ def refresh(self): self.set_checked(self.params.get_bool(self.param, False)) +class BigParamCycler(BigButton): + """Button that cycles through int values on tap, shows current value as subtitle.""" + def __init__(self, text: str, param: str, max_val: int, callback: Callable | None = None): + self._param = param + self._max_val = max_val + self._callback = callback + self._params = Params() + val = self._params.get(self._param) or 0 + super().__init__(text, f"{val}/{max_val}") + + def _handle_mouse_release(self, mouse_pos: MousePos): + super()._handle_mouse_release(mouse_pos) + val = (self._params.get(self._param) or 0) + 1 + if val > self._max_val: + val = 0 + self._params.put_nonblocking(self._param, val) + self.set_value(f"{val}/{self._max_val}") + if self._callback: + self._callback(val) + + # TODO: param control base class class BigCircleParamControl(BigCircleToggle): def __init__(self, icon: rl.Texture, param: str, toggle_callback: Callable | None = None, From 100d6c14fe267abc30b12e44b80e27189244e6f3 Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Thu, 12 Mar 2026 04:29:38 -0700 Subject: [PATCH 78/82] no brake --- selfdrive/glowd/glowd.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/selfdrive/glowd/glowd.py b/selfdrive/glowd/glowd.py index f3721ad361f017..a2c5e452a11134 100644 --- a/selfdrive/glowd/glowd.py +++ b/selfdrive/glowd/glowd.py @@ -8,7 +8,6 @@ Color mapping: - RPM → hue (green idle → yellow → amber → purple at redline) - RPM rate-of-change → bounce filter overshoots color on downshifts/rev matches - - Braking → brief red flash (0.4s) on press - Standstill → safe rainbow (green ↔ yellow, filter-friendly) - Standstill 60s+ → full hue rainbow @@ -86,7 +85,6 @@ def __init__(self): self._mods = GlowMod(0) self._prev_brake = False self._brake_pressed_t = 0.0 - dt = 1.0 / UPDATE_HZ # RPM bounce filter — overshoots on rapid changes (downshifts), settles back @@ -180,8 +178,8 @@ def get_color(self, sm) -> tuple[int, int, int]: cs = sm['carState'] # Modifier: brake — dark red flash on rising edge - if self._mods & GlowMod.BRAKE: - return (128, 0, 0) + # if self._mods & GlowMod.BRAKE: + # return (128, 0, 0) # Base color from state if self.state == GlowState.STANDSTILL: From c16e6f7397ceee177318160ec300d576ac9e3bf6 Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Thu, 12 Mar 2026 04:30:18 -0700 Subject: [PATCH 79/82] lower max rpm --- selfdrive/glowd/glowd.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/selfdrive/glowd/glowd.py b/selfdrive/glowd/glowd.py index a2c5e452a11134..a705fec0b691ab 100644 --- a/selfdrive/glowd/glowd.py +++ b/selfdrive/glowd/glowd.py @@ -34,7 +34,7 @@ # RPM thresholds RPM_MIN = 800 -RPM_COLOR_MAX = 5500 +RPM_COLOR_MAX = 4000 DEFAULT_BRIGHTNESS = 4 # ~57%, level 0-6 From 52b09ca0d2894a034abf6a810a7378a5863a71b7 Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Mon, 16 Mar 2026 04:10:32 -0700 Subject: [PATCH 80/82] toggle to disable glow and rm other toggles --- common/params_keys.h | 3 +- selfdrive/glowd/glowd.py | 36 +++++-------------- selfdrive/ui/mici/layouts/settings/toggles.py | 9 ++--- tools/underglow/sp105e.py | 2 +- 4 files changed, 14 insertions(+), 36 deletions(-) diff --git a/common/params_keys.h b/common/params_keys.h index 899fe2624d5f6b..66e273dcf6550a 100644 --- a/common/params_keys.h +++ b/common/params_keys.h @@ -50,8 +50,7 @@ inline static std::unordered_map keys = { {"GithubUsername", {PERSISTENT, STRING}}, {"GitRemote", {PERSISTENT, STRING}}, {"GlowBrightness", {PERSISTENT, INT, "4"}}, - {"GlowMode", {PERSISTENT, BOOL}}, - {"GlowStandstillOnly", {PERSISTENT, BOOL, "1"}}, + {"GlowEnabled", {PERSISTENT, BOOL, "1"}}, {"GlowStatus", {CLEAR_ON_MANAGER_START, JSON}}, {"GsmApn", {PERSISTENT, STRING}}, {"GsmMetered", {PERSISTENT, BOOL, "1"}}, diff --git a/selfdrive/glowd/glowd.py b/selfdrive/glowd/glowd.py index a705fec0b691ab..d9186143bad424 100644 --- a/selfdrive/glowd/glowd.py +++ b/selfdrive/glowd/glowd.py @@ -123,15 +123,10 @@ def _rainbow_full_color(self) -> tuple[int, int, int]: r, g, b = colorsys.hsv_to_rgb(hue, 1.0, 1.0) return int(r * 255), int(g * 255), int(b * 255) - def update(self, sm, chill: bool): + def update(self, sm): cs = sm['carState'] now = time.monotonic() - if chill: - self.state = GlowState.DRIVING - self._mods = GlowMod(0) - return - # Base state transitions if self.state == GlowState.DRIVING: if cs.vEgo < 1 and cs.engineRpm < 1500: @@ -245,11 +240,13 @@ def signal_handler(signum, frame): signal.signal(signal.SIGINT, signal_handler) params = Params() + if not params.get_bool("GlowEnabled"): + print("glowd: disabled via GlowEnabled param") + _put_glow_status(params, "disabled") + return + _put_glow_status(params, "connecting") - chill_mode = params.get_bool("GlowMode") - standstill_only = params.get_bool("GlowStandstillOnly") brightness = params.get("GlowBrightness") or DEFAULT_BRIGHTNESS - last_param_read = 0.0 client = await ble_connect(brightness) _put_glow_status(params, "connected" if client else "disconnected") @@ -259,17 +256,12 @@ def signal_handler(signum, frame): rk = Ratekeeper(UPDATE_HZ) last_reconnect_attempt = 0.0 - print(f"glowd: running at {UPDATE_HZ}Hz, chill={chill_mode}") + print(f"glowd: running at {UPDATE_HZ}Hz") while not do_exit: sm.update(0) - # Refresh params every 5s now = time.monotonic() - if now - last_param_read > 2.5: - chill_mode = params.get_bool("GlowMode") - standstill_only = params.get_bool("GlowStandstillOnly") - last_param_read = now # If disconnected, try to reconnect every 5s if client is None: @@ -283,17 +275,7 @@ def signal_handler(signum, frame): continue if sm.updated['carState']: - ctrl.update(sm, chill_mode) - - if standstill_only and ctrl.state == GlowState.DRIVING: - if ctrl.last_color != (0, 0, 0): - await sp105e.set_power(client, on=False) - ctrl.last_color = (0, 0, 0) - _put_glow_status(params, "connected") - rk.keep_time() - continue - elif standstill_only and ctrl.last_color == (0, 0, 0): - await sp105e.set_power(client, on=True) + ctrl.update(sm) raw_color = ctrl.get_color(sm) color = ctrl.smooth_color(raw_color) @@ -301,7 +283,7 @@ def signal_handler(signum, frame): if color != ctrl.last_color: if DEBUG: cs = sm['carState'] - print(f"glowd: state={ctrl.state.name} RPM={cs.engineRpm:.0f} v={cs.vEgo:.1f} brake={cs.brakePressed} chill={chill_mode} → RGB{color}") + print(f"glowd: state={ctrl.state.name} RPM={cs.engineRpm:.0f} v={cs.vEgo:.1f} brake={cs.brakePressed} → RGB{color}") try: await sp105e.set_color(client, *color) diff --git a/selfdrive/ui/mici/layouts/settings/toggles.py b/selfdrive/ui/mici/layouts/settings/toggles.py index d556a7e317abe0..9e60696fe33459 100644 --- a/selfdrive/ui/mici/layouts/settings/toggles.py +++ b/selfdrive/ui/mici/layouts/settings/toggles.py @@ -20,14 +20,12 @@ def __init__(self): always_on_dm_toggle = BigParamControl("always-on driver monitor", "AlwaysOnDM") record_front = BigParamControl("record & upload driver camera", "RecordFront", toggle_callback=restart_needed_callback) record_mic = BigParamControl("record & upload mic audio", "RecordAudio", toggle_callback=restart_needed_callback) - glow_toggle = BigParamControl("chill glow", "GlowMode") - glow_standstill_toggle = BigParamControl("standstill only glow", "GlowStandstillOnly") + glow_enabled = BigParamControl("enable underglow", "GlowEnabled", toggle_callback=restart_needed_callback) glow_brightness = BigParamCycler("glow brightness", "GlowBrightness", 6, callback=restart_needed_callback) enable_openpilot = BigParamControl("enable openpilot", "OpenpilotEnabledToggle", toggle_callback=restart_needed_callback) self._scroller.add_widgets([ - glow_toggle, - glow_standstill_toggle, + glow_enabled, glow_brightness, self._personality_toggle, self._experimental_btn, @@ -47,8 +45,7 @@ def __init__(self): ("AlwaysOnDM", always_on_dm_toggle), ("RecordFront", record_front), ("RecordAudio", record_mic), - ("GlowMode", glow_toggle), - ("GlowStandstillOnly", glow_standstill_toggle), + ("GlowEnabled", glow_enabled), ("OpenpilotEnabledToggle", enable_openpilot), ) diff --git a/tools/underglow/sp105e.py b/tools/underglow/sp105e.py index 1db7e93cb6dc30..91b5b23129fbc3 100755 --- a/tools/underglow/sp105e.py +++ b/tools/underglow/sp105e.py @@ -192,7 +192,7 @@ async def is_on(client) -> bool: # --- High-level commands --- async def set_color(client, r, g, b): - await send(client, color_packet(r, g, b), response=True) + await send(client, color_packet(r, g, b)) async def power_toggle(client): From c1eab5589c29cb342231949055c5e337ae92b39f Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Mon, 16 Mar 2026 04:15:38 -0700 Subject: [PATCH 81/82] tweak rpms --- selfdrive/glowd/glowd.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/selfdrive/glowd/glowd.py b/selfdrive/glowd/glowd.py index d9186143bad424..00bbac041847a9 100644 --- a/selfdrive/glowd/glowd.py +++ b/selfdrive/glowd/glowd.py @@ -33,8 +33,8 @@ DEBUG = True # RPM thresholds -RPM_MIN = 800 -RPM_COLOR_MAX = 4000 +RPM_MIN = 1500 +RPM_COLOR_MAX = 5500 DEFAULT_BRIGHTNESS = 4 # ~57%, level 0-6 From 561e373f2ce43208185dcbe83182ea74e90e6185 Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Mon, 16 Mar 2026 04:39:46 -0700 Subject: [PATCH 82/82] rpm baseline --- selfdrive/glowd/glowd.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/selfdrive/glowd/glowd.py b/selfdrive/glowd/glowd.py index 00bbac041847a9..4eefe9eb0cce38 100644 --- a/selfdrive/glowd/glowd.py +++ b/selfdrive/glowd/glowd.py @@ -24,7 +24,7 @@ from enum import IntEnum, IntFlag import cereal.messaging as messaging -from openpilot.common.filter_simple import BounceFilter, FirstOrderFilter +from openpilot.common.filter_simple import FirstOrderFilter from openpilot.common.params import Params from openpilot.common.realtime import Ratekeeper @@ -37,6 +37,7 @@ RPM_COLOR_MAX = 5500 DEFAULT_BRIGHTNESS = 4 # ~57%, level 0-6 +DIFF_SCALE = 2.5 # multiplier on rpm-baseline diff for color mapping # Timing UPDATE_HZ = 15 @@ -62,8 +63,8 @@ def rpm_to_color(rpm: float) -> tuple[int, int, int]: Avoids pure red and blue. Low range uses HSV, high range blends RGB.""" t = max(0.0, min(1.0, (rpm - RPM_MIN) / (RPM_COLOR_MAX - RPM_MIN))) if t < 0.7: - # green (0.33) → orange (0.08) in HSV, stays greener at low RPM - hue = 0.33 - (0.33 - 0.08) * (t / 0.7) ** 1.5 + # green (0.33) → orange (0.08) in HSV + hue = 0.33 - (0.33 - 0.08) * (t / 0.7) r, g, b = colorsys.hsv_to_rgb(hue, 1.0, 1.0) return int(r * 255), int(g * 255), int(b * 255) else: @@ -87,8 +88,8 @@ def __init__(self): self._brake_pressed_t = 0.0 dt = 1.0 / UPDATE_HZ - # RPM bounce filter — overshoots on rapid changes (downshifts), settles back - self._rpm_bounce = BounceFilter(RPM_MIN, 0.3, dt, initialized=False, bounce=3) + # RPM baseline filter — tracks steady-state RPM, color driven by rpm - baseline + self._rpm_baseline = FirstOrderFilter(0.0, 5.0, dt, initialized=False) # HSV smoothing filters self._hx_filter = FirstOrderFilter(0.0, 0.5, dt, initialized=False) # cos(hue) @@ -181,9 +182,10 @@ def get_color(self, sm) -> tuple[int, int, int]: return self._rainbow_safe_color() if self.state == GlowState.STANDSTILL_FULL: return self._rainbow_full_color() - # Bounce filter adds overshoot on rapid RPM changes (downshifts, rev matches) - effective_rpm = self._rpm_bounce.update(cs.engineRpm) - return rpm_to_color(effective_rpm) + # Color driven by rpm - baseline: cruise = green, rev changes = color + baseline = self._rpm_baseline.update(cs.engineRpm) + diff = max(0, cs.engineRpm - baseline) * DIFF_SCALE + return rpm_to_color(RPM_MIN + diff) def _put_glow_status(params, status: str, color: tuple[int, int, int] = (0, 0, 0)):