From e8f65bc6520e2af5472b085fb17040a8eea6880d Mon Sep 17 00:00:00 2001 From: Jason Wen Date: Tue, 17 Feb 2026 13:17:03 -0500 Subject: [PATCH 1/6] Controls: Support for Torque Lateral Control v0 Tune (#1693) * init * no * more * tree it * final * comment * only with enforce torque * only with enforce torque * missed * no * lint * Apply suggestion from @sunnyhaibin * sunnylink metadata sync * Apply suggestion from @sunnyhaibin --------- Co-authored-by: nayan --- common/params_keys.h | 1 + selfdrive/controls/controlsd.py | 2 + .../steering_sub_layouts/torque_settings.py | 79 ++++++++++- .../selfdrive/controls/controlsd_ext.py | 12 ++ .../controls/lib/latcontrol_torque_v0.py | 128 ++++++++++++++++++ .../lib/latcontrol_torque_versions.json | 8 ++ sunnypilot/sunnylink/params_metadata.json | 18 +++ .../sunnylink/tests/test_params_sync.py | 82 +++++++++++ .../sunnylink/tools/update_params_metadata.py | 29 ++++ 9 files changed, 357 insertions(+), 2 deletions(-) create mode 100644 sunnypilot/selfdrive/controls/lib/latcontrol_torque_v0.py create mode 100644 sunnypilot/selfdrive/controls/lib/latcontrol_torque_versions.json diff --git a/common/params_keys.h b/common/params_keys.h index dba074226..6f27d9582 100644 --- a/common/params_keys.h +++ b/common/params_keys.h @@ -268,6 +268,7 @@ inline static std::unordered_map keys = { {"EnforceTorqueControl", {PERSISTENT | BACKUP, BOOL}}, {"LiveTorqueParamsToggle", {PERSISTENT | BACKUP , BOOL}}, {"LiveTorqueParamsRelaxedToggle", {PERSISTENT | BACKUP , BOOL}}, + {"TorqueControlTune", {PERSISTENT | BACKUP, FLOAT}}, {"TorqueParamsOverrideEnabled", {PERSISTENT | BACKUP, BOOL, "0"}}, {"TorqueParamsOverrideFriction", {PERSISTENT | BACKUP, FLOAT, "0.1"}}, {"TorqueParamsOverrideLatAccelFactor", {PERSISTENT | BACKUP, FLOAT, "2.5"}}, diff --git a/selfdrive/controls/controlsd.py b/selfdrive/controls/controlsd.py index 9d0e5c9f1..12b146e80 100755 --- a/selfdrive/controls/controlsd.py +++ b/selfdrive/controls/controlsd.py @@ -64,6 +64,8 @@ class Controls(ControlsExt): elif self.CP.lateralTuning.which() == 'torque': self.LaC = LatControlTorque(self.CP, self.CP_SP, self.CI, DT_CTRL) + self.LaC = ControlsExt.initialize_lateral_control(self, self.LaC, self.CI, DT_CTRL) + def update(self): self.sm.update(15) if self.sm.updated["liveCalibration"]: diff --git a/selfdrive/ui/sunnypilot/layouts/settings/steering_sub_layouts/torque_settings.py b/selfdrive/ui/sunnypilot/layouts/settings/steering_sub_layouts/torque_settings.py index dceee8b51..b4302e1ab 100644 --- a/selfdrive/ui/sunnypilot/layouts/settings/steering_sub_layouts/torque_settings.py +++ b/selfdrive/ui/sunnypilot/layouts/settings/steering_sub_layouts/torque_settings.py @@ -4,15 +4,24 @@ Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. This file is part of sunnypilot and is licensed under the MIT License. See the LICENSE.md file in the root directory for more details. """ +import json +import math +import os from collections.abc import Callable import pyray as rl +from openpilot.common.basedir import BASEDIR from openpilot.selfdrive.ui.ui_state import ui_state +from openpilot.system.ui.lib.application import gui_app, FontWeight from openpilot.system.ui.lib.multilang import tr -from openpilot.system.ui.sunnypilot.widgets.list_view import toggle_item_sp, option_item_sp +from openpilot.system.ui.sunnypilot.lib.utils import NoElideButtonAction +from openpilot.system.ui.sunnypilot.widgets.list_view import ListItemSP, toggle_item_sp, option_item_sp +from openpilot.system.ui.sunnypilot.widgets.tree_dialog import TreeOptionDialog, TreeFolder, TreeNode +from openpilot.system.ui.widgets import Widget, DialogResult from openpilot.system.ui.widgets.network import NavButton from openpilot.system.ui.widgets.scroller_tici import Scroller -from openpilot.system.ui.widgets import Widget + +TORQUE_VERSIONS_PATH = os.path.join(BASEDIR, "sunnypilot", "selfdrive", "controls", "lib", "latcontrol_torque_versions.json") class TorqueSettingsLayout(Widget): @@ -20,10 +29,23 @@ class TorqueSettingsLayout(Widget): super().__init__() self._back_button = NavButton(tr("Back")) self._back_button.set_click_callback(back_btn_callback) + self._torque_version_dialog: TreeOptionDialog | None = None + self.cached_torque_versions = {} + self._load_versions() items = self._initialize_items() self._scroller = Scroller(items, line_separator=True, spacing=0) + def _load_versions(self): + with open(TORQUE_VERSIONS_PATH) as f: + self.cached_torque_versions = json.load(f) + def _initialize_items(self): + self._torque_control_versions = ListItemSP( + title=tr("Torque Control Tune Version"), + description="Select the version of Torque Control Tune to use.", + action_item=NoElideButtonAction(tr("SELECT")), + callback=self._show_torque_version_dialog, + ) self._self_tune_toggle = toggle_item_sp( param="LiveTorqueParamsToggle", title=lambda: tr("Self-Tune"), @@ -73,6 +95,7 @@ class TorqueSettingsLayout(Widget): ) items = [ + self._torque_control_versions, self._self_tune_toggle, self._relaxed_tune_toggle, self._custom_tune_toggle, @@ -103,6 +126,7 @@ class TorqueSettingsLayout(Widget): title_text = tr("Real-Time & Offline") if ui_state.params.get("TorqueParamsOverrideEnabled") else tr("Offline Only") self._torque_lat_accel_factor.set_title(lambda: tr("Lateral Acceleration Factor") + " (" + title_text + ")") self._torque_friction.set_title(lambda: tr("Friction") + " (" + title_text + ")") + self._torque_control_versions.action_item.set_value(self._get_current_torque_version_label()) def _render(self, rect): self._back_button.set_position(self._rect.x, self._rect.y + 20) @@ -113,3 +137,54 @@ class TorqueSettingsLayout(Widget): def show_event(self): self._scroller.show_event() + + def _get_current_torque_version_label(self): + current_val_bytes = ui_state.params.get("TorqueControlTune") + if current_val_bytes is None: + return tr("Default") + + try: + current_val = float(current_val_bytes) + for label, info in self.cached_torque_versions.items(): + if math.isclose(float(info["version"]), current_val, rel_tol=1e-5): + return label + except (ValueError, KeyError): + pass + + return tr("Default") + + def _show_torque_version_dialog(self): + options_map = {} + for label, info in self.cached_torque_versions.items(): + try: + options_map[label] = float(info["version"]) + except (ValueError, KeyError): + pass + + # Sort options by label in descending order + sorted_labels = sorted(options_map.keys(), key=lambda k: options_map[k], reverse=True) + + nodes = [TreeNode(tr("Default"))] + for label in sorted_labels: + nodes.append(TreeNode(label)) + + folders = [TreeFolder("", nodes)] + + current_label = self._get_current_torque_version_label() + + def handle_selection(result: int): + if result == DialogResult.CONFIRM and self._torque_version_dialog: + selected_ref = self._torque_version_dialog.selection_ref + if selected_ref == tr("Default"): + ui_state.params.remove("TorqueControlTune") + elif selected_ref in options_map: + ui_state.params.put("TorqueControlTune", options_map[selected_ref]) + self._torque_version_dialog = None + + self._torque_version_dialog = TreeOptionDialog( + tr("Select Torque Control Tune Version"), + folders, + current_ref=current_label, + option_font_weight=FontWeight.UNIFONT, + ) + gui_app.set_modal_overlay(self._torque_version_dialog, callback=handle_selection) diff --git a/sunnypilot/selfdrive/controls/controlsd_ext.py b/sunnypilot/selfdrive/controls/controlsd_ext.py index 3f6053d15..3f6ef2b43 100644 --- a/sunnypilot/selfdrive/controls/controlsd_ext.py +++ b/sunnypilot/selfdrive/controls/controlsd_ext.py @@ -16,6 +16,7 @@ from openpilot.sunnypilot import PARAMS_UPDATE_PERIOD from openpilot.sunnypilot.livedelay.helpers import get_lat_delay from openpilot.sunnypilot.modeld.modeld_base import ModelStateBase from openpilot.sunnypilot.selfdrive.controls.lib.blinker_pause_lateral import BlinkerPauseLateral +from openpilot.sunnypilot.selfdrive.controls.lib.latcontrol_torque_v0 import LatControlTorque as LatControlTorqueV0 class ControlsExt(ModelStateBase): @@ -33,6 +34,17 @@ class ControlsExt(ModelStateBase): self.sm_services_ext = ['radarState', 'selfdriveStateSP'] self.pm_services_ext = ['carControlSP'] + def initialize_lateral_control(self, lac, CI, dt): + enforce_torque_control = self.params.get_bool("EnforceTorqueControl") + torque_versions = self.params.get("TorqueControlTune") + if not enforce_torque_control: + return lac + + if torque_versions == 0.0: # v0 + return LatControlTorqueV0(self.CP, self.CP_SP, CI, dt) + else: + return lac + def get_params_sp(self, sm: messaging.SubMaster) -> None: if time.monotonic() - self._param_update_time > PARAMS_UPDATE_PERIOD: self.blinker_pause_lateral.get_params() diff --git a/sunnypilot/selfdrive/controls/lib/latcontrol_torque_v0.py b/sunnypilot/selfdrive/controls/lib/latcontrol_torque_v0.py new file mode 100644 index 000000000..f7874cc53 --- /dev/null +++ b/sunnypilot/selfdrive/controls/lib/latcontrol_torque_v0.py @@ -0,0 +1,128 @@ +import math +import numpy as np +from collections import deque + +from cereal import log +from opendbc.car.lateral import get_friction +from openpilot.common.constants import ACCELERATION_DUE_TO_GRAVITY +from openpilot.common.filter_simple import FirstOrderFilter +from openpilot.selfdrive.controls.lib.latcontrol import LatControl +from openpilot.common.pid import PIDController + +from openpilot.sunnypilot.selfdrive.controls.lib.latcontrol_torque_ext import LatControlTorqueExt + +# At higher speeds (25+mph) we can assume: +# Lateral acceleration achieved by a specific car correlates to +# torque applied to the steering rack. It does not correlate to +# wheel slip, or to speed. + +# This controller applies torque to achieve desired lateral +# accelerations. To compensate for the low speed effects the +# proportional gain is increased at low speeds by the PID controller. +# Additionally, there is friction in the steering wheel that needs +# to be overcome to move it at all, this is compensated for too. + +KP = 1.0 +KI = 0.3 +KD = 0.0 +INTERP_SPEEDS = [1, 1.5, 2.0, 3.0, 5, 7.5, 10, 15, 30] +KP_INTERP = [250, 120, 65, 30, 11.5, 5.5, 3.5, 2.0, KP] + +LP_FILTER_CUTOFF_HZ = 1.2 +LAT_ACCEL_REQUEST_BUFFER_SECONDS = 1.0 +FRICTION_THRESHOLD = 0.3 +VERSION = 0 + + +class LatControlTorque(LatControl): + def __init__(self, CP, CP_SP, CI, dt): + super().__init__(CP, CP_SP, CI, dt) + self.torque_params = CP.lateralTuning.torque.as_builder() + self.torque_from_lateral_accel = CI.torque_from_lateral_accel() + self.lateral_accel_from_torque = CI.lateral_accel_from_torque() + self.pid = PIDController([INTERP_SPEEDS, KP_INTERP], KI, KD, rate=1/self.dt) + self.update_limits() + self.steering_angle_deadzone_deg = self.torque_params.steeringAngleDeadzoneDeg + self.lat_accel_request_buffer_len = int(LAT_ACCEL_REQUEST_BUFFER_SECONDS / self.dt) + self.lat_accel_request_buffer = deque([0.] * self.lat_accel_request_buffer_len , maxlen=self.lat_accel_request_buffer_len) + self.previous_measurement = 0.0 + self.measurement_rate_filter = FirstOrderFilter(0.0, 1 / (2 * np.pi * LP_FILTER_CUTOFF_HZ), self.dt) + + self.extension = LatControlTorqueExt(self, CP, CP_SP, CI) + + def update_live_torque_params(self, latAccelFactor, latAccelOffset, friction): + self.torque_params.latAccelFactor = latAccelFactor + self.torque_params.latAccelOffset = latAccelOffset + self.torque_params.friction = friction + self.update_limits() + + def update_limits(self): + self.pid.set_limits(self.lateral_accel_from_torque(self.steer_max, self.torque_params), + self.lateral_accel_from_torque(-self.steer_max, self.torque_params)) + + def update(self, active, CS, VM, params, steer_limited_by_safety, desired_curvature, calibrated_pose, curvature_limited, lat_delay): + # Override torque params from extension + if self.extension.update_override_torque_params(self.torque_params): + self.update_limits() + + pid_log = log.ControlsState.LateralTorqueState.new_message() + pid_log.version = VERSION + if not active: + output_torque = 0.0 + pid_log.active = False + else: + measured_curvature = -VM.calc_curvature(math.radians(CS.steeringAngleDeg - params.angleOffsetDeg), CS.vEgo, params.roll) + roll_compensation = params.roll * ACCELERATION_DUE_TO_GRAVITY + curvature_deadzone = abs(VM.calc_curvature(math.radians(self.steering_angle_deadzone_deg), CS.vEgo, 0.0)) + lateral_accel_deadzone = curvature_deadzone * CS.vEgo ** 2 + + delay_frames = int(np.clip(lat_delay / self.dt, 1, self.lat_accel_request_buffer_len)) + expected_lateral_accel = self.lat_accel_request_buffer[-delay_frames] + # TODO factor out lateral jerk from error to later replace it with delay independent alternative + future_desired_lateral_accel = desired_curvature * CS.vEgo ** 2 + self.lat_accel_request_buffer.append(future_desired_lateral_accel) + gravity_adjusted_future_lateral_accel = future_desired_lateral_accel - roll_compensation + desired_lateral_jerk = (future_desired_lateral_accel - expected_lateral_accel) / lat_delay + + measurement = measured_curvature * CS.vEgo ** 2 + measurement_rate = self.measurement_rate_filter.update((measurement - self.previous_measurement) / self.dt) + self.previous_measurement = measurement + + setpoint = lat_delay * desired_lateral_jerk + expected_lateral_accel + error = setpoint - measurement + + # do error correction in lateral acceleration space, convert at end to handle non-linear torque responses correctly + pid_log.error = float(error) + ff = gravity_adjusted_future_lateral_accel + # latAccelOffset corrects roll compensation bias from device roll misalignment relative to car roll + ff -= self.torque_params.latAccelOffset + # TODO jerk is weighted by lat_delay for legacy reasons, but should be made independent of it + ff += get_friction(error, lateral_accel_deadzone, FRICTION_THRESHOLD, self.torque_params) + + freeze_integrator = steer_limited_by_safety or CS.steeringPressed or CS.vEgo < 5 + output_lataccel = self.pid.update(pid_log.error, + -measurement_rate, + feedforward=ff, + speed=CS.vEgo, + freeze_integrator=freeze_integrator) + output_torque = self.torque_from_lateral_accel(output_lataccel, self.torque_params) + + # Lateral acceleration torque controller extension updates + # Overrides pid_log.error and output_torque + pid_log, output_torque = self.extension.update(CS, VM, self.pid, params, ff, pid_log, setpoint, measurement, calibrated_pose, roll_compensation, + future_desired_lateral_accel, measurement, lateral_accel_deadzone, gravity_adjusted_future_lateral_accel, + desired_curvature, measured_curvature, steer_limited_by_safety, output_torque) + + pid_log.active = True + pid_log.p = float(self.pid.p) + pid_log.i = float(self.pid.i) + pid_log.d = float(self.pid.d) + pid_log.f = float(self.pid.f) + pid_log.output = float(-output_torque) # TODO: log lat accel? + pid_log.actualLateralAccel = float(measurement) + pid_log.desiredLateralAccel = float(setpoint) + pid_log.desiredLateralJerk = float(desired_lateral_jerk) + pid_log.saturated = bool(self._check_saturation(self.steer_max - abs(output_torque) < 1e-3, CS, steer_limited_by_safety, curvature_limited)) + + # TODO left is positive in this convention + return -output_torque, 0.0, pid_log diff --git a/sunnypilot/selfdrive/controls/lib/latcontrol_torque_versions.json b/sunnypilot/selfdrive/controls/lib/latcontrol_torque_versions.json new file mode 100644 index 000000000..21b5884a0 --- /dev/null +++ b/sunnypilot/selfdrive/controls/lib/latcontrol_torque_versions.json @@ -0,0 +1,8 @@ +{ + "v1.0": { + "version": "1.0" + }, + "v0.0": { + "version": "0.0" + } +} diff --git a/sunnypilot/sunnylink/params_metadata.json b/sunnypilot/sunnylink/params_metadata.json index ad94ad35f..67ce903ab 100644 --- a/sunnypilot/sunnylink/params_metadata.json +++ b/sunnypilot/sunnylink/params_metadata.json @@ -1247,6 +1247,24 @@ "title": "[TIZI/TICI only] Steering Arc", "description": "Display steering arc on the driving screen when lateral control is enabled." }, + "TorqueControlTune": { + "title": "Torque Control Tune Version", + "description": "Select the version of Torque Control Tune to use.", + "options": [ + { + "value": "", + "label": "Default" + }, + { + "value": 1.0, + "label": "v1.0" + }, + { + "value": 0.0, + "label": "v0.0" + } + ] + }, "TorqueParamsOverrideEnabled": { "title": "Manual Real-Time Tuning", "description": "" diff --git a/sunnypilot/sunnylink/tests/test_params_sync.py b/sunnypilot/sunnylink/tests/test_params_sync.py index 26bdca42d..05114de49 100644 --- a/sunnypilot/sunnylink/tests/test_params_sync.py +++ b/sunnypilot/sunnylink/tests/test_params_sync.py @@ -200,3 +200,85 @@ def test_known_params_metadata(): assert acc_long["min"] == 1 assert acc_long["max"] == 10 assert acc_long["step"] == 1 + + +def test_torque_control_tune_versions_in_sync(): + """ + Test that TorqueControlTune options in params_metadata.json match versions in latcontrol_torque_versions.json. + + Why: + The TorqueControlTune dropdown in the UI should always reflect the available torque tune versions. + If versions are added/removed from latcontrol_torque_versions.json, the metadata must be updated accordingly. + + Expected: + - TorqueControlTune should have a 'Default' option with empty string value + - All versions from latcontrol_torque_versions.json should be present in the options + - The version values and labels should match between both files + """ + from openpilot.common.basedir import BASEDIR + + versions_json_path = os.path.join(BASEDIR, "sunnypilot", "selfdrive", "controls", "lib", "latcontrol_torque_versions.json") + sync_script_path = "python3 sunnypilot/sunnylink/tools/sync_torque_versions.py" + + # Load both files + with open(METADATA_PATH) as f: + metadata = json.load(f) + + with open(versions_json_path) as f: + versions = json.load(f) + + # Get TorqueControlTune metadata + torque_tune = metadata.get("TorqueControlTune") + if torque_tune is None: + pytest.fail(f"TorqueControlTune not found in params_metadata.json. Please run '{sync_script_path}' to sync.") + + if "options" not in torque_tune: + pytest.fail(f"TorqueControlTune must have options. Please run '{sync_script_path}' to sync.") + + options = torque_tune["options"] + if not isinstance(options, list): + pytest.fail(f"TorqueControlTune options must be a list. Please run '{sync_script_path}' to sync.") + + if len(options) == 0: + pytest.fail(f"TorqueControlTune must have at least one option. Please run '{sync_script_path}' to sync.") + + # Check that Default option exists + default_option = next((opt for opt in options if opt.get("value") == ""), None) + if default_option is None: + pytest.fail(f"TorqueControlTune must have a 'Default' option with empty string value. Please run '{sync_script_path}' to sync.") + + if default_option.get("label") != "Default": + pytest.fail(f"Default option must have label 'Default'. Please run '{sync_script_path}' to sync.") + + # Build expected options from versions.json + expected_version_keys = set(versions.keys()) + actual_version_keys = set() + + for option in options: + if option.get("value") == "": + continue # Skip the default option + + label = option.get("label") + value = option.get("value") + + # Check that this option corresponds to a version + if label not in versions: + pytest.fail(f"Option label '{label}' not found in latcontrol_torque_versions.json. Please run '{sync_script_path}' to sync.") + + # Check that the value matches the version number + expected_value = float(versions[label]["version"]) + if value != expected_value: + pytest.fail(f"Option '{label}' has value {value}, expected {expected_value}. Please run '{sync_script_path}' to sync.") + + actual_version_keys.add(label) + + # Check that all versions are represented + missing_versions = expected_version_keys - actual_version_keys + if missing_versions: + pytest.fail(f"The following versions are missing from TorqueControlTune options: {missing_versions}. " + + f"Please run '{sync_script_path}' to sync.") + + extra_versions = actual_version_keys - expected_version_keys + if extra_versions: + pytest.fail("The following versions in TorqueControlTune options are not in latcontrol_torque_versions.json: " + + f"{extra_versions}. Please run '{sync_script_path}' to sync.") diff --git a/sunnypilot/sunnylink/tools/update_params_metadata.py b/sunnypilot/sunnylink/tools/update_params_metadata.py index ac2ef556e..3ef91debe 100755 --- a/sunnypilot/sunnylink/tools/update_params_metadata.py +++ b/sunnypilot/sunnylink/tools/update_params_metadata.py @@ -8,9 +8,11 @@ See the LICENSE.md file in the root directory for more details. import json import os +from openpilot.common.basedir import BASEDIR from openpilot.common.params import Params METADATA_PATH = os.path.join(os.path.dirname(__file__), "../params_metadata.json") +TORQUE_VERSIONS_JSON = os.path.join(BASEDIR, "sunnypilot", "selfdrive", "controls", "lib", "latcontrol_torque_versions.json") def main(): @@ -51,6 +53,33 @@ def main(): print(f"Updated {METADATA_PATH}") + # update torque versions param + update_torque_versions_param() + +def update_torque_versions_param(): + with open(TORQUE_VERSIONS_JSON) as f: + current_versions = json.load(f) + + try: + with open(METADATA_PATH) as f: + params_metadata = json.load(f) + + options = [{"value": "", "label": "Default"}] + for version_key, version_data in current_versions.items(): + version_value = float(version_data["version"]) + options.append({"value": version_value, "label": str(version_key)}) + + if "TorqueControlTune" in params_metadata: + params_metadata["TorqueControlTune"]["options"] = options + + with open(METADATA_PATH, 'w') as f: + json.dump(params_metadata, f, indent=2) + f.write('\n') + + print(f"Updated TorqueControlTune options in params_metadata.json with {len(options)} options: \n{options}") + + except Exception as e: + print(f"Failed to update TorqueControlTune versions in params_metadata.json: {e}") if __name__ == "__main__": main() From c07ddcefb09f221aa878b8f99224808e8fb621ae Mon Sep 17 00:00:00 2001 From: Jason Wen Date: Tue, 17 Feb 2026 19:48:37 -0500 Subject: [PATCH 2/6] version: bump to 2026.001.000 --- sunnypilot/common/version.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sunnypilot/common/version.h b/sunnypilot/common/version.h index 6833a508d..1c7fcd2e6 100644 --- a/sunnypilot/common/version.h +++ b/sunnypilot/common/version.h @@ -1 +1 @@ -#define SUNNYPILOT_VERSION "2025.003.000" +#define SUNNYPILOT_VERSION "2026.001.000" From 80e23509ba349c8b1b1492150257a21d4bfaf9ec Mon Sep 17 00:00:00 2001 From: Jason Wen Date: Tue, 17 Feb 2026 22:33:28 -0500 Subject: [PATCH 3/6] Update CHANGELOG.md --- CHANGELOG.md | 100 ++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 99 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 61bc81baf..d22324bcf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,103 @@ -sunnypilot Version 2025.003.000 (20xx-xx-xx) +sunnypilot Version 2026.001.000 (2026-03-xx) ======================== +* What's Changed (sunnypilot/sunnypilot) + * Complete rewrite of the user interface from Qt C++ to Raylib Python + * comma four support + * ui: sunnypilot toggle style by @nayan8teen + * ui: fix scroll panel mouse wheel behavior by @nayan8teen + * ui: sunnypilot panels by @nayan8teen + * sunnylink: centralize key pair handling in sunnylink registration by @devtekve + * ui: reimplement sunnypilot branding with Raylib by @sunnyhaibin + * ui: Platform Selector by @Discountchubbs + * ui: vehicle brand settings by @Discountchubbs + * ui: sunnylink client-side implementation by @nayan8teen + * ui: `NetworkUISP` by @Discountchubbs + * ui: add sunnypilot font by @nayan8teen + * ui: sunnypilot sponsor tier color mapping by @sunnyhaibin + * ui: sunnylink panel by @nayan8teen + * ui: Models panel by @Discountchubbs + * ui: software panel by @Discountchubbs + * modeld_v2: support planplus outputs by @Discountchubbs + * ui: OSM panel by @Discountchubbs + * ui: Developer panel extension by @Discountchubbs + * sunnylink: Vehicle Selector support by @sunnyhaibin + * [TIZI/TICI] ui: Developer Metrics by @rav4kumar + * [comma 4] ui: sunnylink panel by @nayan8teen + * ui: lateral-only and longitudinal-only UI statuses support by @royjr + * sunnylink: elliptic curve keys support and improve key path handling by @nayan8teen + * sunnylink: block remote modification of SSH key parameters by @zikeji + * [TIZI/TICI] ui: rainbow path by @rav4kumar + * [TIZI/TICI] ui: chevron metrics by @rav4kumar + * ui: include MADS enabled state to `engaged` check by @sunnyhaibin + * Toyota: Enforce Factory Longitudinal Control by @sunnyhaibin + * ui: fix malformed dongle ID display on the PC if dongleID is not set by @dzid26 + * SL: Re enable and validate ingestion of swaglogs by @devtekve + * modeld_v2: planplus model tuning by @Discountchubbs + * ui: fix Always Offroad button visibility by @nayan8teen + * Reimplement sunnypilot Terms of Service & sunnylink Consent Screens by @sunnyhaibin + * [TIZI/TICI] ui: update dmoji position and Developer UI adjustments by @rav4kumar + * modeld: configurable camera offset by @Discountchubbs + * [TIZI/TICI] ui: sunnylink status on sidebar by @Copilot + * ui: Global Brightness Override by @nayan8teen + * ui: Customizable Interactive Timeout by @sunnyhaibin + * sunnylink: add units to param metadata by @nayan8teen + * ui: Customizable Onroad Brightness by @sunnyhaibin + * [TIZI/TICI] ui: Steering panel by @nayan8teen + * [TIZI/TICI] ui: Rocket Fuel by @rav4kumar + * [TIZI/TICI] ui: MICI style turn signals by @rav4kumar + * [TIZI/TICI] ui: MICI style blindspot indicators by @sunnyhaibin + * [MICI] ui: display blindspot indicators when available by @rav4kumar + * [TIZI/TICI] ui: Road Name by @rav4kumar + * [TIZI/TICI] ui: Blue "Exit Always Offroad" button by @dzid26 + * [TIZI/TICI] ui: Speed Limit by @rav4kumar + * Reapply "latcontrol_torque: lower kp and lower friction threshold (commaai/openpilot#36619)" by @sunnyhaibin + * [TIZI/TICI] ui: steering arc by @royjr + * [TIZI/TICI] ui: Smart Cruise Control elements by @sunnyhaibin + * [TIZI/TICI] ui: Green Light and Lead Departure elements by @sunnyhaibin + * [TIZI/TICI] ui: standstill timer by @sunnyhaibin + * [MICI] ui: driving models selector by @Discountchubbs + * [TIZI/TICI] ui: Hide vEgo and True vEgo by @sunnyhaibin + * [TIZI/TICI] ui: Visuals panel by @nayan8teen + * Device: Retain QuickBoot state after op switch by @nayan8teen + * [TIZI/TICI] ui: Trips panel by @sunnyhaibin + * [TIZI/TICI] ui: dynamic ICBM status by @sunnyhaibin + * [TIZI/TICI] ui: Cruise panel by @sunnyhaibin + * ui: better wake mode support by @nayan8teen + * Pause Lateral Control with Blinker: Post-Blinker Delay by @CHaucke89 + * SCC-V: Use p97 for predicted lateral accel by @yasu-oh + * Controls: Support for Torque Lateral Control v0 Tune by @sunnyhaibin +* What's Changed (sunnypilot/opendbc) + * Honda: DBC for Accord 9th Generation by @mvl-boston + * FCA: update tire stiffness values for `RAM_HD` by @dparring + * Honda: Nidec hybrid baseline brake support by @mvl-boston + * Subaru Global Gen2: bump steering limits and update tuning by @sunnyhaibin + * Toyota: Enforce Stock Longitudinal Control by @rav4kumar + * Nissan: use MADS enabled status for LKAS HUD logic by @downquark7 + * Reapply "Lateral: lower friction threshold (#2915)" (#378) by @sunnyhaibin + * HKG: add KIA_FORTE_2019_NON_SCC fingerprint by @royjr + * Nissan: Parse cruise control buttons by @downquark7 + * Rivian: Add stalk down ACC behavior to match stock Rivian by @lukasloetkolben + * Tesla: remove `TESLA_MODEL_X` from `dashcamOnly` by @ssysm + * Hyundai Longitudinal: refactor tuning by @Discountchubbs + * Tesla: add fingerprint for Model 3 Performance HW4 by @sunnyhaibin + * Toyota: do not disable radar when smartDSU or CAN Filter detected by @sunnyhaibin + * Honda: add missing `GasInterceptor` messages to Taiwan Odyssey DBC by @mvl-boston + * GM: remove `CHEVROLET_EQUINOX_NON_ACC_3RD_GEN` from `dashcamOnly` by @sunnyhaibin +* New Contributors (sunnypilot/sunnypilot) + * @TheSecurityDev made their first contribution in "ui: fix sidebar scroll in UI screenshots" + * @zikeji made their first contribution in "sunnylink: block remote modification of SSH key parameters" + * @Candy0707 made their first contribution in "[TIZI/TICI] ui: Fix misaligned turn signals and blindspot indicators with sidebar" + * @CHaucke89 made their first contribution in "Pause Lateral Control with Blinker: Post-Blinker Delay" + * @yasu-oh made their first contribution in "SCC-V: Use p97 for predicted lateral accel" +* New Contributors (sunnypilot/opendbc) + * @AmyJeanes made their first contribution in "Tesla: Fix stock LKAS being blocked when MADS is enabled" + * @mvl-boston made their first contribution in "Honda: Update Clarity brake to renamed DBC message name" + * @dzid26 made their first contribution in "Tesla: Parse speed limit from CAN" + * @firestar5683 made their first contribution in "GM: Non-ACC platforms with steering only support" + * @downquark7 made their first contribution in "Nissan: use MADS enabled status for LKAS HUD logic" + * @royjr made their first contribution in "HKG: add KIA_FORTE_2019_NON_SCC fingerprint" + * @ssysm made their first contribution in "Tesla: remove `TESLA_MODEL_X` from `dashcamOnly`" +* Full Changelog: https://github.com/sunnypilot/sunnypilot/compare/v2025.002.000...v2026.001.000 sunnypilot Version 2025.002.000 (2025-11-06) ======================== From 57b461a186d838aeb2041c7947d94f7a8e9784ae Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 18 Feb 2026 01:32:49 -0500 Subject: [PATCH 4/6] [bot] Update Python packages (#1704) * Update Python packages * Update CHANGELOG.md --------- Co-authored-by: github-actions[bot] Co-authored-by: Jason Wen --- CHANGELOG.md | 1 + opendbc_repo | 2 +- uv.lock | 15 +++++++-------- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d22324bcf..8ad026b12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -83,6 +83,7 @@ sunnypilot Version 2026.001.000 (2026-03-xx) * Toyota: do not disable radar when smartDSU or CAN Filter detected by @sunnyhaibin * Honda: add missing `GasInterceptor` messages to Taiwan Odyssey DBC by @mvl-boston * GM: remove `CHEVROLET_EQUINOX_NON_ACC_3RD_GEN` from `dashcamOnly` by @sunnyhaibin + * GM: remove `CHEVROLET_BOLT_NON_ACC_2ND_GEN` from `dashcamOnly` by @sunnyhaibin * New Contributors (sunnypilot/sunnypilot) * @TheSecurityDev made their first contribution in "ui: fix sidebar scroll in UI screenshots" * @zikeji made their first contribution in "sunnylink: block remote modification of SSH key parameters" diff --git a/opendbc_repo b/opendbc_repo index 828957709..a996ed701 160000 --- a/opendbc_repo +++ b/opendbc_repo @@ -1 +1 @@ -Subproject commit 8289577096cf303e3f8c53a86d0e7355651b53b5 +Subproject commit a996ed701bd0efe0ebcefa057373ba426dfb3a11 diff --git a/uv.lock b/uv.lock index 782c20849..a6b43b6fe 100644 --- a/uv.lock +++ b/uv.lock @@ -356,13 +356,12 @@ wheels = [ [[package]] name = "dearpygui" -version = "2.1.1" +version = "2.2" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/79/41/2146e8d03d28b5a66d5282beb26ffd9ab68a729a29d31e2fe91809271bf5/dearpygui-2.1.1-cp312-cp312-macosx_10_6_x86_64.whl", hash = "sha256:238aea7b4be7376f564dae8edd563b280ec1483a03786022969938507691e017", size = 2101529, upload-time = "2025-11-14T14:47:39.646Z" }, - { url = "https://files.pythonhosted.org/packages/b0/c5/fcc37ef834fe225241aa4f18d77aaa2903134f283077978d65a901c624c6/dearpygui-2.1.1-cp312-cp312-macosx_13_0_arm64.whl", hash = "sha256:c27ca6ecd4913555b717f3bb341c0b6a27d6c9fdc9932f0b3c31ae2ef893ae35", size = 1895555, upload-time = "2025-11-14T14:47:48.149Z" }, - { url = "https://files.pythonhosted.org/packages/74/66/19f454ba02d5f03a847cc1dfee4a849cd2307d97add5ba26fecdca318adb/dearpygui-2.1.1-cp312-cp312-manylinux1_x86_64.whl", hash = "sha256:8c071e9c165d89217bdcdaf769c6069252fcaee50bf369489add524107932273", size = 2641509, upload-time = "2025-11-14T14:47:54.581Z" }, - { url = "https://files.pythonhosted.org/packages/5e/58/d01538556103d544a5a5b4cbcb00646ff92d8a97f0a6283a56bede4307c8/dearpygui-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:9f2291313d2035f8a4108e13f60d8c1a0e7c19af7554a7739a3fd15b3d5af8f7", size = 1808971, upload-time = "2025-11-14T14:47:28.15Z" }, + { url = "https://files.pythonhosted.org/packages/17/c8/b4afdac89c7bf458513366af3143f7383d7b09721637989c95788d93e24c/dearpygui-2.2-cp312-cp312-macosx_13_0_arm64.whl", hash = "sha256:34ceae1ca1b65444e49012d6851312e44f08713da1b8cc0150cf41f1c207af9c", size = 1931443, upload-time = "2026-02-17T14:21:54.394Z" }, + { url = "https://files.pythonhosted.org/packages/43/93/a2d083b2e0edb095be815662cc41e40cf9ea7b65d6323e47bb30df7eb284/dearpygui-2.2-cp312-cp312-manylinux1_x86_64.whl", hash = "sha256:e1fae9ae59fec0e41773df64c80311a6ba67696219dde5506a2a4c013e8bcdfa", size = 2592645, upload-time = "2026-02-17T14:22:02.869Z" }, + { url = "https://files.pythonhosted.org/packages/80/ba/eae13acaad479f522db853e8b1ccd695a7bc8da2b9685c1d70a3b318df89/dearpygui-2.2-cp312-cp312-win_amd64.whl", hash = "sha256:7d399543b5a26ab6426ef3bbd776e55520b491b3e169647bde5e6b2de3701b35", size = 1830531, upload-time = "2026-02-17T14:21:43.386Z" }, ] [[package]] @@ -4089,15 +4088,15 @@ wheels = [ [[package]] name = "sentry-sdk" -version = "2.52.0" +version = "2.53.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/59/eb/1b497650eb564701f9a7b8a95c51b2abe9347ed2c0b290ba78f027ebe4ea/sentry_sdk-2.52.0.tar.gz", hash = "sha256:fa0bec872cfec0302970b2996825723d67390cdd5f0229fb9efed93bd5384899", size = 410273, upload-time = "2026-02-04T15:03:54.706Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/06/66c8b705179bc54087845f28fd1b72f83751b6e9a195628e2e9af9926505/sentry_sdk-2.53.0.tar.gz", hash = "sha256:6520ef2c4acd823f28efc55e43eb6ce2e6d9f954a95a3aa96b6fd14871e92b77", size = 412369, upload-time = "2026-02-16T11:11:14.743Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ca/63/2c6daf59d86b1c30600bff679d039f57fd1932af82c43c0bde1cbc55e8d4/sentry_sdk-2.52.0-py2.py3-none-any.whl", hash = "sha256:931c8f86169fc6f2752cb5c4e6480f0d516112e78750c312e081ababecbaf2ed", size = 435547, upload-time = "2026-02-04T15:03:51.567Z" }, + { url = "https://files.pythonhosted.org/packages/47/d4/2fdf854bc3b9c7f55219678f812600a20a138af2dd847d99004994eada8f/sentry_sdk-2.53.0-py2.py3-none-any.whl", hash = "sha256:46e1ed8d84355ae54406c924f6b290c3d61f4048625989a723fd622aab838899", size = 437908, upload-time = "2026-02-16T11:11:13.227Z" }, ] [[package]] From 5eed9490c6ed92895d1c67636574b5b4854b2395 Mon Sep 17 00:00:00 2001 From: Jason Wen Date: Wed, 18 Feb 2026 02:02:34 -0500 Subject: [PATCH 5/6] ci: remove double prebuilt workflow runs --- .github/workflows/sunnypilot-master-dev-prep.yaml | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/.github/workflows/sunnypilot-master-dev-prep.yaml b/.github/workflows/sunnypilot-master-dev-prep.yaml index 8e8e3c3d9..36cfc2e7e 100644 --- a/.github/workflows/sunnypilot-master-dev-prep.yaml +++ b/.github/workflows/sunnypilot-master-dev-prep.yaml @@ -242,9 +242,4 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Trigger prebuilt workflow - if: success() && steps.push-changes.outputs.has_changes == 'true' - run: | - gh workflow run sunnypilot-build-prebuilt.yaml --ref "${{ inputs.target_branch || env.DEFAULT_TARGET_BRANCH }}" - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + From 3a74f8c568ab429633c87761b9ab00a2808c4b23 Mon Sep 17 00:00:00 2001 From: Jason Wen Date: Wed, 18 Feb 2026 02:57:25 -0500 Subject: [PATCH 6/6] [TIZI/TICI] ui: ensure null checks for `CarParams` and `CarParamsSP` (#1706) * [TIZI/TICI] ui: ensure null checks for `CarParams` and `CarParamsSP` * space --- .github/workflows/sunnypilot-master-dev-prep.yaml | 2 -- selfdrive/ui/sunnypilot/layouts/settings/models.py | 2 +- .../settings/steering_sub_layouts/lane_change_settings.py | 2 +- .../layouts/settings/steering_sub_layouts/mads_settings.py | 4 ++-- selfdrive/ui/sunnypilot/layouts/settings/vehicle/__init__.py | 2 +- .../ui/sunnypilot/layouts/settings/vehicle/brands/hyundai.py | 2 +- .../ui/sunnypilot/layouts/settings/vehicle/brands/subaru.py | 2 +- .../sunnypilot/layouts/settings/vehicle/platform_selector.py | 2 +- 8 files changed, 8 insertions(+), 10 deletions(-) diff --git a/.github/workflows/sunnypilot-master-dev-prep.yaml b/.github/workflows/sunnypilot-master-dev-prep.yaml index 36cfc2e7e..1cb574764 100644 --- a/.github/workflows/sunnypilot-master-dev-prep.yaml +++ b/.github/workflows/sunnypilot-master-dev-prep.yaml @@ -241,5 +241,3 @@ jobs: gh run watch "$RUN_ID" env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - diff --git a/selfdrive/ui/sunnypilot/layouts/settings/models.py b/selfdrive/ui/sunnypilot/layouts/settings/models.py index 5820b34cc..4fb579a0f 100644 --- a/selfdrive/ui/sunnypilot/layouts/settings/models.py +++ b/selfdrive/ui/sunnypilot/layouts/settings/models.py @@ -99,7 +99,7 @@ class ModelsLayout(Widget): "Keeping this on provides the stock openpilot experience.") if lagd_toggle: desc += f"
{tr('Live Steer Delay:')} {ui_state.sm['liveDelay'].lateralDelay:.3f} s" - elif ui_state.CP: + elif ui_state.CP is not None: sw = float(ui_state.params.get("LagdToggleDelay", "0.2")) cp = ui_state.CP.steerActuatorDelay desc += f"
{tr('Actuator Delay:')} {cp:.2f} s + {tr('Software Delay:')} {sw:.2f} s = {tr('Total Delay:')} {cp + sw:.2f} s" diff --git a/selfdrive/ui/sunnypilot/layouts/settings/steering_sub_layouts/lane_change_settings.py b/selfdrive/ui/sunnypilot/layouts/settings/steering_sub_layouts/lane_change_settings.py index 9a5d5202a..fbb9ce7cf 100644 --- a/selfdrive/ui/sunnypilot/layouts/settings/steering_sub_layouts/lane_change_settings.py +++ b/selfdrive/ui/sunnypilot/layouts/settings/steering_sub_layouts/lane_change_settings.py @@ -75,7 +75,7 @@ class LaneChangeSettingsLayout(Widget): self._scroller.show_event() def _update_toggles(self): - enable_bsm = ui_state.CP and ui_state.CP.enableBsm + enable_bsm = ui_state.CP is not None and ui_state.CP.enableBsm if not enable_bsm and ui_state.params.get_bool("AutoLaneChangeBsmDelay"): ui_state.params.remove("AutoLaneChangeBsmDelay") self._bsm_delay.action_item.set_enabled(enable_bsm and ui_state.params.get("AutoLaneChangeTimer", return_default=True) > AutoLaneChangeMode.NUDGE) diff --git a/selfdrive/ui/sunnypilot/layouts/settings/steering_sub_layouts/mads_settings.py b/selfdrive/ui/sunnypilot/layouts/settings/steering_sub_layouts/mads_settings.py index 3542adf28..098fcf8ce 100644 --- a/selfdrive/ui/sunnypilot/layouts/settings/steering_sub_layouts/mads_settings.py +++ b/selfdrive/ui/sunnypilot/layouts/settings/steering_sub_layouts/mads_settings.py @@ -91,12 +91,12 @@ class MadsSettingsLayout(Widget): if bundle: brand = bundle.get("brand", "") if not brand: - brand = ui_state.CP.brand if ui_state.CP else "" + brand = ui_state.CP.brand if ui_state.CP is not None else "" if brand == "rivian": return True elif brand == "tesla": - return not (ui_state.CP_SP and ui_state.CP_SP.flags & TeslaFlagsSP.HAS_VEHICLE_BUS) + return not (ui_state.CP_SP is not None and ui_state.CP_SP.flags & TeslaFlagsSP.HAS_VEHICLE_BUS) return False def _update_steering_mode_description(self, button_index: int): diff --git a/selfdrive/ui/sunnypilot/layouts/settings/vehicle/__init__.py b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/__init__.py index 0dd12d76f..d8fdd61e7 100644 --- a/selfdrive/ui/sunnypilot/layouts/settings/vehicle/__init__.py +++ b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/__init__.py @@ -35,7 +35,7 @@ class VehicleLayout(Widget): def get_brand(): if bundle := ui_state.params.get("CarPlatformBundle"): return bundle.get("brand", "") - elif ui_state.CP and ui_state.CP.carFingerprint != "MOCK": + elif ui_state.CP is not None and ui_state.CP.carFingerprint != "MOCK": return ui_state.CP.brand return "" diff --git a/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/hyundai.py b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/hyundai.py index f6849eb20..351dacbbc 100644 --- a/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/hyundai.py +++ b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/hyundai.py @@ -32,7 +32,7 @@ class HyundaiSettings(BrandSettings): if bundle: platform = bundle.get("platform") self.alpha_long_available = CAR[platform] not in (UNSUPPORTED_LONGITUDINAL_CAR | CANFD_UNSUPPORTED_LONGITUDINAL_CAR) - elif ui_state.CP: + elif ui_state.CP is not None: self.alpha_long_available = ui_state.CP.alphaLongitudinalAvailable tuning_param = int(ui_state.params.get("HyundaiLongitudinalTuning") or "0") diff --git a/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/subaru.py b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/subaru.py index 66e7ec1d5..5ef53a0e4 100644 --- a/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/subaru.py +++ b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/subaru.py @@ -39,7 +39,7 @@ class SubaruSettings(BrandSettings): platform = bundle.get("platform") config = CAR[platform].config self.has_stop_and_go = not (config.flags & (SubaruFlags.GLOBAL_GEN2 | SubaruFlags.HYBRID)) - elif ui_state.CP: + elif ui_state.CP is not None: self.has_stop_and_go = not (ui_state.CP.flags & (SubaruFlags.GLOBAL_GEN2 | SubaruFlags.HYBRID)) disabled_msg = self.stop_and_go_disabled_msg() diff --git a/selfdrive/ui/sunnypilot/layouts/settings/vehicle/platform_selector.py b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/platform_selector.py index db9059527..517a7d962 100644 --- a/selfdrive/ui/sunnypilot/layouts/settings/vehicle/platform_selector.py +++ b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/platform_selector.py @@ -131,7 +131,7 @@ class PlatformSelector(Button): self._platform = bundle.get("name", "") self.set_text(self._platform) self.color = style.BLUE - elif ui_state.CP and ui_state.CP.carFingerprint != "MOCK": + elif ui_state.CP is not None and ui_state.CP.carFingerprint != "MOCK": self._platform = ui_state.CP.carFingerprint self.set_text(self._platform) self.color = style.GREEN