From 4914445415f1b5ae5adc61ff672323a05ec3f8e5 Mon Sep 17 00:00:00 2001 From: Nayan Date: Fri, 28 Nov 2025 01:02:31 -0500 Subject: [PATCH 1/9] ui: sunnypilot MultiButtonControl (#1478) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * commaai/openpilot:d05cb31e2e916fba41ba8167030945f427fd811b * bump opendbc * bump opendbc * bump opendbc * bump opendbc * bump opendbc * sunnypilot: remove Qt * cabana: revert to stock Qt * commaai/openpilot:5198b1b079c37742c1050f02ce0aa6dd42b038b9 * commaai/openpilot:954b567b9ba0f3d1ae57d6aa7797fa86dd92ec6e * commaai/openpilot:7534b2a160faa683412c04c1254440e338931c5e * sum more * bump opendbc * not yet * should've been symlink'ed * raylib says wut * quiet mode back * more fixes * no more * too extra red diff on the side * need to bring this back * too extra * let's update docs here * Revert "let's update docs here" This reverts commit 51fe03cd5121e6fdf14657b2c33852c34922b851. * param to control stock vs sp ui * init styles * SP Toggles * Lint * optimizations * multi-button * Lint * param to control stock vs sp ui * init styles * SP Toggles * Lint * optimizations * Panels. With Icons. And Scroller. * patience, grasshopper * more patience, grasshopper * sp raylib preview * fix callback * fix ui preview * add ui previews * better padding * this * support for next line multi-button * uhh * disabled colors * listitem -> listitemsp * listitem -> listitemsp * add show_description method * remove padding from line separator. like, WHY? 😩😩 * scroller -> scroller_tici * scroller -> scroller_tici * ui: `GuiApplicationExt` * add to readme * use gui_app.sunnypilot_ui() * use gui_app.sunnypilot_ui() * use in toggles panel * ugh. no * better & animated * lint * cleanup * lint. LINT * slight * default no inline ty <3 * style change * move stuff around --------- Co-authored-by: Jason Wen --- selfdrive/ui/layouts/settings/toggles.py | 3 +- system/ui/sunnypilot/lib/styles.py | 9 ++ system/ui/sunnypilot/widgets/list_view.py | 108 +++++++++++++++++++--- system/ui/widgets/network.py | 2 + 4 files changed, 108 insertions(+), 14 deletions(-) diff --git a/selfdrive/ui/layouts/settings/toggles.py b/selfdrive/ui/layouts/settings/toggles.py index cd233aa3ac..f5f3a4e9c5 100644 --- a/selfdrive/ui/layouts/settings/toggles.py +++ b/selfdrive/ui/layouts/settings/toggles.py @@ -11,6 +11,7 @@ from openpilot.selfdrive.ui.ui_state import ui_state if gui_app.sunnypilot_ui(): from openpilot.system.ui.sunnypilot.widgets.list_view import toggle_item_sp as toggle_item + from openpilot.system.ui.sunnypilot.widgets.list_view import multiple_button_item_sp as multiple_button_item PERSONALITY_TO_INT = log.LongitudinalPersonality.schema.enumerants @@ -99,7 +100,7 @@ class TogglesLayout(Widget): lambda: tr("Driving Personality"), lambda: tr(DESCRIPTIONS["LongitudinalPersonality"]), buttons=[lambda: tr("Aggressive"), lambda: tr("Standard"), lambda: tr("Relaxed")], - button_width=255, + button_width=300, callback=self._set_longitudinal_personality, selected_index=self._params.get("LongitudinalPersonality", return_default=True), icon="speed_limit.png" diff --git a/system/ui/sunnypilot/lib/styles.py b/system/ui/sunnypilot/lib/styles.py index 4880ad58de..b942f79d33 100644 --- a/system/ui/sunnypilot/lib/styles.py +++ b/system/ui/sunnypilot/lib/styles.py @@ -24,6 +24,10 @@ class Base: TOGGLE_WIDTH = int(TOGGLE_HEIGHT * 1.75) TOGGLE_BG_HEIGHT = TOGGLE_HEIGHT - 20 + # Button Control + BUTTON_WIDTH = 300 + BUTTON_HEIGHT = 120 + @dataclass class DefaultStyleSP(Base): @@ -47,5 +51,10 @@ class DefaultStyleSP(Base): TOGGLE_DISABLED_OFF_COLOR = DISABLED_OFF_BG_COLOR TOGGLE_DISABLED_KNOB_COLOR = rl.Color(88, 88, 88, 255) # Lighter Grey + # Multi Button Control + MBC_TRANSPARENT = rl.Color(255, 255, 255, 0) + MBC_BG_CHECKED_ENABLED = rl.Color(0x69, 0x68, 0x68, 0xFF) + MBC_DISABLED = rl.Color(0xFF, 0xFF, 0xFF, 0x33) + style = DefaultStyleSP diff --git a/system/ui/sunnypilot/widgets/list_view.py b/system/ui/sunnypilot/widgets/list_view.py index dcf8f6019d..4ecb9a4881 100644 --- a/system/ui/sunnypilot/widgets/list_view.py +++ b/system/ui/sunnypilot/widgets/list_view.py @@ -7,9 +7,11 @@ See the LICENSE.md file in the root directory for more details. from collections.abc import Callable import pyray as rl +from openpilot.common.params import Params +from openpilot.system.ui.lib.application import MousePos from openpilot.system.ui.lib.text_measure import measure_text_cached from openpilot.system.ui.sunnypilot.widgets.toggle import ToggleSP -from openpilot.system.ui.widgets.list_view import ListItem, ToggleAction, ItemAction +from openpilot.system.ui.widgets.list_view import ListItem, ToggleAction, ItemAction, MultipleButtonAction, _resolve_value from openpilot.system.ui.sunnypilot.lib.styles import style @@ -20,11 +22,74 @@ class ToggleActionSP(ToggleAction): self.toggle = ToggleSP(initial_state=initial_state, callback=callback, param=param) +class MultipleButtonActionSP(MultipleButtonAction): + def __init__(self, buttons: list[str | Callable[[], str]], button_width: int, selected_index: int = 0, callback: Callable = None, + param: str | None = None): + MultipleButtonAction.__init__(self, buttons, button_width, selected_index, callback) + self.param_key = param + self.params = Params() + if self.param_key: + self.selected_button = int(self.params.get(self.param_key, return_default=True)) + self._anim_x: float | None = None + + def _render(self, rect: rl.Rectangle): + + button_y = rect.y + (rect.height - style.BUTTON_HEIGHT) / 2 + + total_width = len(self.buttons) * self.button_width + track_rect = rl.Rectangle(rect.x, button_y, total_width, style.BUTTON_HEIGHT) + + bg_color = style.MBC_TRANSPARENT + text_color = style.ITEM_TEXT_COLOR if self.enabled else style.MBC_DISABLED + highlight_color = style.MBC_BG_CHECKED_ENABLED if self.enabled else style.MBC_DISABLED + + # background + rl.draw_rectangle_rounded(track_rect, 0.2, 20, bg_color) + + # border + border_color = style.MBC_BG_CHECKED_ENABLED if self.enabled else style.MBC_DISABLED + rl.draw_rectangle_rounded_lines_ex(track_rect, 0.2, 20, 2, border_color) + + # highlight with animation + target_x = rect.x + self.selected_button * self.button_width + if not self._anim_x: + self._anim_x = target_x + self._anim_x += (target_x - self._anim_x) * 0.2 + + highlight_rect = rl.Rectangle(self._anim_x, button_y, self.button_width, style.BUTTON_HEIGHT) + rl.draw_rectangle_rounded(highlight_rect, 0.2, 20, highlight_color) + + # text + for i, _text in enumerate(self.buttons): + button_x = rect.x + i * self.button_width + + text = _resolve_value(_text, "") + text_size = measure_text_cached(self._font, text, 40) + text_x = button_x + (self.button_width - text_size.x) / 2 + text_y = button_y + (style.BUTTON_HEIGHT - text_size.y) / 2 + + rl.draw_text_ex(self._font, text, rl.Vector2(text_x, text_y), 40, 0, text_color) + + def _handle_mouse_release(self, mouse_pos: MousePos): + MultipleButtonAction._handle_mouse_release(self, mouse_pos) + if self.param_key: + self.params.put(self.param_key, self.selected_button) + + class ListItemSP(ListItem): def __init__(self, title: str | Callable[[], str] = "", icon: str | None = None, description: str | Callable[[], str] | None = None, description_visible: bool = False, callback: Callable | None = None, - action_item: ItemAction | None = None): + action_item: ItemAction | None = None, inline: bool = True): ListItem.__init__(self, title, icon, description, description_visible, callback, action_item) + self.inline = inline + if not self.inline: + self._rect.height += style.ITEM_BASE_HEIGHT/1.75 + + def get_item_height(self, font: rl.Font, max_width: int) -> float: + height = super().get_item_height(font, max_width) + if not self.inline: + height = height + style.ITEM_BASE_HEIGHT/1.75 + return height def show_description(self, show: bool): self._set_description_visible(show) @@ -33,6 +98,10 @@ class ListItemSP(ListItem): if not self.action_item: return rl.Rectangle(0, 0, 0, 0) + if not self.inline: + action_y = item_rect.y + self._text_size.y + style.ITEM_PADDING * 3 + return rl.Rectangle(item_rect.x + style.ITEM_PADDING, action_y, item_rect.width - (style.ITEM_PADDING * 2), style.BUTTON_HEIGHT) + right_width = self.action_item.rect.width if right_width == 0: # Full width action (like DualButtonAction) return rl.Rectangle(item_rect.x + style.ITEM_PADDING, item_rect.y, @@ -47,6 +116,13 @@ class ListItemSP(ListItem): return rl.Rectangle(action_x, action_y, action_width, style.ITEM_BASE_HEIGHT) def _render(self, _): + if not self.is_visible: + return + + # Don't draw items that are not in parent's viewport + if (self._rect.y + self.rect.height) <= self._parent_rect.y or self._rect.y >= (self._parent_rect.y + self._parent_rect.height): + return + content_x = self._rect.x + style.ITEM_PADDING text_x = content_x left_action_item = isinstance(self.action_item, ToggleAction) @@ -62,8 +138,8 @@ class ListItemSP(ListItem): # Draw title if self.title: - text_size = measure_text_cached(self._font, self.title, style.ITEM_TEXT_FONT_SIZE) - item_y = self._rect.y + (style.ITEM_BASE_HEIGHT - text_size.y) // 2 + self._text_size = measure_text_cached(self._font, self.title, style.ITEM_TEXT_FONT_SIZE) + item_y = self._rect.y + (style.ITEM_BASE_HEIGHT - self._text_size.y) // 2 rl.draw_text_ex(self._font, self.title, rl.Vector2(text_x, item_y), style.ITEM_TEXT_FONT_SIZE, 0, style.ITEM_TEXT_COLOR) # Render toggle and handle callback @@ -74,14 +150,13 @@ class ListItemSP(ListItem): else: if self.title: # Draw main text - text_size = measure_text_cached(self._font, self.title, style.ITEM_TEXT_FONT_SIZE) - item_y = self._rect.y + (style.ITEM_BASE_HEIGHT - text_size.y) // 2 + self._text_size = measure_text_cached(self._font, self.title, style.ITEM_TEXT_FONT_SIZE) + item_y = self._rect.y + (style.ITEM_BASE_HEIGHT - self._text_size.y) // 2 if self.inline else self._rect.y + style.ITEM_PADDING * 1.5 rl.draw_text_ex(self._font, self.title, rl.Vector2(text_x, item_y), style.ITEM_TEXT_FONT_SIZE, 0, style.ITEM_TEXT_COLOR) # Draw right item if present if self.action_item: right_rect = self.get_right_item_rect(self._rect) - right_rect.y = self._rect.y if self.action_item.render(right_rect) and self.action_item.enabled: # Right item was clicked/activated if self.callback: @@ -91,12 +166,12 @@ class ListItemSP(ListItem): if self.description_visible: content_width = int(self._rect.width - style.ITEM_PADDING * 2) description_height = self._html_renderer.get_total_height(content_width) - description_rect = rl.Rectangle( - self._rect.x + style.ITEM_PADDING, - self._rect.y + style.ITEM_DESC_V_OFFSET, - content_width, - description_height - ) + + desc_y = self._rect.y + style.ITEM_DESC_V_OFFSET + if not self.inline and self.action_item: + desc_y = self.action_item.rect.y + style.ITEM_DESC_V_OFFSET - style.ITEM_PADDING * 1.75 + + description_rect = rl.Rectangle(self._rect.x + style.ITEM_PADDING, desc_y, content_width, description_height) self._html_renderer.render(description_rect) @@ -104,3 +179,10 @@ def toggle_item_sp(title: str | Callable[[], str], description: str | Callable[[ callback: Callable | None = None, icon: str = "", enabled: bool | Callable[[], bool] = True, param: str | None = None) -> ListItemSP: action = ToggleActionSP(initial_state=initial_state, enabled=enabled, callback=callback, param=param) return ListItemSP(title=title, description=description, action_item=action, icon=icon, callback=callback) + + +def multiple_button_item_sp(title: str | Callable[[], str], description: str | Callable[[], str], buttons: list[str | Callable[[], str]], + selected_index: int = 0, button_width: int = style.BUTTON_WIDTH, callback: Callable = None, + icon: str = "", param: str | None = None, inline: bool = False) -> ListItemSP: + action = MultipleButtonActionSP(buttons, button_width, selected_index, callback=callback, param=param) + return ListItemSP(title=title, description=description, icon=icon, action_item=action, inline=inline) diff --git a/system/ui/widgets/network.py b/system/ui/widgets/network.py index fa47d35536..71755cc284 100644 --- a/system/ui/widgets/network.py +++ b/system/ui/widgets/network.py @@ -16,7 +16,9 @@ from openpilot.system.ui.widgets.scroller_tici import Scroller from openpilot.system.ui.widgets.list_view import ButtonAction, ListItem, MultipleButtonAction, ToggleAction, button_item, text_item if gui_app.sunnypilot_ui(): + from openpilot.system.ui.sunnypilot.widgets.list_view import ListItemSP as ListItem from openpilot.system.ui.sunnypilot.widgets.list_view import ToggleActionSP as ToggleAction + from openpilot.system.ui.sunnypilot.widgets.list_view import MultipleButtonActionSP as MultipleButtonAction # These are only used for AdvancedNetworkSettings, standalone apps just need WifiManagerUI try: From 1d6d0fb85c94c663be087683eb690a7578a18ad9 Mon Sep 17 00:00:00 2001 From: DevTekVE Date: Sat, 29 Nov 2025 02:19:30 +0100 Subject: [PATCH 2/9] ci: prevent build OOM by throttling locationd concurrency (#1527) * ci: add conditional swap creation to prebuilt workflow - Dynamically creates an 8GB swap file on systems with less than 8GB RAM. - Ensures builds complete reliably on low-memory environments. - Introduced cleanup step to remove swap after workflow execution. * Nice save * clean * reduce swap size in prebuilt workflow - Adjusted swap file size from 8GB to 4GB to optimize resource usage. * remove swap creation from prebuilt workflow - Simplified workflow by removing dynamic swap file creation and cleanup. - Adjusted resource management to rely on existing system resources. * update sunnypilot build workflow to use explicit script calls - Replaced `op` commands with explicit `/data/openpilot/tools/op.sh` script calls for better reliability and clarity. * update sunnypilot build workflow to use systemd for process management - Replaced `/data/openpilot/tools/op.sh` script calls with `systemctl` commands for improved compatibility and reliability. - Ensures consistent resource management during start and stop operations. * Splitting a bit the build then? * split build steps in sunnypilot prebuilt workflow - Added separate build steps for `modeld`, `modeld_v2`, and `locationd` with descriptive messages. - Improves logging and clarity during the build process. * limit locationd parallel processes aiming to help with resource consumption * typo * adding op's location d AND bumping to 4 cores for them * Apply suggestion from @sunnyhaibin --------- Co-authored-by: Jason Wen --- .github/workflows/sunnypilot-build-prebuilt.yaml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/workflows/sunnypilot-build-prebuilt.yaml b/.github/workflows/sunnypilot-build-prebuilt.yaml index d3ad2d2419..8af877963e 100644 --- a/.github/workflows/sunnypilot-build-prebuilt.yaml +++ b/.github/workflows/sunnypilot-build-prebuilt.yaml @@ -180,6 +180,15 @@ jobs: ./release/release_files.py | sort | uniq | rsync -rRl${RUNNER_DEBUG:+v} --files-from=- . $BUILD_DIR/ cd $BUILD_DIR sed -i '/from .board.jungle import PandaJungle, PandaJungleDFU/s/^/#/' panda/__init__.py + echo "Building sunnypilot's modeld..." + scons -j$(nproc) cache_dir=${{env.SCONS_CACHE_DIR}} --minimal sunnypilot/modeld + echo "Building sunnypilot's modeld_v2..." + scons -j$(nproc) cache_dir=${{env.SCONS_CACHE_DIR}} --minimal sunnypilot/modeld_v2 + echo "Building sunnypilot's locationd..." + scons -j4 cache_dir=${{env.SCONS_CACHE_DIR}} --minimal sunnypilot/selfdrive/locationd + echo "Building openpilot's locationd..." + scons -j4 cache_dir=${{env.SCONS_CACHE_DIR}} --minimal selfdrive/locationd + echo "Building rest of sunnypilot" scons -j$(nproc) cache_dir=${{env.SCONS_CACHE_DIR}} --minimal touch ${BUILD_DIR}/prebuilt if [[ "${{ runner.debug }}" == "1" ]]; then From 9595a6f246505df380642d19aad26b2871aea755 Mon Sep 17 00:00:00 2001 From: Jason Wen Date: Fri, 28 Nov 2025 20:46:09 -0500 Subject: [PATCH 3/9] ui Qt: remove leftover files (#1530) --- selfdrive/ui/qt/offroad/offroad_home.cc | 158 ------------------------ selfdrive/ui/qt/offroad/offroad_home.h | 59 --------- 2 files changed, 217 deletions(-) delete mode 100644 selfdrive/ui/qt/offroad/offroad_home.cc delete mode 100644 selfdrive/ui/qt/offroad/offroad_home.h diff --git a/selfdrive/ui/qt/offroad/offroad_home.cc b/selfdrive/ui/qt/offroad/offroad_home.cc deleted file mode 100644 index 9ca41e5567..0000000000 --- a/selfdrive/ui/qt/offroad/offroad_home.cc +++ /dev/null @@ -1,158 +0,0 @@ -/** - * Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. - * - * This file is part of sunnypilot and is licensed under the MIT License. - * See the LICENSE.md file in the root directory for more details. - */ - -#include "selfdrive/ui/qt/offroad/offroad_home.h" - -#include "selfdrive/ui/qt/offroad/experimental_mode.h" -#include "selfdrive/ui/qt/util.h" -#include "selfdrive/ui/qt/widgets/prime.h" - -// OffroadHome: the offroad home page - -OffroadHome::OffroadHome(QWidget* parent) : QFrame(parent) { - QVBoxLayout* main_layout = new QVBoxLayout(this); - main_layout->setContentsMargins(40, 40, 40, 40); - - // top header - header_layout = new QHBoxLayout(); - header_layout->setContentsMargins(0, 0, 0, 0); - header_layout->setSpacing(16); - - update_notif = new QPushButton(tr("UPDATE")); - update_notif->setVisible(false); - update_notif->setStyleSheet("background-color: #364DEF;"); - QObject::connect(update_notif, &QPushButton::clicked, [=]() { center_layout->setCurrentIndex(1); }); - header_layout->addWidget(update_notif, 0, Qt::AlignHCenter | Qt::AlignLeft); - - alert_notif = new QPushButton(); - alert_notif->setVisible(false); - alert_notif->setStyleSheet("background-color: #E22C2C;"); - QObject::connect(alert_notif, &QPushButton::clicked, [=] { center_layout->setCurrentIndex(2); }); - header_layout->addWidget(alert_notif, 0, Qt::AlignHCenter | Qt::AlignLeft); - - version = new ElidedLabel(); - header_layout->addWidget(version, 0, Qt::AlignHCenter | Qt::AlignRight); - - main_layout->addLayout(header_layout); - - // main content - main_layout->addSpacing(25); - center_layout = new QStackedLayout(); - - QWidget *home_widget = new QWidget(this); - { - home_layout = new QHBoxLayout(home_widget); - home_layout->setContentsMargins(0, 0, 0, 0); - home_layout->setSpacing(30); - -#ifndef SUNNYPILOT - // left: PrimeAdWidget - QStackedWidget *left_widget = new QStackedWidget(this); - QVBoxLayout *left_prime_layout = new QVBoxLayout(); - left_prime_layout->setContentsMargins(0, 0, 0, 0); - QWidget *prime_user = new PrimeUserWidget(); - prime_user->setStyleSheet(R"( - border-radius: 10px; - background-color: #333333; - )"); - left_prime_layout->addWidget(prime_user); - left_prime_layout->addStretch(); - left_widget->addWidget(new LayoutWidget(left_prime_layout)); - left_widget->addWidget(new PrimeAdWidget); - left_widget->setStyleSheet("border-radius: 10px;"); - - connect(uiState()->prime_state, &PrimeState::changed, [left_widget]() { - left_widget->setCurrentIndex(uiState()->prime_state->isSubscribed() ? 0 : 1); - }); - - home_layout->addWidget(left_widget, 1); -#endif - - // right: ExperimentalModeButton, SetupWidget - QWidget* right_widget = new QWidget(this); - QVBoxLayout* right_column = new QVBoxLayout(right_widget); - right_column->setContentsMargins(0, 0, 0, 0); - right_widget->setFixedWidth(750); - right_column->setSpacing(30); - - ExperimentalModeButton *experimental_mode = new ExperimentalModeButton(this); - QObject::connect(experimental_mode, &ExperimentalModeButton::openSettings, this, &OffroadHome::openSettings); - right_column->addWidget(experimental_mode, 1); - - SetupWidget *setup_widget = new SetupWidget; - QObject::connect(setup_widget, &SetupWidget::openSettings, this, &OffroadHome::openSettings); - right_column->addWidget(setup_widget, 1); - - home_layout->addWidget(right_widget, 1); - } - center_layout->addWidget(home_widget); - - // add update & alerts widgets - update_widget = new UpdateAlert(); - QObject::connect(update_widget, &UpdateAlert::dismiss, [=]() { center_layout->setCurrentIndex(0); }); - center_layout->addWidget(update_widget); - alerts_widget = new OffroadAlert(); - QObject::connect(alerts_widget, &OffroadAlert::dismiss, [=]() { center_layout->setCurrentIndex(0); }); - center_layout->addWidget(alerts_widget); - - main_layout->addLayout(center_layout, 1); - - // set up refresh timer - timer = new QTimer(this); - timer->callOnTimeout(this, &OffroadHome::refresh); - - setStyleSheet(R"( - * { - color: white; - } - OffroadHome { - background-color: black; - } - OffroadHome > QPushButton { - padding: 15px 30px; - border-radius: 5px; - font-size: 40px; - font-weight: 500; - } - OffroadHome > QLabel { - font-size: 55px; - } - )"); -} - -void OffroadHome::showEvent(QShowEvent *event) { - refresh(); - timer->start(10 * 1000); -} - -void OffroadHome::hideEvent(QHideEvent *event) { - timer->stop(); -} - -void OffroadHome::refresh() { - version->setText(getBrand() + " " + QString::fromStdString(params.get("UpdaterCurrentDescription"))); - - bool updateAvailable = update_widget->refresh(); - int alerts = alerts_widget->refresh(); - - // pop-up new notification - int idx = center_layout->currentIndex(); - if (!updateAvailable && !alerts) { - idx = 0; - } else if (updateAvailable && (!update_notif->isVisible() || (!alerts && idx == 2))) { - idx = 1; - } else if (alerts && (!alert_notif->isVisible() || (!updateAvailable && idx == 1))) { - idx = 2; - } - center_layout->setCurrentIndex(idx); - - update_notif->setVisible(updateAvailable); - alert_notif->setVisible(alerts); - if (alerts) { - alert_notif->setText(QString::number(alerts) + (alerts > 1 ? tr(" ALERTS") : tr(" ALERT"))); - } -} diff --git a/selfdrive/ui/qt/offroad/offroad_home.h b/selfdrive/ui/qt/offroad/offroad_home.h deleted file mode 100644 index cac37d58cd..0000000000 --- a/selfdrive/ui/qt/offroad/offroad_home.h +++ /dev/null @@ -1,59 +0,0 @@ -/** - * Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. - * - * This file is part of sunnypilot and is licensed under the MIT License. - * See the LICENSE.md file in the root directory for more details. - */ - -#pragma once - -#include "common/params.h" -#include "selfdrive/ui/qt/body.h" -#include "selfdrive/ui/qt/widgets/offroad_alerts.h" - -#ifdef SUNNYPILOT -#include "selfdrive/ui/sunnypilot/qt/widgets/controls.h" -#include "selfdrive/ui/sunnypilot/qt/onroad/onroad_home.h" -#include "selfdrive/ui/sunnypilot/qt/sidebar.h" -#include "selfdrive/ui/sunnypilot/qt/widgets/prime.h" -#define OnroadWindow OnroadWindowSP -#define LayoutWidget LayoutWidgetSP -#define Sidebar SidebarSP -#define ElidedLabel ElidedLabelSP -#define SetupWidget SetupWidgetSP -#else -#include "selfdrive/ui/qt/widgets/controls.h" -#include "selfdrive/ui/qt/onroad/onroad_home.h" -#include "selfdrive/ui/qt/sidebar.h" -#include "selfdrive/ui/qt/widgets/prime.h" -#endif - -class OffroadHome : public QFrame { - Q_OBJECT - -public: - explicit OffroadHome(QWidget* parent = 0); - - signals: - void openSettings(int index = 0, const QString ¶m = ""); - -protected: - QHBoxLayout *home_layout; - QHBoxLayout *header_layout; - - void showEvent(QShowEvent *event) override; - void refresh(); - -private: - void hideEvent(QHideEvent *event) override; - - Params params; - - QTimer* timer; - ElidedLabel* version; - QStackedLayout* center_layout; - UpdateAlert *update_widget; - OffroadAlert* alerts_widget; - QPushButton* alert_notif; - QPushButton* update_notif; -}; From aa5a7ecb31c5f04f0bb4759e663f025be5550c42 Mon Sep 17 00:00:00 2001 From: Jason Wen Date: Sat, 29 Nov 2025 02:24:12 -0500 Subject: [PATCH 4/9] ci: no parallelism for LiveLocationKalman compile (#1531) * ci: no parallelism for locationd compile * just LLK * bump to 2 --- .../workflows/sunnypilot-build-prebuilt.yaml | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/.github/workflows/sunnypilot-build-prebuilt.yaml b/.github/workflows/sunnypilot-build-prebuilt.yaml index 8af877963e..50456f93d3 100644 --- a/.github/workflows/sunnypilot-build-prebuilt.yaml +++ b/.github/workflows/sunnypilot-build-prebuilt.yaml @@ -6,10 +6,10 @@ env: CI_DIR: ${{ github.workspace }}/release/ci SCONS_CACHE_DIR: ${{ github.workspace }}/release/ci/scons_cache PUBLIC_REPO_URL: "https://github.com/sunnypilot/sunnypilot" - + # Branch configurations STAGING_SOURCE_BRANCH: 'master' - + # Runtime configuration SOURCE_BRANCH: "${{ github.head_ref || github.ref_name }}" @@ -75,7 +75,7 @@ jobs: cancel="$(echo "$CONFIG" | jq -r '.cancel_publish_in_progress')"; echo "cancel_publish_in_progress=$( [ "$cancel" = "null" ] && echo "true" || echo $cancel)" >> $GITHUB_OUTPUT echo "publish_concurrency_group=publish-${BRANCH}$( [ "$cancel" = "null" ] || [ "$cancel" = "true" ] || echo "${{ github.sha }}" )" >> $GITHUB_OUTPUT - + is_stable_branch="$(echo "$CONFIG" | jq -r '.stable_branch // false')"; echo "is_stable_branch=$is_stable_branch" >> $GITHUB_OUTPUT @@ -85,7 +85,7 @@ jobs: fi echo "build=$BUILD" >> $GITHUB_OUTPUT cat $GITHUB_OUTPUT - + validate_tests: runs-on: ubuntu-24.04 needs: [ prepare_strategy ] @@ -119,7 +119,7 @@ jobs: needs.prepare_strategy.result == 'success' && (needs.validate_tests.result == 'success' || needs.validate_tests.result == 'skipped') && (!contains(github.event_name, 'pull_request') || - (github.event.action == 'labeled' && github.event.label.name == 'prebuilt')) + (github.event.action == 'labeled' && github.event.label.name == 'prebuilt')) }} steps: - uses: actions/checkout@v4 @@ -134,7 +134,7 @@ jobs: with: path: ${{env.SCONS_CACHE_DIR}} key: scons-${{ runner.os }}-${{ runner.arch }}-${{ env.SOURCE_BRANCH }}-${{ github.sha }} - # Note: GitHub Actions enforces cache isolation between different build sources (PR builds, workflow dispatches, etc.) + # Note: GitHub Actions enforces cache isolation between different build sources (PR builds, workflow dispatches, etc.) # for security. Only caches from the default branch are shared across all builds. This is by design and cannot be overridden. restore-keys: | scons-${{ runner.os }}-${{ runner.arch }}-${{ env.SOURCE_BRANCH }} @@ -148,7 +148,7 @@ jobs: echo "version=${{ needs.prepare_strategy.outputs.version }}" >> $GITHUB_OUTPUT echo "extra_version_identifier=${{ needs.prepare_strategy.outputs.extra_version_identifier }}" >> $GITHUB_OUTPUT echo "commit_sha=${{ github.sha }}" >> $GITHUB_OUTPUT - + # Set up common environment source /etc/profile; export UV_PROJECT_ENVIRONMENT=${HOME}/venv @@ -185,9 +185,9 @@ jobs: echo "Building sunnypilot's modeld_v2..." scons -j$(nproc) cache_dir=${{env.SCONS_CACHE_DIR}} --minimal sunnypilot/modeld_v2 echo "Building sunnypilot's locationd..." - scons -j4 cache_dir=${{env.SCONS_CACHE_DIR}} --minimal sunnypilot/selfdrive/locationd + scons -j2 cache_dir=${{env.SCONS_CACHE_DIR}} --minimal sunnypilot/selfdrive/locationd echo "Building openpilot's locationd..." - scons -j4 cache_dir=${{env.SCONS_CACHE_DIR}} --minimal selfdrive/locationd + scons -j$(nproc) cache_dir=${{env.SCONS_CACHE_DIR}} --minimal selfdrive/locationd echo "Building rest of sunnypilot" scons -j$(nproc) cache_dir=${{env.SCONS_CACHE_DIR}} --minimal touch ${BUILD_DIR}/prebuilt @@ -250,8 +250,8 @@ jobs: if: always() run: | PYTHONPATH=$PYTHONPATH:${{ github.workspace }}/ ${{ github.workspace }}/scripts/manage-powersave.py --enable - - + + publish: concurrency: # We do a bit of a hack here to avoid canceling the publishing job if a new commit comes in while we're publishing by adding the sha to the group name. @@ -302,7 +302,7 @@ jobs: echo "1. Go to: ${{ github.server_url }}/${{ github.repository }}/settings/variables/actions/AUTO_DEPLOY_PREBUILT_BRANCHES" echo "2. Current value: ${{ vars.AUTO_DEPLOY_PREBUILT_BRANCHES }}" echo "3. Update as needed (JSON array with no spaces)" - + - name: Tag ${{ needs.prepare_strategy.outputs.environment }} if: ${{ needs.prepare_strategy.outputs.is_stable_branch == 'true' && (github.event_name != 'push' || !startsWith(github.ref, 'refs/tags/')) }} run: | @@ -311,7 +311,7 @@ jobs: git push -f origin ${TAG} notify: - needs: + needs: - prepare_strategy - build - publish @@ -340,7 +340,7 @@ jobs: ${{ vars.DISCOURSE_GENERAL_UPDATE_NOTICE }} EOF ) - + { echo 'content< Date: Sat, 29 Nov 2025 03:10:59 -0500 Subject: [PATCH 5/9] ui: Option Control (#1479) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * param to control stock vs sp ui * init styles * SP Toggles * Lint * optimizations * Panels. With Icons. And Scroller. * patience, grasshopper * more patience, grasshopper * sp raylib preview * fix callback * fix ui preview * add ui previews * Option Control * Need this * better padding * this * listitem -> listitemsp * add show_description method * remove padding from line separator. like, WHY? 😩😩 * simplify * I. SAID. SIMPLIFY. * AAARGGGGGG..... * option control value fix * forgot about the setter * scroller -> scroller_tici * scroller -> scroller_tici * ui: `GuiApplicationExt` * add to readme * use gui_app.sunnypilot_ui() * use gui_app.sunnypilot_ui() * ugh. no * new style. old style. * lint * rename * old but gold <3 --------- Co-authored-by: discountchubbs Co-authored-by: Jason Wen Co-authored-by: James Vecellio-Grant <159560811+Discountchubbs@users.noreply.github.com> --- system/ui/sunnypilot/lib/styles.py | 9 + system/ui/sunnypilot/widgets/list_view.py | 14 ++ .../ui/sunnypilot/widgets/option_control.py | 165 ++++++++++++++++++ 3 files changed, 188 insertions(+) create mode 100644 system/ui/sunnypilot/widgets/option_control.py diff --git a/system/ui/sunnypilot/lib/styles.py b/system/ui/sunnypilot/lib/styles.py index b942f79d33..0fb4a5c309 100644 --- a/system/ui/sunnypilot/lib/styles.py +++ b/system/ui/sunnypilot/lib/styles.py @@ -56,5 +56,14 @@ class DefaultStyleSP(Base): MBC_BG_CHECKED_ENABLED = rl.Color(0x69, 0x68, 0x68, 0xFF) MBC_DISABLED = rl.Color(0xFF, 0xFF, 0xFF, 0x33) + # Option Control + OPTION_CONTROL_CONTAINER_BG = OFF_BG_COLOR + OPTION_CONTROL_BTN_ENABLED = rl.Color(88, 88, 88, 255) + OPTION_CONTROL_BTN_PRESSED = rl.Color(0x69, 0x68, 0x68, 0xFF) + OPTION_CONTROL_BTN_DISABLED = DISABLED_OFF_BG_COLOR + OPTION_CONTROL_TEXT_ENABLED = rl.WHITE + OPTION_CONTROL_TEXT_PRESSED = rl.WHITE + OPTION_CONTROL_TEXT_DISABLED = ITEM_DISABLED_TEXT_COLOR + style = DefaultStyleSP diff --git a/system/ui/sunnypilot/widgets/list_view.py b/system/ui/sunnypilot/widgets/list_view.py index 4ecb9a4881..955adaa73e 100644 --- a/system/ui/sunnypilot/widgets/list_view.py +++ b/system/ui/sunnypilot/widgets/list_view.py @@ -13,6 +13,7 @@ from openpilot.system.ui.lib.text_measure import measure_text_cached from openpilot.system.ui.sunnypilot.widgets.toggle import ToggleSP from openpilot.system.ui.widgets.list_view import ListItem, ToggleAction, ItemAction, MultipleButtonAction, _resolve_value from openpilot.system.ui.sunnypilot.lib.styles import style +from openpilot.system.ui.sunnypilot.widgets.option_control import OptionControlSP, LABEL_WIDTH class ToggleActionSP(ToggleAction): @@ -186,3 +187,16 @@ def multiple_button_item_sp(title: str | Callable[[], str], description: str | C icon: str = "", param: str | None = None, inline: bool = False) -> ListItemSP: action = MultipleButtonActionSP(buttons, button_width, selected_index, callback=callback, param=param) return ListItemSP(title=title, description=description, icon=icon, action_item=action, inline=inline) + + +def option_item_sp(title: str | Callable[[], str], param: str, + min_value: int, max_value: int, description: str | Callable[[], str] | None = None, + value_change_step: int = 1, on_value_changed: Callable[[int], None] | None = None, + enabled: bool | Callable[[], bool] = True, + icon: str = "", label_width: int = LABEL_WIDTH, value_map: dict[int, int] | None = None, + use_float_scaling: bool = False, label_callback: Callable[[int], str] | None = None) -> ListItemSP: + action = OptionControlSP( + param, min_value, max_value, value_change_step, + enabled, on_value_changed, value_map, label_width, use_float_scaling, label_callback + ) + return ListItemSP(title=title, description=description, action_item=action, icon=icon) diff --git a/system/ui/sunnypilot/widgets/option_control.py b/system/ui/sunnypilot/widgets/option_control.py new file mode 100644 index 0000000000..91e9650ebd --- /dev/null +++ b/system/ui/sunnypilot/widgets/option_control.py @@ -0,0 +1,165 @@ +import pyray as rl +from collections.abc import Callable +from openpilot.common.params import Params +from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos +from openpilot.system.ui.lib.text_measure import measure_text_cached +from openpilot.system.ui.sunnypilot.lib.styles import style +from openpilot.system.ui.widgets.list_view import ItemAction + +# Dimensions and styling constants +BUTTON_WIDTH = 150 +BUTTON_HEIGHT = 150 +LABEL_WIDTH = 350 +BUTTON_SPACING = 25 +VALUE_FONT_SIZE = 50 +BUTTON_FONT_SIZE = 60 +CONTAINER_PADDING = 20 + + +class OptionControlSP(ItemAction): + def __init__(self, param: str, min_value: int, max_value: int, + value_change_step: int = 1, enabled: bool | Callable[[], bool] = True, + on_value_changed: Callable[[int], None] | None = None, + value_map: dict[int, int] | None = None, + label_width: int = LABEL_WIDTH, + use_float_scaling: bool = False, label_callback: Callable[[int], str] | None = None): + + super().__init__(enabled=enabled) + self.params = Params() + self.param_key = param + self.min_value = min_value + self.max_value = max_value + self.value_change_step = value_change_step + self._minus_enabled = enabled + self._plus_enabled = enabled + self.on_value_changed = on_value_changed + self.value_map = value_map + self.label_width = label_width + self.use_float_scaling = use_float_scaling + self.current_value = min_value + self.label_callback = label_callback + if self.value_map: + for key in self.value_map: + if self.value_map[key] == self.params.get(self.param_key, return_default=True): + self.current_value = int(key) + break + else: + self.current_value = int(self.params.get(self.param_key, return_default=True)) + + # Initialize font and button styles + self._font = gui_app.font(FontWeight.MEDIUM) + + # Layout rectangles for components + self.minus_btn_rect = rl.Rectangle(0, 0, 0, 0) + self.plus_btn_rect = rl.Rectangle(0, 0, 0, 0) + + def get_value(self) -> int: + """Get the current value of the control""" + return self.current_value + + def set_value(self, value: int): + """Set the control to a specific value""" + if self.min_value <= value <= self.max_value: + self.current_value = value + if self.value_map: + self.params.put(self.param_key, self.value_map[value]) + else: + if self.use_float_scaling: + self.params.put(self.param_key, value / 100.0) + else: + self.params.put(self.param_key, value) + if self.on_value_changed: + self.on_value_changed(value) + + def get_displayed_value(self) -> str: + """Get the displayed value, handling value mapping if present""" + value = self.current_value + + if callable(self.label_callback): + if self.value_map: + return self.label_callback(self.value_map[value]) + else: + return self.label_callback(value) + + if self.value_map: + # Use the value map to get the display string + if value in self.value_map: + return str(self.value_map[value]) # Return the display string + + # If using float scaling, format as float + if self.use_float_scaling: + return f"{value / 100.0:.2f}" + + return str(value) + + def _render(self, rect: rl.Rectangle): + if self._rect.width == 0 or self._rect.height == 0 or not self.is_visible: + return + + control_width = (BUTTON_WIDTH * 2) + self.label_width + (BUTTON_SPACING * 2) + total_width = control_width + (CONTAINER_PADDING * 2) + self._rect.width = total_width + + start_x = self._rect.x + self._rect.width - control_width - (CONTAINER_PADDING * 2) + component_y = rect.y + (rect.height - BUTTON_HEIGHT) / 2 + self.container_rect = rl.Rectangle(start_x, component_y, total_width, BUTTON_HEIGHT) + + # background + rl.draw_rectangle_rounded(self.container_rect, 0.2, 20, style.OPTION_CONTROL_CONTAINER_BG) + + # minus button + self.minus_btn_rect = rl.Rectangle(self.container_rect.x, component_y, BUTTON_WIDTH + CONTAINER_PADDING, + BUTTON_HEIGHT) + + # label + label_x = self.container_rect.x + CONTAINER_PADDING + BUTTON_WIDTH + BUTTON_SPACING + self.label_rect = rl.Rectangle(label_x, component_y, self.label_width, BUTTON_HEIGHT) + + # plus button + plus_x = label_x + self.label_width + BUTTON_SPACING + self.plus_btn_rect = rl.Rectangle(plus_x, component_y, BUTTON_WIDTH + CONTAINER_PADDING, BUTTON_HEIGHT) + + self._minus_enabled = self.enabled and self.current_value > self.min_value + self._plus_enabled = self.enabled and self.current_value < self.max_value + + self._render_button(self.minus_btn_rect, "-", self._minus_enabled) + self._render_value_label() + self._render_button(self.plus_btn_rect, "+", self._plus_enabled) + + def _render_button(self, rect: rl.Rectangle, text: str, enabled: bool): + mouse_pos = rl.get_mouse_position() + is_pressed = (rl.check_collision_point_rec(mouse_pos, rect) and + self._touch_valid() and rl.is_mouse_button_down(rl.MouseButton.MOUSE_BUTTON_LEFT)) + + text_color = style.ITEM_TEXT_COLOR if enabled else style.ITEM_DISABLED_TEXT_COLOR + + # highlight + if enabled and is_pressed: + rl.draw_rectangle_rounded(rect, 0.2, 20, style.OPTION_CONTROL_BTN_PRESSED) + + # button text + text_size = measure_text_cached(self._font, text, BUTTON_FONT_SIZE) + text_x = rect.x + (rect.width - text_size.x) / 2 + text_y = rect.y + (rect.height - text_size.y) / 2 + rl.draw_text_ex(self._font, text, rl.Vector2(text_x, text_y), BUTTON_FONT_SIZE, 0, text_color) + + def _render_value_label(self): + """Render the current value label""" + text = self.get_displayed_value() + text_color = style.ITEM_TEXT_COLOR if self.enabled else style.ITEM_DISABLED_TEXT_COLOR + + text_size = measure_text_cached(self._font, text, VALUE_FONT_SIZE) + text_x = self.label_rect.x + (self.label_rect.width - text_size.x) / 2 + text_y = self.label_rect.y + (self.label_rect.height - text_size.y) / 2 + + rl.draw_text_ex(self._font, text, rl.Vector2(text_x, text_y), VALUE_FONT_SIZE, 0, text_color) + + def _handle_mouse_release(self, mouse_pos: MousePos): + if self._minus_enabled and rl.check_collision_point_rec(mouse_pos, self.minus_btn_rect): + self.current_value -= self.value_change_step + self.current_value = max(self.min_value, self.current_value) + elif self._plus_enabled and rl.check_collision_point_rec(mouse_pos, self.plus_btn_rect): + self.current_value += self.value_change_step + self.current_value = min(self.max_value, self.current_value) + + self.set_value(self.current_value) From bee820f8ed5af568dd0ae1ba584a86c62dd6237e Mon Sep 17 00:00:00 2001 From: James Vecellio-Grant <159560811+Discountchubbs@users.noreply.github.com> Date: Sat, 29 Nov 2025 00:23:03 -0800 Subject: [PATCH 6/9] ui: `InputDialogSP` (#1484) * dialog txt * compare vs what used to be done before InputDialog * rm * final --------- Co-authored-by: Jason Wen --- system/ui/sunnypilot/widgets/__init__.py | 0 system/ui/sunnypilot/widgets/input_dialog.py | 41 ++++++++++++++++++++ 2 files changed, 41 insertions(+) create mode 100644 system/ui/sunnypilot/widgets/__init__.py create mode 100644 system/ui/sunnypilot/widgets/input_dialog.py diff --git a/system/ui/sunnypilot/widgets/__init__.py b/system/ui/sunnypilot/widgets/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/system/ui/sunnypilot/widgets/input_dialog.py b/system/ui/sunnypilot/widgets/input_dialog.py new file mode 100644 index 0000000000..88ab0b1a66 --- /dev/null +++ b/system/ui/sunnypilot/widgets/input_dialog.py @@ -0,0 +1,41 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +from collections.abc import Callable + +from openpilot.common.params import Params +from openpilot.system.ui.lib.application import gui_app +from openpilot.system.ui.lib.multilang import tr +from openpilot.system.ui.widgets import DialogResult +from openpilot.system.ui.widgets.keyboard import Keyboard + + +class InputDialogSP: + def __init__(self, title: str, sub_title: str | None = None, current_text: str = "", param: str | None = None, + callback: Callable[[DialogResult, str], None] | None = None, + min_text_size: int = 0, password_mode: bool = False): + self.callback = callback + self.current_text = current_text + self.keyboard = Keyboard(max_text_size=255, min_text_size=min_text_size, password_mode=password_mode) + self.param = param + self._params = Params() + self.sub_title = sub_title + self.title = title + + def show(self): + self.keyboard.reset(min_text_size=self.keyboard._min_text_size) + self.keyboard.set_title(tr(self.title), *(tr(self.sub_title),) if self.sub_title else ()) + self.keyboard.set_text(self.current_text) + + def internal_callback(result: DialogResult): + text = self.keyboard.text if result == DialogResult.CONFIRM else "" + if result == DialogResult.CONFIRM: + if self.param: + self._params.put(self.param, text) + if self.callback: + self.callback(result, text) + + gui_app.set_modal_overlay(self.keyboard, internal_callback) From 3e29a0ccfead6ef5e08584e0a5a37911543026db Mon Sep 17 00:00:00 2001 From: James Vecellio-Grant <159560811+Discountchubbs@users.noreply.github.com> Date: Sat, 29 Nov 2025 00:30:18 -0800 Subject: [PATCH 7/9] ui: `ProgressBarAction` (#1492) * raylib: progress bar * freaking test dir * easier to see * smoother updating * final --------- Co-authored-by: Jason Wen --- system/ui/sunnypilot/widgets/progress_bar.py | 57 ++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 system/ui/sunnypilot/widgets/progress_bar.py diff --git a/system/ui/sunnypilot/widgets/progress_bar.py b/system/ui/sunnypilot/widgets/progress_bar.py new file mode 100644 index 0000000000..76f4243411 --- /dev/null +++ b/system/ui/sunnypilot/widgets/progress_bar.py @@ -0,0 +1,57 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +import pyray as rl +from openpilot.system.ui.lib.application import gui_app, FontWeight +from openpilot.system.ui.lib.text_measure import measure_text_cached +from openpilot.system.ui.widgets.list_view import ListItem, ItemAction + + +class ProgressBarAction(ItemAction): + def __init__(self, width=600): + super().__init__(width=width) + self.progress = 0.0 + self.text = "" + self.show_progress = False + self.text_color = rl.GRAY + self._font = gui_app.font(FontWeight.NORMAL) + + def update(self, progress, text, show_progress=False, text_color=rl.GRAY): + self.progress = progress + self.text = text + self.show_progress = show_progress + self.text_color = text_color + + def _render(self, rect: rl.Rectangle): + font_size = 40 + text_size = measure_text_cached(self._font, self.text, font_size) + padding = 30 + bar_width = text_size.x + 2 * padding + text_x = (bar_width - text_size.x) / 2 + + if self.show_progress and len(parts := self.text.split(' - ', 1)) == 2: + prefix = parts[0] + max_prefix_w = measure_text_cached(self._font, "100%", font_size).x + current_prefix_w = measure_text_cached(self._font, prefix, font_size).x + + bar_width = (text_size.x - current_prefix_w + max_prefix_w) + 2 * padding + text_x = padding + (max_prefix_w - current_prefix_w) + + bar_height = 60 + bar_rect = rl.Rectangle(rect.x + rect.width - bar_width, rect.y + (rect.height - bar_height) / 2, bar_width, bar_height) + + if self.show_progress: + inner_rect = rl.Rectangle(bar_rect.x + 4, bar_rect.y + 4, bar_rect.width - 8, bar_rect.height - 8) + if inner_rect.width > 0: + fill_width = max(0, min(inner_rect.width, inner_rect.width * (self.progress / 100.0))) + rl.draw_rectangle_rounded(rl.Rectangle(inner_rect.x, inner_rect.y, fill_width, inner_rect.height), 0.2, 10, rl.Color(30, 121, 232, 255)) + + rl.draw_text_ex(self._font, self.text, rl.Vector2(bar_rect.x + text_x, bar_rect.y + (bar_height - text_size.y) / 2), font_size, 0, self.text_color) + + +def progress_item(title): + action = ProgressBarAction() + return ListItem(title=title, action_item=action) From a4454721ea604d5dd1e4c53995ac4130d1b8034c Mon Sep 17 00:00:00 2001 From: DevTekVE Date: Sat, 29 Nov 2025 09:52:01 +0100 Subject: [PATCH 8/9] sunnylink: dynamic param metadata (#1522) * feat(params): add support for parameter metadata retrieval - Introduced `getKeyMetadata` method for accessing metadata associated with params. - Enhanced `getParamsAllKeysV1` to include metadata parsing and optional dynamic enum generation. - Extended unit tests to verify metadata parsing, enum mapping, and edge cases. * Revert "feat(params): add support for parameter metadata retrieval" This reverts commit 865b695ff966bfab7dab4d211fb2d91f06fe92db. * update: integrate params metadata management and unit tests - Added `update_params_metadata.py` to manage and update parameters metadata. - Enhanced `getParamsAllKeysV1` to include metadata for params. - Created comprehensive tests (`test_params_metadata.py`, `test_params_sync.py`) to validate metadata integrity and params consistency. * update: improve params metadata readability and enhance enums - Renamed params titles for clarity and consistency. - Added enum options and mappings to selected params for better usability. * update: enhance params metadata with improved enum structures - Replaced plain enum lists with detailed objects (`value`, `label`) for clarity. - Standardized parameter options for consistency across metadata. * update: add validation constraints to params metadata - Introduced `min`, `max`, and `step` attributes for improved parameter range validation. - Enhances user input handling and ensures consistency in metadata. * lint * more lint stuff and permissions * does this suffice? * more lint * update: refine params type hinting and remove unused shebang - Adjusted type annotation in `params_dict` for better compatibility. - Removed unnecessary shebang from `test_params_metadata.py`. * update: expand test coverage for params metadata validation - Added detailed test cases to ensure metadata consistency (`options`, `constraints`, `titles`). - Validates API response alignment with `params_metadata.json`. * the finals * names --------- Co-authored-by: Jason Wen --- sunnypilot/sunnylink/athena/sunnylinkd.py | 32 +- sunnypilot/sunnylink/params_metadata.json | 1100 +++++++++++++++++ .../sunnylink/tests/test_params_metadata.py | 86 ++ .../sunnylink/tests/test_params_sync.py | 202 +++ .../sunnylink/tools/update_params_metadata.py | 56 + 5 files changed, 1469 insertions(+), 7 deletions(-) create mode 100644 sunnypilot/sunnylink/params_metadata.json create mode 100644 sunnypilot/sunnylink/tests/test_params_metadata.py create mode 100644 sunnypilot/sunnylink/tests/test_params_sync.py create mode 100755 sunnypilot/sunnylink/tools/update_params_metadata.py diff --git a/sunnypilot/sunnylink/athena/sunnylinkd.py b/sunnypilot/sunnylink/athena/sunnylinkd.py index 1e3713c7ef..a57b6ef55f 100755 --- a/sunnypilot/sunnylink/athena/sunnylinkd.py +++ b/sunnypilot/sunnylink/athena/sunnylinkd.py @@ -1,5 +1,10 @@ #!/usr/bin/env python3 +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" from __future__ import annotations import base64 @@ -32,9 +37,11 @@ LOCAL_PORT_WHITELIST = {8022} SUNNYLINK_LOG_ATTR_NAME = "user.sunny.upload" SUNNYLINK_RECONNECT_TIMEOUT_S = 70 # FYI changing this will also would require a change on sidebar.cc DISALLOW_LOG_UPLOAD = threading.Event() +METADATA_PATH = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "params_metadata.json") params = Params() + def handle_long_poll(ws: WebSocket, exit_event: threading.Event | None) -> None: cloudlog.info("sunnylinkd.handle_long_poll started") sm = messaging.SubMaster(['deviceState']) @@ -180,16 +187,30 @@ def getParamsAllKeys() -> list[str]: @dispatcher.add_method def getParamsAllKeysV1() -> dict[str, str]: + try: + with open(METADATA_PATH) as f: + metadata = json.load(f) + except Exception: + cloudlog.exception("sunnylinkd.getParamsAllKeysV1.exception") + metadata = {} + available_keys: list[str] = [k.decode('utf-8') for k in Params().all_keys()] - params_dict: dict[str, list[dict[str, str | bool | int | None]]] = {"params": []} + params_dict: dict[str, list[dict[str, str | bool | int | object | dict | None]]] = {"params": []} for key in available_keys: value = get_param_as_byte(key, get_default=True) - params_dict["params"].append({ + + param_entry = { "key": key, "type": int(params.get_type(key).value), "default_value": base64.b64encode(value).decode('utf-8') if value else None, - }) + } + + if key in metadata: + meta_copy = metadata[key].copy() + param_entry["_extra"] = meta_copy + + params_dict["params"].append(param_entry) return {"keys": json.dumps(params_dict.get("params", []))} @@ -238,10 +259,7 @@ def startLocalProxy(global_end_event: threading.Event, remote_ws_uri: str, local cloudlog.debug("athena.startLocalProxy.starting") ws = create_connection( - remote_ws_uri, - header={"Authorization": f"Bearer {sunnylink_api.get_token()}"}, - enable_multithread=True, - sslopt={"cert_reqs": ssl.CERT_NONE} + remote_ws_uri, header={"Authorization": f"Bearer {sunnylink_api.get_token()}"}, enable_multithread=True, sslopt={"cert_reqs": ssl.CERT_NONE} ) return start_local_proxy_shim(global_end_event, local_port, ws) diff --git a/sunnypilot/sunnylink/params_metadata.json b/sunnypilot/sunnylink/params_metadata.json new file mode 100644 index 0000000000..83ec3c7af8 --- /dev/null +++ b/sunnypilot/sunnylink/params_metadata.json @@ -0,0 +1,1100 @@ +{ + "AccessToken": { + "title": "AccessTokenIsNice", + "description": "" + }, + "AdbEnabled": { + "title": "Enable ADB", + "description": "" + }, + "AlphaLongitudinalEnabled": { + "title": "Alpha Longitudinal", + "description": "" + }, + "AlwaysOnDM": { + "title": "Always-on Driver Monitor", + "description": "" + }, + "ApiCache_Device": { + "title": "Api Cache Device", + "description": "" + }, + "ApiCache_DriveStats": { + "title": "Api Cache Drive Stats", + "description": "" + }, + "ApiCache_FirehoseStats": { + "title": "Firehose Mode Stats", + "description": "" + }, + "AssistNowToken": { + "title": "Assist Now Token", + "description": "" + }, + "AthenadPid": { + "title": "Athenad Pid", + "description": "" + }, + "AthenadRecentlyViewedRoutes": { + "title": "Athenad Recently Viewed Routes", + "description": "" + }, + "AthenadUploadQueue": { + "title": "Athenad Upload Queue", + "description": "" + }, + "AutoLaneChangeBsmDelay": { + "title": "Auto Lane Change BSM Delay", + "description": "" + }, + "AutoLaneChangeTimer": { + "title": "Auto Lane Change Timer", + "description": "", + "options": [ + { + "value": -1, + "label": "Off" + }, + { + "value": 0, + "label": "Nudge" + }, + { + "value": 1, + "label": "Nudgeless" + }, + { + "value": 2, + "label": "0.5s" + }, + { + "value": 3, + "label": "1s" + }, + { + "value": 4, + "label": "2s" + }, + { + "value": 5, + "label": "3s" + } + ] + }, + "BackupManager_CreateBackup": { + "title": "Create Backup", + "description": "" + }, + "BackupManager_RestoreVersion": { + "title": "Restore Version", + "description": "" + }, + "BlindSpot": { + "title": "Blind Spot Detection", + "description": "" + }, + "BlinkerMinLateralControlSpeed": { + "title": "Blinker Min Lateral Control Speed", + "description": "" + }, + "BlinkerPauseLateralControl": { + "title": "Blinker Pause Lateral Control", + "description": "" + }, + "BootCount": { + "title": "Boot Count", + "description": "" + }, + "Brightness": { + "title": "Screen Brightness", + "description": "" + }, + "CalibrationParams": { + "title": "Calibration Params", + "description": "" + }, + "CameraDebugExpGain": { + "title": "Camera Debug Exp Gain", + "description": "" + }, + "CameraDebugExpTime": { + "title": "Camera Debug Exp Time", + "description": "" + }, + "CarBatteryCapacity": { + "title": "Car Battery Capacity", + "description": "" + }, + "CarParams": { + "title": "Car Params", + "description": "" + }, + "CarParamsCache": { + "title": "Car Params Cache", + "description": "" + }, + "CarParamsPersistent": { + "title": "Car Params Persistent", + "description": "" + }, + "CarParamsPrevRoute": { + "title": "Car Params Prev Route", + "description": "" + }, + "CarParamsSP": { + "title": "Car Params Sp", + "description": "" + }, + "CarParamsSPCache": { + "title": "Car Params Sp Cache", + "description": "" + }, + "CarParamsSPPersistent": { + "title": "Car Params Sp Persistent", + "description": "" + }, + "CarPlatformBundle": { + "title": "Car Platform Bundle", + "description": "" + }, + "ChevronInfo": { + "title": "Chevron Info", + "description": "" + }, + "CompletedTrainingVersion": { + "title": "Completed Training Version", + "description": "" + }, + "ControlsReady": { + "title": "Controls Ready", + "description": "" + }, + "CurrentBootlog": { + "title": "Current Bootlog", + "description": "" + }, + "CurrentRoute": { + "title": "Current Route", + "description": "" + }, + "CustomAccIncrementsEnabled": { + "title": "Custom ACC Increments Enabled", + "description": "" + }, + "CustomAccLongPressIncrement": { + "title": "Custom ACC Long Press Increment", + "description": "", + "min": 1, + "max": 10, + "step": 1 + }, + "CustomAccShortPressIncrement": { + "title": "Custom ACC Short Press Increment", + "description": "", + "min": 1, + "max": 10, + "step": 1 + }, + "CustomTorqueParams": { + "title": "Custom Torque Params", + "description": "" + }, + "DevUIInfo": { + "title": "Developer UI Info", + "description": "" + }, + "DeviceBootMode": { + "title": "Device Boot Mode", + "description": "", + "options": [ + { + "value": 0, + "label": "Standard" + }, + { + "value": 1, + "label": "Always Offroad" + } + ] + }, + "DisableLogging": { + "title": "Disable Logging", + "description": "" + }, + "DisablePowerDown": { + "title": "Disable Power Down", + "description": "" + }, + "DisableUpdates": { + "title": "Disable Updates", + "description": "" + }, + "DisengageOnAccelerator": { + "title": "Disengage On Accelerator", + "description": "" + }, + "DoReboot": { + "title": "Reboot", + "description": "" + }, + "DoShutdown": { + "title": "Power Off", + "description": "" + }, + "DoUninstall": { + "title": "Uninstall sunnypilot", + "description": "" + }, + "DongleId": { + "title": "Device ID", + "description": "" + }, + "DriverTooDistracted": { + "title": "Driver Too Distracted", + "description": "" + }, + "DynamicExperimentalControl": { + "title": "Dynamic Experimental Control", + "description": "" + }, + "EnableCopyparty": { + "title": "Enable Copyparty", + "description": "" + }, + "EnableGithubRunner": { + "title": "Enable GitHub Runner", + "description": "" + }, + "EnableSunnylinkUploader": { + "title": "Enable sunnylink Uploader", + "description": "" + }, + "EnforceTorqueControl": { + "title": "Enforce Torque Control", + "description": "" + }, + "ExperimentalMode": { + "title": "Experimental Mode", + "description": "" + }, + "ExperimentalModeConfirmed": { + "title": "Experimental Mode Confirmed", + "description": "" + }, + "FirmwareQueryDone": { + "title": "Firmware Query Done", + "description": "" + }, + "ForcePowerDown": { + "title": "Force Power Down", + "description": "" + }, + "GitBranch": { + "title": "Git Branch", + "description": "" + }, + "GitCommit": { + "title": "Git Commit", + "description": "" + }, + "GitCommitDate": { + "title": "Git Commit Date", + "description": "" + }, + "GitDiff": { + "title": "Git Diff", + "description": "" + }, + "GitRemote": { + "title": "Git Remote", + "description": "" + }, + "GithubRunnerSufficientVoltage": { + "title": "Github Runner Sufficient Voltage", + "description": "" + }, + "GithubSshKeys": { + "title": "Github Ssh Keys", + "description": "" + }, + "GithubUsername": { + "title": "GitHub Username", + "description": "" + }, + "GreenLightAlert": { + "title": "Green Light Alert", + "description": "" + }, + "GsmApn": { + "title": "GSM APN", + "description": "" + }, + "GsmMetered": { + "title": "Gsm Metered", + "description": "" + }, + "GsmRoaming": { + "title": "GSM Roaming", + "description": "" + }, + "HardwareSerial": { + "title": "Serial Number", + "description": "" + }, + "HasAcceptedTerms": { + "title": "Has Accepted Terms", + "description": "" + }, + "HideVEgoUI": { + "title": "Hide vEgo UI", + "description": "" + }, + "HyundaiLongitudinalTuning": { + "title": "Hyundai Longitudinal Tuning", + "description": "", + "options": [ + { + "value": 0, + "label": "Off" + }, + { + "value": 1, + "label": "Dynamic" + }, + { + "value": 2, + "label": "Predictive" + } + ] + }, + "InstallDate": { + "title": "Install Date", + "description": "" + }, + "IntelligentCruiseButtonManagement": { + "title": "Intelligent Cruise Button Management", + "description": "" + }, + "InteractivityTimeout": { + "title": "Interactivity Timeout", + "description": "" + }, + "IsDevelopmentBranch": { + "title": "Is Development Branch", + "description": "" + }, + "IsDriverViewEnabled": { + "title": "Is Driver View Enabled", + "description": "" + }, + "IsEngaged": { + "title": "Is Engaged", + "description": "" + }, + "IsLdwEnabled": { + "title": "Lane Departure Warnings", + "description": "" + }, + "IsMetric": { + "title": "Use Metric Units", + "description": "" + }, + "IsOffroad": { + "title": "Is Offroad", + "description": "" + }, + "IsOnroad": { + "title": "Is Onroad", + "description": "" + }, + "IsReleaseBranch": { + "title": "Is Release Branch", + "description": "" + }, + "IsReleaseSpBranch": { + "title": "Is Release Sp Branch", + "description": "" + }, + "IsRhdDetected": { + "title": "Is Rhd Detected", + "description": "" + }, + "IsTakingSnapshot": { + "title": "Is Taking Snapshot", + "description": "" + }, + "IsTestedBranch": { + "title": "Is Tested Branch", + "description": "" + }, + "JoystickDebugMode": { + "title": "Joystick Debug Mode", + "description": "" + }, + "LagdToggle": { + "title": "LaGD Toggle", + "description": "" + }, + "LagdToggleDelay": { + "title": "LaGD Toggle Delay", + "description": "" + }, + "LagdValueCache": { + "title": "LaGD Value Cache", + "description": "" + }, + "LaneTurnDesire": { + "title": "Lane Turn Desire", + "description": "" + }, + "LaneTurnValue": { + "title": "Lane Turn Value", + "description": "", + "min": 0, + "max": 20, + "step": 1 + }, + "LanguageSetting": { + "title": "Language", + "description": "" + }, + "LastAthenaPingTime": { + "title": "Last Athena Ping Time", + "description": "" + }, + "LastGPSPosition": { + "title": "Last Gps Position", + "description": "" + }, + "LastGPSPositionLLK": { + "title": "Last GPS Position LLK", + "description": "" + }, + "LastManagerExitReason": { + "title": "Last Manager Exit Reason", + "description": "" + }, + "LastOffroadStatusPacket": { + "title": "Last Offroad Status Packet", + "description": "" + }, + "LastPowerDropDetected": { + "title": "Last Power Drop Detected", + "description": "" + }, + "LastSunnylinkPingTime": { + "title": "Last sunnylink Ping Time", + "description": "" + }, + "LastUpdateException": { + "title": "Last Update Exception", + "description": "" + }, + "LastUpdateRouteCount": { + "title": "Last Update Route Count", + "description": "" + }, + "LastUpdateTime": { + "title": "Last Update Time", + "description": "" + }, + "LastUpdateUptimeOnroad": { + "title": "Last Update Uptime Onroad", + "description": "" + }, + "LeadDepartAlert": { + "title": "Lead Depart Alert", + "description": "" + }, + "LiveDelay": { + "title": "Live Delay", + "description": "" + }, + "LiveParameters": { + "title": "Live Parameters", + "description": "" + }, + "LiveParametersV2": { + "title": "Live Parameters V2", + "description": "" + }, + "LiveTorqueParameters": { + "title": "Live Torque Parameters", + "description": "" + }, + "LiveTorqueParamsRelaxedToggle": { + "title": "Live Torque Params Relaxed Toggle", + "description": "" + }, + "LiveTorqueParamsToggle": { + "title": "Live Torque Params Toggle", + "description": "" + }, + "LocationFilterInitialState": { + "title": "Location Filter Initial State", + "description": "" + }, + "LongitudinalManeuverMode": { + "title": "Longitudinal Maneuver Mode", + "description": "" + }, + "LongitudinalPersonality": { + "title": "Driving Personality", + "description": "", + "options": [ + { + "value": 0, + "label": "Aggressive" + }, + { + "value": 1, + "label": "Standard" + }, + { + "value": 2, + "label": "Relaxed" + } + ] + }, + "Mads": { + "title": "MADS Enabled", + "description": "" + }, + "MadsMainCruiseAllowed": { + "title": "MADS Main Cruise Allowed", + "description": "" + }, + "MadsSteeringMode": { + "title": "MADS Steering Mode", + "description": "", + "options": [ + { + "value": 0, + "label": "Remain Active" + }, + { + "value": 1, + "label": "Pause" + }, + { + "value": 2, + "label": "Disengage" + } + ] + }, + "MadsUnifiedEngagementMode": { + "title": "MADS Unified Engagement Mode", + "description": "" + }, + "MapAdvisorySpeedLimit": { + "title": "Map Advisory Speed Limit", + "description": "" + }, + "MapSpeedLimit": { + "title": "Map Speed Limit", + "description": "" + }, + "MapTargetVelocities": { + "title": "Map Target Velocities", + "description": "" + }, + "MapdVersion": { + "title": "Mapd Version", + "description": "" + }, + "MaxTimeOffroad": { + "title": "Max Time Offroad", + "description": "" + }, + "ModelManager_ActiveBundle": { + "title": "Model Manager Active Bundle", + "description": "" + }, + "ModelManager_ClearCache": { + "title": "Model Manager Clear Cache", + "description": "" + }, + "ModelManager_DownloadIndex": { + "title": "Model Manager Download Index", + "description": "" + }, + "ModelManager_Favs": { + "title": "Model Manager Favorites", + "description": "" + }, + "ModelManager_LastSyncTime": { + "title": "Model Manager Last Sync Time", + "description": "" + }, + "ModelManager_ModelsCache": { + "title": "Model Manager Models Cache", + "description": "" + }, + "ModelRunnerTypeCache": { + "title": "Model Runner Type Cache", + "description": "" + }, + "NetworkMetered": { + "title": "Network Usage", + "description": "", + "options": [ + { + "value": 0, + "label": "Default" + }, + { + "value": 1, + "label": "Metered" + }, + { + "value": 2, + "label": "Unmetered" + } + ] + }, + "NeuralNetworkLateralControl": { + "title": "Neural Network Lateral Control", + "description": "" + }, + "NextMapSpeedLimit": { + "title": "Next Map Speed Limit", + "description": "" + }, + "OSMDownloadBounds": { + "title": "OSM Download Bounds", + "description": "" + }, + "OSMDownloadLocations": { + "title": "OSM Download Locations", + "description": "" + }, + "OSMDownloadProgress": { + "title": "OSM Download Progress", + "description": "" + }, + "ObdMultiplexingChanged": { + "title": "Obd Multiplexing Changed", + "description": "" + }, + "ObdMultiplexingEnabled": { + "title": "Obd Multiplexing Enabled", + "description": "" + }, + "OffroadMode": { + "title": "Offroad Mode", + "description": "" + }, + "Offroad_CarUnrecognized": { + "title": "Offroad Car Unrecognized", + "description": "" + }, + "Offroad_ConnectivityNeeded": { + "title": "Offroad Connectivity Needed", + "description": "" + }, + "Offroad_ConnectivityNeededPrompt": { + "title": "Offroad Connectivity Needed Prompt", + "description": "" + }, + "Offroad_DriverMonitoringUncertain": { + "title": "Offroad Driver Monitoring Uncertain", + "description": "" + }, + "Offroad_ExcessiveActuation": { + "title": "Offroad Excessive Actuation", + "description": "" + }, + "Offroad_IsTakingSnapshot": { + "title": "Offroad Is Taking Snapshot", + "description": "" + }, + "Offroad_NeosUpdate": { + "title": "Offroad Neos Update", + "description": "" + }, + "Offroad_NoFirmware": { + "title": "Offroad No Firmware", + "description": "" + }, + "Offroad_OSMUpdateRequired": { + "title": "Offroad OSM Update Required", + "description": "" + }, + "Offroad_Recalibration": { + "title": "Offroad Recalibration", + "description": "" + }, + "Offroad_TemperatureTooHigh": { + "title": "Offroad Temperature Too High", + "description": "" + }, + "Offroad_TiciSupport": { + "title": "Offroad Tici Support", + "description": "" + }, + "Offroad_UnregisteredHardware": { + "title": "Offroad Unregistered Hardware", + "description": "" + }, + "Offroad_UpdateFailed": { + "title": "Offroad Update Failed", + "description": "" + }, + "OnroadCycleRequested": { + "title": "Onroad Cycle Requested", + "description": "" + }, + "OnroadScreenOffBrightness": { + "title": "Onroad Screen Off Brightness", + "description": "", + "min": 0, + "max": 100, + "step": 5 + }, + "OnroadScreenOffControl": { + "title": "Onroad Screen Off Control", + "description": "" + }, + "OnroadScreenOffTimer": { + "title": "Onroad Screen Off Timer", + "description": "", + "min": 0, + "max": 60, + "step": 1 + }, + "OnroadUploads": { + "title": "Onroad Uploads", + "description": "" + }, + "OpenpilotEnabledToggle": { + "title": "Enable sunnypilot", + "description": "" + }, + "OsmDbUpdatesCheck": { + "title": "OSM DB Updates Check", + "description": "" + }, + "OsmDownloadedDate": { + "title": "OSM Downloaded Date", + "description": "" + }, + "OsmLocal": { + "title": "OSM Local", + "description": "" + }, + "OsmLocationName": { + "title": "OSM Location Name", + "description": "" + }, + "OsmLocationTitle": { + "title": "OSM Location Title", + "description": "" + }, + "OsmLocationUrl": { + "title": "OSM Location URL", + "description": "" + }, + "OsmStateName": { + "title": "OSM State Name", + "description": "" + }, + "OsmStateTitle": { + "title": "OSM State Title", + "description": "" + }, + "OsmWayTest": { + "title": "OSM Way Test", + "description": "" + }, + "PandaHeartbeatLost": { + "title": "Panda Heartbeat Lost", + "description": "" + }, + "PandaSignatures": { + "title": "Panda Signatures", + "description": "" + }, + "PandaSomResetTriggered": { + "title": "Panda Som Reset Triggered", + "description": "" + }, + "PrimeType": { + "title": "Prime Type", + "description": "" + }, + "QuickBootToggle": { + "title": "Quick Boot", + "description": "" + }, + "QuietMode": { + "title": "Quiet Mode", + "description": "" + }, + "RainbowMode": { + "title": "Rainbow Mode", + "description": "" + }, + "RecordAudio": { + "title": "Record & Upload Mic Audio", + "description": "" + }, + "RecordAudioFeedback": { + "title": "Record Audio Feedback", + "description": "" + }, + "RecordFront": { + "title": "Record & Upload Driver Camera", + "description": "" + }, + "RecordFrontLock": { + "title": "Record Front Lock", + "description": "" + }, + "RoadName": { + "title": "Road Name", + "description": "" + }, + "RoadNameToggle": { + "title": "Road Name Toggle", + "description": "" + }, + "RouteCount": { + "title": "Route Count", + "description": "" + }, + "SecOCKey": { + "title": "Sec Oc Key", + "description": "" + }, + "ShowAdvancedControls": { + "title": "Show Advanced Controls", + "description": "" + }, + "ShowDebugInfo": { + "title": "UI Debug Mode", + "description": "" + }, + "ShowTurnSignals": { + "title": "Show Turn Signals", + "description": "" + }, + "SmartCruiseControlMap": { + "title": "Smart Cruise Control Map", + "description": "" + }, + "SmartCruiseControlVision": { + "title": "Smart Cruise Control Vision", + "description": "" + }, + "SnoozeUpdate": { + "title": "Snooze Update", + "description": "" + }, + "SpeedLimitMode": { + "title": "Speed Limit Mode", + "description": "", + "options": [ + { + "value": 0, + "label": "Off" + }, + { + "value": 1, + "label": "Information" + }, + { + "value": 2, + "label": "Warning" + }, + { + "value": 3, + "label": "Assist" + } + ] + }, + "SpeedLimitOffsetType": { + "title": "Speed Limit Offset Type", + "description": "", + "options": [ + { + "value": 0, + "label": "Off" + }, + { + "value": 1, + "label": "Fixed" + }, + { + "value": 2, + "label": "Percentage" + } + ] + }, + "SpeedLimitPolicy": { + "title": "Speed Limit Policy", + "description": "", + "options": [ + { + "value": 0, + "label": "Car State Only" + }, + { + "value": 1, + "label": "Map Data Only" + }, + { + "value": 2, + "label": "Car State Priority" + }, + { + "value": 3, + "label": "Map Data Priority" + }, + { + "value": 4, + "label": "Combined" + } + ] + }, + "SpeedLimitValueOffset": { + "title": "Speed Limit Value Offset", + "description": "", + "min": -30, + "max": 30, + "step": 1 + }, + "SshEnabled": { + "title": "Enable SSH", + "description": "" + }, + "StandstillTimer": { + "title": "Standstill Timer", + "description": "" + }, + "SubaruStopAndGo": { + "title": "Subaru Stop and Go", + "description": "" + }, + "SubaruStopAndGoManualParkingBrake": { + "title": "Subaru Stop and Go Manual Parking Brake", + "description": "" + }, + "SunnylinkCache_Roles": { + "title": "sunnylink Cache Roles", + "description": "" + }, + "SunnylinkCache_Users": { + "title": "sunnylink Cache Users", + "description": "" + }, + "SunnylinkDongleId": { + "title": "sunnylink Dongle ID", + "description": "" + }, + "SunnylinkEnabled": { + "title": "sunnylink Enabled", + "description": "" + }, + "SunnylinkTempFault": { + "title": "sunnylink Temp Fault", + "description": "" + }, + "SunnylinkdPid": { + "title": "Sunnylinkd Pid", + "description": "" + }, + "TermsVersion": { + "title": "Terms Version", + "description": "" + }, + "TeslaCoopSteering": { + "title": "Tesla Coop Steering", + "description": "" + }, + "TorqueParamsOverrideEnabled": { + "title": "Torque Params Override Enabled", + "description": "" + }, + "TorqueParamsOverrideFriction": { + "title": "Torque Params Override Friction", + "description": "", + "min": 0.0, + "max": 1.0, + "step": 0.01 + }, + "TorqueParamsOverrideLatAccelFactor": { + "title": "Torque Params Override Lat Accel Factor", + "description": "", + "min": 0.1, + "max": 5.0, + "step": 0.1 + }, + "TrainingVersion": { + "title": "Training Version", + "description": "" + }, + "TrueVEgoUI": { + "title": "True vEgo UI", + "description": "" + }, + "UbloxAvailable": { + "title": "Ublox Available", + "description": "" + }, + "UpdateAvailable": { + "title": "Update Available", + "description": "" + }, + "UpdateFailedCount": { + "title": "Update Failed Count", + "description": "" + }, + "UpdaterAvailableBranches": { + "title": "Updater Available Branches", + "description": "" + }, + "UpdaterCurrentDescription": { + "title": "Updater Current Description", + "description": "" + }, + "UpdaterCurrentReleaseNotes": { + "title": "Updater Current Release Notes", + "description": "" + }, + "UpdaterFetchAvailable": { + "title": "Updater Fetch Available", + "description": "" + }, + "UpdaterLastFetchTime": { + "title": "Updater Last Fetch Time", + "description": "" + }, + "UpdaterNewDescription": { + "title": "Updater New Description", + "description": "" + }, + "UpdaterNewReleaseNotes": { + "title": "Updater New Release Notes", + "description": "" + }, + "UpdaterState": { + "title": "Updater State", + "description": "" + }, + "UpdaterTargetBranch": { + "title": "Updater Target Branch", + "description": "" + }, + "UptimeOffroad": { + "title": "Uptime Offroad", + "description": "" + }, + "UptimeOnroad": { + "title": "Uptime Onroad", + "description": "" + }, + "Version": { + "title": "openpilot Version", + "description": "" + } +} diff --git a/sunnypilot/sunnylink/tests/test_params_metadata.py b/sunnypilot/sunnylink/tests/test_params_metadata.py new file mode 100644 index 0000000000..f4f1fbc4b1 --- /dev/null +++ b/sunnypilot/sunnylink/tests/test_params_metadata.py @@ -0,0 +1,86 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +import json + +from openpilot.sunnypilot.sunnylink.athena.sunnylinkd import getParamsAllKeysV1, METADATA_PATH + + +def test_get_params_all_keys_v1(): + """ + Test the getParamsAllKeysV1 API endpoint. + + Why: + This endpoint is used by the UI (and potentially external tools) to fetch the list of + available parameters along with their metadata (titles, descriptions, options, constraints). + We need to ensure it returns the correct structure and that the metadata from + params_metadata.json is correctly merged into the response. + + Expected: + - The response should contain a "keys" field which is a JSON string of a list of parameters. + - Each parameter object should have "key", "type", "default_value", and optionally "_extra". + - The "_extra" field should contain the rich metadata (title, options, min/max, etc.) matching + the source of truth (params_metadata.json). + """ + response = getParamsAllKeysV1() + assert "keys" in response + + keys_json = response["keys"] + params_list = json.loads(keys_json) + + assert isinstance(params_list, list) + assert len(params_list) > 0 + + # Check structure of first item + first_param = params_list[0] + assert "key" in first_param + assert "type" in first_param + assert "default_value" in first_param + + if "_extra" in first_param: + assert isinstance(first_param["_extra"], dict) + assert "default" not in first_param["_extra"] + assert "type" not in first_param["_extra"] + + # Load the source of truth + with open(METADATA_PATH) as f: + metadata = json.load(f) + + # Verify that the API response matches the metadata file for a few sample keys + # This ensures the plumbing is working without being brittle to content changes + + # 1. Check a key that should have metadata + keys_with_metadata = [k for k in params_list if k["key"] in metadata] + assert len(keys_with_metadata) > 0, "No parameters found that match metadata keys" + + for param in keys_with_metadata[:5]: # Check first 5 matches + key = param["key"] + expected_meta = metadata[key] + + assert "_extra" in param, f"Parameter {key} should have _extra field" + actual_meta = param["_extra"] + + # Verify all fields in JSON are present in the API response + for meta_key, meta_val in expected_meta.items(): + assert meta_key in actual_meta, f"Missing {meta_key} in API response for {key}" + assert actual_meta[meta_key] == meta_val, f"Mismatch for {key}.{meta_key}: expected {meta_val}, got {actual_meta[meta_key]}" + + # 2. Check that we are correctly serving options if they exist + params_with_options = [k for k in keys_with_metadata if "options" in k.get("_extra", {})] + if params_with_options: + param = params_with_options[0] + key = param["key"] + assert isinstance(param["_extra"]["options"], list), f"Options for {key} should be a list" + assert param["_extra"]["options"] == metadata[key]["options"] + + # 3. Check that we are correctly serving numeric constraints if they exist + params_with_constraints = [k for k in keys_with_metadata if "min" in k.get("_extra", {})] + if params_with_constraints: + param = params_with_constraints[0] + key = param["key"] + assert param["_extra"]["min"] == metadata[key]["min"] + assert param["_extra"]["max"] == metadata[key]["max"] + assert param["_extra"]["step"] == metadata[key]["step"] diff --git a/sunnypilot/sunnylink/tests/test_params_sync.py b/sunnypilot/sunnylink/tests/test_params_sync.py new file mode 100644 index 0000000000..26bdca42d6 --- /dev/null +++ b/sunnypilot/sunnylink/tests/test_params_sync.py @@ -0,0 +1,202 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +import json +import os +import pytest + +from openpilot.common.params import Params +from openpilot.sunnypilot.sunnylink.athena.sunnylinkd import METADATA_PATH + + +def test_metadata_json_exists(): + """ + Test that the params_metadata.json file exists at the expected path. + + Why: + The metadata file is the source of truth for parameter descriptions, options, and constraints. + If it's missing, the UI will not be able to display rich information for parameters. + + Expected: + The file should exist at sunnypilot/sunnylink/params_metadata.json. + """ + assert os.path.exists(METADATA_PATH), f"Metadata file not found at {METADATA_PATH}" + + +def test_metadata_json_valid(): + """ + Test that the params_metadata.json file contains valid JSON. + + Why: + Invalid JSON will cause the metadata loading to fail, potentially crashing the UI or + resulting in missing metadata. + + Expected: + The file content should be parseable as a JSON object (dictionary). + """ + with open(METADATA_PATH) as f: + try: + data = json.load(f) + except json.JSONDecodeError: + pytest.fail("Metadata file is not valid JSON") + + assert isinstance(data, dict), "Metadata root must be a dictionary" + + +def test_all_params_have_metadata(): + """ + Test that every parameter in the codebase has a corresponding entry in params_metadata.json. + + Why: + We want to ensure 100% coverage of parameter metadata. Any parameter added to the codebase + should also be documented in the metadata file. + + Expected: + There should be no parameters in Params() that are missing from the metadata file. + If this fails, run 'python3 sunnypilot/sunnylink/tools/update_params_metadata.py'. + """ + params = Params() + all_keys = [k.decode('utf-8') for k in params.all_keys()] + + with open(METADATA_PATH) as f: + metadata = json.load(f) + + missing_keys = [key for key in all_keys if key not in metadata] + + if missing_keys: + pytest.fail( + f"The following parameters are missing from metadata: {missing_keys}. " + + "Please run 'python3 sunnypilot/sunnylink/tools/update_params_metadata.py' to update." + ) + + +def test_metadata_keys_exist_in_params(): + """ + Test that all keys in params_metadata.json actually exist in the codebase. + + Why: + We want to avoid stale metadata for parameters that have been removed or renamed. + This keeps the metadata file clean and relevant. + + Expected: + There should be no keys in the metadata file that are not present in Params(). + This prints a warning rather than failing, as it's less critical than missing metadata. + """ + params = Params() + all_keys = {k.decode('utf-8') for k in params.all_keys()} + + with open(METADATA_PATH) as f: + metadata = json.load(f) + + extra_keys = [key for key in metadata.keys() if key not in all_keys] + + if extra_keys: + print(f"Warning: The following keys in metadata do not exist in Params: {extra_keys}") + + +def test_no_default_titles(): + """ + Test that no parameter has a title that is identical to its key. + + Why: + The default behavior of the update script is to set the title equal to the key. + We want to force developers to provide human-readable, descriptive titles for all parameters. + + Expected: + No parameter metadata should have 'title' == 'key'. + """ + with open(METADATA_PATH) as f: + metadata = json.load(f) + + default_title_keys = [key for key, meta in metadata.items() if meta.get("title") == key] + + if default_title_keys: + pytest.fail( + f"The following parameters have default titles (title == key): {default_title_keys}. " + + "Please update 'params_metadata.json' with descriptive titles." + ) + + +def test_options_structure(): + """ + Test that the 'options' field in metadata follows the correct structure. + + Why: + The UI expects 'options' to be a list of objects with 'value' and 'label' keys. + Incorrect structure will break the UI rendering for dropdowns/toggles. + + Expected: + If 'options' is present, it must be a list of dicts, and each dict must have 'value' and 'label'. + """ + with open(METADATA_PATH) as f: + metadata = json.load(f) + + for key, meta in metadata.items(): + if "options" in meta: + options = meta["options"] + assert isinstance(options, list), f"Options for {key} must be a list" + for option in options: + assert isinstance(option, dict), f"Option in {key} must be a dictionary" + assert "value" in option, f"Option in {key} must have a 'value' key" + assert "label" in option, f"Option in {key} must have a 'label' key" + + +def test_numeric_constraints(): + """ + Test that numeric parameters have valid 'min', 'max', and 'step' constraints. + + Why: + The UI uses these constraints to validate user input and render sliders/steppers. + Missing or invalid constraints can lead to UI bugs or invalid parameter values. + + Expected: + If any of min/max/step is present, ALL of them must be present. + They must be numbers (int/float), and min must be less than max. + """ + with open(METADATA_PATH) as f: + metadata = json.load(f) + + for key, meta in metadata.items(): + if "min" in meta or "max" in meta or "step" in meta: + assert "min" in meta, f"Numeric param {key} must have 'min'" + assert "max" in meta, f"Numeric param {key} must have 'max'" + assert "step" in meta, f"Numeric param {key} must have 'step'" + + assert isinstance(meta["min"], (int, float)), f"Min for {key} must be number" + assert isinstance(meta["max"], (int, float)), f"Max for {key} must be number" + assert isinstance(meta["step"], (int, float)), f"Step for {key} must be number" + assert meta["min"] < meta["max"], f"Min must be less than max for {key}" + + +def test_known_params_metadata(): + """ + Test specific known parameters to ensure they have the expected rich metadata. + + Why: + This acts as a spot check to ensure that our rich metadata population logic is working correctly + and that critical parameters (like LongitudinalPersonality) have their options and constraints preserved. + + Expected: + 'LongitudinalPersonality' should have 3 options (Aggressive, Standard, Relaxed). + 'CustomAccLongPressIncrement' should have min=1, max=10, step=1. + """ + with open(METADATA_PATH) as f: + metadata = json.load(f) + + # Check an enum-like param + lp = metadata.get("LongitudinalPersonality") + assert lp is not None + assert "options" in lp + assert len(lp["options"]) == 3 + assert lp["options"][0]["label"] == "Aggressive" + assert lp["options"][0]["value"] == 0 + + # Check a numeric param + acc_long = metadata.get("CustomAccLongPressIncrement") + assert acc_long is not None + assert acc_long["min"] == 1 + assert acc_long["max"] == 10 + assert acc_long["step"] == 1 diff --git a/sunnypilot/sunnylink/tools/update_params_metadata.py b/sunnypilot/sunnylink/tools/update_params_metadata.py new file mode 100755 index 0000000000..ac2ef556e6 --- /dev/null +++ b/sunnypilot/sunnylink/tools/update_params_metadata.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +import json +import os + +from openpilot.common.params import Params + +METADATA_PATH = os.path.join(os.path.dirname(__file__), "../params_metadata.json") + + +def main(): + params = Params() + all_keys = params.all_keys() + + if os.path.exists(METADATA_PATH): + with open(METADATA_PATH) as f: + try: + data = json.load(f) + except json.JSONDecodeError: + data = {} + else: + data = {} + + # Add new keys + for key in all_keys: + key_str = key.decode("utf-8") + if key_str not in data: + print(f"Adding new key: {key_str}") + data[key_str] = { + "title": key_str, + "description": "", + } + + # Remove deleted keys + # keys_to_remove = [k for k in data.keys() if k.encode("utf-8") not in all_keys] + # for k in keys_to_remove: + # print(f"Removing deleted key: {k}") + # del data[k] + + # Sort keys + sorted_data = dict(sorted(data.items())) + + with open(METADATA_PATH, "w") as f: + json.dump(sorted_data, f, indent=2) + f.write("\n") + + print(f"Updated {METADATA_PATH}") + + +if __name__ == "__main__": + main() From 023b842e3cb346b6740a223a9ae91abc1d515b5c Mon Sep 17 00:00:00 2001 From: Jason Wen Date: Sat, 29 Nov 2025 03:58:58 -0500 Subject: [PATCH 9/9] controlsd_ext: use time.monotonic to check params intervals (#1481) * controlsd_ext: use time.monotonic to check params intervals * test * Revert "test" This reverts commit 151ac3bc6812cdd1693c1a7650f44bb3bcd8910e. --- selfdrive/controls/controlsd.py | 36 +++++-------------- .../selfdrive/controls/controlsd_ext.py | 20 ++++++++--- 2 files changed, 24 insertions(+), 32 deletions(-) diff --git a/selfdrive/controls/controlsd.py b/selfdrive/controls/controlsd.py index eaaf5a51e9..9d0e5c9f15 100755 --- a/selfdrive/controls/controlsd.py +++ b/selfdrive/controls/controlsd.py @@ -1,7 +1,5 @@ #!/usr/bin/env python3 import math -import threading -import time from numbers import Number from cereal import car, log @@ -22,8 +20,6 @@ from openpilot.selfdrive.controls.lib.longcontrol import LongControl from openpilot.selfdrive.modeld.modeld import LAT_SMOOTH_SECONDS from openpilot.selfdrive.locationd.helpers import PoseCalibrator, Pose -from openpilot.sunnypilot.livedelay.helpers import get_lat_delay -from openpilot.sunnypilot.modeld.modeld_base import ModelStateBase from openpilot.sunnypilot.selfdrive.controls.controlsd_ext import ControlsExt State = log.SelfdriveState.OpenpilotState @@ -33,7 +29,7 @@ LaneChangeDirection = log.LaneChangeDirection ACTUATOR_FIELDS = tuple(car.CarControl.Actuators.schema.fields.keys()) -class Controls(ControlsExt, ModelStateBase): +class Controls(ControlsExt): def __init__(self) -> None: self.params = Params() cloudlog.info("controlsd is waiting for CarParams") @@ -42,7 +38,6 @@ class Controls(ControlsExt, ModelStateBase): # Initialize sunnypilot controlsd extension and base model state ControlsExt.__init__(self, self.CP, self.params) - ModelStateBase.__init__(self) self.CI = interfaces[self.CP.carFingerprint](self.CP, self.CP_SP) @@ -231,30 +226,15 @@ class Controls(ControlsExt, ModelStateBase): cc_send.carControl = CC self.pm.send('carControl', cc_send) - def params_thread(self, evt): - while not evt.is_set(): - self.get_params_sp() - - if self.CP.lateralTuning.which() == 'torque': - self.lat_delay = get_lat_delay(self.params, self.sm["liveDelay"].lateralDelay) - - time.sleep(0.1) - def run(self): rk = Ratekeeper(100, print_delay_threshold=None) - e = threading.Event() - t = threading.Thread(target=self.params_thread, args=(e,)) - try: - t.start() - while True: - self.update() - CC, lac_log = self.state_control() - self.publish(CC, lac_log) - self.run_ext(self.sm, self.pm) - rk.monitor_time() - finally: - e.set() - t.join() + while True: + self.update() + CC, lac_log = self.state_control() + self.publish(CC, lac_log) + self.get_params_sp(self.sm) + self.run_ext(self.sm, self.pm) + rk.monitor_time() def main(): diff --git a/sunnypilot/selfdrive/controls/controlsd_ext.py b/sunnypilot/selfdrive/controls/controlsd_ext.py index 8caeeaeabc..3f6053d158 100644 --- a/sunnypilot/selfdrive/controls/controlsd_ext.py +++ b/sunnypilot/selfdrive/controls/controlsd_ext.py @@ -4,21 +4,27 @@ Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. This file is part of sunnypilot and is licensed under the MIT License. See the LICENSE.md file in the root directory for more details. """ +import time + import cereal.messaging as messaging from cereal import log, custom from opendbc.car import structs from openpilot.common.params import Params from openpilot.common.swaglog import cloudlog +from openpilot.sunnypilot import PARAMS_UPDATE_PERIOD +from openpilot.sunnypilot.livedelay.helpers import get_lat_delay +from openpilot.sunnypilot.modeld.modeld_base import ModelStateBase from openpilot.sunnypilot.selfdrive.controls.lib.blinker_pause_lateral import BlinkerPauseLateral -class ControlsExt: +class ControlsExt(ModelStateBase): def __init__(self, CP: structs.CarParams, params: Params): + ModelStateBase.__init__(self) self.CP = CP self.params = params + self._param_update_time: float = 0.0 self.blinker_pause_lateral = BlinkerPauseLateral() - self.get_params_sp() cloudlog.info("controlsd_ext is waiting for CarParamsSP") self.CP_SP = messaging.log_from_bytes(params.get("CarParamsSP", block=True), custom.CarParamsSP) @@ -27,8 +33,14 @@ class ControlsExt: self.sm_services_ext = ['radarState', 'selfdriveStateSP'] self.pm_services_ext = ['carControlSP'] - def get_params_sp(self) -> None: - self.blinker_pause_lateral.get_params() + def get_params_sp(self, sm: messaging.SubMaster) -> None: + if time.monotonic() - self._param_update_time > PARAMS_UPDATE_PERIOD: + self.blinker_pause_lateral.get_params() + + if self.CP.lateralTuning.which() == 'torque': + self.lat_delay = get_lat_delay(self.params, sm["liveDelay"].lateralDelay) + + self._param_update_time = time.monotonic() def get_lat_active(self, sm: messaging.SubMaster) -> bool: if self.blinker_pause_lateral.update(sm['carState']):