From 7e03277962091dc9081a97f89673a86705dcffb7 Mon Sep 17 00:00:00 2001 From: Jason Wen Date: Mon, 13 Oct 2025 03:06:39 -0400 Subject: [PATCH 01/20] ui: bigger cluster set speed fonts when ICBM is active (#1369) --- selfdrive/ui/sunnypilot/qt/onroad/hud.cc | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/selfdrive/ui/sunnypilot/qt/onroad/hud.cc b/selfdrive/ui/sunnypilot/qt/onroad/hud.cc index 718bfb514b..3ed8feb993 100644 --- a/selfdrive/ui/sunnypilot/qt/onroad/hud.cc +++ b/selfdrive/ui/sunnypilot/qt/onroad/hud.cc @@ -698,11 +698,13 @@ void HudRendererSP::drawSetSpeedSP(QPainter &p, const QRect &surface_rect) { } else { icbm_active_counter = 0; } + int max_str_size = (icbm_active_counter != 0) ? 60 : 40; + int max_str_y = (icbm_active_counter != 0) ? 15 : 27; QString max_str = (icbm_active_counter != 0) ? QString::number(std::nearbyint(speedCluster)) : tr("MAX"); - p.setFont(InterFont(40, QFont::DemiBold)); + p.setFont(InterFont(max_str_size, QFont::DemiBold)); p.setPen(max_color); - p.drawText(set_speed_rect.adjusted(0, 27, 0, 0), Qt::AlignTop | Qt::AlignHCenter, max_str); + p.drawText(set_speed_rect.adjusted(0, max_str_y, 0, 0), Qt::AlignTop | Qt::AlignHCenter, max_str); // Draw set speed QString setSpeedStr = is_cruise_set ? QString::number(std::nearbyint(set_speed)) : "–"; From e5f1f86ac2d19550dcd6238635811a0a2efa09b7 Mon Sep 17 00:00:00 2001 From: Jason Wen Date: Mon, 13 Oct 2025 19:37:58 -0400 Subject: [PATCH 02/20] params: helper to clamp out-of-range int params (#1373) * params: helpers to clamp out-of-range values * lint * inline * fix access * actually fix the param * inherit them * more lint --- selfdrive/selfdrived/selfdrived.py | 8 ++++++- sunnypilot/__init__.py | 21 +++++++++++++++++++ .../controls/lib/speed_limit/common.py | 9 ++++---- .../lib/speed_limit/speed_limit_resolver.py | 15 +++++++++++-- 4 files changed, 46 insertions(+), 7 deletions(-) diff --git a/selfdrive/selfdrived/selfdrived.py b/selfdrive/selfdrived/selfdrived.py index 4bc75021f7..0a4ea749e4 100755 --- a/selfdrive/selfdrived/selfdrived.py +++ b/selfdrive/selfdrived/selfdrived.py @@ -24,6 +24,7 @@ from openpilot.selfdrive.selfdrived.alertmanager import AlertManager, set_offroa from openpilot.system.version import get_build_metadata from openpilot.sunnypilot.mads.mads import ModularAssistiveDrivingSystem +from openpilot.sunnypilot import get_sanitize_int_param from openpilot.sunnypilot.selfdrive.car.car_specific import CarSpecificEventsSP from openpilot.sunnypilot.selfdrive.car.cruise_helpers import CruiseHelper from openpilot.sunnypilot.selfdrive.car.intelligent_cruise_button_management.controller import IntelligentCruiseButtonManagement @@ -130,7 +131,12 @@ class SelfdriveD(CruiseHelper): self.logged_comm_issue = None self.not_running_prev = None self.experimental_mode = False - self.personality = self.params.get("LongitudinalPersonality", return_default=True) + self.personality = get_sanitize_int_param( + "LongitudinalPersonality", + min(log.LongitudinalPersonality.schema.enumerants.values()), + max(log.LongitudinalPersonality.schema.enumerants.values()), + self.params + ) self.recalibrating_seen = False self.state_machine = StateMachine() self.rk = Ratekeeper(100, print_delay_threshold=None) diff --git a/sunnypilot/__init__.py b/sunnypilot/__init__.py index dd9870597d..ccacd0be0a 100644 --- a/sunnypilot/__init__.py +++ b/sunnypilot/__init__.py @@ -5,6 +5,7 @@ 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 enum import IntEnum import hashlib PARAMS_UPDATE_PERIOD = 3 # seconds @@ -16,3 +17,23 @@ def get_file_hash(path: str) -> str: for byte_block in iter(lambda: f.read(4096), b""): sha256_hash.update(byte_block) return sha256_hash.hexdigest() + + +class IntEnumBase(IntEnum): + @classmethod + def min(cls): + return min(cls) + + @classmethod + def max(cls): + return max(cls) + + +def get_sanitize_int_param(key: str, min_val: int, max_val: int, params) -> int: + val: int = params.get(key, return_default=True) + clipped_val = max(min_val, min(max_val, val)) + + if clipped_val != val: + params.put(key, clipped_val) + + return clipped_val diff --git a/sunnypilot/selfdrive/controls/lib/speed_limit/common.py b/sunnypilot/selfdrive/controls/lib/speed_limit/common.py index baf9328032..c46768464e 100644 --- a/sunnypilot/selfdrive/controls/lib/speed_limit/common.py +++ b/sunnypilot/selfdrive/controls/lib/speed_limit/common.py @@ -4,10 +4,11 @@ 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 enum import IntEnum + +from openpilot.sunnypilot import IntEnumBase -class Policy(IntEnum): +class Policy(IntEnumBase): car_state_only = 0 map_data_only = 1 car_state_priority = 2 @@ -15,13 +16,13 @@ class Policy(IntEnum): combined = 4 -class OffsetType(IntEnum): +class OffsetType(IntEnumBase): off = 0 fixed = 1 percentage = 2 -class Mode(IntEnum): +class Mode(IntEnumBase): off = 0 information = 1 warning = 2 diff --git a/sunnypilot/selfdrive/controls/lib/speed_limit/speed_limit_resolver.py b/sunnypilot/selfdrive/controls/lib/speed_limit/speed_limit_resolver.py index 51fb8f6d64..459334d156 100644 --- a/sunnypilot/selfdrive/controls/lib/speed_limit/speed_limit_resolver.py +++ b/sunnypilot/selfdrive/controls/lib/speed_limit/speed_limit_resolver.py @@ -12,7 +12,7 @@ from openpilot.common.constants import CV from openpilot.common.gps import get_gps_location_service from openpilot.common.params import Params from openpilot.common.realtime import DT_MDL -from openpilot.sunnypilot import PARAMS_UPDATE_PERIOD +from openpilot.sunnypilot import PARAMS_UPDATE_PERIOD, get_sanitize_int_param from openpilot.sunnypilot.selfdrive.controls.lib.speed_limit import LIMIT_MAX_MAP_DATA_AGE, LIMIT_ADAPT_ACC from openpilot.sunnypilot.selfdrive.controls.lib.speed_limit.common import Policy, OffsetType @@ -42,6 +42,12 @@ class SpeedLimitResolver: self.distance_solutions = {} # Store for distance to current speed limit start for different sources self.policy = self.params.get("SpeedLimitPolicy", return_default=True) + self.policy = get_sanitize_int_param( + "SpeedLimitPolicy", + Policy.min().value, + Policy.max().value, + self.params + ) self._policy_to_sources_map = { Policy.car_state_only: [SpeedLimitSource.car], Policy.map_data_only: [SpeedLimitSource.map], @@ -54,7 +60,12 @@ class SpeedLimitResolver: self._reset_limit_sources(source) self.is_metric = self.params.get_bool("IsMetric") - self.offset_type = self.params.get("SpeedLimitOffsetType", return_default=True) + self.offset_type = get_sanitize_int_param( + "SpeedLimitOffsetType", + OffsetType.min().value, + OffsetType.max().value, + self.params + ) self.offset_value = self.params.get("SpeedLimitValueOffset", return_default=True) self.speed_limit = 0. From 285fd9760698673d99a51cf407d480d2bc4e0531 Mon Sep 17 00:00:00 2001 From: Jason Wen Date: Mon, 13 Oct 2025 20:36:24 -0400 Subject: [PATCH 03/20] ui: only draw speedCluster speed over "MAX" when ICBM is enabled (#1374) --- selfdrive/ui/sunnypilot/qt/onroad/hud.cc | 4 +++- selfdrive/ui/sunnypilot/qt/onroad/hud.h | 1 + selfdrive/ui/sunnypilot/ui.cc | 2 +- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/selfdrive/ui/sunnypilot/qt/onroad/hud.cc b/selfdrive/ui/sunnypilot/qt/onroad/hud.cc index 3ed8feb993..8c913cec17 100644 --- a/selfdrive/ui/sunnypilot/qt/onroad/hud.cc +++ b/selfdrive/ui/sunnypilot/qt/onroad/hud.cc @@ -35,6 +35,7 @@ void HudRendererSP::updateState(const UIState &s) { const auto gpsLocation = is_gps_location_external ? sm["gpsLocationExternal"].getGpsLocationExternal() : sm["gpsLocation"].getGpsLocation(); const auto ltp = sm["liveTorqueParameters"].getLiveTorqueParameters(); const auto car_params = sm["carParams"].getCarParams(); + const auto car_params_sp = sm["carParamsSP"].getCarParamsSP(); const auto lp_sp = sm["longitudinalPlanSP"].getLongitudinalPlanSP(); const auto lmd = sm["liveMapDataSP"].getLiveMapDataSP(); @@ -130,6 +131,7 @@ void HudRendererSP::updateState(const UIState &s) { carControlEnabled = car_control.getEnabled(); speedCluster = car_state.getCruiseState().getSpeedCluster() * speedConv; + pcmCruiseSpeed = car_params_sp.getPcmCruiseSpeed(); } void HudRendererSP::draw(QPainter &p, const QRect &surface_rect) { @@ -689,7 +691,7 @@ void HudRendererSP::drawSetSpeedSP(QPainter &p, const QRect &surface_rect) { } // Draw "MAX" or carState.cruiseState.speedCluster (when ICBM is active) text - if (carControlEnabled) { + if (!pcmCruiseSpeed && carControlEnabled) { if (std::nearbyint(set_speed) != std::nearbyint(speedCluster)) { icbm_active_counter = 3 * UI_FREQ; } else if (icbm_active_counter > 0) { diff --git a/selfdrive/ui/sunnypilot/qt/onroad/hud.h b/selfdrive/ui/sunnypilot/qt/onroad/hud.h index 554c9c5d0d..bc7ee81de3 100644 --- a/selfdrive/ui/sunnypilot/qt/onroad/hud.h +++ b/selfdrive/ui/sunnypilot/qt/onroad/hud.h @@ -120,4 +120,5 @@ private: bool carControlEnabled; float speedCluster = 0; int icbm_active_counter = 0; + bool pcmCruiseSpeed; }; diff --git a/selfdrive/ui/sunnypilot/ui.cc b/selfdrive/ui/sunnypilot/ui.cc index ab1ca0e6a6..df7b3ce5ca 100644 --- a/selfdrive/ui/sunnypilot/ui.cc +++ b/selfdrive/ui/sunnypilot/ui.cc @@ -29,7 +29,7 @@ UIStateSP::UIStateSP(QObject *parent) : UIState(parent) { "wideRoadCameraState", "managerState", "selfdriveState", "longitudinalPlan", "modelManagerSP", "selfdriveStateSP", "longitudinalPlanSP", "backupManagerSP", "carControl", "gpsLocationExternal", "gpsLocation", "liveTorqueParameters", - "carStateSP", "liveParameters", "liveMapDataSP" + "carStateSP", "liveParameters", "liveMapDataSP", "carParamsSP" }); // update timer From 39e73cc46ea666787ff49bb1926d9d5625c1ae29 Mon Sep 17 00:00:00 2001 From: Kumar <36933347+rav4kumar@users.noreply.github.com> Date: Mon, 13 Oct 2025 18:45:59 -0700 Subject: [PATCH 04/20] ui: add ModelRendererSP::draw (#1372) * ModelRendererSP::draw * match * less * huh? * unused --------- Co-authored-by: Jason Wen --- selfdrive/ui/qt/onroad/model.cc | 3 +- selfdrive/ui/qt/onroad/model.h | 4 - selfdrive/ui/sunnypilot/qt/onroad/model.cc | 143 ++++++++++++--------- selfdrive/ui/sunnypilot/qt/onroad/model.h | 14 +- 4 files changed, 88 insertions(+), 76 deletions(-) diff --git a/selfdrive/ui/qt/onroad/model.cc b/selfdrive/ui/qt/onroad/model.cc index a4165be216..176af56613 100644 --- a/selfdrive/ui/qt/onroad/model.cc +++ b/selfdrive/ui/qt/onroad/model.cc @@ -22,7 +22,7 @@ void ModelRenderer::draw(QPainter &painter, const QRect &surface_rect) { update_model(model, lead_one); drawLaneLines(painter); - drawPath(painter, model, surface_rect); + drawPath(painter, model, surface_rect.height()); if (longitudinal_control && sm.alive("radarState")) { update_leads(radar_state, model.getPosition()); @@ -173,7 +173,6 @@ QColor ModelRenderer::blendColors(const QColor &start, const QColor &end, float (1 - t) * start.alphaF() + t * end.alphaF()); } - void ModelRenderer::drawLead(QPainter &painter, const cereal::RadarState::LeadData::Reader &lead_data, const QPointF &vd, const QRect &surface_rect) { const float speedBuff = 10.; diff --git a/selfdrive/ui/qt/onroad/model.h b/selfdrive/ui/qt/onroad/model.h index 3ef8ba5320..0a58d345c4 100644 --- a/selfdrive/ui/qt/onroad/model.h +++ b/selfdrive/ui/qt/onroad/model.h @@ -39,9 +39,6 @@ protected: virtual void update_model(const cereal::ModelDataV2::Reader &model, const cereal::RadarState::LeadData::Reader &lead); void drawLaneLines(QPainter &painter); void drawPath(QPainter &painter, const cereal::ModelDataV2::Reader &model, int height); - virtual void drawPath(QPainter &painter, const cereal::ModelDataV2::Reader &model, const QRect &surface_rect) {; - drawPath(painter, model, surface_rect.height()); - } void updatePathGradient(QLinearGradient &bg); QColor blendColors(const QColor &start, const QColor &end, float t); @@ -58,5 +55,4 @@ protected: QPointF lead_vertices[2] = {}; Eigen::Matrix3f car_space_transform = Eigen::Matrix3f::Zero(); QRectF clip_region; - }; diff --git a/selfdrive/ui/sunnypilot/qt/onroad/model.cc b/selfdrive/ui/sunnypilot/qt/onroad/model.cc index 14943b1d48..086b703e10 100644 --- a/selfdrive/ui/sunnypilot/qt/onroad/model.cc +++ b/selfdrive/ui/sunnypilot/qt/onroad/model.cc @@ -21,74 +21,63 @@ void ModelRendererSP::update_model(const cereal::ModelDataV2::Reader &model, con mapLineToPolygon(model.getLaneLines()[2], 0.2, -0.05, &right_blindspot_vertices, max_idx_barrier); } -void ModelRendererSP::drawPath(QPainter &painter, const cereal::ModelDataV2::Reader &model, const QRect &surface_rect) { +void ModelRendererSP::draw(QPainter &painter, const QRect &surface_rect) { + ModelRenderer::draw(painter, surface_rect); auto *s = uiState(); auto &sm = *(s->sm); - bool blindspot = s->scene.blindspot_ui; - if (blindspot) { - bool left_blindspot = sm["carState"].getCarState().getLeftBlindspot(); - bool right_blindspot = sm["carState"].getCarState().getRightBlindspot(); - - //painter.setBrush(QColor::fromRgbF(1.0, 0.0, 0.0, 0.4)); // Red with alpha for blind spot - - if (left_blindspot && !left_blindspot_vertices.isEmpty()) { - QLinearGradient gradient(0, 0, surface_rect.width(), 0); // Horizontal gradient from left to right - gradient.setColorAt(0.0, QColor(255, 165, 0, 102)); // Orange with alpha - gradient.setColorAt(1.0, QColor(255, 255, 0, 102)); // Yellow with alpha - painter.setBrush(gradient); - painter.drawPolygon(left_blindspot_vertices); - } - - if (right_blindspot && !right_blindspot_vertices.isEmpty()) { - QLinearGradient gradient(surface_rect.width(), 0, 0, 0); // Horizontal gradient from right to left - gradient.setColorAt(0.0, QColor(255, 165, 0, 102)); // Orange with alpha - gradient.setColorAt(1.0, QColor(255, 255, 0, 102)); // Yellow with alpha - painter.setBrush(gradient); - painter.drawPolygon(right_blindspot_vertices); - } + if (sm.rcv_frame("liveCalibration") < s->scene.started_frame || + sm.rcv_frame("modelV2") < s->scene.started_frame) { + return; } + painter.save(); + + const auto &model = sm["modelV2"].getModelV2(); + const auto &radar_state = sm["radarState"].getRadarState(); + const auto &lead_one = radar_state.getLeadOne(); + const auto &car_state = sm["carState"].getCarState(); + + update_model(model, lead_one); + drawLaneLines(painter); + + bool blindspot = s->scene.blindspot_ui; bool rainbow = s->scene.rainbow_mode; - //float v_ego = sm["carState"].getCarState().getVEgo(); + + bool left_blindspot = car_state.getLeftBlindspot(); + bool right_blindspot = car_state.getRightBlindspot(); + + if (blindspot) { + drawBlindspot(painter, surface_rect, left_blindspot, right_blindspot); + } if (rainbow) { - // Simple time-based animation - float time_offset = std::chrono::duration_cast( - std::chrono::steady_clock::now().time_since_epoch()).count() / 1000.0f; - - // simple linear gradient from bottom to top - QLinearGradient bg(0, surface_rect.height(), 0, 0); - - // evenly spaced colors across the spectrum - // The animation shifts the entire spectrum smoothly - float animation_speed = 40.0f; // speed vroom vroom - float hue_offset = fmod(time_offset * animation_speed, 360.0f); - - // 6-8 color stops for smooth transitions more color makes it laggy - const int num_stops = 7; - for (int i = 0; i < num_stops; i++) { - float position = static_cast(i) / (num_stops - 1); - - float hue = fmod(hue_offset + position * 360.0f, 360.0f); - float saturation = 0.9f; - float lightness = 0.6f; - - // Alpha fades out towards the far end of the path - float alpha = 0.8f * (1.0f - position * 0.3f); - - QColor color = QColor::fromHslF(hue / 360.0f, saturation, lightness, alpha); - bg.setColorAt(position, color); - } - - painter.setBrush(bg); - painter.drawPolygon(track_vertices); + drawRainbowPath(painter, surface_rect); } else { - // Normal path rendering ModelRenderer::drawPath(painter, model, surface_rect.height()); } drawLeadStatus(painter, surface_rect.height(), surface_rect.width()); + + painter.restore(); +} + +void ModelRendererSP::drawBlindspot(QPainter &painter, const QRect &surface_rect, bool left_blindspot, bool right_blindspot) { + if (left_blindspot && !left_blindspot_vertices.isEmpty()) { + QLinearGradient gradient(0, 0, surface_rect.width(), 0); // Horizontal gradient from left to right + gradient.setColorAt(0.0, QColor(255, 165, 0, 102)); // Orange with alpha + gradient.setColorAt(1.0, QColor(255, 255, 0, 102)); // Yellow with alpha + painter.setBrush(gradient); + painter.drawPolygon(left_blindspot_vertices); + } + + if (right_blindspot && !right_blindspot_vertices.isEmpty()) { + QLinearGradient gradient(surface_rect.width(), 0, 0, 0); // Horizontal gradient from right to left + gradient.setColorAt(0.0, QColor(255, 165, 0, 102)); // Orange with alpha + gradient.setColorAt(1.0, QColor(255, 255, 0, 102)); // Yellow with alpha + painter.setBrush(gradient); + painter.drawPolygon(right_blindspot_vertices); + } } void ModelRendererSP::drawLeadStatus(QPainter &painter, int height, int width) { @@ -121,19 +110,16 @@ void ModelRendererSP::drawLeadStatus(QPainter &painter, int height, int width) { } if (has_lead_one) { - drawLeadStatusAtPosition(painter, lead_one, lead_vertices[0], height, width, "L1"); + drawLeadStatusPosition(painter, lead_one, lead_vertices[0], height, width); } if (has_lead_two && std::abs(lead_one.getDRel() - lead_two.getDRel()) > 3.0) { - drawLeadStatusAtPosition(painter, lead_two, lead_vertices[1], height, width, "L2"); + drawLeadStatusPosition(painter, lead_two, lead_vertices[1], height, width); } } -void ModelRendererSP::drawLeadStatusAtPosition(QPainter &painter, - const cereal::RadarState::LeadData::Reader &lead_data, - const QPointF &chevron_pos, - int height, int width, - const QString &label) { +void ModelRendererSP::drawLeadStatusPosition(QPainter &painter, const cereal::RadarState::LeadData::Reader &lead_data, + const QPointF &chevron_pos, int height, int width) { float d_rel = lead_data.getDRel(); float v_rel = lead_data.getVRel(); auto *s = uiState(); @@ -223,3 +209,36 @@ void ModelRendererSP::drawLeadStatusAtPosition(QPainter &painter, painter.setPen(Qt::NoPen); } + +void ModelRendererSP::drawRainbowPath(QPainter &painter, const QRect &surface_rect) { + // Simple time-based animation + float time_offset = std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count() / 1000.0f; + + // simple linear gradient from bottom to top + QLinearGradient bg(0, surface_rect.height(), 0, 0); + + // evenly spaced colors across the spectrum + // The animation shifts the entire spectrum smoothly + float animation_speed = 40.0f; // speed vroom vroom + float hue_offset = fmod(time_offset * animation_speed, 360.0f); + + // 6-8 color stops for smooth transitions more color makes it laggy + const int num_stops = 7; + for (int i = 0; i < num_stops; i++) { + float position = static_cast(i) / (num_stops - 1); + + float hue = fmod(hue_offset + position * 360.0f, 360.0f); + float saturation = 0.9f; + float lightness = 0.6f; + + // Alpha fades out towards the far end of the path + float alpha = 0.8f * (1.0f - position * 0.3f); + + QColor color = QColor::fromHslF(hue / 360.0f, saturation, lightness, alpha); + bg.setColorAt(position, color); + } + + painter.setBrush(bg); + painter.drawPolygon(track_vertices); +} diff --git a/selfdrive/ui/sunnypilot/qt/onroad/model.h b/selfdrive/ui/sunnypilot/qt/onroad/model.h index 73999f0059..68068f2068 100644 --- a/selfdrive/ui/sunnypilot/qt/onroad/model.h +++ b/selfdrive/ui/sunnypilot/qt/onroad/model.h @@ -13,17 +13,15 @@ class ModelRendererSP : public ModelRenderer { public: ModelRendererSP() = default; + void draw(QPainter &painter, const QRect &surface_rect); + private: void update_model(const cereal::ModelDataV2::Reader &model, const cereal::RadarState::LeadData::Reader &lead) override; - void drawPath(QPainter &painter, const cereal::ModelDataV2::Reader &model, const QRect &rect) override; - - // Lead status display methods void drawLeadStatus(QPainter &painter, int height, int width); - void drawLeadStatusAtPosition(QPainter &painter, - const cereal::RadarState::LeadData::Reader &lead_data, - const QPointF &chevron_pos, - int height, int width, - const QString &label); + void drawLeadStatusPosition(QPainter &painter, const cereal::RadarState::LeadData::Reader &lead_data, + const QPointF &chevron_pos, int height, int width); + void drawBlindspot(QPainter &painter, const QRect &surface_rect, bool left_blindspot, bool right_blindspot); + void drawRainbowPath(QPainter &painter, const QRect &surface_rect); QPolygonF left_blindspot_vertices; QPolygonF right_blindspot_vertices; From 7229c7541eac0fc321252d4c53a98534ec1fbb07 Mon Sep 17 00:00:00 2001 From: James Vecellio-Grant <159560811+Discountchubbs@users.noreply.github.com> Date: Mon, 13 Oct 2025 19:02:53 -0700 Subject: [PATCH 05/20] capnp: consolidate TurnDirection enum (#1370) Co-authored-by: Jason Wen --- cereal/custom.capnp | 10 +++++----- selfdrive/controls/lib/desire_helper.py | 10 +++++----- sunnypilot/selfdrive/controls/lib/lane_turn_desire.py | 10 +++++----- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/cereal/custom.capnp b/cereal/custom.capnp index 833845d36d..20f0984620 100644 --- a/cereal/custom.capnp +++ b/cereal/custom.capnp @@ -404,12 +404,12 @@ struct LiveMapDataSP @0xf416ec09499d9d19 { struct ModelDataV2SP @0xa1680744031fdb2d { laneTurnDirection @0 :TurnDirection; -} -enum TurnDirection { - none @0; - turnLeft @1; - turnRight @2; + enum TurnDirection { + none @0; + turnLeft @1; + turnRight @2; + } } struct CustomReserved10 @0xcb9fd56c7057593a { diff --git a/selfdrive/controls/lib/desire_helper.py b/selfdrive/controls/lib/desire_helper.py index e72d464d06..bf24fe2602 100644 --- a/selfdrive/controls/lib/desire_helper.py +++ b/selfdrive/controls/lib/desire_helper.py @@ -32,9 +32,9 @@ DESIRES = { } TURN_DESIRES = { - custom.TurnDirection.none: log.Desire.none, - custom.TurnDirection.turnLeft: log.Desire.turnLeft, - custom.TurnDirection.turnRight: log.Desire.turnRight, + custom.ModelDataV2SP.TurnDirection.none: log.Desire.none, + custom.ModelDataV2SP.TurnDirection.turnLeft: log.Desire.turnLeft, + custom.ModelDataV2SP.TurnDirection.turnRight: log.Desire.turnRight, } @@ -49,7 +49,7 @@ class DesireHelper: self.desire = log.Desire.none self.alc = AutoLaneChangeController(self) self.lane_turn_controller = LaneTurnController(self) - self.lane_turn_direction = custom.TurnDirection.none + self.lane_turn_direction = custom.ModelDataV2SP.TurnDirection.none @staticmethod def get_lane_change_direction(CS): @@ -126,7 +126,7 @@ class DesireHelper: self.prev_one_blinker = one_blinker - if self.lane_turn_direction != custom.TurnDirection.none: + if self.lane_turn_direction != custom.ModelDataV2SP.TurnDirection.none: self.desire = TURN_DESIRES[self.lane_turn_direction] else: self.desire = DESIRES[self.lane_change_direction][self.lane_change_state] diff --git a/sunnypilot/selfdrive/controls/lib/lane_turn_desire.py b/sunnypilot/selfdrive/controls/lib/lane_turn_desire.py index 00ce026abb..7767fdae74 100644 --- a/sunnypilot/selfdrive/controls/lib/lane_turn_desire.py +++ b/sunnypilot/selfdrive/controls/lib/lane_turn_desire.py @@ -15,7 +15,7 @@ LANE_CHANGE_SPEED_MIN = 20 * CV.MPH_TO_MS class LaneTurnController: def __init__(self, desire_helper): self.DH = desire_helper - self.turn_direction = custom.TurnDirection.none + self.turn_direction = custom.ModelDataV2SP.TurnDirection.none self.params = Params() self.lane_turn_value = float(self.params.get("LaneTurnValue", return_default=True)) * CV.MPH_TO_MS self.param_read_counter = 0 @@ -33,13 +33,13 @@ class LaneTurnController: def update_lane_turn(self, blindspot_left: bool, blindspot_right: bool, left_blinker: bool, right_blinker: bool, v_ego: float) -> None: if left_blinker and not right_blinker and v_ego < self.lane_turn_value and not blindspot_left: - self.turn_direction = custom.TurnDirection.turnLeft + self.turn_direction = custom.ModelDataV2SP.TurnDirection.turnLeft elif right_blinker and not left_blinker and v_ego < self.lane_turn_value and not blindspot_right: - self.turn_direction = custom.TurnDirection.turnRight + self.turn_direction = custom.ModelDataV2SP.TurnDirection.turnRight else: - self.turn_direction = custom.TurnDirection.none + self.turn_direction = custom.ModelDataV2SP.TurnDirection.none def get_turn_direction(self): if not self.enabled: - return custom.TurnDirection.none + return custom.ModelDataV2SP.TurnDirection.none return self.turn_direction From 59c64acc2901e7156fddadc9194e99e3fa44ebd5 Mon Sep 17 00:00:00 2001 From: Jason Wen Date: Mon, 13 Oct 2025 22:26:47 -0400 Subject: [PATCH 06/20] Subaru: Stop and Go support (beta) (#1375) * Subaru: Stop and Go auto-resume support * bump * bump * fix * bump * fix init * wat * use just standstill for now * Revert "use just standstill for now" This reverts commit f72cce68921ffe15b8914ce42d213758a1807619. * bump * bump * fix it * only send at 10 * bump * fix type * forget about planner resume, it sucks * try to send off_accel * still need it * always send * disable safety checks for now * same * more * all the time for both * don't need i guess * bump * try 15 frames per try * all should have it * try 3 for all * use throttle for all preglobal? * bump * bump * separate thresholds between preglobal and global * longer wait before sending * shorter time but immediately resend * quick * new timeout * about to cry * same thing but another try * no need * round 3 * try 1.4 * lower! * 1.2 * last try * beta asf * bump --- common/params_keys.h | 2 + opendbc_repo | 2 +- .../settings/vehicle/subaru_settings.cc | 45 +++++++++++++++++++ .../settings/vehicle/subaru_settings.h | 31 +++++++++++++ sunnypilot/selfdrive/car/interfaces.py | 6 +++ 5 files changed, 85 insertions(+), 1 deletion(-) diff --git a/common/params_keys.h b/common/params_keys.h index 3b5d02a429..ecb73a9456 100644 --- a/common/params_keys.h +++ b/common/params_keys.h @@ -205,6 +205,8 @@ inline static std::unordered_map keys = { // sunnypilot car specific params {"HyundaiLongitudinalTuning", {PERSISTENT | BACKUP, INT, "0"}}, + {"SubaruStopAndGo", {PERSISTENT | BACKUP, BOOL, "0"}}, + {"SubaruStopAndGoManualParkingBrake", {PERSISTENT | BACKUP, BOOL, "0"}}, {"DynamicExperimentalControl", {PERSISTENT | BACKUP, BOOL, "0"}}, {"BlindSpot", {PERSISTENT | BACKUP, BOOL, "0"}}, diff --git a/opendbc_repo b/opendbc_repo index b592ecdd3b..b8a00bddda 160000 --- a/opendbc_repo +++ b/opendbc_repo @@ -1 +1 @@ -Subproject commit b592ecdd3b571a1acee0c04726117a137cec5832 +Subproject commit b8a00bddda562f981b24e099a3850209579e890a diff --git a/selfdrive/ui/sunnypilot/qt/offroad/settings/vehicle/subaru_settings.cc b/selfdrive/ui/sunnypilot/qt/offroad/settings/vehicle/subaru_settings.cc index 47c4057f44..302740a94a 100644 --- a/selfdrive/ui/sunnypilot/qt/offroad/settings/vehicle/subaru_settings.cc +++ b/selfdrive/ui/sunnypilot/qt/offroad/settings/vehicle/subaru_settings.cc @@ -8,7 +8,52 @@ #include "selfdrive/ui/sunnypilot/qt/offroad/settings/vehicle/subaru_settings.h" SubaruSettings::SubaruSettings(QWidget *parent) : BrandSettingsInterface(parent) { + stopAndGoToggle = new ParamControl("SubaruStopAndGo", tr("Stop and Go (Beta)"), "", ""); + stopAndGoToggle->setConfirmation(true, false); + list->addItem(stopAndGoToggle); + + stopAndGoManualParkingBrakeToggle = new ParamControl( + "SubaruStopAndGoManualParkingBrake", + tr("Stop and Go for Manual Parking Brake (Beta)"), + "", + "" + ); + stopAndGoManualParkingBrakeToggle->setConfirmation(true, false); + list->addItem(stopAndGoManualParkingBrakeToggle); } void SubaruSettings::updateSettings() { + auto cp_bytes = params.get("CarParamsPersistent"); + if (!cp_bytes.empty()) { + AlignedBuffer aligned_buf; + capnp::FlatArrayMessageReader cmsg(aligned_buf.align(cp_bytes.data(), cp_bytes.size())); + cereal::CarParams::Reader CP = cmsg.getRoot(); + + is_subaru = CP.getBrand() == "subaru"; + + if (is_subaru) { + if (!(CP.getFlags() & (SUBARU_FLAG_GLOBAL_GEN2 | SUBARU_FLAG_HYBRID))) { + has_stop_and_go = true; + } + } + } else { + is_subaru = false; + has_stop_and_go = false; + } + + bool stop_and_go_disabled = !offroad || !has_stop_and_go; + QString stop_and_go_desc = stopAndGoDescriptionBuilder(stopAndGoDesc); + QString stop_and_go_manual_parking_brake_desc = stopAndGoDescriptionBuilder(stopAndGoManualParkingBrakeDesc); + if (stop_and_go_disabled) { + stop_and_go_desc = stopAndGoDescriptionBuilder(stopAndGoDesc, stopAndGoDisabledMsg()); + stop_and_go_manual_parking_brake_desc = stopAndGoDescriptionBuilder(stopAndGoManualParkingBrakeDesc, stopAndGoDisabledMsg()); + } + + stopAndGoToggle->setEnabled(has_stop_and_go); + stopAndGoToggle->setDescription(stop_and_go_desc); + stopAndGoToggle->showDescription(); + + stopAndGoManualParkingBrakeToggle->setEnabled(has_stop_and_go); + stopAndGoManualParkingBrakeToggle->setDescription(stop_and_go_manual_parking_brake_desc); + stopAndGoManualParkingBrakeToggle->showDescription(); } diff --git a/selfdrive/ui/sunnypilot/qt/offroad/settings/vehicle/subaru_settings.h b/selfdrive/ui/sunnypilot/qt/offroad/settings/vehicle/subaru_settings.h index a715951ad9..2bb160beba 100644 --- a/selfdrive/ui/sunnypilot/qt/offroad/settings/vehicle/subaru_settings.h +++ b/selfdrive/ui/sunnypilot/qt/offroad/settings/vehicle/subaru_settings.h @@ -14,6 +14,9 @@ #include "selfdrive/ui/sunnypilot/qt/offroad/settings/settings.h" #include "selfdrive/ui/sunnypilot/qt/widgets/controls.h" +const int SUBARU_FLAG_GLOBAL_GEN2 = 4; +const int SUBARU_FLAG_HYBRID = 32; + class SubaruSettings : public BrandSettingsInterface { Q_OBJECT @@ -23,4 +26,32 @@ public: private: bool offroad = false; + bool is_subaru; + bool has_stop_and_go; + + ParamControl* stopAndGoToggle; + ParamControl* stopAndGoManualParkingBrakeToggle; + + QString stopAndGoDesc = tr("Experimental feature to enable auto-resume during stop-and-go for certain supported Subaru platforms."); + QString stopAndGoManualParkingBrakeDesc = tr("Experimental feature to enable stop and go for Subaru Global models with manual handbrake. Models with electric parking brake should keep this disabled. Thanks to martinl for this implementation!"); + + QString stopAndGoDisabledMsg() const { + if (is_subaru && !has_stop_and_go) { + return tr("This feature is currently not available on this platform."); + } + + if (!is_subaru) { + return tr("Start the car to check car compatibility."); + } + + if (!offroad) { + return tr("Enable \"Always Offroad\" in Device panel, or turn vehicle off to toggle."); + } + + return QString(); + } + + static QString stopAndGoDescriptionBuilder(const QString &base_description, const QString &custom_description = "") { + return "" + custom_description + "

" + base_description; + } }; diff --git a/sunnypilot/selfdrive/car/interfaces.py b/sunnypilot/selfdrive/car/interfaces.py index ed0c11fb19..3072cde8cd 100644 --- a/sunnypilot/selfdrive/car/interfaces.py +++ b/sunnypilot/selfdrive/car/interfaces.py @@ -84,4 +84,10 @@ def initialize_params(params) -> list[dict[str, Any]]: "HyundaiLongitudinalTuning" ]) + # subaru + keys.extend([ + "SubaruStopAndGo", + "SubaruStopAndGoManualParkingBrake", + ]) + return [{k: params.get(k, return_default=True)} for k in keys] From 339bc0b8b3c4a192aa4e982039bc2ac28246c080 Mon Sep 17 00:00:00 2001 From: Jason Wen Date: Tue, 14 Oct 2025 00:19:21 -0400 Subject: [PATCH 07/20] Revert "capnp: consolidate TurnDirection enum" (#1376) Revert "capnp: consolidate TurnDirection enum (#1370)" This reverts commit 7229c7541eac0fc321252d4c53a98534ec1fbb07. --- cereal/custom.capnp | 10 +++++----- selfdrive/controls/lib/desire_helper.py | 10 +++++----- sunnypilot/selfdrive/controls/lib/lane_turn_desire.py | 10 +++++----- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/cereal/custom.capnp b/cereal/custom.capnp index 20f0984620..833845d36d 100644 --- a/cereal/custom.capnp +++ b/cereal/custom.capnp @@ -404,12 +404,12 @@ struct LiveMapDataSP @0xf416ec09499d9d19 { struct ModelDataV2SP @0xa1680744031fdb2d { laneTurnDirection @0 :TurnDirection; +} - enum TurnDirection { - none @0; - turnLeft @1; - turnRight @2; - } +enum TurnDirection { + none @0; + turnLeft @1; + turnRight @2; } struct CustomReserved10 @0xcb9fd56c7057593a { diff --git a/selfdrive/controls/lib/desire_helper.py b/selfdrive/controls/lib/desire_helper.py index bf24fe2602..e72d464d06 100644 --- a/selfdrive/controls/lib/desire_helper.py +++ b/selfdrive/controls/lib/desire_helper.py @@ -32,9 +32,9 @@ DESIRES = { } TURN_DESIRES = { - custom.ModelDataV2SP.TurnDirection.none: log.Desire.none, - custom.ModelDataV2SP.TurnDirection.turnLeft: log.Desire.turnLeft, - custom.ModelDataV2SP.TurnDirection.turnRight: log.Desire.turnRight, + custom.TurnDirection.none: log.Desire.none, + custom.TurnDirection.turnLeft: log.Desire.turnLeft, + custom.TurnDirection.turnRight: log.Desire.turnRight, } @@ -49,7 +49,7 @@ class DesireHelper: self.desire = log.Desire.none self.alc = AutoLaneChangeController(self) self.lane_turn_controller = LaneTurnController(self) - self.lane_turn_direction = custom.ModelDataV2SP.TurnDirection.none + self.lane_turn_direction = custom.TurnDirection.none @staticmethod def get_lane_change_direction(CS): @@ -126,7 +126,7 @@ class DesireHelper: self.prev_one_blinker = one_blinker - if self.lane_turn_direction != custom.ModelDataV2SP.TurnDirection.none: + if self.lane_turn_direction != custom.TurnDirection.none: self.desire = TURN_DESIRES[self.lane_turn_direction] else: self.desire = DESIRES[self.lane_change_direction][self.lane_change_state] diff --git a/sunnypilot/selfdrive/controls/lib/lane_turn_desire.py b/sunnypilot/selfdrive/controls/lib/lane_turn_desire.py index 7767fdae74..00ce026abb 100644 --- a/sunnypilot/selfdrive/controls/lib/lane_turn_desire.py +++ b/sunnypilot/selfdrive/controls/lib/lane_turn_desire.py @@ -15,7 +15,7 @@ LANE_CHANGE_SPEED_MIN = 20 * CV.MPH_TO_MS class LaneTurnController: def __init__(self, desire_helper): self.DH = desire_helper - self.turn_direction = custom.ModelDataV2SP.TurnDirection.none + self.turn_direction = custom.TurnDirection.none self.params = Params() self.lane_turn_value = float(self.params.get("LaneTurnValue", return_default=True)) * CV.MPH_TO_MS self.param_read_counter = 0 @@ -33,13 +33,13 @@ class LaneTurnController: def update_lane_turn(self, blindspot_left: bool, blindspot_right: bool, left_blinker: bool, right_blinker: bool, v_ego: float) -> None: if left_blinker and not right_blinker and v_ego < self.lane_turn_value and not blindspot_left: - self.turn_direction = custom.ModelDataV2SP.TurnDirection.turnLeft + self.turn_direction = custom.TurnDirection.turnLeft elif right_blinker and not left_blinker and v_ego < self.lane_turn_value and not blindspot_right: - self.turn_direction = custom.ModelDataV2SP.TurnDirection.turnRight + self.turn_direction = custom.TurnDirection.turnRight else: - self.turn_direction = custom.ModelDataV2SP.TurnDirection.none + self.turn_direction = custom.TurnDirection.none def get_turn_direction(self): if not self.enabled: - return custom.ModelDataV2SP.TurnDirection.none + return custom.TurnDirection.none return self.turn_direction From 7f5342f3784c841b275fcbf213ef5e339f1fe991 Mon Sep 17 00:00:00 2001 From: Jason Wen Date: Tue, 14 Oct 2025 01:13:20 -0400 Subject: [PATCH 08/20] soundd: custom audible alerts (#1377) * Revert "capnp: consolidate TurnDirection enum (#1370)" This reverts commit 7229c7541eac0fc321252d4c53a98534ec1fbb07. * soundd: custom audible alerts * comment --- cereal/custom.capnp | 39 +++++++++++++++++++++++++++++++++++++++ selfdrive/ui/soundd.py | 9 ++++++++- 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/cereal/custom.capnp b/cereal/custom.capnp index 833845d36d..5243380d31 100644 --- a/cereal/custom.capnp +++ b/cereal/custom.capnp @@ -69,6 +69,45 @@ struct LeadData { struct SelfdriveStateSP @0x81c2f05a394cf4af { mads @0 :ModularAssistiveDrivingSystem; intelligentCruiseButtonManagement @1 :IntelligentCruiseButtonManagement; + + enum AudibleAlert { + none @0; + + engage @1; + disengage @2; + refuse @3; + + warningSoft @4; + warningImmediate @5; + + prompt @6; + promptRepeat @7; + promptDistracted @8; + + # unused, these are reserved for upstream events so we don't collide + reserved9 @9; + reserved10 @10; + reserved11 @11; + reserved12 @12; + reserved13 @13; + reserved14 @14; + reserved15 @15; + reserved16 @16; + reserved17 @17; + reserved18 @18; + reserved19 @19; + reserved20 @20; + reserved21 @21; + reserved22 @22; + reserved23 @23; + reserved24 @24; + reserved25 @25; + reserved26 @26; + reserved27 @27; + reserved28 @28; + reserved29 @29; + reserved30 @30; + } } struct ModelManagerSP @0xaedffd8f31e7b55d { diff --git a/selfdrive/ui/soundd.py b/selfdrive/ui/soundd.py index a94456efe0..485c406266 100644 --- a/selfdrive/ui/soundd.py +++ b/selfdrive/ui/soundd.py @@ -4,7 +4,7 @@ import time import wave -from cereal import car, messaging +from cereal import car, messaging, custom from openpilot.common.basedir import BASEDIR from openpilot.common.filter_simple import FirstOrderFilter from openpilot.common.realtime import Ratekeeper @@ -26,8 +26,13 @@ AMBIENT_DB = 30 # DB where MIN_VOLUME is applied DB_SCALE = 30 # AMBIENT_DB + DB_SCALE is where MAX_VOLUME is applied AudibleAlert = car.CarControl.HUDControl.AudibleAlert +AudibleAlertSP = custom.SelfdriveStateSP.AudibleAlert +sound_list_sp: dict[int, tuple[str, int | None, float]] = { + # AudibleAlertSP, file name, play count (none for infinite) +} + sound_list: dict[int, tuple[str, int | None, float]] = { # AudibleAlert, file name, play count (none for infinite) AudibleAlert.engage: ("engage.wav", 1, MAX_VOLUME), @@ -40,6 +45,8 @@ sound_list: dict[int, tuple[str, int | None, float]] = { AudibleAlert.warningSoft: ("warning_soft.wav", None, MAX_VOLUME), AudibleAlert.warningImmediate: ("warning_immediate.wav", None, MAX_VOLUME), + + **sound_list_sp, } def check_selfdrive_timeout_alert(sm): From 4bd020e92b0ee7fe678985efdb7cd2efd6ecf9cd Mon Sep 17 00:00:00 2001 From: Jason Wen Date: Tue, 14 Oct 2025 09:19:26 -0400 Subject: [PATCH 09/20] Speed Limit Assist: audible alerts for certain states (#1378) --- cereal/custom.capnp | 3 +++ selfdrive/assets/sounds/prompt_single_high.wav | 3 +++ selfdrive/assets/sounds/prompt_single_low.wav | 3 +++ selfdrive/ui/soundd.py | 2 ++ sunnypilot/selfdrive/selfdrived/events.py | 9 +++++---- 5 files changed, 16 insertions(+), 4 deletions(-) create mode 100644 selfdrive/assets/sounds/prompt_single_high.wav create mode 100644 selfdrive/assets/sounds/prompt_single_low.wav diff --git a/cereal/custom.capnp b/cereal/custom.capnp index 5243380d31..e81749dbac 100644 --- a/cereal/custom.capnp +++ b/cereal/custom.capnp @@ -107,6 +107,9 @@ struct SelfdriveStateSP @0x81c2f05a394cf4af { reserved28 @28; reserved29 @29; reserved30 @30; + + promptSingleLow @31; + promptSingleHigh @32; } } diff --git a/selfdrive/assets/sounds/prompt_single_high.wav b/selfdrive/assets/sounds/prompt_single_high.wav new file mode 100644 index 0000000000..202483d17f --- /dev/null +++ b/selfdrive/assets/sounds/prompt_single_high.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:dbfa5858c0a672411ffdc691efdecb06d01ae458cc1df409bcf3fdeaa4756f72 +size 34638 diff --git a/selfdrive/assets/sounds/prompt_single_low.wav b/selfdrive/assets/sounds/prompt_single_low.wav new file mode 100644 index 0000000000..925401ea27 --- /dev/null +++ b/selfdrive/assets/sounds/prompt_single_low.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:db9671bb03e01f119bba1eb6cc0507e0f039ac4e5b7f9f839a87071c52e86e56 +size 44416 diff --git a/selfdrive/ui/soundd.py b/selfdrive/ui/soundd.py index 485c406266..01c7c68949 100644 --- a/selfdrive/ui/soundd.py +++ b/selfdrive/ui/soundd.py @@ -31,6 +31,8 @@ AudibleAlertSP = custom.SelfdriveStateSP.AudibleAlert sound_list_sp: dict[int, tuple[str, int | None, float]] = { # AudibleAlertSP, file name, play count (none for infinite) + AudibleAlertSP.promptSingleLow: ("prompt_single_low.wav", 1, MAX_VOLUME), + AudibleAlertSP.promptSingleHigh: ("prompt_single_high.wav", 1, MAX_VOLUME), } sound_list: dict[int, tuple[str, int | None, float]] = { diff --git a/sunnypilot/selfdrive/selfdrived/events.py b/sunnypilot/selfdrive/selfdrived/events.py index e9f4b29bae..5cd7255bc3 100644 --- a/sunnypilot/selfdrive/selfdrived/events.py +++ b/sunnypilot/selfdrive/selfdrived/events.py @@ -11,6 +11,7 @@ AlertSize = log.SelfdriveState.AlertSize AlertStatus = log.SelfdriveState.AlertStatus VisualAlert = car.CarControl.HUDControl.VisualAlert AudibleAlert = car.CarControl.HUDControl.AudibleAlert +AudibleAlertSP = custom.SelfdriveStateSP.AudibleAlert EventNameSP = custom.OnroadEventSP.EventName @@ -58,7 +59,7 @@ def speed_limit_pre_active_alert(CP: car.CarParams, CS: car.CarState, sm: messag "Speed Limit Assist: Activation Required", alert_2_str, AlertStatus.normal, AlertSize.mid, - Priority.LOW, VisualAlert.none, AudibleAlert.none, .1) + Priority.LOW, VisualAlert.none, AudibleAlertSP.promptSingleLow, .1) class EventsSP(EventsBase): @@ -202,7 +203,7 @@ EVENTS_SP: dict[int, dict[str, Alert | AlertCallbackType]] = { "Automatically adjusting to the posted speed limit", "", AlertStatus.normal, AlertSize.small, - Priority.LOW, VisualAlert.none, AudibleAlert.none, 5.), + Priority.LOW, VisualAlert.none, AudibleAlertSP.promptSingleHigh, 5.), }, EventNameSP.speedLimitChanged: { @@ -210,7 +211,7 @@ EVENTS_SP: dict[int, dict[str, Alert | AlertCallbackType]] = { "Set speed changed", "", AlertStatus.normal, AlertSize.small, - Priority.LOW, VisualAlert.none, AudibleAlert.none, 5.), + Priority.LOW, VisualAlert.none, AudibleAlertSP.promptSingleHigh, 5.), }, EventNameSP.speedLimitPreActive: { @@ -222,7 +223,7 @@ EVENTS_SP: dict[int, dict[str, Alert | AlertCallbackType]] = { "Automatically adjusting to the last speed limit", "", AlertStatus.normal, AlertSize.small, - Priority.LOW, VisualAlert.none, AudibleAlert.none, 5.), + Priority.LOW, VisualAlert.none, AudibleAlertSP.promptSingleHigh, 5.), }, EventNameSP.e2eChime: { From fec6382b964c304d6729ad1b26913377760aa949 Mon Sep 17 00:00:00 2001 From: Nayan Date: Tue, 14 Oct 2025 11:29:27 -0400 Subject: [PATCH 10/20] UI: Fix Speed Limit Assist (SLA) Translations (#1379) Fix SLA Translations --- .../longitudinal/speed_limit/helpers.h | 24 ++-- .../speed_limit/speed_limit_policy.cc | 10 +- .../speed_limit/speed_limit_settings.cc | 14 +-- selfdrive/ui/translations/main_ko.ts | 112 +++++++++--------- 4 files changed, 80 insertions(+), 80 deletions(-) diff --git a/selfdrive/ui/sunnypilot/qt/offroad/settings/longitudinal/speed_limit/helpers.h b/selfdrive/ui/sunnypilot/qt/offroad/settings/longitudinal/speed_limit/helpers.h index 295072d1ef..6c02d627fa 100644 --- a/selfdrive/ui/sunnypilot/qt/offroad/settings/longitudinal/speed_limit/helpers.h +++ b/selfdrive/ui/sunnypilot/qt/offroad/settings/longitudinal/speed_limit/helpers.h @@ -13,9 +13,9 @@ enum class SpeedLimitOffsetType { }; inline const QString SpeedLimitOffsetTypeTexts[]{ - QObject::tr("None"), - QObject::tr("Fixed"), - QObject::tr("Percent"), + QT_TRANSLATE_NOOP("SpeedLimitSettings", "None"), + QT_TRANSLATE_NOOP("SpeedLimitSettings", "Fixed"), + QT_TRANSLATE_NOOP("SpeedLimitSettings", "Percent"), }; enum class SpeedLimitSourcePolicy { @@ -27,11 +27,11 @@ enum class SpeedLimitSourcePolicy { }; inline const QString SpeedLimitSourcePolicyTexts[]{ - QObject::tr("Car\nOnly"), - QObject::tr("Map\nOnly"), - QObject::tr("Car\nFirst"), - QObject::tr("Map\nFirst"), - QObject::tr("Combined\nData") + QT_TRANSLATE_NOOP("SpeedLimitPolicy", "Car\nOnly"), + QT_TRANSLATE_NOOP("SpeedLimitPolicy", "Map\nOnly"), + QT_TRANSLATE_NOOP("SpeedLimitPolicy", "Car\nFirst"), + QT_TRANSLATE_NOOP("SpeedLimitPolicy", "Map\nFirst"), + QT_TRANSLATE_NOOP("SpeedLimitPolicy", "Combined\nData") }; enum class SpeedLimitMode { @@ -42,8 +42,8 @@ enum class SpeedLimitMode { }; inline const QString SpeedLimitModeTexts[]{ - QObject::tr("Off"), - QObject::tr("Information"), - QObject::tr("Warning"), - QObject::tr("Assist"), + QT_TRANSLATE_NOOP("SpeedLimitSettings", "Off"), + QT_TRANSLATE_NOOP("SpeedLimitSettings", "Information"), + QT_TRANSLATE_NOOP("SpeedLimitSettings", "Warning"), + QT_TRANSLATE_NOOP("SpeedLimitSettings", "Assist"), }; diff --git a/selfdrive/ui/sunnypilot/qt/offroad/settings/longitudinal/speed_limit/speed_limit_policy.cc b/selfdrive/ui/sunnypilot/qt/offroad/settings/longitudinal/speed_limit/speed_limit_policy.cc index 764a8e0208..c54ff11f69 100644 --- a/selfdrive/ui/sunnypilot/qt/offroad/settings/longitudinal/speed_limit/speed_limit_policy.cc +++ b/selfdrive/ui/sunnypilot/qt/offroad/settings/longitudinal/speed_limit/speed_limit_policy.cc @@ -23,11 +23,11 @@ SpeedLimitPolicy::SpeedLimitPolicy(QWidget *parent) : QWidget(parent) { ListWidgetSP *list = new ListWidgetSP(this); std::vector speed_limit_policy_texts{ - SpeedLimitSourcePolicyTexts[static_cast(SpeedLimitSourcePolicy::CAR_ONLY)], - SpeedLimitSourcePolicyTexts[static_cast(SpeedLimitSourcePolicy::MAP_ONLY)], - SpeedLimitSourcePolicyTexts[static_cast(SpeedLimitSourcePolicy::CAR_FIRST)], - SpeedLimitSourcePolicyTexts[static_cast(SpeedLimitSourcePolicy::MAP_FIRST)], - SpeedLimitSourcePolicyTexts[static_cast(SpeedLimitSourcePolicy::COMBINED)] + tr(SpeedLimitSourcePolicyTexts[static_cast(SpeedLimitSourcePolicy::CAR_ONLY)].toStdString().c_str()), + tr(SpeedLimitSourcePolicyTexts[static_cast(SpeedLimitSourcePolicy::MAP_ONLY)].toStdString().c_str()), + tr(SpeedLimitSourcePolicyTexts[static_cast(SpeedLimitSourcePolicy::CAR_FIRST)].toStdString().c_str()), + tr(SpeedLimitSourcePolicyTexts[static_cast(SpeedLimitSourcePolicy::MAP_FIRST)].toStdString().c_str()), + tr(SpeedLimitSourcePolicyTexts[static_cast(SpeedLimitSourcePolicy::COMBINED)].toStdString().c_str()) }; speed_limit_policy = new ButtonParamControlSP( "SpeedLimitPolicy", diff --git a/selfdrive/ui/sunnypilot/qt/offroad/settings/longitudinal/speed_limit/speed_limit_settings.cc b/selfdrive/ui/sunnypilot/qt/offroad/settings/longitudinal/speed_limit/speed_limit_settings.cc index 3efbfeed86..f64198f97e 100644 --- a/selfdrive/ui/sunnypilot/qt/offroad/settings/longitudinal/speed_limit/speed_limit_settings.cc +++ b/selfdrive/ui/sunnypilot/qt/offroad/settings/longitudinal/speed_limit/speed_limit_settings.cc @@ -25,10 +25,10 @@ SpeedLimitSettings::SpeedLimitSettings(QWidget *parent) : QStackedWidget(parent) speedLimitPolicyScreen = new SpeedLimitPolicy(this); std::vector speed_limit_mode_texts{ - SpeedLimitModeTexts[static_cast(SpeedLimitMode::OFF)], - SpeedLimitModeTexts[static_cast(SpeedLimitMode::INFORMATION)], - SpeedLimitModeTexts[static_cast(SpeedLimitMode::WARNING)], - SpeedLimitModeTexts[static_cast(SpeedLimitMode::ASSIST)], + tr(SpeedLimitModeTexts[static_cast(SpeedLimitMode::OFF)].toStdString().c_str()), + tr(SpeedLimitModeTexts[static_cast(SpeedLimitMode::INFORMATION)].toStdString().c_str()), + tr(SpeedLimitModeTexts[static_cast(SpeedLimitMode::WARNING)].toStdString().c_str()), + tr(SpeedLimitModeTexts[static_cast(SpeedLimitMode::ASSIST)].toStdString().c_str()) }; speed_limit_mode_settings = new ButtonParamControlSP( "SpeedLimitMode", @@ -64,9 +64,9 @@ SpeedLimitSettings::SpeedLimitSettings(QWidget *parent) : QStackedWidget(parent) QVBoxLayout *offsetLayout = new QVBoxLayout(offsetFrame); std::vector speed_limit_offset_texts{ - SpeedLimitOffsetTypeTexts[static_cast(SpeedLimitOffsetType::NONE)], - SpeedLimitOffsetTypeTexts[static_cast(SpeedLimitOffsetType::FIXED)], - SpeedLimitOffsetTypeTexts[static_cast(SpeedLimitOffsetType::PERCENT)] + tr(SpeedLimitOffsetTypeTexts[static_cast(SpeedLimitOffsetType::NONE)].toStdString().c_str()), + tr(SpeedLimitOffsetTypeTexts[static_cast(SpeedLimitOffsetType::FIXED)].toStdString().c_str()), + tr(SpeedLimitOffsetTypeTexts[static_cast(SpeedLimitOffsetType::PERCENT)].toStdString().c_str()) }; speed_limit_offset_settings = new ButtonParamControlSP( "SpeedLimitOffsetType", diff --git a/selfdrive/ui/translations/main_ko.ts b/selfdrive/ui/translations/main_ko.ts index d86468ae6e..164ce09f07 100644 --- a/selfdrive/ui/translations/main_ko.ts +++ b/selfdrive/ui/translations/main_ko.ts @@ -1778,62 +1778,6 @@ Warning: You are on a metered connection! sunnypilot sunnypilot - - None - 없음 - - - Fixed - 고정 - - - Percent - 비율 - - - Car -Only - 차량만 - - - Map -Only - 지도만 - - - Car -First - 차량 -우선 - - - Map -First - 지도 -우선 - - - Combined -Data - 결합 -데이터 - - - Off - 끄기 - - - Information - 정보 - - - Warning - 경고 - - - Assist - 보조 - SettingsWindow @@ -2198,6 +2142,34 @@ Data ⦿ Combined: Use combined Speed Limit data from Car & OpenStreetMaps ⦿ 결합: 차량 및 OpenStreetMaps의 속도 제한 결합 데이터 사용 + + Car +Only + 차량만 + + + Map +Only + 지도만 + + + Car +First + 차량 +우선 + + + Map +First + 지도 +우선 + + + Combined +Data + 결합 +데이터 + SpeedLimitSettings @@ -2245,6 +2217,34 @@ Data ⦿ Assist: Adjusts the vehicle's cruise speed based on the current road's speed limit when operating the +/- buttons. ⦿ 보조: +/- 버튼을 조작할 때 현재 도로의 제한 속도를 기준으로 차량의 크루즈 속도를 조정합니다. + + None + 없음 + + + Fixed + 고정 + + + Percent + 비율 + + + Off + 끄기 + + + Information + 정보 + + + Warning + 경고 + + + Assist + 보조 + SshControl From d3e3628a9586f131b23a1c1cac9520c21a3626ff Mon Sep 17 00:00:00 2001 From: Jason Wen Date: Tue, 14 Oct 2025 11:40:42 -0400 Subject: [PATCH 11/20] ui: only draw ahead speed limit if it's parsed from OSM (#1380) --- selfdrive/ui/sunnypilot/qt/onroad/hud.cc | 4 +++- selfdrive/ui/sunnypilot/qt/onroad/hud.h | 1 + .../controls/lib/speed_limit/speed_limit_resolver.py | 1 + 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/selfdrive/ui/sunnypilot/qt/onroad/hud.cc b/selfdrive/ui/sunnypilot/qt/onroad/hud.cc index 8c913cec17..fa57e2b334 100644 --- a/selfdrive/ui/sunnypilot/qt/onroad/hud.cc +++ b/selfdrive/ui/sunnypilot/qt/onroad/hud.cc @@ -46,6 +46,7 @@ void HudRendererSP::updateState(const UIState &s) { speedLimitValid = lp_sp.getSpeedLimit().getResolver().getSpeedLimitValid(); speedLimitLastValid = lp_sp.getSpeedLimit().getResolver().getSpeedLimitLastValid(); speedLimitFinalLast = lp_sp.getSpeedLimit().getResolver().getSpeedLimitFinalLast() * speedConv; + speedLimitSource = lp_sp.getSpeedLimit().getResolver().getSource(); speedLimitMode = static_cast(s.scene.speed_limit_mode); speedLimitAssistState = lp_sp.getSpeedLimit().getAssist().getState(); speedLimitAssistActive = lp_sp.getSpeedLimit().getAssist().getActive(); @@ -547,7 +548,8 @@ void HudRendererSP::drawSpeedLimitSigns(QPainter &p, QRect &sign_rect) { } void HudRendererSP::drawUpcomingSpeedLimit(QPainter &p) { - bool speed_limit_ahead = speedLimitAheadValid && speedLimitAhead > 0 && speedLimitAhead != speedLimit && speedLimitAheadValidFrame > 0; + bool speed_limit_ahead = speedLimitAheadValid && speedLimitAhead > 0 && speedLimitAhead != speedLimit && speedLimitAheadValidFrame > 0 && + speedLimitSource == cereal::LongitudinalPlanSP::SpeedLimit::Source::MAP; if (!speed_limit_ahead) { return; } diff --git a/selfdrive/ui/sunnypilot/qt/onroad/hud.h b/selfdrive/ui/sunnypilot/qt/onroad/hud.h index bc7ee81de3..f9135ee9ef 100644 --- a/selfdrive/ui/sunnypilot/qt/onroad/hud.h +++ b/selfdrive/ui/sunnypilot/qt/onroad/hud.h @@ -83,6 +83,7 @@ private: bool speedLimitValid; bool speedLimitLastValid; float speedLimitFinalLast; + cereal::LongitudinalPlanSP::SpeedLimit::Source speedLimitSource; bool speedLimitAheadValid; float speedLimitAhead; float speedLimitAheadDistance; diff --git a/sunnypilot/selfdrive/controls/lib/speed_limit/speed_limit_resolver.py b/sunnypilot/selfdrive/controls/lib/speed_limit/speed_limit_resolver.py index 459334d156..35965c0e18 100644 --- a/sunnypilot/selfdrive/controls/lib/speed_limit/speed_limit_resolver.py +++ b/sunnypilot/selfdrive/controls/lib/speed_limit/speed_limit_resolver.py @@ -142,6 +142,7 @@ class SpeedLimitResolver: self.limit_solutions[SpeedLimitSource.map] = speed_limit self.distance_solutions[SpeedLimitSource.map] = 0. + # FIXME-SP: this is not working as expected if 0. < next_speed_limit < self.v_ego: adapt_time = (next_speed_limit - self.v_ego) / LIMIT_ADAPT_ACC adapt_distance = self.v_ego * adapt_time + 0.5 * LIMIT_ADAPT_ACC * adapt_time ** 2 From 9a14baac4deac77ef4d9171a3ce4af2754317035 Mon Sep 17 00:00:00 2001 From: Nayan Date: Tue, 14 Oct 2025 20:01:42 -0400 Subject: [PATCH 12/20] Green Light and Lead Departure alerts improvements (#1381) --- .../controls/lib/e2e_alerts_helper.py | 64 +++++++++++++++---- sunnypilot/selfdrive/selfdrived/events.py | 2 +- 2 files changed, 51 insertions(+), 15 deletions(-) diff --git a/sunnypilot/selfdrive/controls/lib/e2e_alerts_helper.py b/sunnypilot/selfdrive/controls/lib/e2e_alerts_helper.py index 0135b2806c..5a92d878d6 100644 --- a/sunnypilot/selfdrive/controls/lib/e2e_alerts_helper.py +++ b/sunnypilot/selfdrive/controls/lib/e2e_alerts_helper.py @@ -12,47 +12,83 @@ from openpilot.common.realtime import DT_MDL from openpilot.sunnypilot import PARAMS_UPDATE_PERIOD from openpilot.sunnypilot.selfdrive.selfdrived.events import EventsSP -TRIGGER_THRESHOLD = 30 +GREEN_LIGHT_X_THRESHOLD = 30 class E2EAlertsHelper: def __init__(self): self._params = Params() - self._frame = -1 + self.frame = -1 self.green_light_alert = False self.green_light_alert_enabled = self._params.get_bool("GreenLightAlert") self.lead_depart_alert = False self.lead_depart_alert_enabled = self._params.get_bool("LeadDepartAlert") + self.alert_allowed = False + self.green_light_alert_count = 0 + self.last_lead_distance = -1 + self.last_moving_frame = -1 + def _read_params(self) -> None: - if self._frame % int(PARAMS_UPDATE_PERIOD / DT_MDL) == 0: + if self.frame % int(PARAMS_UPDATE_PERIOD / DT_MDL) == 0: self.green_light_alert_enabled = self._params.get_bool("GreenLightAlert") self.lead_depart_alert_enabled = self._params.get_bool("LeadDepartAlert") - self._frame += 1 - def update(self, sm: messaging.SubMaster, events_sp: EventsSP) -> None: self._read_params() - if not (self.green_light_alert_enabled or self.lead_depart_alert_enabled): - return - CS = sm['carState'] CC = sm['carControl'] model_x = sm['modelV2'].position.x max_idx = len(model_x) - 1 has_lead = sm['radarState'].leadOne.status - lead_vRel: float = sm['radarState'].leadOne.vRel + lead_dRel = sm['radarState'].leadOne.dRel + standstill = CS.standstill + moving = not standstill and CS.vEgo > 0.1 + _allowed = standstill and not CS.gasPressed and not CC.enabled - # Green light alert - self.green_light_alert = (self.green_light_alert_enabled and model_x[max_idx] > TRIGGER_THRESHOLD - and not has_lead and CS.standstill and not CS.gasPressed and not CC.enabled) + if moving: + self.last_moving_frame = self.frame + recent_moving = self.last_moving_frame == -1 or (self.frame - self.last_moving_frame) * DT_MDL < 2.0 + + if standstill and not recent_moving: + self.alert_allowed = True + elif not standstill: + self.alert_allowed = False + self.green_light_alert_count = 0 + self.last_lead_distance = -1 + + # Green Light Alert + _green_light_alert = False + if self.green_light_alert_enabled and _allowed and not has_lead and model_x[max_idx] > GREEN_LIGHT_X_THRESHOLD: + if self.alert_allowed: + self.green_light_alert_count += 1 + else: + self.green_light_alert_count = 0 + + if self.green_light_alert_count > 2 and self.alert_allowed: + _green_light_alert = True + self.alert_allowed = False + else: + self.green_light_alert_count = 0 + + self.green_light_alert = _green_light_alert # Lead Departure Alert - self.lead_depart_alert = (self.lead_depart_alert_enabled and CS.standstill and model_x[max_idx] > 30 - and has_lead and lead_vRel > 1 and not CS.gasPressed) + _lead_depart_alert = False + if self.lead_depart_alert_enabled and _allowed and has_lead: + if self.last_lead_distance == -1 or lead_dRel < self.last_lead_distance: + self.last_lead_distance = lead_dRel + + if self.last_lead_distance != -1 and (lead_dRel - self.last_lead_distance > 1.0) and self.alert_allowed: + _lead_depart_alert = True + self.alert_allowed = False + + self.lead_depart_alert = _lead_depart_alert if self.green_light_alert or self.lead_depart_alert: events_sp.add(custom.OnroadEventSP.EventName.e2eChime) + + self.frame += 1 diff --git a/sunnypilot/selfdrive/selfdrived/events.py b/sunnypilot/selfdrive/selfdrived/events.py index 5cd7255bc3..5d5424bb16 100644 --- a/sunnypilot/selfdrive/selfdrived/events.py +++ b/sunnypilot/selfdrive/selfdrived/events.py @@ -231,6 +231,6 @@ EVENTS_SP: dict[int, dict[str, Alert | AlertCallbackType]] = { "", "", AlertStatus.normal, AlertSize.none, - Priority.MID, VisualAlert.none, AudibleAlert.prompt, 0.1), + Priority.MID, VisualAlert.none, AudibleAlert.prompt, 3.), }, } From 734151f59bf535054719e0ffc9a9621bbe963da3 Mon Sep 17 00:00:00 2001 From: James Vecellio-Grant <159560811+Discountchubbs@users.noreply.github.com> Date: Tue, 14 Oct 2025 20:32:10 -0700 Subject: [PATCH 13/20] Reapply "capnp: consolidate TurnDirection enum" (#1376) (#1382) * Reapply "capnp: consolidate TurnDirection enum" (#1376) This reverts commit 339bc0b8b3c4a192aa4e982039bc2ac28246c080. * cache it * format --------- Co-authored-by: Jason Wen --- cereal/custom.capnp | 10 +- selfdrive/controls/lib/desire_helper.py | 11 +- selfdrive/selfdrived/selfdrived.py | 5 +- .../controls/lib/lane_turn_desire.py | 12 +- .../lib/tests/test_lane_turn_desire.py | 160 +++++++++--------- 5 files changed, 101 insertions(+), 97 deletions(-) diff --git a/cereal/custom.capnp b/cereal/custom.capnp index e81749dbac..5c0a004fa6 100644 --- a/cereal/custom.capnp +++ b/cereal/custom.capnp @@ -446,12 +446,12 @@ struct LiveMapDataSP @0xf416ec09499d9d19 { struct ModelDataV2SP @0xa1680744031fdb2d { laneTurnDirection @0 :TurnDirection; -} -enum TurnDirection { - none @0; - turnLeft @1; - turnRight @2; + enum TurnDirection { + none @0; + turnLeft @1; + turnRight @2; + } } struct CustomReserved10 @0xcb9fd56c7057593a { diff --git a/selfdrive/controls/lib/desire_helper.py b/selfdrive/controls/lib/desire_helper.py index e72d464d06..16908fa3e3 100644 --- a/selfdrive/controls/lib/desire_helper.py +++ b/selfdrive/controls/lib/desire_helper.py @@ -6,6 +6,7 @@ from openpilot.sunnypilot.selfdrive.controls.lib.lane_turn_desire import LaneTur LaneChangeState = log.LaneChangeState LaneChangeDirection = log.LaneChangeDirection +TurnDirection = custom.ModelDataV2SP.TurnDirection LANE_CHANGE_SPEED_MIN = 20 * CV.MPH_TO_MS LANE_CHANGE_TIME_MAX = 10. @@ -32,9 +33,9 @@ DESIRES = { } TURN_DESIRES = { - custom.TurnDirection.none: log.Desire.none, - custom.TurnDirection.turnLeft: log.Desire.turnLeft, - custom.TurnDirection.turnRight: log.Desire.turnRight, + TurnDirection.none: log.Desire.none, + TurnDirection.turnLeft: log.Desire.turnLeft, + TurnDirection.turnRight: log.Desire.turnRight, } @@ -49,7 +50,7 @@ class DesireHelper: self.desire = log.Desire.none self.alc = AutoLaneChangeController(self) self.lane_turn_controller = LaneTurnController(self) - self.lane_turn_direction = custom.TurnDirection.none + self.lane_turn_direction = TurnDirection.none @staticmethod def get_lane_change_direction(CS): @@ -126,7 +127,7 @@ class DesireHelper: self.prev_one_blinker = one_blinker - if self.lane_turn_direction != custom.TurnDirection.none: + if self.lane_turn_direction != TurnDirection.none: self.desire = TURN_DESIRES[self.lane_turn_direction] else: self.desire = DESIRES[self.lane_change_direction][self.lane_change_state] diff --git a/selfdrive/selfdrived/selfdrived.py b/selfdrive/selfdrived/selfdrived.py index 0a4ea749e4..3bc616fb04 100755 --- a/selfdrive/selfdrived/selfdrived.py +++ b/selfdrive/selfdrived/selfdrived.py @@ -44,6 +44,7 @@ LaneChangeDirection = log.LaneChangeDirection EventName = log.OnroadEvent.EventName ButtonType = car.CarState.ButtonEvent.Type SafetyModel = car.CarParams.SafetyModel +TurnDirection = custom.ModelDataV2SP.TurnDirection IGNORED_SAFETY_MODES = (SafetyModel.silent, SafetyModel.noOutput) @@ -305,9 +306,9 @@ class SelfdriveD(CruiseHelper): # Handle lane turn lane_turn_direction = self.sm['modelDataV2SP'].laneTurnDirection - if lane_turn_direction == custom.TurnDirection.turnLeft: + if lane_turn_direction == TurnDirection.turnLeft: self.events_sp.add(custom.OnroadEventSP.EventName.laneTurnLeft) - elif lane_turn_direction == custom.TurnDirection.turnRight: + elif lane_turn_direction == TurnDirection.turnRight: self.events_sp.add(custom.OnroadEventSP.EventName.laneTurnRight) for i, pandaState in enumerate(self.sm['pandaStates']): diff --git a/sunnypilot/selfdrive/controls/lib/lane_turn_desire.py b/sunnypilot/selfdrive/controls/lib/lane_turn_desire.py index 00ce026abb..fa35ebb125 100644 --- a/sunnypilot/selfdrive/controls/lib/lane_turn_desire.py +++ b/sunnypilot/selfdrive/controls/lib/lane_turn_desire.py @@ -9,13 +9,15 @@ from cereal import custom from openpilot.common.constants import CV from openpilot.common.params import Params +TurnDirection = custom.ModelDataV2SP.TurnDirection + LANE_CHANGE_SPEED_MIN = 20 * CV.MPH_TO_MS class LaneTurnController: def __init__(self, desire_helper): self.DH = desire_helper - self.turn_direction = custom.TurnDirection.none + self.turn_direction = TurnDirection.none self.params = Params() self.lane_turn_value = float(self.params.get("LaneTurnValue", return_default=True)) * CV.MPH_TO_MS self.param_read_counter = 0 @@ -33,13 +35,13 @@ class LaneTurnController: def update_lane_turn(self, blindspot_left: bool, blindspot_right: bool, left_blinker: bool, right_blinker: bool, v_ego: float) -> None: if left_blinker and not right_blinker and v_ego < self.lane_turn_value and not blindspot_left: - self.turn_direction = custom.TurnDirection.turnLeft + self.turn_direction = TurnDirection.turnLeft elif right_blinker and not left_blinker and v_ego < self.lane_turn_value and not blindspot_right: - self.turn_direction = custom.TurnDirection.turnRight + self.turn_direction = TurnDirection.turnRight else: - self.turn_direction = custom.TurnDirection.none + self.turn_direction = TurnDirection.none def get_turn_direction(self): if not self.enabled: - return custom.TurnDirection.none + return TurnDirection.none return self.turn_direction diff --git a/sunnypilot/selfdrive/controls/lib/tests/test_lane_turn_desire.py b/sunnypilot/selfdrive/controls/lib/tests/test_lane_turn_desire.py index 5633ed6efc..57fe7b684f 100644 --- a/sunnypilot/selfdrive/controls/lib/tests/test_lane_turn_desire.py +++ b/sunnypilot/selfdrive/controls/lib/tests/test_lane_turn_desire.py @@ -1,113 +1,113 @@ import pytest -from cereal import log +from cereal import log, custom from openpilot.common.params import Params from openpilot.selfdrive.controls.lib.desire_helper import DesireHelper from openpilot.sunnypilot.selfdrive.controls.lib.lane_turn_desire import LaneTurnController, LANE_CHANGE_SPEED_MIN from openpilot.sunnypilot.selfdrive.controls.lib.auto_lane_change import AutoLaneChangeMode - -class TurnDirection: - none = 0 - turnLeft = 1 - turnRight = 2 +TurnDirection = custom.ModelDataV2SP.TurnDirection @pytest.mark.parametrize("left_blinker,right_blinker,v_ego,blindspot_left,blindspot_right,expected", [ - (True, False, 5, False, False, TurnDirection.turnLeft), - (False, True, 6, False, False, TurnDirection.turnRight), - (True, False, 9, False, False, TurnDirection.none), - (True, False, 7, True, False, TurnDirection.none), - (False, True, 6, False, True, TurnDirection.none), - (False, False, 5, False, False, TurnDirection.none), - (True, True, 5, False, False, TurnDirection.none), + (True, False, 5, False, False, TurnDirection.turnLeft), + (False, True, 6, False, False, TurnDirection.turnRight), + (True, False, 9, False, False, TurnDirection.none), + (True, False, 7, True, False, TurnDirection.none), + (False, True, 6, False, True, TurnDirection.none), + (False, False, 5, False, False, TurnDirection.none), + (True, True, 5, False, False, TurnDirection.none), ]) def test_lane_turn_desire_conditions(left_blinker, right_blinker, v_ego, blindspot_left, blindspot_right, expected): - dh = DesireHelper() - controller = LaneTurnController(dh) - controller.enabled = True - controller.lane_turn_value = LANE_CHANGE_SPEED_MIN - controller.turn_direction = TurnDirection.none - controller.update_lane_turn(blindspot_left, blindspot_right, left_blinker, right_blinker, v_ego) - assert controller.get_turn_direction() == expected + dh = DesireHelper() + controller = LaneTurnController(dh) + controller.enabled = True + controller.lane_turn_value = LANE_CHANGE_SPEED_MIN + controller.turn_direction = TurnDirection.none + controller.update_lane_turn(blindspot_left, blindspot_right, left_blinker, right_blinker, v_ego) + assert controller.get_turn_direction() == expected def test_lane_turn_desire_disabled(): - dh = DesireHelper() - controller = LaneTurnController(dh) - controller.enabled = False - controller.lane_turn_value = LANE_CHANGE_SPEED_MIN - controller.turn_direction = TurnDirection.none - controller.update_lane_turn(False, False, True, False, 7) - assert controller.get_turn_direction() == TurnDirection.none + dh = DesireHelper() + controller = LaneTurnController(dh) + controller.enabled = False + controller.lane_turn_value = LANE_CHANGE_SPEED_MIN + controller.turn_direction = TurnDirection.none + controller.update_lane_turn(False, False, True, False, 7) + assert controller.get_turn_direction() == TurnDirection.none def test_lane_turn_overrides_lane_change(): - dh = DesireHelper() - controller = LaneTurnController(dh) - controller.enabled = True - controller.lane_turn_value = LANE_CHANGE_SPEED_MIN - controller.turn_direction = TurnDirection.none - # left turn desire - controller.update_lane_turn(False, False, True, False, 5) - assert controller.get_turn_direction() == TurnDirection.turnLeft - # right turn desire - controller.update_lane_turn(False, False, False, True, 6) - assert controller.get_turn_direction() == TurnDirection.turnRight - # no turn - controller.update_lane_turn(False, False, False, False, 7) - assert controller.get_turn_direction() == TurnDirection.none + dh = DesireHelper() + controller = LaneTurnController(dh) + controller.enabled = True + controller.lane_turn_value = LANE_CHANGE_SPEED_MIN + controller.turn_direction = TurnDirection.none + # left turn desire + controller.update_lane_turn(False, False, True, False, 5) + assert controller.get_turn_direction() == TurnDirection.turnLeft + # right turn desire + controller.update_lane_turn(False, False, False, True, 6) + assert controller.get_turn_direction() == TurnDirection.turnRight + # no turn + controller.update_lane_turn(False, False, False, False, 7) + assert controller.get_turn_direction() == TurnDirection.none @pytest.mark.parametrize("v_ego,expected", [ - (8.93, TurnDirection.turnLeft), # just below threshold - (8.96, TurnDirection.none), # above threshold - (8.95, TurnDirection.none), # just above threshold + (8.93, TurnDirection.turnLeft), # just below threshold + (8.96, TurnDirection.none), # above threshold + (8.95, TurnDirection.none), # just above threshold ]) def test_lane_turn_desire_speed_boundary(v_ego, expected): - dh = DesireHelper() - controller = LaneTurnController(dh) - controller.enabled = True - controller.lane_turn_value = LANE_CHANGE_SPEED_MIN - controller.turn_direction = TurnDirection.none - controller.update_lane_turn(False, True, True, False, v_ego) - assert controller.get_turn_direction() == expected + dh = DesireHelper() + controller = LaneTurnController(dh) + controller.enabled = True + controller.lane_turn_value = LANE_CHANGE_SPEED_MIN + controller.turn_direction = TurnDirection.none + controller.update_lane_turn(False, True, True, False, v_ego) + assert controller.get_turn_direction() == expected class DummyCarState: - def __init__(self, vEgo=0, leftBlinker=False, rightBlinker=False, leftBlindspot=False, rightBlindspot=False, - steeringPressed=False, steeringTorque=0, brakePressed=False): - self.vEgo = vEgo - self.leftBlinker = leftBlinker - self.rightBlinker = rightBlinker - self.leftBlindspot = leftBlindspot - self.rightBlindspot = rightBlindspot - self.steeringPressed = steeringPressed - self.steeringTorque = steeringTorque - self.brakePressed = brakePressed + def __init__(self, vEgo=0, leftBlinker=False, rightBlinker=False, leftBlindspot=False, rightBlindspot=False, + steeringPressed=False, steeringTorque=0, brakePressed=False): + self.vEgo = vEgo + self.leftBlinker = leftBlinker + self.rightBlinker = rightBlinker + self.leftBlindspot = leftBlindspot + self.rightBlindspot = rightBlindspot + self.steeringPressed = steeringPressed + self.steeringTorque = steeringTorque + self.brakePressed = brakePressed + @pytest.fixture def set_lane_turn_params(): - params = Params() - params.put("LaneTurnDesire", True) - params.put("LaneTurnValue", 20.0) + params = Params() + params.put("LaneTurnDesire", True) + params.put("LaneTurnValue", 20.0) + @pytest.mark.parametrize("carstate, lateral_active, lane_change_prob, expected_desire", [ - # Lane turn desire overrides lane change desire - (DummyCarState(vEgo=5, leftBlinker=True, rightBlinker=False, leftBlindspot=False, rightBlindspot=False), True, 1.0, log.Desire.turnLeft), - (DummyCarState(vEgo=7, leftBlinker=False, rightBlinker=True, leftBlindspot=False, rightBlindspot=False), True, 1.0, log.Desire.turnRight), - # Lane change desire only (no turn desires) - (DummyCarState(vEgo=9, leftBlinker=True, rightBlinker=False, leftBlindspot=False, rightBlindspot=False, - steeringPressed=True, steeringTorque=1), True, 1.0, log.Desire.laneChangeLeft), - (DummyCarState(vEgo=9, leftBlinker=False, rightBlinker=True, leftBlindspot=False, rightBlindspot=False, - steeringPressed=True, steeringTorque=-1), True, 1.0, log.Desire.laneChangeRight), - # No desire (inactive) - (DummyCarState(vEgo=9, leftBlinker=False, rightBlinker=False), False, 1.0, log.Desire.none), - (DummyCarState(vEgo=4, leftBlinker=False, rightBlinker=False), True, 1.0, log.Desire.none), # No blinkers? no desire! + # Lane turn desire overrides lane change desire + (DummyCarState(vEgo=5, leftBlinker=True, rightBlinker=False, leftBlindspot=False, rightBlindspot=False), True, 1.0, + log.Desire.turnLeft), + (DummyCarState(vEgo=7, leftBlinker=False, rightBlinker=True, leftBlindspot=False, rightBlindspot=False), True, 1.0, + log.Desire.turnRight), + # Lane change desire only (no turn desires) + (DummyCarState(vEgo=9, leftBlinker=True, rightBlinker=False, leftBlindspot=False, rightBlindspot=False, + steeringPressed=True, steeringTorque=1), True, 1.0, log.Desire.laneChangeLeft), + (DummyCarState(vEgo=9, leftBlinker=False, rightBlinker=True, leftBlindspot=False, rightBlindspot=False, + steeringPressed=True, steeringTorque=-1), True, 1.0, log.Desire.laneChangeRight), + # No desire (inactive) + (DummyCarState(vEgo=9, leftBlinker=False, rightBlinker=False), False, 1.0, log.Desire.none), + (DummyCarState(vEgo=4, leftBlinker=False, rightBlinker=False), True, 1.0, log.Desire.none), # No blinkers? no desire! ]) def test_desire_helper_integration(carstate, lateral_active, lane_change_prob, expected_desire, set_lane_turn_params): - dh = DesireHelper() - dh.alc.lane_change_set_timer = AutoLaneChangeMode.NUDGE - for _ in range(10): - dh.update(carstate, lateral_active, lane_change_prob) - assert dh.desire == expected_desire # The first four tests were unit tests to test the controller, where this tests the integration in desire helpers + dh = DesireHelper() + dh.alc.lane_change_set_timer = AutoLaneChangeMode.NUDGE + for _ in range(10): + dh.update(carstate, lateral_active, lane_change_prob) + assert dh.desire == expected_desire # The first four tests were unit tests to test the controller, where this tests the integration in desire helpers From e0ccc175e4b49dc389ede1bc522b7de64056e450 Mon Sep 17 00:00:00 2001 From: Jason Wen Date: Wed, 15 Oct 2025 00:59:40 -0400 Subject: [PATCH 14/20] liveMapDataSP: improve speed limit validation logic (#1383) --- sunnypilot/mapd/live_map_data/base_map_data.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/sunnypilot/mapd/live_map_data/base_map_data.py b/sunnypilot/mapd/live_map_data/base_map_data.py index 25925937f5..723864dd2a 100644 --- a/sunnypilot/mapd/live_map_data/base_map_data.py +++ b/sunnypilot/mapd/live_map_data/base_map_data.py @@ -8,8 +8,12 @@ from abc import abstractmethod, ABC import cereal.messaging as messaging from openpilot.common.params import Params +from openpilot.common.constants import CV +from openpilot.selfdrive.car.cruise import V_CRUISE_UNSET from openpilot.sunnypilot.navd.helpers import coordinate_from_param +MAX_SPEED_LIMIT = V_CRUISE_UNSET * CV.KPH_TO_MS + class BaseMapData(ABC): def __init__(self): @@ -46,9 +50,9 @@ class BaseMapData(ABC): mapd_sp_send.valid = self.sm['liveLocationKalman'].gpsOK live_map_data = mapd_sp_send.liveMapDataSP - live_map_data.speedLimitValid = bool(speed_limit > 0) + live_map_data.speedLimitValid = bool(MAX_SPEED_LIMIT > speed_limit > 0) live_map_data.speedLimit = speed_limit - live_map_data.speedLimitAheadValid = bool(next_speed_limit > 0) + live_map_data.speedLimitAheadValid = bool(MAX_SPEED_LIMIT > next_speed_limit > 0) live_map_data.speedLimitAhead = next_speed_limit live_map_data.speedLimitAheadDistance = next_speed_limit_distance live_map_data.roadName = self.get_current_road_name() From 6d51d64285fdfef07f09845d8d96425bc0a769ab Mon Sep 17 00:00:00 2001 From: Jason Wen Date: Wed, 15 Oct 2025 09:46:53 -0400 Subject: [PATCH 15/20] interfaces: clean up unsupported params during initialization (#1385) * interfaces: clean up unsupported params during initialization * fix * logging and no DEC when no long * ui * ui --- .../speed_limit/speed_limit_settings.cc | 6 ++++- .../qt/offroad/settings/longitudinal_panel.cc | 13 ++++++++-- sunnypilot/selfdrive/car/interfaces.py | 24 +++++++++++++++++++ 3 files changed, 40 insertions(+), 3 deletions(-) diff --git a/selfdrive/ui/sunnypilot/qt/offroad/settings/longitudinal/speed_limit/speed_limit_settings.cc b/selfdrive/ui/sunnypilot/qt/offroad/settings/longitudinal/speed_limit/speed_limit_settings.cc index f64198f97e..95aa4ef26f 100644 --- a/selfdrive/ui/sunnypilot/qt/offroad/settings/longitudinal/speed_limit/speed_limit_settings.cc +++ b/selfdrive/ui/sunnypilot/qt/offroad/settings/longitudinal/speed_limit/speed_limit_settings.cc @@ -122,6 +122,10 @@ void SpeedLimitSettings::refresh() { has_longitudinal_control = hasLongitudinalControl(CP); intelligent_cruise_button_management_available = CP_SP.getIntelligentCruiseButtonManagementAvailable(); + + if (!has_longitudinal_control && CP_SP.getPcmCruiseSpeed()) { + params.put("SpeedLimitMode", std::to_string(static_cast(SpeedLimitMode::WARNING))); + } } else { has_longitudinal_control = false; intelligent_cruise_button_management_available = false; @@ -148,7 +152,7 @@ void SpeedLimitSettings::refresh() { speed_limit_mode_settings->setEnableSelectedButtons(true, convertSpeedLimitModeValues(getSpeedLimitModeValues())); } else { speed_limit_mode_settings->setEnableSelectedButtons(true, convertSpeedLimitModeValues( - {SpeedLimitMode::OFF,SpeedLimitMode::INFORMATION, SpeedLimitMode::WARNING})); + {SpeedLimitMode::OFF, SpeedLimitMode::INFORMATION, SpeedLimitMode::WARNING})); } speed_limit_mode_settings->showDescription(); diff --git a/selfdrive/ui/sunnypilot/qt/offroad/settings/longitudinal_panel.cc b/selfdrive/ui/sunnypilot/qt/offroad/settings/longitudinal_panel.cc index 31124a3242..a5098a13e6 100644 --- a/selfdrive/ui/sunnypilot/qt/offroad/settings/longitudinal_panel.cc +++ b/selfdrive/ui/sunnypilot/qt/offroad/settings/longitudinal_panel.cc @@ -104,6 +104,17 @@ void LongitudinalPanel::refresh(bool _offroad) { has_longitudinal_control = hasLongitudinalControl(CP); is_pcm_cruise = CP.getPcmCruise(); intelligent_cruise_button_management_available = CP_SP.getIntelligentCruiseButtonManagementAvailable(); + + if (!intelligent_cruise_button_management_available || has_longitudinal_control) { + params.remove("IntelligentCruiseButtonManagement"); + } + + if (!has_longitudinal_control && CP_SP.getPcmCruiseSpeed()) { + params.remove("CustomAccIncrementsEnabled"); + params.remove("DynamicExperimentalControl"); + params.remove("SmartCruiseControlVision"); + params.remove("SmartCruiseControlMap"); + } } else { has_longitudinal_control = false; is_pcm_cruise = false; @@ -127,11 +138,9 @@ void LongitudinalPanel::refresh(bool _offroad) { customAccIncrement->setDescription(accEnabledDescription); } } else { - params.remove("CustomAccIncrementsEnabled"); customAccIncrement->toggleFlipped(false); customAccIncrement->setDescription(accNoLongDescription); customAccIncrement->showDescription(); - params.remove("IntelligentCruiseButtonManagement"); intelligentCruiseButtonManagement->toggleFlipped(false); } } diff --git a/sunnypilot/selfdrive/car/interfaces.py b/sunnypilot/selfdrive/car/interfaces.py index 3072cde8cd..5467639a63 100644 --- a/sunnypilot/selfdrive/car/interfaces.py +++ b/sunnypilot/selfdrive/car/interfaces.py @@ -11,6 +11,7 @@ from opendbc.car.interfaces import CarInterfaceBase from openpilot.common.params import Params from openpilot.common.swaglog import cloudlog from openpilot.sunnypilot.selfdrive.controls.lib.nnlc.helpers import get_nn_model_path +from openpilot.sunnypilot.selfdrive.controls.lib.speed_limit.common import Mode as SpeedLimitMode import openpilot.system.sentry as sentry @@ -66,6 +67,28 @@ def _initialize_torque_lateral_control(CI: CarInterfaceBase, CP: structs.CarPara CI.configure_torque_tune(CP.carFingerprint, CP.lateralTuning) +def _cleanup_unsupported_params(CP: structs.CarParams, CP_SP: structs.CarParamsSP, params: Params = None) -> None: + if params is None: + params = Params() + + if CP.steerControlType == structs.CarParams.SteerControlType.angle: + cloudlog.warning("SteerControlType is angle, cleaning up params") + params.remove("NeuralNetworkLateralControl") + params.remove("EnforceTorqueControl") + + if not CP_SP.intelligentCruiseButtonManagementAvailable or CP.openpilotLongitudinalControl: + cloudlog.warning("ICBM not available or openpilot Longitudinal Control enabled, cleaning up params") + params.remove("IntelligentCruiseButtonManagement") + + if not CP.openpilotLongitudinalControl and CP_SP.pcmCruiseSpeed: + cloudlog.warning("openpilot Longitudinal Control and ICBM not available, cleaning up params") + params.remove("DynamicExperimentalControl") + params.remove("CustomAccIncrementsEnabled") + params.remove("SmartCruiseControlVision") + params.remove("SmartCruiseControlMap") + params.put("SpeedLimitMode", int(SpeedLimitMode.warning)) + + def setup_interfaces(CI: CarInterfaceBase, params: Params = None) -> None: CP = CI.CP CP_SP = CI.CP_SP @@ -74,6 +97,7 @@ def setup_interfaces(CI: CarInterfaceBase, params: Params = None) -> None: nnlc_enabled = _initialize_neural_network_lateral_control(CP, CP_SP, params) _initialize_intelligent_cruise_button_management(CP, CP_SP, params) _initialize_torque_lateral_control(CI, CP, enforce_torque, nnlc_enabled) + _cleanup_unsupported_params(CP, CP_SP) def initialize_params(params) -> list[dict[str, Any]]: From d7e1c42c2b73a3c96fa17908ee1118a5171f4183 Mon Sep 17 00:00:00 2001 From: Jason Wen Date: Wed, 15 Oct 2025 12:25:23 -0400 Subject: [PATCH 16/20] ui: move Dynamic Experimental Control (DEC) toggle to Longitudinal panel (#1388) - Implemented a new toggle for enabling Dynamic Experimental Control (DEC) in longitudinal settings. - Removed previous implementation for DEC from general settings. - Updated accessibility based on longitudinal control status. --- selfdrive/ui/qt/offroad/settings.cc | 7 ------- .../qt/offroad/settings/longitudinal_panel.cc | 10 ++++++++++ .../qt/offroad/settings/longitudinal_panel.h | 1 + 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/selfdrive/ui/qt/offroad/settings.cc b/selfdrive/ui/qt/offroad/settings.cc index 205a14624a..6f594d939a 100644 --- a/selfdrive/ui/qt/offroad/settings.cc +++ b/selfdrive/ui/qt/offroad/settings.cc @@ -33,13 +33,6 @@ TogglesPanel::TogglesPanel(SettingsWindow *parent) : ListWidget(parent) { "../assets/icons/experimental_white.svg", false, }, - { - "DynamicExperimentalControl", - tr("Enable Dynamic Experimental Control"), - tr("Enable toggle to allow the model to determine when to use sunnypilot ACC or sunnypilot End to End Longitudinal."), - "../assets/offroad/icon_blank.png", - false, - }, { "DisengageOnAccelerator", tr("Disengage on Accelerator Pedal"), diff --git a/selfdrive/ui/sunnypilot/qt/offroad/settings/longitudinal_panel.cc b/selfdrive/ui/sunnypilot/qt/offroad/settings/longitudinal_panel.cc index a5098a13e6..f2c7ea3825 100644 --- a/selfdrive/ui/sunnypilot/qt/offroad/settings/longitudinal_panel.cc +++ b/selfdrive/ui/sunnypilot/qt/offroad/settings/longitudinal_panel.cc @@ -43,6 +43,15 @@ LongitudinalPanel::LongitudinalPanel(QWidget *parent) : QWidget(parent) { intelligentCruiseButtonManagement->setConfirmation(true, false); list->addItem(intelligentCruiseButtonManagement); + dynamicExperimentalControl = new ParamControlSP( + "DynamicExperimentalControl", + tr("Dynamic Experimental Control (DEC)"), + tr("Enable toggle to allow the model to determine when to use sunnypilot ACC or sunnypilot End to End Longitudinal."), + "", + this + ); + list->addItem(dynamicExperimentalControl); + SmartCruiseControlVision = new ParamControl( "SmartCruiseControlVision", tr("Smart Cruise Control - Vision"), @@ -153,6 +162,7 @@ void LongitudinalPanel::refresh(bool _offroad) { customAccIncrement->setEnabled(cai_allowed && !offroad); customAccIncrement->refresh(); + dynamicExperimentalControl->setEnabled(has_longitudinal_control); SmartCruiseControlVision->setEnabled(has_longitudinal_control || icbm_allowed); SmartCruiseControlMap->setEnabled(has_longitudinal_control || icbm_allowed); diff --git a/selfdrive/ui/sunnypilot/qt/offroad/settings/longitudinal_panel.h b/selfdrive/ui/sunnypilot/qt/offroad/settings/longitudinal_panel.h index a94369a560..127b7871eb 100644 --- a/selfdrive/ui/sunnypilot/qt/offroad/settings/longitudinal_panel.h +++ b/selfdrive/ui/sunnypilot/qt/offroad/settings/longitudinal_panel.h @@ -35,6 +35,7 @@ private: ParamControl *SmartCruiseControlVision; ParamControl *SmartCruiseControlMap; ParamControl *intelligentCruiseButtonManagement = nullptr; + ParamControl *dynamicExperimentalControl = nullptr; SpeedLimitSettings *speedLimitScreen; PushButtonSP *speedLimitSettings; }; From fd0aa9feb8a8d41242e0e498184dcee9c4741ebe Mon Sep 17 00:00:00 2001 From: DevTekVE Date: Wed, 15 Oct 2025 19:54:02 +0200 Subject: [PATCH 17/20] ci: add documentation sync and publishing workflows to discourse (#1387) * ci: add documentation sync and publishing workflows - Added `sync_docs.rb` to automate syncing and processing documentation. - Integrated GitHub Actions workflows to validate and publish docs. - Supports dynamic title generation via Gemini API and proper formatting conversions. * ci: add documentation sync and publishing workflows - Added `sync_docs.rb` to automate syncing and processing documentation. - Integrated GitHub Actions workflows to validate and publish docs. - Supports dynamic title generation via Gemini API and proper formatting conversions. * no need and fix * maybe * i think it wants this * i think it wants this * i think it wants this * send it * Fix the link * Improve CI documentation processing logic and link handling - Updated MkDocs conversions for "tabs" and callout styles for better Obsidian compatibility. - Enhanced internal markdown link rewriting to resolve Discourse topic links. - Reduced rate limit for Gemini API requests, improving call stability. - Fixed GitHub link in generated documents to include specific document paths. --- .github/workflows/forum-docs.yml | 46 ++ docs_sp/index.md | 3 + release/ci/Gemfile | 5 + release/ci/lib/api.rb | 112 +++++ release/ci/lib/local_doc.rb | 108 +++++ release/ci/lib/util.rb | 15 + release/ci/sync_docs.rb | 762 +++++++++++++++++++++++++++++++ 7 files changed, 1051 insertions(+) create mode 100644 .github/workflows/forum-docs.yml create mode 100644 docs_sp/index.md create mode 100644 release/ci/Gemfile create mode 100644 release/ci/lib/api.rb create mode 100644 release/ci/lib/local_doc.rb create mode 100644 release/ci/lib/util.rb create mode 100755 release/ci/sync_docs.rb diff --git a/.github/workflows/forum-docs.yml b/.github/workflows/forum-docs.yml new file mode 100644 index 0000000000..8f342faf3c --- /dev/null +++ b/.github/workflows/forum-docs.yml @@ -0,0 +1,46 @@ +name: Publish Docs +on: + push: + branches: + - main + pull_request: + workflow_dispatch: + inputs: + publish: + description: 'Set to true to publish to forum' + required: false + type: boolean + +env: + DOCS_CATEGORY_ID: 114 + DOCS_DATA_EXPLORER_QUERY_ID: 2 + DOCS_TARGET: https://forum.sunnypilot.ai + DOCS_API_KEY: ${{ secrets.DISCOURSE_API_KEY }} + GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} + +jobs: +# dry_run: +# runs-on: ubuntu-latest +# steps: +# - uses: actions/checkout@v4 +# - uses: ruby/setup-ruby@v1 +# with: +# ruby-version: "3.3" +# bundler-cache: true +# working-directory: ./release/ci +# - run: bundle exec ./sync_docs.rb --dry-run +# working-directory: ./release/ci + + publish: +# if: github.ref == 'refs/heads/main' || (github.event_name == 'workflow_dispatch' && github.event.inputs.publish == 'true') +# needs: dry_run + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.3" + bundler-cache: true + working-directory: ./release/ci + - run: bundle exec ./sync_docs.rb + working-directory: ./release/ci diff --git a/docs_sp/index.md b/docs_sp/index.md new file mode 100644 index 0000000000..50e0e47f42 --- /dev/null +++ b/docs_sp/index.md @@ -0,0 +1,3 @@ +The documentation here is as best-effort sync from the official https://sunnypilot.github.io/ for ease of access and for AI enhancement when users asks on the forum. + + diff --git a/release/ci/Gemfile b/release/ci/Gemfile new file mode 100644 index 0000000000..4adf68df40 --- /dev/null +++ b/release/ci/Gemfile @@ -0,0 +1,5 @@ +source "https://rubygems.org" +gem "faraday" +gem "faraday-retry" +gem "faraday-multipart" +gem "listen" \ No newline at end of file diff --git a/release/ci/lib/api.rb b/release/ci/lib/api.rb new file mode 100644 index 0000000000..09ba173dd9 --- /dev/null +++ b/release/ci/lib/api.rb @@ -0,0 +1,112 @@ +# frozen_string_literal: true + +module API + def self.client + @client ||= + Faraday.new(url: DOCS_TARGET) do |conn| + conn.request :multipart + conn.request :url_encoded + conn.request :retry, + { + methods: %i[get post delete put], + retry_statuses: [429], + max: 3, + retry_block: ->(env:, options:, retry_count:, exception:, will_retry_in:) do + puts "Rate limited... will retry in #{will_retry_in}s" + end, + exceptions: [Faraday::TooManyRequestsError] + } + conn.response :json, content_type: "application/json" + conn.response :raise_error + conn.adapter Faraday.default_adapter + conn.headers["Api-Key"] = DOCS_API_KEY + end + end + + def self.edit_post(post_id:, raw:, title: nil, category: nil) + if dry_run? + puts " DRY RUN: skipping PUT /posts/#{post_id}" + return + end + + params = { + post: { + raw: raw, + edit_reason: "Synced from github.com/discourse/discourse-developer-docs" + } + } + params[:title] = title if title + params[:category] = category if category + client.put("/posts/#{post_id}", params) + end + + def self.create_topic(external_id:, raw:, category:, title:) + if dry_run? + puts " DRY RUN: skipping POST /posts" + return + end + client.post("/posts", { title: title, raw: raw, external_id: external_id, category: category }) + end + + def self.trash_topic(topic_id:) + if dry_run? + puts " DRY RUN: skipping DELETE /t/#{topic_id}.json" + return + end + + client.delete("/t/#{topic_id}.json") + end + + def self.fetch_current_state + result = + client.post( + "/admin/plugins/explorer/queries/#{DATA_EXPLORER_QUERY_ID}/run", + { params: { category_id: CATEGORY_ID.to_s }.to_json } + ).body + + raise "Data explorer query failed" if result["success"] != true + + if result["columns"] != %w[t_id first_p_id external_id title raw deleted_at is_index_topic] + raise "Data explorer query returned unexpected columns: #{result["columns"].inspect}" + end + + result["rows"].map do |row| + { + topic_id: row[0], + first_post_id: row[1], + external_id: row[2], + title: row[3], + raw: row[4], + deleted_at: row[5], + is_index_topic: row[6] + } + end + end + + def self.restore_topic(topic_id:) + path = "/t/#{topic_id}/recover.json" + + if dry_run? + puts " DRY RUN: skipping PUT #{path}" + return + end + + client.put(path) + end + + def self.upload_file(path) + if dry_run? + puts " DRY RUN: skipping POST /uploads.json" + return { "short_url" => "upload://placeholder" } + end + + client.post( + "/uploads.json", + { type: "composer", synchronous: true, file: Faraday::UploadIO.new(path, "image/png") } + ).body + end + + def self.dry_run? + [nil, true].include?(DRY_RUN) + end +end diff --git a/release/ci/lib/local_doc.rb b/release/ci/lib/local_doc.rb new file mode 100644 index 0000000000..0b362de54d --- /dev/null +++ b/release/ci/lib/local_doc.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +Asset = Struct.new(:local_path, :local_sha1, :remote_short_url, keyword_init: true) + +class LocalDoc + attr_accessor :path, + :frontmatter, + :content, + :topic_id, + :first_post_id, + :remote_content, + :remote_title, + :remote_deleted, + :assets + + def initialize(**kwargs) + kwargs.each { |k, v| send("#{k}=", v) } + self.assets ||= [] + end + + def external_id + "DOC-#{frontmatter["id"]}" + end + + def section + path_segments = path.split("/") + path_segments[0] if path_segments.size > 1 + end + + def content_with_uploads + unused_assets = assets.dup + + result = + content.gsub(/![^\]]+\]\(([^)]+)\)/) do |match| + path = $1 + next match if !path.start_with?("/") + + resolved = File.expand_path("#{__dir__}/../#{path}") + assets_dir = File.expand_path("#{__dir__}/../assets/") + raise "Invalid path: #{resolved}" if !resolved.start_with?(assets_dir) + + digest = Digest::SHA1.file(resolved).hexdigest + + asset = assets.find { |a| a.local_sha1 == digest } + unused_assets.delete(asset) + if !asset + puts " Uploading #{path}..." + result = API.upload_file(resolved) + raise "File upload failed: #{result.inspect}" if !result["short_url"] + asset = + Asset.new(local_path: path, local_sha1: digest, remote_short_url: result["short_url"]) + assets.push(asset) + end + + short_url = asset.remote_short_url + + match.gsub(path, short_url) + end + + unused_assets.each { |asset| assets.delete(asset) } + + result = <<~MD + #{result} + + --- + + This document is version controlled - suggest changes [on github](https://github.com/sunnypilot/sunnypilot/blob/master/docs_sp/#{path}). + MD + + if assets.size == 0 + result + else + <<~MD + #{result} + + MD + end + end + + def serialized_assets + JSON.pretty_generate( + assets.map do |asset| + { + local_path: asset.local_path, + local_sha1: asset.local_sha1, + remote_short_url: asset.remote_short_url + } + end + ) + end + + def remote_content=(value) + if value.match(//m) + self.assets = JSON.parse($1).map { |raw_asset| Asset.new(**raw_asset) } + end + + value.gsub!(/![^\]]+\]\(([^)]+)\)/) do |match| + url = $1 + found_asset = assets.find { |a| a.remote_short_url == url } + match.sub!(path, found_asset.local_path) if found_asset + match + end + + @remote_content = value + end +end diff --git a/release/ci/lib/util.rb b/release/ci/lib/util.rb new file mode 100644 index 0000000000..df470a9b4f --- /dev/null +++ b/release/ci/lib/util.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Util + def self.parse_md(raw) + if match = raw.match(/\A---\s*\n(.+?)\n---\n?(.*)\z/m) + raw_frontmatter, content = match.captures + frontmatter = YAML.safe_load(raw_frontmatter) + else + content = raw + frontmatter = {} + end + + [frontmatter, content] + end +end diff --git a/release/ci/sync_docs.rb b/release/ci/sync_docs.rb new file mode 100755 index 0000000000..888abba136 --- /dev/null +++ b/release/ci/sync_docs.rb @@ -0,0 +1,762 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "yaml" +require "faraday" +require "faraday/retry" +require "faraday/multipart" +require "listen" +require "json" +require "digest" + +CATEGORY_ID = ENV["DOCS_CATEGORY_ID"].to_i +DATA_EXPLORER_QUERY_ID = ENV["DOCS_DATA_EXPLORER_QUERY_ID"].to_i +DOCS_TARGET = ENV["DOCS_TARGET"] +DOCS_API_KEY = ENV["DOCS_API_KEY"] +GEMINI_API_KEY = ENV["GEMINI_API_KEY"] + +VERBOSE = ARGV.include?("-v") +WATCH = ARGV.include?("--watch") +DRY_RUN = ARGV.include?("--dry-run") + +require_relative "lib/local_doc" +require_relative "lib/api" +require_relative "lib/util" + +# Gemini API client for title generation +class GeminiClient + GEMINI_API_URL = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-lite:generateContent" + #GEMINI_API_URL = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent" +# MAX_REQUESTS_PER_MINUTE = 15 + MAX_REQUESTS_PER_MINUTE = 9 + RATE_LIMIT_WINDOW = 60 # seconds + + def initialize(api_key) + @api_key = api_key + @request_timestamps = [] + @mutex = Mutex.new + end + + def generate_titles(file_path, content) + return nil unless @api_key + + wait_for_rate_limit + + prompt = build_prompt(file_path, content) + + response = Faraday.post( + "#{GEMINI_API_URL}?key=#{@api_key}", + { contents: [{ parts: [{ text: prompt }] }] }.to_json, + "Content-Type" => "application/json" + ) + + parse_response(response) + rescue => e + puts "Error calling Gemini API: #{e.message}" + nil + end + + private + + def wait_for_rate_limit + @mutex.synchronize do + now = Time.now + + # Remove timestamps older than the rate limit window + @request_timestamps.reject! { |ts| now - ts > RATE_LIMIT_WINDOW } + + # If we've hit the limit, wait until the oldest request expires + if @request_timestamps.length >= MAX_REQUESTS_PER_MINUTE + oldest_request = @request_timestamps.first + sleep_time = RATE_LIMIT_WINDOW - (now - oldest_request) + 0.1 # Add small buffer + + if sleep_time > 0 + puts "Rate limit reached (#{MAX_REQUESTS_PER_MINUTE}/min). Waiting #{sleep_time.round(1)}s..." + sleep(sleep_time) + + # Clean up again after waiting + now = Time.now + @request_timestamps.reject! { |ts| now - ts > RATE_LIMIT_WINDOW } + end + end + + # Record this request + @request_timestamps << Time.now + end + end + + def build_prompt(file_path, content) + <<~PROMPT + You are helping to generate documentation metadata. Given a markdown file path and its content, generate appropriate titles. + + File path: #{file_path} + + Content preview: + #{content[0..500]} + + Please analyze the file path and content, then provide: + 1. A full title (3-8 words, descriptive and professional, MUST be at least 15 characters long) + 2. A short title (1-3 words, concise, MUST be at least 15 characters long) + + CRITICAL: Both titles MUST be at least 15 characters long. If a title would be shorter, expand it with relevant context. + + Respond ONLY with valid JSON in this exact format: + { + "title": "Your Full Title Here", + "short_title": "Short Title Here" + } + + Do not include any other text, explanation, or markdown formatting. + PROMPT + end + + def parse_response(response) + body = JSON.parse(response.body) + text = body.dig("candidates", 0, "content", "parts", 0, "text") + + return nil unless text + + # Extract JSON from potential markdown code blocks + json_text = text.strip.gsub(/^```json\n/, "").gsub(/\n```$/, "").strip + + parsed = JSON.parse(json_text) + + # Validate minimum length + if parsed["title"] && parsed["title"].length < 15 + parsed["title"] = parsed["title"].ljust(15) + end + + if parsed["short_title"] && parsed["short_title"].length < 15 + parsed["short_title"] = parsed["short_title"].ljust(15) + end + + parsed + rescue => e + puts "Error parsing Gemini response: #{e.message}" + nil + end +end + +# Convert MkDocs "=== Tabs" sections to Obsidian callouts +def convert_mkdocs_tabs_to_callouts(content, debug: false) + lines = content.lines + result = [] + i = 0 + match_count = 0 + + while i < lines.length + line = lines[i] + + # Detect a MkDocs tab start, e.g., === "sunnypilot not installed" + if line =~ /^===\s+"([^"]+)"\s*$/ + match_count += 1 + tab_title = $1.strip + + # Collect all indented lines following the tab + body_lines = [] + i += 1 + while i < lines.length + current_line = lines[i] + + # Check if line is indented (4+ spaces or tab) + if current_line =~ /^(?: |\t)/ + body_lines << current_line + i += 1 + # Check if it's a blank line - peek ahead like we do for callouts + elsif current_line =~ /^\s*$/ + peek_index = i + 1 + has_more_indented = false + while peek_index < lines.length + if lines[peek_index] =~ /^(?: |\t)/ + has_more_indented = true + break + elsif lines[peek_index] =~ /^\s*$/ + peek_index += 1 + else + break + end + end + + if has_more_indented + body_lines << current_line + i += 1 + else + break + end + else + # Non-indented, non-blank line (could be next tab!) - stop + break + end + end + + puts "DEBUG: Tab '#{tab_title}', lines: #{body_lines.length}" if debug + + # Convert the tab section to a callout + result << "> [!note] #{tab_title}\n" + body_lines.each do |body_line| + # Remove the first level of indentation (4 spaces or 1 tab) and add > prefix + stripped = body_line.sub(/^(?: |\t)/, '') + if stripped.strip.empty? + result << ">\n" + else + result << ">#{stripped}" + end + end + else + result << line + i += 1 + end + end + + puts "DEBUG: Total tab matches: #{match_count}" if debug + result.join +end + + +# New implementation of MkDocs Material callout to Obsidian converter +def convert_mkdocs_to_obsidian_callouts(content, debug: false) + # Map of MkDocs callout types to Obsidian equivalents + callout_map = { + 'note' => 'note', + 'tip' => 'tip', + 'important' => 'important', + 'warning' => 'warning', + 'caution' => 'caution', + 'info' => 'info', + 'success' => 'success', + 'question' => 'question', + 'failure' => 'failure', + 'danger' => 'danger', + 'bug' => 'bug', + 'example' => 'example', + 'quote' => 'quote', + 'abstract' => 'abstract', + 'summary' => 'summary', + 'tldr' => 'tldr' + } + + lines = content.lines + result = [] + i = 0 + match_count = 0 + + while i < lines.length + line = lines[i] + + # Check if this line starts a callout (can be indented or not) + # Capture any leading indentation + if line =~ /^(\s*)!!!\s+(#{callout_map.keys.map { |k| Regexp.escape(k) }.join('|')})(?:\s+"([^"]*)")?\s*$/ + leading_indent = $1 + mkdocs_type = $2 + custom_title = $3 + match_count += 1 + obsidian_type = callout_map[mkdocs_type] + + # Determine the base indentation level (number of spaces/tabs before !!!) + base_indent_size = leading_indent.length + + # Collect all lines that have MORE indentation than the base + body_lines = [] + i += 1 + while i < lines.length + current_line = lines[i] + + # Check if line has the required base indentation plus more (for body content) + # Body content should be indented relative to the !!! line + required_indent = leading_indent + " " # base + 4 more spaces + alt_required_indent = leading_indent + "\t" # base + 1 tab + + if current_line.start_with?(required_indent) || current_line.start_with?(alt_required_indent) + body_lines << current_line + i += 1 + # Check if line is blank - might be between paragraphs + elsif current_line =~ /^\s*$/ + peek_index = i + 1 + has_more_indented = false + while peek_index < lines.length + peek_line = lines[peek_index] + if peek_line.start_with?(required_indent) || peek_line.start_with?(alt_required_indent) + has_more_indented = true + break + elsif peek_line =~ /^\s*$/ + peek_index += 1 + else + break + end + end + + if has_more_indented + body_lines << current_line + i += 1 + else + break + end + else + # Line doesn't have required indentation - stop + break + end + end + + puts "DEBUG: Match #{match_count} - indent: #{base_indent_size} spaces, type: #{mkdocs_type}, title: #{custom_title.inspect}, body lines: #{body_lines.length}" if debug + + # Build the converted callout, preserving the base indentation + if body_lines.empty? + if custom_title && !custom_title.empty? + result << "#{leading_indent}> [!#{obsidian_type}] \"#{custom_title}\"\n" + else + result << "#{leading_indent}> [!#{obsidian_type}]\n" + end + else + if custom_title && !custom_title.empty? + result << "#{leading_indent}> [!#{obsidian_type}] \"#{custom_title}\"\n" + else + result << "#{leading_indent}> [!#{obsidian_type}]\n" + end + # Add > prefix to each body line, removing the extra level of indentation + body_lines.each do |body_line| + # Remove the base indent + one level (4 spaces or tab) + stripped = body_line.sub(/^#{Regexp.escape(leading_indent)}(?: |\t)/, '') + result << "#{leading_indent}>#{stripped}" + end + end + else + result << line + i += 1 + end + end + + puts "DEBUG: Total matches found: #{match_count}" if debug + + result.join +end + +# Convert MkDocs Material icons to standard emojis +def convert_material_icons_to_emojis(content) + # Map of common Material icons to emoji equivalents + icon_map = { + # Check/success icons + ':material-check:' => '✅', + ':material-check-circle:' => '✅', + ':material-check-bold:' => '✅', + + # Close/error icons + ':material-close:' => '❌', + ':material-close-circle:' => '❌', + ':material-alert-circle:' => '⚠️', + + # Info icons + ':material-information:' => 'ℹ️', + ':material-information-outline:' => 'ℹ️', + ':material-help-circle:' => '❓', + + # Arrow icons + ':material-arrow-right:' => '→', + ':material-arrow-left:' => '←', + ':material-arrow-up:' => '↑', + ':material-arrow-down:' => '↓', + + # Other common icons + ':material-lightbulb:' => '💡', + ':material-star:' => '⭐', + ':material-heart:' => '❤️', + ':material-fire:' => '🔥', + ':material-flag:' => '🚩', + ':material-link:' => '🔗', + ':material-pencil:' => '✏️', + ':material-delete:' => '🗑️', + ':material-calendar:' => '📅', + ':material-clock:' => '🕐', + ':material-email:' => '📧', + ':material-phone:' => '📞', + } + + # Replace material icons with emojis, ignoring any style attributes + icon_map.each do |material_icon, emoji| + # Match the icon with optional style attributes like { style="color: #EF5350" } + content.gsub!(/#{Regexp.escape(material_icon)}\{\s*style="[^"]*"\s*\}/, emoji) + # Also match without style attributes + content.gsub!(material_icon, emoji) + end + + content +end + +# Helper method to generate frontmatter from file path +def generate_frontmatter_from_path(path, content = nil, gemini_client = nil) + # Remove .md extension and get the base name + base_name = File.basename(path, ".md") + + # Generate id from the full path (without extension) + # Replace / with - + # IMPORTANT: The LocalDoc#external_id method adds "DOC-" prefix (4 chars) + # So we need to limit the base ID to 46 chars to stay under the 50 char API limit + full_id = path.sub(/\.md$/, "").gsub("/", "-") + + # Maximum length for the base ID (50 char API limit - 4 char "DOC-" prefix) + max_base_id_length = 46 + + if full_id.length > max_base_id_length + # Take first 37 chars and append an 8-char hash for uniqueness (37 + 1 dash + 8 = 46) + hash_suffix = Digest::MD5.hexdigest(path)[0..7] + id = "#{full_id[0..36]}-#{hash_suffix}" + else + id = full_id + end + + # Try to use Gemini for title generation + if gemini_client && content + gemini_titles = gemini_client.generate_titles(path, content) + + if gemini_titles + return { + "id" => id, + "title" => gemini_titles["title"], + "short_title" => gemini_titles["short_title"] + } + end + end + + # Fallback to original logic if Gemini fails or is not available + title = base_name.split(/[-_]/).map(&:capitalize).join(" ") + short_title = base_name.split(/[-_]/).map(&:capitalize).join(" ") + + # Ensure minimum length + title = title.ljust(15) if title.length < 15 + short_title = short_title.ljust(15) if short_title.length < 15 + + { + "id" => id, + "title" => title, + "short_title" => short_title + } +end + +# Helper method to generate index.md content for a folder +def generate_folder_index(folder_name) + # Convert folder name to a nice title (e.g., "my-folder" -> "My Folder") + title = folder_name.split(/[-_]/).map(&:capitalize).join(" ") + + "---\ntitle: #{title}\n---\n" +end + +# Convert internal markdown links (.md) to Discourse topic links +def rewrite_internal_links(content, docs) + require "uri" + + content.gsub(/\]\(([^)]+\.md)(#[^)]+)?\)/) do |match| + raw_link = $1 + anchor = $2 || "" + # Strip any ./ or ../ from the beginning, but preserve subfolders + normalized = raw_link.gsub(%r{^\./}, "").gsub(%r{^\.\./}, "") + # Remove trailing .md + normalized = normalized.gsub(/\.md$/, "") + + # Try percent-decoding (handles %20 etc) + begin + normalized_decoded = URI.decode_www_form_component(normalized) + rescue + normalized_decoded = normalized + end + + candidates = [] + + # Strategy 1: exact match against doc.path without .md + candidates += docs.select { |d| d.path.sub(/\.md$/, "") == normalized_decoded } + + # Strategy 2: ends_with (useful if docs have a different root) + if candidates.empty? + candidates += docs.select { |d| d.path.end_with?("#{normalized_decoded}.md") } + end + + # Strategy 3: match by basename (e.g., linking to index.md or same-named file in subfolder) + if candidates.empty? + basename = File.basename(normalized_decoded) + candidates += docs.select { |d| File.basename(d.path, ".md") == basename } + end + + # Strategy 4: index.md handling — if link pointed to a folder/index.md, allow folder match + if candidates.empty? && normalized_decoded.end_with?("/index") + folder = normalized_decoded.sub(/\/index$/, "") + candidates += docs.select { |d| File.dirname(d.path) == folder && File.basename(d.path, ".md") == "index" } + end + + # Pick the best candidate (prefer exact match) + target_doc = + if candidates.any? + # prefer exact equality if present + exact = candidates.find { |d| d.path.sub(/\.md$/, "") == normalized_decoded } + exact || candidates.first + else + nil + end + + if target_doc && target_doc.topic_id + # Return a Discourse link preserving the anchor + "](/t/-/#{target_doc.topic_id}?silent=true#{anchor})" + else + if VERBOSE + puts "⚠️ rewrite_internal_links: unresolved '#{raw_link}' -> normalized='#{normalized_decoded}'" + # show up to 10 possible docs to help debugging + sample = docs.first(10).map(&:path).join(", ") + puts " sample docs: #{sample}" + end + # Return original match unchanged so the link doesn't become invalid text + match + end + end +end + +# Initialize Gemini client if API key is available +gemini_client = GEMINI_API_KEY ? GeminiClient.new(GEMINI_API_KEY) : nil + +if gemini_client + puts "✓ Gemini API configured for title generation" +else + puts "⚠ GEMINI_API_KEY not set - using fallback title generation" +end + +docs = [] + +puts "Reading local docs..." +BASE = "#{__dir__}/../../docs_sp/" + +# Generate index.md for each folder that doesn't have one +folders_needing_index = Set.new +Dir.glob("**/", base: BASE).each do |folder| + next if folder == "./" || folder.empty? + + folder_path = folder.chomp("/") + index_path = File.join(BASE, folder_path, "index.md") + + unless File.exist?(index_path) + folders_needing_index.add(folder_path) + + # Get the folder name (last component of the path) + folder_name = File.basename(folder_path) + + # Generate the index.md content + index_content = generate_folder_index(folder_name) + + # Write the index.md file + File.write(index_path, index_content) + puts "Generated index.md for folder: #{folder_path}" if VERBOSE + end +end + +puts "Generated #{folders_needing_index.size} index.md files" if folders_needing_index.any? + +Dir + .glob("**/*.md", base: BASE) + .each do |path| + next if path.end_with?("index.md") + next if path.include?("SAFETY") + + content = File.read(File.join(BASE, path)) + + frontmatter, content = Util.parse_md(content) + + # Convert MkDocs Material callouts to Obsidian format + content = convert_mkdocs_tabs_to_callouts(content) + content = convert_mkdocs_to_obsidian_callouts(content) + content = convert_material_icons_to_emojis(content) + + # Generate missing frontmatter fields dynamically + generated = generate_frontmatter_from_path(path, content, gemini_client) + + # Apply the generated values, ensuring ID is limited to 50 chars + frontmatter["id"] = generated["id"] + frontmatter["title"] ||= generated["title"] + frontmatter["short_title"] ||= generated["short_title"] + + puts "Generated frontmatter for '#{path}': id='#{frontmatter["id"]}', title='#{frontmatter["title"]}'" if VERBOSE + + docs.push(LocalDoc.new(frontmatter:, path:, content:)) + end + +puts "Rewriting internal links..." +docs.each do |doc| + doc.content = rewrite_internal_links(doc.content, docs) +end + +puts "Validating local docs..." +docs + .group_by { |doc| doc.external_id } + .each do |id, docs| + if docs.size > 1 + puts "- duplicate external_id '#{id}' found in:" + docs.each { |doc| puts "- #{doc.path}" } + exit 1 + end + end + +exit 0 if !DOCS_API_KEY + +puts "Fetching remote info via data-explorer..." +remote_topics = API.fetch_current_state + +puts "Mapping to existing topics..." +map_to_remote = + lambda do + docs.each do |doc| + puts "- checking '#{doc.external_id}'..." if VERBOSE + if topic_info = remote_topics.find { |t| t[:external_id] == doc.external_id } + doc.topic_id = topic_info[:topic_id] + doc.first_post_id = topic_info[:first_post_id] + doc.remote_title = topic_info[:title] + doc.remote_content = topic_info[:raw] + doc.remote_deleted = topic_info[:deleted_at] + puts " found topic_id: #{doc.topic_id}" if VERBOSE + else + puts " not found" if VERBOSE + end + end + end +map_to_remote.call + +puts "Deleting topics if necessary..." + +cat_desc_topic = remote_topics.find { |t| t[:is_index_topic] } +if cat_desc_topic.nil? + puts "Docs category is missing an index topic" + exit 1 +end +cat_desc_topic_id = cat_desc_topic[:topic_id] + +remote_topics + .reject { |remote_doc| remote_doc[:deleted_at] } + .reject { |remote_doc| docs.any? { |doc| doc.topic_id == remote_doc[:topic_id] } } + .reject { |remote_doc| remote_doc[:topic_id] == cat_desc_topic_id } + .each do |remote_doc| + id = remote_doc[:topic_id] + puts "- deleting topic #{id}..." + API.trash_topic(topic_id: id) + end + +puts "Restoring topics if necessary..." +docs + .filter(&:remote_deleted) + .each do |doc| + puts "- restoring '#{doc.external_id}'..." + API.restore_topic(topic_id: doc.topic_id) + end + +puts "Creating missing topics..." +created_any = false +docs.each do |doc| + next if doc.topic_id + + created_any = true + puts "- creating '#{doc.external_id} with title '#{doc.frontmatter["title"]}'..." + converted_content = convert_mkdocs_to_obsidian_callouts(doc.content_with_uploads) + API.create_topic( + external_id: doc.external_id, + raw: converted_content, + category: CATEGORY_ID, + title: doc.frontmatter["title"] + ) +rescue Faraday::UnprocessableEntityError => e + puts " 422 error: #{e.response[:body]}" + raise e +end + +if created_any + puts "Re-fetching remote info..." + remote_topics = API.fetch_current_state + map_to_remote.call +end + +puts "Updating content..." +docs.each do |doc| + if doc.topic_id.nil? + next if DRY_RUN + raise "Topic ID not found for '#{doc.external_id}'. Something went wrong with creating it?" + end + + # Convert callouts in the content before comparison and upload + converted_content = convert_mkdocs_to_obsidian_callouts(doc.content_with_uploads) + + if converted_content.strip == doc.remote_content.strip && + doc.frontmatter["title"] == doc.remote_title + puts "- no changes required for '#{doc.external_id}' (topic_id: #{doc.topic_id})" if VERBOSE + next + end + + puts "- updating '#{doc.external_id}' (topic_id: #{doc.topic_id})... new title: '#{doc.frontmatter["title"]}'" + API.edit_post( + post_id: doc.first_post_id, + raw: converted_content, + title: doc.frontmatter["title"], + category: CATEGORY_ID + ) +rescue Faraday::UnprocessableEntityError => e + puts " 422 error: #{e.response[:body]}" + raise e +end + +puts "Building index..." +_, index_content = Util.parse_md(File.read("#{BASE}index.md")) +index_content += "\n\n" +docs + .group_by { |doc| doc.section } + .each do |section, section_docs| + if section + section_frontmatter, _ = Util.parse_md(File.read("#{BASE}#{section}/index.md")) + index_content += "## #{section_frontmatter["title"]}\n\n" + end + + section_docs.each do |doc| + index_content += + "- #{doc.frontmatter["short_title"]}: [#{doc.frontmatter["title"]}](/t/-/#{doc.topic_id}?silent=true)\n" + end + index_content += "\n" + end + +index_post_info = remote_topics.find { |t| t[:topic_id] == cat_desc_topic_id } + +if index_post_info[:raw].strip == index_content.strip + puts "- no changes required for index" +else + puts "- updating index..." + API.edit_post(post_id: index_post_info[:first_post_id], raw: index_content) +end + +if WATCH + puts "Watching for changes to files..." + + Listen + .to("#{__dir__}/docs") do |modified, added, removed| + if added.size > 0 || removed.size > 0 + puts "Files added/removed. Restarting sync..." + exec("ruby", "#{__dir__}/sync_docs", *ARGV) + end + + modified.each do |path| + relative = path.sub(BASE, "") + doc = docs.find { |d| d.path == relative } + raise "Modified file not recognized: #{relative}" if doc.nil? + + print "- updating '#{doc.external_id}' (topic_id: #{doc.topic_id})..." + new_frontmatter, new_content = Util.parse_md(File.read(path)) + if %w[id short_title].any? { |key| doc.frontmatter[key] != new_frontmatter[key] } + puts "Frontmatter changed. Restarting sync..." + exec("ruby", "#{__dir__}/sync_docs", *ARGV) + end + doc.content, doc.frontmatter = new_content, new_frontmatter + + # Convert callouts before uploading + converted_content = convert_mkdocs_to_obsidian_callouts(doc.content_with_uploads) + + API.edit_post( + post_id: doc.first_post_id, + raw: converted_content, + title: doc.frontmatter["title"] + ) + puts " done" + end + end + .start + + sleep +else + puts "Done." +end \ No newline at end of file From f1ca81debf97153a0b531bfe6d774869af83bc77 Mon Sep 17 00:00:00 2001 From: Jason Wen Date: Wed, 15 Oct 2025 16:20:31 -0400 Subject: [PATCH 18/20] ui: chevron should always be on top of driving path (#1391) --- selfdrive/ui/sunnypilot/qt/onroad/model.cc | 34 ++++++++++++++-------- 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/selfdrive/ui/sunnypilot/qt/onroad/model.cc b/selfdrive/ui/sunnypilot/qt/onroad/model.cc index 086b703e10..590ade24a1 100644 --- a/selfdrive/ui/sunnypilot/qt/onroad/model.cc +++ b/selfdrive/ui/sunnypilot/qt/onroad/model.cc @@ -22,7 +22,6 @@ void ModelRendererSP::update_model(const cereal::ModelDataV2::Reader &model, con } void ModelRendererSP::draw(QPainter &painter, const QRect &surface_rect) { - ModelRenderer::draw(painter, surface_rect); auto *s = uiState(); auto &sm = *(s->sm); @@ -31,6 +30,11 @@ void ModelRendererSP::draw(QPainter &painter, const QRect &surface_rect) { return; } + clip_region = surface_rect.adjusted(-CLIP_MARGIN, -CLIP_MARGIN, CLIP_MARGIN, CLIP_MARGIN); + experimental_mode = sm["selfdriveState"].getSelfdriveState().getExperimentalMode(); + longitudinal_control = sm["carParams"].getCarParams().getOpenpilotLongitudinalControl(); + path_offset_z = sm["liveCalibration"].getLiveCalibration().getHeight()[0]; + painter.save(); const auto &model = sm["modelV2"].getModelV2(); @@ -41,22 +45,28 @@ void ModelRendererSP::draw(QPainter &painter, const QRect &surface_rect) { update_model(model, lead_one); drawLaneLines(painter); - bool blindspot = s->scene.blindspot_ui; - bool rainbow = s->scene.rainbow_mode; - - bool left_blindspot = car_state.getLeftBlindspot(); - bool right_blindspot = car_state.getRightBlindspot(); - - if (blindspot) { - drawBlindspot(painter, surface_rect, left_blindspot, right_blindspot); - } - - if (rainbow) { + if (s->scene.rainbow_mode) { drawRainbowPath(painter, surface_rect); } else { ModelRenderer::drawPath(painter, model, surface_rect.height()); } + if (longitudinal_control && sm.alive("radarState")) { + update_leads(radar_state, model.getPosition()); + const auto &lead_two = radar_state.getLeadTwo(); + if (lead_one.getStatus()) { + drawLead(painter, lead_one, lead_vertices[0], surface_rect); + } + if (lead_two.getStatus() && (std::abs(lead_one.getDRel() - lead_two.getDRel()) > 3.0)) { + drawLead(painter, lead_two, lead_vertices[1], surface_rect); + } + } + + if (s->scene.blindspot_ui) { + const bool left_blindspot = car_state.getLeftBlindspot(); + const bool right_blindspot = car_state.getRightBlindspot(); + drawBlindspot(painter, surface_rect, left_blindspot, right_blindspot); + } drawLeadStatus(painter, surface_rect.height(), surface_rect.width()); painter.restore(); From c438aeb5a5ce4d4e5c8aee5c68a5eaac08ce3afd Mon Sep 17 00:00:00 2001 From: Jason Wen Date: Wed, 15 Oct 2025 16:47:00 -0400 Subject: [PATCH 19/20] ui: check for updated message before updating states in HUD (#1392) --- selfdrive/ui/sunnypilot/qt/onroad/hud.cc | 105 ++++++++++++++--------- selfdrive/ui/sunnypilot/qt/onroad/hud.h | 2 +- 2 files changed, 64 insertions(+), 43 deletions(-) diff --git a/selfdrive/ui/sunnypilot/qt/onroad/hud.cc b/selfdrive/ui/sunnypilot/qt/onroad/hud.cc index fa57e2b334..d8176051fb 100644 --- a/selfdrive/ui/sunnypilot/qt/onroad/hud.cc +++ b/selfdrive/ui/sunnypilot/qt/onroad/hud.cc @@ -26,31 +26,54 @@ HudRendererSP::HudRendererSP() { void HudRendererSP::updateState(const UIState &s) { HudRenderer::updateState(s); + float speedConv = is_metric ? MS_TO_KPH : MS_TO_MPH; + devUiInfo = s.scene.dev_ui_info; + roadName = s.scene.road_name; + showTurnSignals = s.scene.turn_signals; + speedLimitMode = static_cast(s.scene.speed_limit_mode); + speedUnit = is_metric ? tr("km/h") : tr("mph"); + standstillTimer = s.scene.standstill_timer; + const SubMaster &sm = *(s.sm); const auto cs = sm["controlsState"].getControlsState(); const auto car_state = sm["carState"].getCarState(); const auto car_control = sm["carControl"].getCarControl(); const auto radar_state = sm["radarState"].getRadarState(); const auto is_gps_location_external = sm.rcv_frame("gpsLocationExternal") > 1; - const auto gpsLocation = is_gps_location_external ? sm["gpsLocationExternal"].getGpsLocationExternal() : sm["gpsLocation"].getGpsLocation(); + const char *gps_source = is_gps_location_external ? "gpsLocationExternal" : "gpsLocation"; + const auto gpsLocation = is_gps_location_external ? sm[gps_source].getGpsLocationExternal() : sm[gps_source].getGpsLocation(); const auto ltp = sm["liveTorqueParameters"].getLiveTorqueParameters(); const auto car_params = sm["carParams"].getCarParams(); const auto car_params_sp = sm["carParamsSP"].getCarParamsSP(); const auto lp_sp = sm["longitudinalPlanSP"].getLongitudinalPlanSP(); const auto lmd = sm["liveMapDataSP"].getLiveMapDataSP(); - float speedConv = is_metric ? MS_TO_KPH : MS_TO_MPH; - speedLimit = lp_sp.getSpeedLimit().getResolver().getSpeedLimit() * speedConv; - speedLimitLast = lp_sp.getSpeedLimit().getResolver().getSpeedLimitLast() * speedConv; - speedLimitOffset = lp_sp.getSpeedLimit().getResolver().getSpeedLimitOffset() * speedConv; - speedLimitValid = lp_sp.getSpeedLimit().getResolver().getSpeedLimitValid(); - speedLimitLastValid = lp_sp.getSpeedLimit().getResolver().getSpeedLimitLastValid(); - speedLimitFinalLast = lp_sp.getSpeedLimit().getResolver().getSpeedLimitFinalLast() * speedConv; - speedLimitSource = lp_sp.getSpeedLimit().getResolver().getSource(); - speedLimitMode = static_cast(s.scene.speed_limit_mode); - speedLimitAssistState = lp_sp.getSpeedLimit().getAssist().getState(); - speedLimitAssistActive = lp_sp.getSpeedLimit().getAssist().getActive(); - roadName = s.scene.road_name; + if (sm.updated("carParams")) { + steerControlType = car_params.getSteerControlType(); + } + + if (sm.updated("carParamsSP")) { + pcmCruiseSpeed = car_params_sp.getPcmCruiseSpeed(); + } + + if (sm.updated("longitudinalPlanSP")) { + speedLimit = lp_sp.getSpeedLimit().getResolver().getSpeedLimit() * speedConv; + speedLimitLast = lp_sp.getSpeedLimit().getResolver().getSpeedLimitLast() * speedConv; + speedLimitOffset = lp_sp.getSpeedLimit().getResolver().getSpeedLimitOffset() * speedConv; + speedLimitValid = lp_sp.getSpeedLimit().getResolver().getSpeedLimitValid(); + speedLimitLastValid = lp_sp.getSpeedLimit().getResolver().getSpeedLimitLastValid(); + speedLimitFinalLast = lp_sp.getSpeedLimit().getResolver().getSpeedLimitFinalLast() * speedConv; + speedLimitSource = lp_sp.getSpeedLimit().getResolver().getSource(); + speedLimitAssistState = lp_sp.getSpeedLimit().getAssist().getState(); + speedLimitAssistActive = lp_sp.getSpeedLimit().getAssist().getActive(); + smartCruiseControlVisionEnabled = lp_sp.getSmartCruiseControl().getVision().getEnabled(); + smartCruiseControlVisionActive = lp_sp.getSmartCruiseControl().getVision().getActive(); + smartCruiseControlMapEnabled = lp_sp.getSmartCruiseControl().getMap().getEnabled(); + smartCruiseControlMapActive = lp_sp.getSmartCruiseControl().getMap().getActive(); + greenLightAlert = lp_sp.getE2eAlerts().getGreenLightAlert(); + leadDepartAlert = lp_sp.getE2eAlerts().getLeadDepartAlert(); + } + if (sm.updated("liveMapDataSP")) { roadNameStr = QString::fromStdString(lmd.getRoadName()); speedLimitAheadValid = lmd.getSpeedLimitAheadValid(); @@ -66,7 +89,7 @@ void HudRendererSP::updateState(const UIState &s) { static int reverse_delay = 0; bool reverse_allowed = false; - if (int(car_state.getGearShifter()) != 4) { + if (car_state.getGearShifter() != cereal::CarState::GearShifter::REVERSE) { reverse_delay = 0; reverse_allowed = false; } else { @@ -78,46 +101,47 @@ void HudRendererSP::updateState(const UIState &s) { reversing = reverse_allowed; + if (sm.updated("liveParameters")) { + roll = sm["liveParameters"].getLiveParameters().getRoll(); + } + + if (sm.updated("deviceState")) { + memoryUsagePercent = sm["deviceState"].getDeviceState().getMemoryUsagePercent(); + } + + if (sm.updated(gps_source)) { + gpsAccuracy = is_gps_location_external ? gpsLocation.getHorizontalAccuracy() : 1.0; // External reports accuracy, internal does not. + altitude = gpsLocation.getAltitude(); + bearingAccuracyDeg = gpsLocation.getBearingAccuracyDeg(); + bearingDeg = gpsLocation.getBearingDeg(); + } + + if (sm.updated("liveTorqueParameters")) { + torquedUseParams = ltp.getUseParams(); + latAccelFactorFiltered = ltp.getLatAccelFactorFiltered(); + frictionCoefficientFiltered = ltp.getFrictionCoefficientFiltered(); + liveValid = ltp.getLiveValid(); + } + latActive = car_control.getLatActive(); + actuators = car_control.getActuators(); + longOverride = car_control.getCruiseControl().getOverride(); + carControlEnabled = car_control.getEnabled(); + steerOverride = car_state.getSteeringPressed(); - - devUiInfo = s.scene.dev_ui_info; - - speedUnit = is_metric ? tr("km/h") : tr("mph"); lead_d_rel = radar_state.getLeadOne().getDRel(); lead_v_rel = radar_state.getLeadOne().getVRel(); lead_status = radar_state.getLeadOne().getStatus(); - steerControlType = car_params.getSteerControlType(); - actuators = car_control.getActuators(); torqueLateral = steerControlType == cereal::CarParams::SteerControlType::TORQUE; angleSteers = car_state.getSteeringAngleDeg(); desiredCurvature = cs.getDesiredCurvature(); curvature = cs.getCurvature(); - roll = sm["liveParameters"].getLiveParameters().getRoll(); - memoryUsagePercent = sm["deviceState"].getDeviceState().getMemoryUsagePercent(); - gpsAccuracy = is_gps_location_external ? gpsLocation.getHorizontalAccuracy() : 1.0; // External reports accuracy, internal does not. - altitude = gpsLocation.getAltitude(); vEgo = car_state.getVEgo(); aEgo = car_state.getAEgo(); steeringTorqueEps = car_state.getSteeringTorqueEps(); - bearingAccuracyDeg = gpsLocation.getBearingAccuracyDeg(); - bearingDeg = gpsLocation.getBearingDeg(); - torquedUseParams = ltp.getUseParams(); - latAccelFactorFiltered = ltp.getLatAccelFactorFiltered(); - frictionCoefficientFiltered = ltp.getFrictionCoefficientFiltered(); - liveValid = ltp.getLiveValid(); - standstillTimer = s.scene.standstill_timer; isStandstill = car_state.getStandstill(); if (not s.scene.started) standstillElapsedTime = 0.0; - longOverride = car_control.getCruiseControl().getOverride(); - smartCruiseControlVisionEnabled = lp_sp.getSmartCruiseControl().getVision().getEnabled(); - smartCruiseControlVisionActive = lp_sp.getSmartCruiseControl().getVision().getActive(); - smartCruiseControlMapEnabled = lp_sp.getSmartCruiseControl().getMap().getEnabled(); - smartCruiseControlMapActive = lp_sp.getSmartCruiseControl().getMap().getActive(); - - greenLightAlert = lp_sp.getE2eAlerts().getGreenLightAlert(); - leadDepartAlert = lp_sp.getE2eAlerts().getLeadDepartAlert(); // override stock current speed values float v_ego = (v_ego_cluster_seen && !s.scene.trueVEgoUI) ? car_state.getVEgoCluster() : car_state.getVEgo(); @@ -128,11 +152,8 @@ void HudRendererSP::updateState(const UIState &s) { rightBlinkerOn = car_state.getRightBlinker(); leftBlindspot = car_state.getLeftBlindspot(); rightBlindspot = car_state.getRightBlindspot(); - showTurnSignals = s.scene.turn_signals; - carControlEnabled = car_control.getEnabled(); speedCluster = car_state.getCruiseState().getSpeedCluster() * speedConv; - pcmCruiseSpeed = car_params_sp.getPcmCruiseSpeed(); } void HudRendererSP::draw(QPainter &p, const QRect &surface_rect) { diff --git a/selfdrive/ui/sunnypilot/qt/onroad/hud.h b/selfdrive/ui/sunnypilot/qt/onroad/hud.h index f9135ee9ef..a32fdb28a7 100644 --- a/selfdrive/ui/sunnypilot/qt/onroad/hud.h +++ b/selfdrive/ui/sunnypilot/qt/onroad/hud.h @@ -121,5 +121,5 @@ private: bool carControlEnabled; float speedCluster = 0; int icbm_active_counter = 0; - bool pcmCruiseSpeed; + bool pcmCruiseSpeed = true; }; From 99bd9075d539b3f74af8610626a02d22ce53d931 Mon Sep 17 00:00:00 2001 From: Nayan Date: Wed, 15 Oct 2025 17:18:31 -0400 Subject: [PATCH 20/20] ui: Fix Onroad Screen-Off default param (#1389) Change defaults Co-authored-by: Jason Wen --- common/params_keys.h | 4 ++-- .../qt/offroad/settings/display/onroad_screen_brightness.cc | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/common/params_keys.h b/common/params_keys.h index ecb73a9456..dd58462a95 100644 --- a/common/params_keys.h +++ b/common/params_keys.h @@ -160,9 +160,9 @@ inline static std::unordered_map keys = { {"ModelRunnerTypeCache", {CLEAR_ON_ONROAD_TRANSITION, INT}}, {"OffroadMode", {CLEAR_ON_MANAGER_START, BOOL}}, {"Offroad_TiciSupport", {CLEAR_ON_MANAGER_START, JSON}}, - {"OnroadScreenOffBrightness", {PERSISTENT | BACKUP, INT, "100"}}, + {"OnroadScreenOffBrightness", {PERSISTENT | BACKUP, INT, "0"}}, {"OnroadScreenOffControl", {PERSISTENT | BACKUP, BOOL}}, - {"OnroadScreenOffTimer", {PERSISTENT | BACKUP, INT, "0"}}, + {"OnroadScreenOffTimer", {PERSISTENT | BACKUP, INT, "15"}}, {"OnroadUploads", {PERSISTENT | BACKUP, BOOL, "1"}}, {"QuickBootToggle", {PERSISTENT | BACKUP, BOOL, "0"}}, {"QuietMode", {PERSISTENT | BACKUP, BOOL, "0"}}, diff --git a/selfdrive/ui/sunnypilot/qt/offroad/settings/display/onroad_screen_brightness.cc b/selfdrive/ui/sunnypilot/qt/offroad/settings/display/onroad_screen_brightness.cc index f28d9e45e8..e29d06cb82 100644 --- a/selfdrive/ui/sunnypilot/qt/offroad/settings/display/onroad_screen_brightness.cc +++ b/selfdrive/ui/sunnypilot/qt/offroad/settings/display/onroad_screen_brightness.cc @@ -29,7 +29,7 @@ OnroadScreenBrightnessControl::OnroadScreenBrightnessControl(const QString ¶ "Onroad Brightness", "", "", - {0, 100}, 10, true); + {0, 90}, 10, true); connect(onroadScreenOffTimer, &OptionControlSP::updateLabels, this, &OnroadScreenBrightnessControl::refresh); connect(onroadScreenBrightness, &OptionControlSP::updateLabels, this, &OnroadScreenBrightnessControl::refresh);