From 682d738ffac3432549643a9889e1424192f88291 Mon Sep 17 00:00:00 2001 From: Amy Jeanes Date: Sun, 2 Nov 2025 02:47:30 +0000 Subject: [PATCH 1/5] Tesla: Coop Steering (#1283) * Tesla: Coop Steering * bump * bump * sync with opendbc/master * resolve comment * add oscillation warning and add confirmation * styling desc * beta --------- Co-authored-by: Jason Wen --- common/params_keys.h | 1 + opendbc_repo | 2 +- .../settings/vehicle/tesla_settings.cc | 34 +++++++++++++++++++ .../offroad/settings/vehicle/tesla_settings.h | 2 +- sunnypilot/selfdrive/car/interfaces.py | 5 +++ 5 files changed, 42 insertions(+), 2 deletions(-) diff --git a/common/params_keys.h b/common/params_keys.h index d8485be157..df53261eb2 100644 --- a/common/params_keys.h +++ b/common/params_keys.h @@ -208,6 +208,7 @@ inline static std::unordered_map keys = { {"HyundaiLongitudinalTuning", {PERSISTENT | BACKUP, INT, "0"}}, {"SubaruStopAndGo", {PERSISTENT | BACKUP, BOOL, "0"}}, {"SubaruStopAndGoManualParkingBrake", {PERSISTENT | BACKUP, BOOL, "0"}}, + {"TeslaCoopSteering", {PERSISTENT | BACKUP, BOOL, "0"}}, {"DynamicExperimentalControl", {PERSISTENT | BACKUP, BOOL, "0"}}, {"BlindSpot", {PERSISTENT | BACKUP, BOOL, "0"}}, diff --git a/opendbc_repo b/opendbc_repo index e0e1626820..c7126f8ba6 160000 --- a/opendbc_repo +++ b/opendbc_repo @@ -1 +1 @@ -Subproject commit e0e1626820d6a18a984ae69eebc012180699d41c +Subproject commit c7126f8ba620eb06fe345013081e97503331bbe1 diff --git a/selfdrive/ui/sunnypilot/qt/offroad/settings/vehicle/tesla_settings.cc b/selfdrive/ui/sunnypilot/qt/offroad/settings/vehicle/tesla_settings.cc index 50ab023021..2fffdb8ad4 100644 --- a/selfdrive/ui/sunnypilot/qt/offroad/settings/vehicle/tesla_settings.cc +++ b/selfdrive/ui/sunnypilot/qt/offroad/settings/vehicle/tesla_settings.cc @@ -8,7 +8,41 @@ #include "selfdrive/ui/sunnypilot/qt/offroad/settings/vehicle/tesla_settings.h" TeslaSettings::TeslaSettings(QWidget *parent) : BrandSettingsInterface(parent) { + constexpr int coopSteeringMinKmh = 23; // minimum speed for cooperative steering (enforced by Tesla firmware) + constexpr int oemSteeringMinKmh = 48; // minimum speed for OEM lane departure avoidance (enforced by Tesla firmware) + bool is_metric = params.getBool("IsMetric"); + QString unit = is_metric ? "km/h" : "mph"; + int display_value_coop; + int display_value_oem; + if (is_metric) { + display_value_coop = coopSteeringMinKmh; + display_value_oem = oemSteeringMinKmh; + } else { + display_value_coop = static_cast(std::round(coopSteeringMinKmh * KM_TO_MILE)); + display_value_oem = static_cast(std::round(oemSteeringMinKmh * KM_TO_MILE)); + } + const QString coop_desc = QString("%1

" + "%2
" + "%3
") + .arg(tr("Warning: May experience steering oscillations below %5 %6 during turns, recommend disabling this feature if you experience these.")) + .arg(tr("Allows the driver to provide limited steering input while openpilot is engaged.")) + .arg(tr("Only works above %4 %6.")) + .arg(display_value_coop) + .arg(display_value_oem) + .arg(unit); + + coopSteeringToggle = new ParamControlSP( + "TeslaCoopSteering", + tr("Cooperative Steering (Beta)"), + coop_desc, + "", + this + ); + list->addItem(coopSteeringToggle); + coopSteeringToggle->showDescription(); + coopSteeringToggle->setConfirmation(true, false); } void TeslaSettings::updateSettings() { + coopSteeringToggle->setEnabled(offroad); } diff --git a/selfdrive/ui/sunnypilot/qt/offroad/settings/vehicle/tesla_settings.h b/selfdrive/ui/sunnypilot/qt/offroad/settings/vehicle/tesla_settings.h index 37f2936cdf..a1294513ee 100644 --- a/selfdrive/ui/sunnypilot/qt/offroad/settings/vehicle/tesla_settings.h +++ b/selfdrive/ui/sunnypilot/qt/offroad/settings/vehicle/tesla_settings.h @@ -22,5 +22,5 @@ public: void updateSettings() override; private: - bool offroad = false; + ParamControlSP *coopSteeringToggle = nullptr; }; diff --git a/sunnypilot/selfdrive/car/interfaces.py b/sunnypilot/selfdrive/car/interfaces.py index 7d63d3c5ba..59cfeabd6e 100644 --- a/sunnypilot/selfdrive/car/interfaces.py +++ b/sunnypilot/selfdrive/car/interfaces.py @@ -115,4 +115,9 @@ def initialize_params(params) -> list[dict[str, Any]]: "SubaruStopAndGoManualParkingBrake", ]) + # tesla + keys.extend([ + "TeslaCoopSteering", + ]) + return [{k: params.get(k, return_default=True)} for k in keys] From b81d5bca3c4418fad601f103f070f309077c16ed Mon Sep 17 00:00:00 2001 From: DevTekVE Date: Sun, 2 Nov 2025 06:50:41 +0100 Subject: [PATCH 2/5] ui: update discord references and add forum widget (#1440) * sunnylink: introduce community popup with QR code embedding - Added `SunnylinkCommunityPopup` widget to promote the sunnypilot Community Forum. - Integrated a QR code generator and display for quick access. - Updated `WiFiPromptWidget` to include a "Learn More" button triggering the community popup. * sunnylink: adjust community popup styling for better layout - Reduced font size of description text slightly for consistency. - Decreased QR code dimensions to improve visual balance. * Making more space out of thin air * sunnylink: update community references to use forum links - Replaced Discord links with Community Forum URLs for better alignment. - Improved clarity in sponsorship instructions. --- selfdrive/ui/qt/widgets/wifi.cc | 11 +- selfdrive/ui/qt/widgets/wifi.h | 5 + selfdrive/ui/sunnypilot/SConscript | 1 + .../settings/sunnylink/community_widget.cc | 139 ++++++++++++++++++ .../settings/sunnylink/community_widget.h | 40 +++++ .../settings/sunnylink/sponsor_widget.cc | 4 +- 6 files changed, 193 insertions(+), 7 deletions(-) create mode 100644 selfdrive/ui/sunnypilot/qt/offroad/settings/sunnylink/community_widget.cc create mode 100644 selfdrive/ui/sunnypilot/qt/offroad/settings/sunnylink/community_widget.h diff --git a/selfdrive/ui/qt/widgets/wifi.cc b/selfdrive/ui/qt/widgets/wifi.cc index d7eb8beeb3..8958e48c93 100644 --- a/selfdrive/ui/qt/widgets/wifi.cc +++ b/selfdrive/ui/qt/widgets/wifi.cc @@ -11,17 +11,18 @@ WiFiPromptWidget::WiFiPromptWidget(QWidget *parent) : QFrame(parent) { main_layout->setContentsMargins(56, 40, 56, 40); main_layout->setSpacing(42); - QLabel *title = new QLabel(tr("🔥 Firehose Mode 🔥")); - title->setStyleSheet("font-size: 64px; font-weight: 500;"); + community_popup = new SunnylinkCommunityPopup(this); + QLabel *title = new QLabel(tr("sunnypilot Community")); + title->setStyleSheet("font-size: 56px; font-weight: 500;"); main_layout->addWidget(title); - QLabel *desc = new QLabel(tr("Maximize your training data uploads to improve openpilot's driving models.")); + QLabel *desc = new QLabel(tr("Need help or have ideas?
Join our community now!")); desc->setStyleSheet("font-size: 40px; font-weight: 400;"); desc->setWordWrap(true); main_layout->addWidget(desc); - QPushButton *settings_btn = new QPushButton(tr("Open")); - connect(settings_btn, &QPushButton::clicked, [=]() { emit openSettings(1, "FirehosePanel"); }); + QPushButton *settings_btn = new QPushButton(tr("Learn More")); + connect(settings_btn, &QPushButton::clicked, [=]() { community_popup->exec(); }); settings_btn->setStyleSheet(R"( QPushButton { font-size: 48px; diff --git a/selfdrive/ui/qt/widgets/wifi.h b/selfdrive/ui/qt/widgets/wifi.h index 3e68a15b7b..8ef9dca91a 100644 --- a/selfdrive/ui/qt/widgets/wifi.h +++ b/selfdrive/ui/qt/widgets/wifi.h @@ -3,12 +3,17 @@ #include #include +#include "selfdrive/ui/sunnypilot/qt/offroad/settings/sunnylink/community_widget.h" + class WiFiPromptWidget : public QFrame { Q_OBJECT public: explicit WiFiPromptWidget(QWidget* parent = 0); +private: + SunnylinkCommunityPopup *community_popup; + signals: void openSettings(int index = 0, const QString ¶m = ""); }; diff --git a/selfdrive/ui/sunnypilot/SConscript b/selfdrive/ui/sunnypilot/SConscript index be92c14261..449093886a 100644 --- a/selfdrive/ui/sunnypilot/SConscript +++ b/selfdrive/ui/sunnypilot/SConscript @@ -35,6 +35,7 @@ qt_src = [ "sunnypilot/qt/offroad/settings/software_panel.cc", "sunnypilot/qt/offroad/settings/sunnylink_panel.cc", "sunnypilot/qt/offroad/settings/sunnylink/sponsor_widget.cc", + "sunnypilot/qt/offroad/settings/sunnylink/community_widget.cc", "sunnypilot/qt/offroad/settings/trips_panel.cc", "sunnypilot/qt/offroad/settings/vehicle_panel.cc", "sunnypilot/qt/offroad/settings/visuals_panel.cc", diff --git a/selfdrive/ui/sunnypilot/qt/offroad/settings/sunnylink/community_widget.cc b/selfdrive/ui/sunnypilot/qt/offroad/settings/sunnylink/community_widget.cc new file mode 100644 index 0000000000..e29e89ee98 --- /dev/null +++ b/selfdrive/ui/sunnypilot/qt/offroad/settings/sunnylink/community_widget.cc @@ -0,0 +1,139 @@ +/** + * Copyright (c) 2025-, sunnypilot 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/sunnypilot/qt/offroad/settings/sunnylink/community_widget.h" + +#include "selfdrive/ui/sunnypilot/ui.h" +#include "selfdrive/ui/sunnypilot/qt/util.h" + +using qrcodegen::QrCode; + +// --- SunnylinkCommunityQRWidget --- + +SunnylinkCommunityQRWidget::SunnylinkCommunityQRWidget(QWidget* parent) + : QWidget(parent) {} + +void SunnylinkCommunityQRWidget::showEvent(QShowEvent *event) { + updateQrCode(SUNNYLINK_COMMUNITY_URL); + update(); +} + +void SunnylinkCommunityQRWidget::updateQrCode(const QString &text) { + QrCode qr = QrCode::encodeText(text.toUtf8().data(), QrCode::Ecc::LOW); + qint32 sz = qr.getSize(); + QImage im(sz, sz, QImage::Format_RGB32); + + QRgb black = qRgb(0, 0, 0); + QRgb white = qRgb(255, 255, 255); + for (int y = 0; y < sz; y++) { + for (int x = 0; x < sz; x++) { + im.setPixel(x, y, qr.getModule(x, y) ? black : white); + } + } + + int final_sz = ((width() / sz) - 1) * sz; + img = QPixmap::fromImage(im.scaled(final_sz, final_sz, Qt::KeepAspectRatio), Qt::MonoOnly); +} + +void SunnylinkCommunityQRWidget::paintEvent(QPaintEvent *e) { + QPainter p(this); + p.fillRect(rect(), Qt::white); + + if (!img.isNull()) { + QSize s = (size() - img.size()) / 2; + p.drawPixmap(s.width(), s.height(), img); + } +} + +// --- SunnylinkCommunityPopup --- + +QStringList SunnylinkCommunityPopup::getInstructions() { + QStringList instructions; + instructions << tr("Scan the QR code and join us!"); + return instructions; +} + +SunnylinkCommunityPopup::SunnylinkCommunityPopup(QWidget* parent) + : DialogBase(parent) { + auto *mainLayout = new QVBoxLayout(this); + mainLayout->setContentsMargins(0, 0, 0, 0); + mainLayout->setSpacing(0); + + // Solarized Light base3 background + setStyleSheet("SunnylinkCommunityPopup { background-color: #FDF6E3; }"); + + // Header spanning full width + auto headerWidget = new QWidget(this); + auto headerLayout = new QHBoxLayout(headerWidget); + headerLayout->setContentsMargins(85, 50, 85, 30); + headerLayout->setSpacing(30); + + auto close = new QPushButton(QIcon(":/icons/close.svg"), "", this); + close->setIconSize(QSize(80, 80)); + close->setStyleSheet("border: none;"); + connect(close, &QPushButton::clicked, this, &QDialog::reject); + headerLayout->addWidget(close, 0, Qt::AlignLeft | Qt::AlignVCenter); + + const auto title = new QLabel(tr("Join the sunnypilot Community Forum"), this); + // Solarized base02 for text + title->setStyleSheet("font-size: 65px; color: #073642;"); + title->setWordWrap(false); + title->setAlignment(Qt::AlignCenter); + headerLayout->addWidget(title, 1); + + // Spacer to balance the close button on the right + auto spacer = new QWidget(this); + spacer->setFixedSize(80, 80); + headerLayout->addWidget(spacer, 0); + + mainLayout->addWidget(headerWidget); + + // Two-column content layout + auto contentLayout = new QHBoxLayout(); + contentLayout->setContentsMargins(0, 0, 0, 0); + contentLayout->setSpacing(0); + mainLayout->addLayout(contentLayout, 66); + + // Left side: description + auto leftLayout = new QVBoxLayout(); + leftLayout->setContentsMargins(85, 40, 50, 70); + leftLayout->setSpacing(35); + contentLayout->addLayout(leftLayout, 40); + + // Hype / intro paragraph + const auto desc = new QLabel(tr( + "We're excited to announce our sunnypilot Community Forum

" + "Over the years, Discord just hasn't scaled well for our growing community.
" + "It's noisy, unsearchable, and great discussions disappear too easily.
" + "Our new community forum aims to fix that by making it easier to find answers, share ideas, track feedback, report bugs, help newcomers and more!

" + "Here's what's waiting for you:
" + "• Fully indexable and discoverable through search engines 🔎
" + "• AI-powered🤖 topic and chat summaries, spam detection, and more
" + "• A trust-level system✅ that rewards meaningful contributions
" + "• Designed to work on your own time.🧘

" + "Scan the QR code on the right and join the discussion!" + ), this); + // Solarized base01 for body text + desc->setStyleSheet("font-size: 40px; color: #586E75;"); + desc->setWordWrap(true); + leftLayout->addWidget(desc); + + leftLayout->addStretch(); + + // Right side: QR code and instructions + auto rightLayout = new QVBoxLayout(); + rightLayout->setContentsMargins(50, 40, 85, 70); + rightLayout->setSpacing(40); + contentLayout->addLayout(rightLayout, 1); + + // QR code (smaller, fixed size) + auto *qr = new SunnylinkCommunityQRWidget(this); + qr->setFixedSize(500, 500); + rightLayout->addStretch(); + rightLayout->addWidget(qr, 0, Qt::AlignCenter); + rightLayout->addStretch(); +} \ No newline at end of file diff --git a/selfdrive/ui/sunnypilot/qt/offroad/settings/sunnylink/community_widget.h b/selfdrive/ui/sunnypilot/qt/offroad/settings/sunnylink/community_widget.h new file mode 100644 index 0000000000..613375d12c --- /dev/null +++ b/selfdrive/ui/sunnypilot/qt/offroad/settings/sunnylink/community_widget.h @@ -0,0 +1,40 @@ +/** +* Copyright (c) 2025-, sunnypilot 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 +#include + +#include "common/util.h" +#include "selfdrive/ui/sunnypilot/qt/widgets/controls.h" + +const QString SUNNYLINK_COMMUNITY_URL = "https://community.sunnypilot.ai/sp-qr"; + +class SunnylinkCommunityQRWidget : public QWidget { + Q_OBJECT + +public: + explicit SunnylinkCommunityQRWidget(QWidget* parent = nullptr); + void paintEvent(QPaintEvent*) override; + +private: + QPixmap img; + void updateQrCode(const QString &text); + void showEvent(QShowEvent *event) override; +}; + +// Popup widget +class SunnylinkCommunityPopup : public DialogBase { + Q_OBJECT + +public: + explicit SunnylinkCommunityPopup(QWidget* parent = nullptr); + +private: + static QStringList getInstructions(); +}; diff --git a/selfdrive/ui/sunnypilot/qt/offroad/settings/sunnylink/sponsor_widget.cc b/selfdrive/ui/sunnypilot/qt/offroad/settings/sunnylink/sponsor_widget.cc index 7b5ce48238..a014eddacb 100644 --- a/selfdrive/ui/sunnypilot/qt/offroad/settings/sunnylink/sponsor_widget.cc +++ b/selfdrive/ui/sunnypilot/qt/offroad/settings/sunnylink/sponsor_widget.cc @@ -79,11 +79,11 @@ QStringList SunnylinkSponsorPopup::getInstructions(bool sponsor_pair) { instructions << tr("Scan the QR code to login to your GitHub account") << tr("Follow the prompts to complete the pairing process") << tr("Re-enter the \"sunnylink\" panel to verify sponsorship status") - << tr("If sponsorship status was not updated, please contact a moderator on Discord at https://discord.gg/sunnypilot"); + << tr("If sponsorship status was not updated, please contact a moderator on our forum at https://community.sunnypilot.ai"); } else { instructions << tr("Scan the QR code to visit sunnyhaibin's GitHub Sponsors page") << tr("Choose your sponsorship tier and confirm your support") - << tr("Join our community on Discord at https://discord.gg/sunnypilot and reach out to a moderator to confirm your sponsor status"); + << tr("Join our Community Forum at https://community.sunnypilot.ai and reach out to a moderator if you have issues"); } return instructions; } From 18af4d6ad6db657bcdbe892614851d24a33f9dcc Mon Sep 17 00:00:00 2001 From: DevTekVE Date: Sun, 2 Nov 2025 20:26:17 +0100 Subject: [PATCH 3/5] ui: Fix spacing in sunnylink panel (#1450) Fix spacing --- selfdrive/ui/sunnypilot/qt/offroad/settings/sunnylink_panel.cc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/selfdrive/ui/sunnypilot/qt/offroad/settings/sunnylink_panel.cc b/selfdrive/ui/sunnypilot/qt/offroad/settings/sunnylink_panel.cc index 924d1d3c09..1b170bb0c2 100644 --- a/selfdrive/ui/sunnypilot/qt/offroad/settings/sunnylink_panel.cc +++ b/selfdrive/ui/sunnypilot/qt/offroad/settings/sunnylink_panel.cc @@ -90,7 +90,7 @@ SunnylinkPanel::SunnylinkPanel(QWidget *parent) : QFrame(parent) { QString sunnylinkUploaderDesc = tr("Enable sunnylink uploader to allow sunnypilot to upload your driving data to sunnypilot servers. (only for highest tiers, and does NOT bring ANY benefit to you. We are just testing data volume.)"); sunnylinkUploaderEnabledBtn = new ParamControlSP( "EnableSunnylinkUploader", - tr("Enable sunnylink uploader (just for testing infrastructure)"), + tr("Enable sunnylink uploader (infrastructure test)"), sunnylinkUploaderDesc, "", nullptr, true); list->addItem(sunnylinkUploaderEnabledBtn); From 071147baafae09c0a3f8305187596f8af16f800b Mon Sep 17 00:00:00 2001 From: Matt Purnell <65473602+mpurnell1@users.noreply.github.com> Date: Sun, 2 Nov 2025 23:52:17 -0600 Subject: [PATCH 4/5] docs: Update README installation branches and discord links (#1453) * Use sunnypilot CARS.md, update number of supported cars, add comma * Update device reference * Update discord links to forum links * Update references to -c3-new branches and release * Update broken link to branches table * Update README.md --------- Co-authored-by: DevTekVE --- README.md | 36 +++++++++++++++++------------------- 1 file changed, 17 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 7e686cef5c..598e1273a7 100644 --- a/README.md +++ b/README.md @@ -3,11 +3,9 @@ ## 🌞 What is sunnypilot? [sunnypilot](https://github.com/sunnyhaibin/sunnypilot) is a fork of comma.ai's openpilot, an open source driver assistance system. sunnypilot offers the user a unique driving experience for over 300+ supported car makes and models with modified behaviors of driving assist engagements. sunnypilot complies with comma.ai's safety rules as accurately as possible. -## 💭 Join our Discord -Join the official sunnypilot Discord server to stay up to date with all the latest features and be a part of shaping the future of sunnypilot! -* https://discord.gg/sunnypilot - - ![](https://dcbadge.vercel.app/api/server/wRW3meAgtx?style=flat) ![Discord Shield](https://discordapp.com/api/guilds/880416502577266699/widget.png?style=shield) +## 💭 Join our Community Forum +Join the official sunnypilot community forum to stay up to date with all the latest features and be a part of shaping the future of sunnypilot! +* https://community.sunnypilot.ai/ ## Documentation https://docs.sunnypilot.ai/ is your one stop shop for everything from features to installation to FAQ about the sunnypilot @@ -16,13 +14,13 @@ https://docs.sunnypilot.ai/ is your one stop shop for everything from features t * A supported device to run this software * a [comma three](https://comma.ai/shop/products/three) or a [C3X](https://comma.ai/shop/comma-3x) * This software -* One of [the 300+ supported cars](https://github.com/commaai/openpilot/blob/master/docs/CARS.md). We support Honda, Toyota, Hyundai, Nissan, Kia, Chrysler, Lexus, Acura, Audi, VW, Ford and more. If your car is not supported but has adaptive cruise control and lane-keeping assist, it's likely able to run sunnypilot. +* One of [the 325+ supported cars](https://github.com/sunnypilot/sunnypilot/blob/master/docs/CARS.md). We support Honda, Toyota, Hyundai, Nissan, Kia, Chrysler, Lexus, Acura, Audi, VW, Ford, and more. If your car is not supported but has adaptive cruise control and lane-keeping assist, it's likely able to run sunnypilot. * A [car harness](https://comma.ai/shop/products/car-harness) to connect to your car Detailed instructions for [how to mount the device in a car](https://comma.ai/setup). ## Installation -Please refer to [Recommended Branches](#-recommended-branches) to find your preferred/supported branch. This guide will assume you want to install the latest `staging-c3-new` branch. +Please refer to [Recommended Branches](#recommended-branches) to find your preferred/supported branch. This guide will assume you want to install the latest `staging` branch. ### If you want to use our newest branches (our rewrite) > [!TIP] @@ -31,28 +29,28 @@ Please refer to [Recommended Branches](#-recommended-branches) to find your pref * sunnypilot not installed or you installed a version before 0.8.17? 1. [Factory reset/uninstall](https://github.com/commaai/openpilot/wiki/FAQ#how-can-i-reset-the-device) the previous software if you have another software/fork installed. 2. After factory reset/uninstall and upon reboot, select `Custom Software` when given the option. - 3. Input the installation URL per [Recommended Branches](#-recommended-branches). Example: ```https://staging-c3-new.sunnypilot.ai```. + 3. Input the installation URL per [Recommended Branches](#recommended-branches). Example: ```https://staging.sunnypilot.ai```. 4. Complete the rest of the installation following the onscreen instructions. * sunnypilot already installed and you installed a version after 0.8.17? - 1. On the comma three, go to `Settings` ▶️ `Software`. + 1. On the comma three/3X, go to `Settings` ▶️ `Software`. 2. At the `Download` option, press `CHECK`. This will fetch the list of latest branches from sunnypilot. 3. At the `Target Branch` option, press `SELECT` to open the Target Branch selector. - 4. Scroll to select the desired branch per Recommended Branches (see below). Example: `staging-c3-new` + 4. Scroll to select the desired branch per Recommended Branches (see below). Example: `staging` - -| Branch | Installation URL | -|:----------------:|:---------------------------------------------:| -| `staging-c3-new` | `https://staging-c3-new.sunnypilot.ai` | -| `dev-c3-new` | `https://dev-c3-new.sunnypilot.ai` | -| `custom-branch` | `https://install.sunnypilot.ai/{branch_name}` | -| `release-c3-new` | **Not yet available**. | +### Recommended Branches +| Branch | Installation URL | +|:---------------:|:---------------------------------------------:| +| `release` | `https://release.sunnypilot.ai` | +| `staging` | `https://staging.sunnypilot.ai` | +| `dev` | `https://dev.sunnypilot.ai` | +| `custom-branch` | `https://install.sunnypilot.ai/{branch_name}` | > [!TIP] -> You can use sunnypilot/targetbranch as an install URL. Example: 'sunnypilot/staging-c3-new'. +> You can use sunnypilot/targetbranch as an install URL. Example: 'sunnypilot/staging'. > [!NOTE] -> Do you require further assistance with software installation? Join the [sunnypilot Discord server](https://discord.sunnypilot.com) and message us in the `#installation-help` channel. +> Do you require further assistance with software installation? Join the [sunnypilot community forum](https://community.sunnypilot.ai/new-topic?category=general/qa) and create a topic in the General/Q&A Category channel.
From 4b5de0eddbf34080106e1ee1e8bf424aa1ce82ca Mon Sep 17 00:00:00 2001 From: DevTekVE Date: Tue, 4 Nov 2025 22:53:31 +0100 Subject: [PATCH 5/5] stats: sunnylink integration (#1454) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * sunnylink: add statsd process and related telemetry logging infrastructure - Introduced `statsd_sp` process for handling Sunnylink-specific stats. - Enhanced metrics logging with improved directory structure and data handling. * sunnylink: re-enable and refine stat_handler for telemetry processing - Reactivated `stat_handler` thread with improved path handling. - Made `stat_handler` more flexible by allowing directory injection. * statsd: fix formatting issue in telemetry string generation - Corrected missing comma between `sunnylink_dongle_id` and `comma_dongle_id`. * update statsd_sp process configuration for enhanced readiness logic - Modified `statsd_sp` initialization to include `always_run` alongside `sunnylink_ready_shim`. - Ensures robust process activation conditions. * refactor(statsd): enhance and unify StatLogSP implementation - Replaced custom `StatLogSP` in sunnylink with centralized implementation from `system.statsd`. - Ensures consistent logic for StatLogSP handling across modules. * fix * refactor(statsd): add intercept parameter to StatLogSP for configurable logging - Introduced optional `intercept` parameter to `StatLogSP` to manage `comma_statlog` initialization. - Updated usage in `sunnylink` to disable interception where unnecessary. * Dont complain * feat(statsd): add raw metric type and SunnyPilot-specific stats collection - Introduced `METRIC_TYPE.RAW` for base64-encoded raw data metrics. - Added `sp_stats` thread to export SunnyPilot params as raw metrics. - Enhanced telemetry handling with decoding and serialization updates. * refactor(statsd): improve `sp_stats` error handling and param processing - Enhanced exception handling for `params.get` to prevent crashes. - Added support for nested dict values to be included in stats. * refactor(statsd): adjust imports and minor code formatting updates - Updated `Ratekeeper` import path for consistency with the `openpilot` module structure. - Fixed minor formatting for improved readability. * refactor(statsd): update typings and remove unused NoReturn annotation - Removed unnecessary `NoReturn` typing for `stats_main` to simplify function definition. - Adjusted `get_influxdb_line_raw` to refine typing for `value` parameter. * cleanup * init * init * slightly more * staticmethod * handle them all * get them models * log with route * more * car * Revert "car" This reverts commit fe1c90cf4d422864388e4a73d5cd25c9e585a9b3. * handle capnp * Revert "handle capnp" This reverts commit c5aea6880333863838a7c258541fb7a2812f2e2b. * 1 more time * Revert "1 more time" This reverts commit a364474fa580d9ef9760a8896f9db76d526abead. * Cleaning to expose wider * feat(interfaces, statsd): log car params to stats system - Added `STATSLOGSP` import and logging to capture `carFingerprint` in metrics. - Improved error handling in `get_influxdb_line_raw` for robust metric generation. * refactor(interfaces): streamline car params logging to stats - Simplified logging by directly converting `CP` to a dictionary. - Removed legacy stats aggregation for clarity. * feat(sunnylink): enable compression for stats in SunnyLink - Added optional compression for stats payload to support large data. - Updated `stat_handler` to handle compression and base64 encoding. * fix(statsd): filter complex types in `get_influxdb_line_raw` - Skips unsupported types (dict, list, bytes) to prevent formatting errors. - Simplifies type annotation for `value` parameter. * fix(statsd): use `json.dumps` for string conversion in `get_influxdb_line_raw` - Ensures proper handling of special characters in values. - Prevents potential formatting issues with raw `str()` conversion. * refactor(interfaces, statsd): update parameter keys for stats logging - Renamed logged keys for better clarity (`sunnypilot_params` → `sunnypilot.car_params`, `device_params`). - Ensures consistency across data logs. * bet --------- Co-authored-by: Jason Wen --- sunnypilot/selfdrive/car/interfaces.py | 5 + sunnypilot/sunnylink/athena/sunnylinkd.py | 5 +- sunnypilot/sunnylink/statsd.py | 278 ++++++++++++++++++++++ system/athena/athenad.py | 24 +- system/hardware/hw.py | 7 + system/manager/process_config.py | 1 + system/statsd.py | 59 ++++- 7 files changed, 368 insertions(+), 11 deletions(-) create mode 100755 sunnypilot/sunnylink/statsd.py diff --git a/sunnypilot/selfdrive/car/interfaces.py b/sunnypilot/selfdrive/car/interfaces.py index 59cfeabd6e..2cfbd1eec8 100644 --- a/sunnypilot/selfdrive/car/interfaces.py +++ b/sunnypilot/selfdrive/car/interfaces.py @@ -15,6 +15,8 @@ from openpilot.sunnypilot.selfdrive.controls.lib.speed_limit.helpers import set_ import openpilot.system.sentry as sentry +from sunnypilot.sunnylink.statsd import STATSLOGSP + def log_fingerprint(CP: structs.CarParams) -> None: if CP.carFingerprint == "MOCK": @@ -100,6 +102,9 @@ def setup_interfaces(CI: CarInterfaceBase, params: Params = None) -> None: _initialize_torque_lateral_control(CI, CP, enforce_torque, nnlc_enabled) _cleanup_unsupported_params(CP, CP_SP) + STATSLOGSP.raw('sunnypilot.car_params', CP.to_dict()) + # STATSLOGSP.raw('sunnypilot_params.car_params_sp', CP_SP.to_dict()) # https://github.com/sunnypilot/opendbc/pull/361 + def initialize_params(params) -> list[dict[str, Any]]: keys: list = [] diff --git a/sunnypilot/sunnylink/athena/sunnylinkd.py b/sunnypilot/sunnylink/athena/sunnylinkd.py index d1b38d656f..4c431c5d34 100755 --- a/sunnypilot/sunnylink/athena/sunnylinkd.py +++ b/sunnypilot/sunnylink/athena/sunnylinkd.py @@ -16,8 +16,9 @@ from functools import partial from openpilot.common.params import Params from openpilot.common.realtime import set_core_affinity from openpilot.common.swaglog import cloudlog +from openpilot.system.hardware.hw import Paths from openpilot.system.athena.athenad import ws_send, jsonrpc_handler, \ - recv_queue, UploadQueueCache, upload_queue, cur_upload_items, backoff, ws_manage, log_handler, start_local_proxy_shim, upload_handler + recv_queue, UploadQueueCache, upload_queue, cur_upload_items, backoff, ws_manage, log_handler, start_local_proxy_shim, upload_handler, stat_handler from websocket import (ABNF, WebSocket, WebSocketException, WebSocketTimeoutException, create_connection, WebSocketConnectionClosedException) @@ -51,7 +52,7 @@ def handle_long_poll(ws: WebSocket, exit_event: threading.Event | None) -> None: threading.Thread(target=ws_queue, args=(end_event,), name='ws_queue'), threading.Thread(target=upload_handler, args=(end_event,), name='upload_handler'), # threading.Thread(target=sunny_log_handler, args=(end_event, comma_prime_cellular_end_event), name='log_handler'), - # threading.Thread(target=stat_handler, args=(end_event,), name='stat_handler'), + threading.Thread(target=stat_handler, args=(end_event, Paths.stats_sp_root(), True), name='stat_handler'), ] + [ threading.Thread(target=jsonrpc_handler, args=(end_event, partial(startLocalProxy, end_event),), name=f'worker_{x}') for x in range(HANDLER_THREADS) diff --git a/sunnypilot/sunnylink/statsd.py b/sunnypilot/sunnylink/statsd.py new file mode 100755 index 0000000000..eefff63516 --- /dev/null +++ b/sunnypilot/sunnylink/statsd.py @@ -0,0 +1,278 @@ +#!/usr/bin/env python3 +import base64 +import json +import os +import threading +import traceback + +import zmq +import time +import uuid +from pathlib import Path +from collections import defaultdict +from datetime import datetime, UTC + +from openpilot.common.params import Params +from cereal.messaging import SubMaster +from openpilot.system.hardware.hw import Paths +from openpilot.common.swaglog import cloudlog +from openpilot.system.hardware import HARDWARE +from openpilot.common.file_helpers import atomic_write_in_dir +from openpilot.system.version import get_build_metadata +from openpilot.system.loggerd.config import STATS_DIR_FILE_LIMIT, STATS_SOCKET, STATS_FLUSH_TIME_S +from openpilot.system.statsd import METRIC_TYPE, StatLogSP +from openpilot.common.realtime import Ratekeeper + +STATSLOGSP = StatLogSP(intercept=False) + +def sp_stats(end_event): + """Collect sunnypilot-specific statistics and send as raw metrics.""" + rk = Ratekeeper(.1, print_delay_threshold=None) + statlogsp = STATSLOGSP + params = Params() + + def flatten_dict(d, parent_key='', sep='.'): + items = {} + if isinstance(d, dict): + for k, v in d.items(): + new_key = f"{parent_key}{sep}{k}" if parent_key else k + items.update(flatten_dict(v, new_key, sep=sep)) + elif isinstance(d, (list, tuple)): + for i, v in enumerate(d): + new_key = f"{parent_key}[{i}]" + items.update(flatten_dict(v, new_key, sep=sep)) + else: + items[parent_key] = d + return items + + # Collect sunnypilot parameters + stats_dict = {} + + param_keys = [ + 'SunnylinkEnabled', + 'AutoLaneChangeBsmDelay', + 'AutoLaneChangeTimer', + 'CarPlatformBundle', + 'CurrentRoute', + 'DevUIInfo', + 'EnableCopyparty', + 'IntelligentCruiseButtonManagement', + 'QuietMode', + 'RainbowMode', + 'ShowAdvancedControls', + 'Mads', + 'MadsMainCruiseAllowed', + 'MadsSteeringMode', + 'MadsUnifiedEngagementMode', + 'ModelManager_ActiveBundle', + 'ModelManager_Favs', + 'EnableSunnylinkUploader', + 'SunnylinkEnabled', + 'InstallDate', + 'UptimeOffroad', + 'UptimeOnroad', + ] + + while not end_event.is_set(): + try: + for key in param_keys: + + try: + value = params.get(key) + except Exception as e: + stats_dict[key] = e + continue + + if value is None: + continue + + if isinstance(value, (dict, list, tuple)): + stats_dict.update(flatten_dict(value, key)) + else: + stats_dict[key] = value + + if stats_dict: + statlogsp.raw('sunnypilot.device_params', stats_dict) + except Exception as e: + cloudlog.error(f"Exception {e}") + finally: + rk.keep_time() + + +def stats_main(end_event): + comma_dongle_id = Params().get("DongleId") + sunnylink_dongle_id = Params().get("SunnylinkDongleId") + + def get_influxdb_line(measurement: str, value: float | dict[str, float], timestamp: datetime, tags: dict) -> str: + res = f"{measurement}" + for k, v in tags.items(): + res += f",{k}={str(v)}" + res += " " + + if isinstance(value, float): + value = {'value': value} + + for k, v in value.items(): + res += f"{k}={str(v)}," + + res += f"sunnylink_dongle_id=\"{sunnylink_dongle_id}\",comma_dongle_id=\"{comma_dongle_id}\" {int(timestamp.timestamp() * 1e9)}\n" + return res + + def get_influxdb_line_raw(measurement: str, value: dict, timestamp: datetime, tags: dict) -> str: + res = f"{measurement}" + try: + custom_tags = "" + for k, v in tags.items(): + custom_tags += f",{k}={str(v)}" + res += custom_tags + + fields = "" + for k, v in value.items(): + # Skip complex types - only keep simple scalar values + if isinstance(v, (dict, list, bytes, bytearray)): + continue + + fields += f"{k}={json.dumps(v)}," + + res += f" {fields}" + except Exception as e: + cloudlog.error(f"Unable to get influxdb line for: {value}") + res += f",invalid=1 reason={e}," + + res += f"sunnylink_dongle_id=\"{sunnylink_dongle_id}\",comma_dongle_id=\"{comma_dongle_id}\" {int(timestamp.timestamp() * 1e9)}\n" + return res + + # open statistics socket + ctx = zmq.Context.instance() + sock = ctx.socket(zmq.PULL) + sock.bind(f"{STATS_SOCKET}_sp") + + STATS_DIR = Paths.stats_sp_root() + + # initialize stats directory + Path(STATS_DIR).mkdir(parents=True, exist_ok=True) + + build_metadata = get_build_metadata() + + # initialize tags + tags = { + 'started': False, + 'version': build_metadata.openpilot.version, + 'branch': build_metadata.channel, + 'dirty': build_metadata.openpilot.is_dirty, + 'origin': build_metadata.openpilot.git_normalized_origin, + 'deviceType': HARDWARE.get_device_type(), + } + + # subscribe to deviceState for started state + sm = SubMaster(['deviceState']) + + idx = 0 + boot_uid = str(uuid.uuid4())[:8] + last_flush_time = time.monotonic() + gauges = {} + samples: dict[str, list[float]] = defaultdict(list) + raws: dict = defaultdict() + try: + while not end_event.is_set(): + started_prev = sm['deviceState'].started + sm.update() + + # Update metrics + while True: + try: + metric = sock.recv_string(zmq.NOBLOCK) + try: + metric_type = metric.split('|')[1] + metric_name = metric.split(':')[0] + metric_value_raw = metric.split('|')[0].split(':')[1] + + if metric_type == METRIC_TYPE.GAUGE: + metric_value = float(metric_value_raw) + gauges[metric_name] = metric_value + elif metric_type == METRIC_TYPE.SAMPLE: + metric_value = float(metric_value_raw) + samples[metric_name].append(metric_value) + elif metric_type == METRIC_TYPE.RAW: + raws[metric_name] = metric_value_raw + else: + cloudlog.event("unknown metric type", metric_type=metric_type) + except Exception: + print(traceback.format_exc()) + cloudlog.event("malformed metric", metric=metric) + except zmq.error.Again: + break + + # flush when started state changes or after FLUSH_TIME_S + if (time.monotonic() > last_flush_time + STATS_FLUSH_TIME_S) or (sm['deviceState'].started != started_prev): + result = "" + current_time = datetime.now(UTC) + tags['started'] = sm['deviceState'].started + + for key, value in raws.items(): + decoded_value = json.loads(base64.b64decode(value).decode('utf-8')) + result += get_influxdb_line_raw(key, decoded_value, current_time, tags) + + for key, value in gauges.items(): + result += get_influxdb_line(f"gauge.{key}", value, current_time, tags) + + for key, values in samples.items(): + values.sort() + sample_count = len(values) + sample_sum = sum(values) + + stats = { + 'count': sample_count, + 'min': values[0], + 'max': values[-1], + 'mean': sample_sum / sample_count, + } + for percentile in [0.05, 0.5, 0.95]: + value = values[int(round(percentile * (sample_count - 1)))] + stats[f"p{int(percentile * 100)}"] = value + + result += get_influxdb_line(f"sample.{key}", stats, current_time, tags) + + # clear intermediate data + gauges.clear() + samples.clear() + last_flush_time = time.monotonic() + + # check that we aren't filling up the drive + if len(os.listdir(STATS_DIR)) < STATS_DIR_FILE_LIMIT: + if len(result) > 0: + stats_path = os.path.join(STATS_DIR, f"{boot_uid}_{idx}") + with atomic_write_in_dir(stats_path) as f: + f.write(result) + idx += 1 + else: + cloudlog.error("stats dir full") + finally: + sock.close() + ctx.term() + + +def main(): + rk = Ratekeeper(1, print_delay_threshold=None) + end_event = threading.Event() + + threads = [ + threading.Thread(target=stats_main, args=(end_event,)), + threading.Thread(target=sp_stats, args=(end_event,)), + ] + + for t in threads: + t.start() + + try: + while all(t.is_alive() for t in threads): + rk.keep_time() + finally: + end_event.set() + + for t in threads: + t.join() + + +if __name__ == "__main__": + main() diff --git a/system/athena/athenad.py b/system/athena/athenad.py index 42c9cf8a1c..716733021a 100755 --- a/system/athena/athenad.py +++ b/system/athena/athenad.py @@ -744,26 +744,40 @@ def log_handler(end_event: threading.Event, log_attr_name=LOG_ATTR_NAME) -> None cloudlog.exception("athena.log_handler.exception") -def stat_handler(end_event: threading.Event) -> None: - STATS_DIR = Paths.stats_root() +def stat_handler(end_event: threading.Event, stats_dir=None, is_sunnylink=False) -> None: + stats_dir = stats_dir or Paths.stats_root() last_scan = 0.0 while not end_event.is_set(): curr_scan = time.monotonic() try: if curr_scan - last_scan > 10: - stat_filenames = list(filter(lambda name: not name.startswith(tempfile.gettempprefix()), os.listdir(STATS_DIR))) + stat_filenames = list(filter(lambda name: not name.startswith(tempfile.gettempprefix()), os.listdir(stats_dir))) if len(stat_filenames) > 0: - stat_path = os.path.join(STATS_DIR, stat_filenames[0]) + stat_path = os.path.join(stats_dir, stat_filenames[0]) with open(stat_path) as f: + payload = f.read() + is_compressed = False + + # Log the current size of the file + if is_sunnylink: + # Compress and encode the data if it exceeds the maximum size + compressed_data = gzip.compress(payload.encode()) + payload = base64.b64encode(compressed_data).decode() + is_compressed = True + jsonrpc = { "method": "storeStats", "params": { - "stats": f.read() + "stats": payload }, "jsonrpc": "2.0", "id": stat_filenames[0] } + + if is_sunnylink and is_compressed: + jsonrpc["params"]["compressed"] = is_compressed + low_priority_send_queue.put_nowait(json.dumps(jsonrpc)) os.remove(stat_path) last_scan = curr_scan diff --git a/system/hardware/hw.py b/system/hardware/hw.py index d24857e8bd..3527aac872 100644 --- a/system/hardware/hw.py +++ b/system/hardware/hw.py @@ -55,6 +55,13 @@ class Paths: else: return "/data/stats/" + @staticmethod + def stats_sp_root() -> str: + if PC: + return str(Path(Paths.comma_home()) / "stats") + else: + return "/data/stats_sp/" + @staticmethod def config_root() -> str: if PC: diff --git a/system/manager/process_config.py b/system/manager/process_config.py index c5f20e511b..6e52635a64 100644 --- a/system/manager/process_config.py +++ b/system/manager/process_config.py @@ -164,6 +164,7 @@ procs = [ # sunnylink <3 DaemonProcess("manage_sunnylinkd", "sunnypilot.sunnylink.athena.manage_sunnylinkd", "SunnylinkdPid"), PythonProcess("sunnylink_registration_manager", "sunnypilot.sunnylink.registration_manager", sunnylink_need_register_shim), + PythonProcess("statsd_sp", "sunnypilot.sunnylink.statsd", and_(always_run, sunnylink_ready_shim)), ] # sunnypilot diff --git a/system/statsd.py b/system/statsd.py index d60064fc91..89ffa0d6fc 100755 --- a/system/statsd.py +++ b/system/statsd.py @@ -1,11 +1,15 @@ #!/usr/bin/env python3 +import base64 +import json import os +from decimal import Decimal + import zmq import time import uuid from pathlib import Path from collections import defaultdict -from datetime import datetime, UTC +from datetime import datetime, UTC, date from typing import NoReturn from openpilot.common.params import Params @@ -21,18 +25,21 @@ from openpilot.system.loggerd.config import STATS_DIR_FILE_LIMIT, STATS_SOCKET, class METRIC_TYPE: GAUGE = 'g' SAMPLE = 'sa' + RAW = 'r' + class StatLog: def __init__(self): self.pid = None self.zctx = None self.sock = None + self.stats_socket = STATS_SOCKET def connect(self) -> None: - self.zctx = zmq.Context() + self.zctx = zmq.Context.instance() or zmq.Context() self.sock = self.zctx.socket(zmq.PUSH) self.sock.setsockopt(zmq.LINGER, 10) - self.sock.connect(STATS_SOCKET) + self.sock.connect(self.stats_socket) self.pid = os.getpid() def __del__(self): @@ -60,6 +67,50 @@ class StatLog: self._send(f"{name}:{value}|{METRIC_TYPE.SAMPLE}") +class StatLogSP(StatLog): + def __init__(self, intercept=True): + """ + Initializes the class instance with an optional parameter to determine + if statistical logging should be configured or not. + + :param intercept: A boolean flag that indicates whether to initialize + the `comma_statlog`. If True, the `comma_statlog` attribute is + instantiated as a `StatLog` object. Defaults to True. + """ + super().__init__() + self.comma_statlog = StatLog() if intercept else None + self.stats_socket = f"{STATS_SOCKET}_sp" + + def connect(self) -> None: + super().connect() + if self.comma_statlog: + self.comma_statlog.connect() + + def __del__(self): + super().__del__() + if self.comma_statlog: + self.comma_statlog.__del__() + + def _send(self, metric: str) -> None: + super()._send(metric) + if self.comma_statlog: + self.comma_statlog._send(metric) + + @staticmethod + def default_converter(obj): + if isinstance(obj, (datetime, date)): + return obj.isoformat() + if isinstance(obj, set): + return list(obj) + if isinstance(obj, Decimal): + return float(obj) + return str(obj) # fallback for unknown types + + def raw(self, name: str, value: dict) -> None: + encoded_dict = base64.b64encode(json.dumps(value, default=self.default_converter).encode("utf-8")).decode("utf-8") + self._send(f"{name}:{encoded_dict}|{METRIC_TYPE.RAW}") + + def main() -> NoReturn: dongle_id = Params().get("DongleId") def get_influxdb_line(measurement: str, value: float | dict[str, float], timestamp: datetime, tags: dict) -> str: @@ -180,4 +231,4 @@ def main() -> NoReturn: if __name__ == "__main__": main() else: - statlog = StatLog() + statlog = StatLogSP(intercept=True)