diff --git a/selfdrive/ui/layouts/settings/starpilot/lateral.py b/selfdrive/ui/layouts/settings/starpilot/lateral.py index 319968ab9..6fe1265f8 100644 --- a/selfdrive/ui/layouts/settings/starpilot/lateral.py +++ b/selfdrive/ui/layouts/settings/starpilot/lateral.py @@ -1,26 +1,31 @@ from __future__ import annotations +import pyray as rl + from openpilot.system.hardware import HARDWARE from openpilot.selfdrive.ui.lib.starpilot_state import starpilot_state from openpilot.system.ui.lib.application import gui_app -from openpilot.system.ui.lib.multilang import tr, tr_noop +from openpilot.system.ui.lib.multilang import tr from openpilot.system.ui.widgets import DialogResult from openpilot.system.ui.widgets.confirm_dialog import ConfirmDialog from openpilot.selfdrive.ui.layouts.settings.starpilot.panel import _SettingsPage from openpilot.selfdrive.ui.layouts.settings.starpilot.aethergrid import ( - SettingRow, SettingSection, AetherSettingsView, COMPACT_PANEL_METRICS, -) -from openpilot.selfdrive.ui.layouts.settings.starpilot.aethergrid import ( - AETHER_LIST_METRICS, + AetherListMetrics, + AetherListColors, AetherSliderDialog, DEFAULT_PANEL_STYLE, + PanelManagerView, + TileGrid, + ToggleTile, + draw_list_group_shell, + draw_section_header, + draw_selection_list_row, + draw_settings_panel_header, + _with_alpha, ) -PANEL_STYLE = DEFAULT_PANEL_STYLE - - def _confirm_reboot_toggle(params, key, state): params.put_bool(key, state) from openpilot.selfdrive.ui.ui_state import ui_state @@ -31,242 +36,543 @@ def _confirm_reboot_toggle(params, key, state): )) -# ═══════════════════════════════════════════════════════════════ -# StarPilotAdvancedLateralLayout -# ═══════════════════════════════════════════════════════════════ +CUSTOM_METRICS = AetherListMetrics( + max_content_width=1560, + outer_margin_x=18, + outer_margin_y=10, + panel_padding_x=16, + panel_padding_top=16, + panel_padding_bottom=12, + header_height=164, + section_gap=12, + section_header_height=28, + section_header_gap=8, + row_height=104, + utility_row_height=88, +) -class StarPilotAdvancedLateralLayout(_SettingsPage): - def __init__(self): +SECTION_GAP = CUSTOM_METRICS.section_gap +SECTION_HEADER_HEIGHT = CUSTOM_METRICS.section_header_height +SECTION_HEADER_GAP = CUSTOM_METRICS.section_header_gap +ROW_HEIGHT = CUSTOM_METRICS.row_height +PANEL_STYLE = DEFAULT_PANEL_STYLE + +_LATERAL_TUNE_KEYS = ["TurnDesires", "NNFF", "NNFFLite", "ForceTorqueController"] +_ADVANCED_LATERAL_KEYS = ["ForceAutoTune", "ForceAutoTuneOff"] + + +def _ensure(params, key): + if not params.get_bool(key): + params.put_bool(key, True) + + +def _sync_parent(params, parent_key, child_keys): + if any(params.get_bool(k) for k in child_keys): + if not params.get_bool(parent_key): + params.put_bool(parent_key, True) + else: + if params.get_bool(parent_key): + params.put_bool(parent_key, False) + + +class SteeringManagerView(PanelManagerView): + METRICS = CUSTOM_METRICS + + def __init__(self, controller: "StarPilotLateralLayout"): super().__init__() - self._build_view() + self._controller = controller + self._shell_rect = rl.Rectangle(0, 0, 0, 0) - def _advanced_enabled(self): - return self._params.get_bool("AdvancedLateralTune") + self._toggle_grid = TileGrid(columns=2, padding=12, min_tile_width=100) + self._toggle_grid.set_touch_valid_callback(lambda: self._scroll_panel.is_touch_valid()) + self._child(self._toggle_grid) - def _using_nnff(self): - return starpilot_state.car_state.hasNNFFLog and self._params.get_bool("LateralTune") and self._params.get_bool("NNFF") + def show_event(self): + super().show_event() + starpilot_state.update(force=True) + self._rebuild_toggle_grid() - def _forcing_auto_tune(self): - return not starpilot_state.car_state.hasAutoTune and self._params.get_bool("ForceAutoTune") + def _on_frame_created(self, frame) -> None: + self._shell_rect = frame.shell - def _forcing_auto_tune_off(self): - return starpilot_state.car_state.hasAutoTune and self._params.get_bool("ForceAutoTuneOff") + def _activate_target(self, target_id: str | None): + if not target_id: + return + prefix, _, value = target_id.partition(":") + if prefix == "select": + self._controller._on_select(value) - def _forcing_torque_controller(self): - return not starpilot_state.car_state.isAngleCar and self._params.get_bool("ForceTorqueController") + def _draw_header(self, rect: rl.Rectangle): + draw_settings_panel_header(rect, tr("Steering"), + tr("Fine-tune lateral control, lane changes, and steering behavior."), + subtitle_size=22) - def _torque_tuning_active(self): - return starpilot_state.car_state.isTorqueCar or self._forcing_torque_controller() or self._using_nnff() + def _build_toggle_defs(self) -> list[dict]: + p = self._controller._params + cs = starpilot_state.car_state + toggles: list[dict] = [] - def _manual_tuning_values_enabled(self): - if starpilot_state.car_state.hasAutoTune: - return self._forcing_auto_tune_off() - return not self._forcing_auto_tune() + toggles.append({ + "key": "AdvancedLateralTune", + "title": tr("Advanced Lateral Tuning"), + "subtitle": tr("Advanced steering control changes to fine-tune how openpilot drives."), + "get": lambda: p.get_bool("AdvancedLateralTune"), + "set": lambda s: p.put_bool("AdvancedLateralTune", s), + }) - def _build_view(self): - adv = self._advanced_enabled - torque = self._torque_tuning_active - manual = self._manual_tuning_values_enabled - nnff = self._using_nnff + toggles.append({ + "key": "AlwaysOnLateral", + "title": tr("Always On Lateral"), + "subtitle": tr("Keep lateral control active even without openpilot engaged."), + "get": lambda: p.get_bool("AlwaysOnLateral"), + "set": lambda s: (_confirm_reboot_toggle(p, "AlwaysOnLateral", s) + if s else p.put_bool("AlwaysOnLateral", False)), + }) - sections = [ - SettingSection(tr_noop("Steering Tuning"), [ - SettingRow("SteerDelay", "value", tr_noop("Actuator Delay"), - subtitle=tr_noop("The time between openpilot's steering command and the vehicle's response."), - get_value=lambda: f"{self._params.get_float('SteerDelay'):.2f}s", - on_click=lambda: self._show_slider("SteerDelay", 0.01, 1.0, step=0.01, unit="s", value_type="float"), - visible=lambda: adv() and starpilot_state.car_state.steerActuatorDelay != 0), - SettingRow("SteerFriction", "value", tr_noop("Friction"), - subtitle=tr_noop("Compensates for steering friction around center."), - get_value=lambda: f"{self._params.get_float('SteerFriction'):.3f}", - on_click=lambda: self._show_slider("SteerFriction", 0.0, max(1.0, starpilot_state.car_state.friction * 1.5), step=0.01, value_type="float"), - visible=lambda: adv() and starpilot_state.car_state.friction != 0 and torque() and not nnff() and manual()), - SettingRow("SteerKP", "value", tr_noop("Kp Factor"), - subtitle=tr_noop("How strongly openpilot corrects lateral position."), - get_value=lambda: f"{self._params.get_float('SteerKP'):.2f}", - on_click=lambda: self._show_slider("SteerKP", max(0.01, starpilot_state.car_state.steerKp) * 0.5, max(0.01, starpilot_state.car_state.steerKp) * 1.5, step=0.01, value_type="float"), - visible=lambda: adv() and starpilot_state.car_state.steerKp != 0 and torque() and not starpilot_state.car_state.isAngleCar), - SettingRow("SteerLatAccel", "value", tr_noop("Lateral Acceleration"), - subtitle=tr_noop("Maps steering torque to turning response."), - get_value=lambda: f"{self._params.get_float('SteerLatAccel'):.2f}", - on_click=lambda: self._show_slider("SteerLatAccel", max(0.01, starpilot_state.car_state.latAccelFactor) * 0.5, max(0.01, starpilot_state.car_state.latAccelFactor) * 1.5, step=0.01, value_type="float"), - visible=lambda: adv() and starpilot_state.car_state.latAccelFactor != 0 and torque() and not nnff() and manual()), - SettingRow("SteerRatio", "value", tr_noop("Steer Ratio"), - subtitle=tr_noop("Adjust the relationship between steering wheel input and road-wheel angle."), - get_value=lambda: f"{self._params.get_float('SteerRatio'):.2f}", - on_click=lambda: self._show_slider("SteerRatio", max(0.01, starpilot_state.car_state.steerRatio) * 0.5, max(0.01, starpilot_state.car_state.steerRatio) * 1.5, step=0.01, value_type="float"), - visible=lambda: adv() and starpilot_state.car_state.steerRatio != 0 and manual()), - SettingRow("ForceAutoTune", "toggle", tr_noop("Force Auto-Tune On"), - subtitle=tr_noop("Force-enable live auto-tuning for friction and lateral acceleration."), - get_state=lambda: self._params.get_bool("ForceAutoTune"), - set_state=lambda s: (self._params.put_bool("ForceAutoTune", s), s and self._params.put_bool("ForceAutoTuneOff", False)), - visible=lambda: adv() and not starpilot_state.car_state.hasAutoTune and not starpilot_state.car_state.isAngleCar and torque()), - SettingRow("ForceAutoTuneOff", "toggle", tr_noop("Force Auto-Tune Off"), - subtitle=tr_noop("Force-disable live auto-tuning and use your set values instead."), - get_state=lambda: self._params.get_bool("ForceAutoTuneOff"), - set_state=lambda s: (self._params.put_bool("ForceAutoTuneOff", s), s and self._params.put_bool("ForceAutoTune", False)), - visible=lambda: adv() and starpilot_state.car_state.hasAutoTune and not starpilot_state.car_state.isAngleCar), - SettingRow("ForceTorqueController", "toggle", tr_noop("Force Torque Controller"), - subtitle=tr_noop("Use torque-based steering control instead of the stock steering mode when supported."), - get_state=lambda: self._params.get_bool("ForceTorqueController"), - set_state=lambda s: _confirm_reboot_toggle(self._params, "ForceTorqueController", s), - visible=lambda: adv() and not starpilot_state.car_state.isAngleCar and not starpilot_state.car_state.isTorqueCar), - ]), - ] - self._manager_view = AetherSettingsView( - self, sections, - header_title=tr_noop("Advanced Lateral Tuning"), - header_subtitle=tr_noop("Adjust steering response, torque controller behavior, and auto-tuning controls."), - panel_style=PANEL_STYLE, - metrics=COMPACT_PANEL_METRICS, + toggles.append({ + "key": "LaneChanges", + "title": tr("Lane Changes"), + "subtitle": tr("Allow openpilot to change lanes."), + "get": lambda: p.get_bool("LaneChanges"), + "set": lambda s: p.put_bool("LaneChanges", s), + }) + + toggles.append({ + "key": "PauseLateralOnSignal", + "title": tr("Turn Signal Only"), + "subtitle": tr("Only pause steering below the set speed when the turn signal is active."), + "get": lambda: p.get_bool("PauseLateralOnSignal"), + "set": lambda s: p.put_bool("PauseLateralOnSignal", s), + }) + + toggles.append({ + "key": "NudgelessLaneChange", + "title": tr("Auto Lane Changes"), + "subtitle": tr("When the turn signal is on, openpilot will automatically change lanes without a nudge."), + "get": lambda: p.get_bool("NudgelessLaneChange"), + "set": lambda s: p.put_bool("NudgelessLaneChange", s), + }) + + toggles.append({ + "key": "OneLaneChange", + "title": tr("One Per Signal"), + "subtitle": tr("Limit automatic lane changes to one per turn-signal activation."), + "get": lambda: p.get_bool("OneLaneChange"), + "set": lambda s: p.put_bool("OneLaneChange", s), + }) + + toggles.append({ + "key": "TurnDesires", + "title": tr("Force Turn Desires"), + "subtitle": tr("While driving below the minimum lane change speed with an active turn signal, instruct openpilot to turn."), + "get": lambda: p.get_bool("TurnDesires"), + "set": lambda s: (p.put_bool("TurnDesires", s), + _sync_parent(p, "LateralTune", _LATERAL_TUNE_KEYS)), + }) + + toggles.append({ + "key": "NavDesiresAllowed", + "title": tr("Use Route Desires"), + "subtitle": tr("Allow an active navigation route to request keep-left, keep-right, and low-speed turn desires."), + "get": lambda: p.get_bool("NavDesiresAllowed"), + "set": lambda s: p.put_bool("NavDesiresAllowed", s), + }) + + toggles.append({ + "key": "NNFF", + "title": tr("NNFF"), + "subtitle": tr("Neural Network FeedForward controller — uses a trained model to predict steering torque."), + "get": lambda: p.get_bool("NNFF"), + "set": lambda s: (p.put_bool("NNFF", s), + s and p.put_bool("NNFFLite", False), + _sync_parent(p, "LateralTune", _LATERAL_TUNE_KEYS)), + "enabled": cs.hasNNFFLog and not cs.isAngleCar, + "disabled_label": tr("Not Available"), + }) + + toggles.append({ + "key": "NNFFLite", + "title": tr("NNFF Lite"), + "subtitle": tr("Lightweight NNFF steering logic when the full model is off."), + "get": lambda: p.get_bool("NNFFLite"), + "set": lambda s: (p.put_bool("NNFFLite", s), + _sync_parent(p, "LateralTune", _LATERAL_TUNE_KEYS)), + "enabled": not cs.isAngleCar, + "disabled_label": tr("Not Available"), + }) + + toggles.append({ + "key": "ForceAutoTune", + "title": tr("Force Auto-Tune On"), + "subtitle": tr("Force-enable live auto-tuning for friction and lateral acceleration."), + "get": lambda: p.get_bool("ForceAutoTune"), + "set": lambda s: (p.put_bool("ForceAutoTune", s), + s and p.put_bool("ForceAutoTuneOff", False), + _sync_parent(p, "AdvancedLateralTune", _ADVANCED_LATERAL_KEYS)), + "enabled": not cs.hasAutoTune and cs.isTorqueCar and not cs.isAngleCar, + "disabled_label": tr("Not Available"), + }) + + toggles.append({ + "key": "ForceAutoTuneOff", + "title": tr("Force Auto-Tune Off"), + "subtitle": tr("Force-disable live auto-tuning and use your set values instead."), + "get": lambda: p.get_bool("ForceAutoTuneOff"), + "set": lambda s: (p.put_bool("ForceAutoTuneOff", s), + s and p.put_bool("ForceAutoTune", False), + _sync_parent(p, "AdvancedLateralTune", _ADVANCED_LATERAL_KEYS)), + "enabled": cs.hasAutoTune and cs.isTorqueCar and not cs.isAngleCar, + "disabled_label": tr("Not Available"), + }) + + toggles.append({ + "key": "ForceTorqueController", + "title": tr("Force Torque Ctrl"), + "subtitle": tr("Use torque-based steering control instead of angle-based for smoother lane keeping, especially in curves."), + "get": lambda: p.get_bool("ForceTorqueController"), + "set": lambda s: (p.put_bool("ForceTorqueController", s), + _sync_parent(p, "LateralTune", _LATERAL_TUNE_KEYS)), + "enabled": not cs.isTorqueCar and not cs.isAngleCar, + "disabled_label": tr("Not Available"), + }) + + return toggles + + def _rebuild_toggle_grid(self): + self._toggle_grid.clear() + for td in self._build_toggle_defs(): + self._toggle_grid.add_tile(ToggleTile( + title=td["title"], + get_state=td["get"], + set_state=td["set"], + bg_color=PANEL_STYLE.accent, + desc=td.get("subtitle", ""), + is_enabled=td.get("enabled", True), + disabled_label=td.get("disabled_label", ""), + )) + + def _measure_content_height(self, width: float) -> float: + sections = self._build_left_sections() + left_h = self._stacked_section_height([s["height"] for s in sections if s.get("visible", True)]) + + tiles_height = 0.0 + if self._toggle_grid.tiles: + N = len(self._toggle_grid.tiles) + gap = self._toggle_grid.gap + if self._uses_two_columns(width): + cols = 2 + tile_rows = (N + cols - 1) // cols + tile_gaps = gap * (tile_rows - 1) if tile_rows > 0 else 0 + tiles_content_h = tile_rows * 130 + tile_gaps + tiles_height = self._section_block_height(tiles_content_h + 24) + else: + cols = 3 + tile_rows = (N + cols - 1) // cols + tile_gaps = gap * (tile_rows - 1) if tile_rows > 0 else 0 + tiles_content_h = tile_rows * 130 + tile_gaps + tiles_height = SECTION_GAP + self._section_block_height(tiles_content_h + 24) + + if self._uses_two_columns(width): + vh = self._scroll_rect.height if self._scroll_rect and self._scroll_rect.height > 0 else tiles_height + return max(left_h, vh) + return left_h + tiles_height + + def _draw_scroll_content(self, rect: rl.Rectangle, width: float): + self._interactive_rects.clear() + y = rect.y + self._scroll_offset + self._draw_panel_content(y, rect.x, width) + + def _draw_panel_content(self, y: float, x: float, width: float): + sections = self._build_left_sections() + + drawn_first = False + + if self._uses_two_columns(width): + column_w = self._column_width(width) + curr_y = y + + for section in sections: + if not section.get("rows"): + continue + if drawn_first: + curr_y += SECTION_GAP + drawn_first = True + draw_section_header(rl.Rectangle(x, curr_y, column_w, SECTION_HEADER_HEIGHT), + section["title"], style=PANEL_STYLE) + curr_y += SECTION_HEADER_HEIGHT + SECTION_HEADER_GAP + row_count = len(section["rows"]) + container_rect = rl.Rectangle(x, curr_y, column_w, row_count * ROW_HEIGHT) + draw_list_group_shell(container_rect, style=PANEL_STYLE) + for index, row in enumerate(section["rows"]): + row_rect = rl.Rectangle(x, curr_y + index * ROW_HEIGHT, column_w, ROW_HEIGHT) + self._draw_row(row_rect, row, is_last=index == row_count - 1) + curr_y += row_count * ROW_HEIGHT + + if self._toggle_grid.tiles: + rx = x + column_w + self.COLUMN_GAP + draw_section_header(rl.Rectangle(rx, y, column_w, SECTION_HEADER_HEIGHT), + tr("Toggles"), style=PANEL_STYLE) + right_container_y = y + SECTION_HEADER_HEIGHT + SECTION_HEADER_GAP + + N = len(self._toggle_grid.tiles) + cols = 2 + self._toggle_grid._columns = cols + gap = self._toggle_grid.gap + tile_rows = (N + cols - 1) // cols + tile_gaps = gap * (tile_rows - 1) if tile_rows > 0 else 0 + tiles_content_h = tile_rows * 130 + tile_gaps + + needed_height = tiles_content_h + 24 + viewport_remaining = (self._scroll_rect.y + self._scroll_rect.height) - right_container_y + container_h = max(needed_height, viewport_remaining) + + draw_list_group_shell(rl.Rectangle(rx, right_container_y, column_w, container_h), style=PANEL_STYLE) + self._toggle_grid.set_parent_rect(self._scroll_rect) + self._toggle_grid.render(rl.Rectangle(rx + 12, right_container_y + 12, column_w - 24, container_h - 24)) + else: + curr_y = y + for section in sections: + if not section.get("rows"): + continue + if drawn_first: + curr_y += SECTION_GAP + drawn_first = True + draw_section_header(rl.Rectangle(x, curr_y, width, SECTION_HEADER_HEIGHT), + section["title"], style=PANEL_STYLE) + curr_y += SECTION_HEADER_HEIGHT + SECTION_HEADER_GAP + row_count = len(section["rows"]) + container_rect = rl.Rectangle(x, curr_y, width, row_count * ROW_HEIGHT) + draw_list_group_shell(container_rect, style=PANEL_STYLE) + for index, row in enumerate(section["rows"]): + row_rect = rl.Rectangle(x, curr_y + index * ROW_HEIGHT, width, ROW_HEIGHT) + self._draw_row(row_rect, row, is_last=index == row_count - 1) + curr_y += row_count * ROW_HEIGHT + + if self._toggle_grid.tiles: + curr_y += SECTION_GAP + draw_section_header(rl.Rectangle(x, curr_y, width, SECTION_HEADER_HEIGHT), + tr("Toggles"), style=PANEL_STYLE) + curr_y += SECTION_HEADER_HEIGHT + SECTION_HEADER_GAP + + N = len(self._toggle_grid.tiles) + cols = 3 + self._toggle_grid._columns = cols + gap = self._toggle_grid.gap + avail_w = width - 24 + tile_rows = (N + cols - 1) // cols + tile_gaps = gap * (tile_rows - 1) if tile_rows > 0 else 0 + tiles_content_h = tile_rows * 130 + tile_gaps + + draw_list_group_shell(rl.Rectangle(x, curr_y, width, tiles_content_h + 24), style=PANEL_STYLE) + self._toggle_grid.set_parent_rect(self._scroll_rect) + self._toggle_grid.render(rl.Rectangle(x + 12, curr_y + 12, avail_w, tiles_content_h)) + + def _draw_row(self, rect: rl.Rectangle, row: dict, is_last: bool): + target_id = row["target_id"] + is_enabled = row.get("is_enabled", True) + hovered, pressed = self._interactive_state(target_id, rect) if is_enabled else (False, False) + + draw_selection_list_row( + rect, title=row["title"], subtitle=row.get("subtitle", ""), + action_text=row["get_value"](), hovered=hovered and is_enabled, + pressed=pressed and is_enabled, is_last=is_last, + action_width=188, action_pill=True, + action_pill_width=row.get("pill_width", 108), action_pill_height=44, + title_size=34, subtitle_size=22, action_text_size=18, + row_separator=PANEL_STYLE.divider_color, + action_fill=PANEL_STYLE.current_fill if is_enabled else _with_alpha(PANEL_STYLE.current_fill, 120), + action_border=PANEL_STYLE.current_border if is_enabled else _with_alpha(PANEL_STYLE.current_border, 100), + action_text_color=AetherListColors.HEADER if is_enabled else AetherListColors.MUTED, ) + def _build_left_sections(self) -> list[dict]: + p = self._controller._params + cs = starpilot_state.car_state + sections: list[dict] = [] + + alt = p.get_bool("AdvancedLateralTune") + aol = p.get_bool("AlwaysOnLateral") + lc = p.get_bool("LaneChanges") + nlc = p.get_bool("NudgelessLaneChange") + pos = p.get_bool("PauseLateralOnSignal") + + if alt: + rows: list[dict] = [] + if cs.steerActuatorDelay != 0: + rows.append({ + "target_id": "select:SteerDelay", + "title": tr("Actuator Delay"), + "subtitle": tr("The time between openpilot's steering command and the vehicle's response."), + "get_value": lambda: f"{p.get_float('SteerDelay'):.2f}s", + "pill_width": 120, + }) + if cs.friction != 0: + rows.append({ + "target_id": "select:SteerFriction", + "title": tr("Friction"), + "subtitle": tr("Compensates for steering friction around center."), + "get_value": lambda: f"{p.get_float('SteerFriction'):.3f}", + "pill_width": 120, + }) + if cs.steerKp != 0: + rows.append({ + "target_id": "select:SteerKP", + "title": tr("Kp Factor"), + "subtitle": tr("How strongly openpilot corrects lateral position."), + "get_value": lambda: f"{p.get_float('SteerKP'):.2f}", + "pill_width": 120, + }) + if cs.latAccelFactor != 0: + rows.append({ + "target_id": "select:SteerLatAccel", + "title": tr("Lateral Acceleration"), + "subtitle": tr("Maps steering torque to turning response."), + "get_value": lambda: f"{p.get_float('SteerLatAccel'):.2f}", + "pill_width": 120, + }) + if cs.steerRatio != 0: + rows.append({ + "target_id": "select:SteerRatio", + "title": tr("Steer Ratio"), + "subtitle": tr("Adjust the relationship between steering wheel input and road-wheel angle."), + "get_value": lambda: f"{p.get_float('SteerRatio'):.2f}", + "pill_width": 120, + }) + sections.append({ + "title": tr("Advanced Tuning"), + "rows": rows, + "visible": True, + "height": self._section_block_height(self._section_height(len(rows), ROW_HEIGHT)), + }) + + if aol: + aol_rows = [{ + "target_id": "select:PauseAOLOnBrake", + "title": tr("Pause AOL On Brake"), + "subtitle": tr("Pause Always On Lateral below this speed while the brake is pressed."), + "get_value": lambda: f"{p.get_int('PauseAOLOnBrake')} mph", + "pill_width": 140, + }] + sections.append({ + "title": tr("Always On Lateral"), + "rows": aol_rows, + "visible": True, + "height": self._section_block_height(self._section_height(len(aol_rows), ROW_HEIGHT)), + }) + + if lc: + lc_rows: list[dict] = [{ + "target_id": "select:MinimumLaneChangeSpeed", + "title": tr("Min Lane Change Speed"), + "subtitle": tr("Lowest speed at which openpilot will change lanes."), + "get_value": lambda: f"{p.get_int('MinimumLaneChangeSpeed')} mph", + "pill_width": 140, + }] + if nlc: + lc_rows.append({ + "target_id": "select:LaneChangeTime", + "title": tr("Lane Change Delay"), + "subtitle": tr("Delay before the start of an automatic lane change. 0 = Instant."), + "get_value": lambda: tr("Instant") if p.get_float("LaneChangeTime") == 0 + else f"{p.get_float('LaneChangeTime'):.1f}s", + "pill_width": 120, + }) + lc_rows.append({ + "target_id": "select:LaneDetectionWidth", + "title": tr("Min Lane Width"), + "subtitle": tr("Prevent automatic lane changes into lanes narrower than this width."), + "get_value": lambda: f"{p.get_float('LaneDetectionWidth'):.1f} ft", + "pill_width": 120, + }) + lc_rows.append({ + "target_id": "select:LaneChangeSmoothing", + "title": tr("Lane Change Smoothing"), + "subtitle": tr("How smoothly openpilot commits to a lane change. 10 = Stock, 1 = Smoothest."), + "get_value": lambda: tr("Stock") if p.get_int("LaneChangeSmoothing") == 10 + else f"{p.get_int('LaneChangeSmoothing')}", + "pill_width": 120, + }) + sections.append({ + "title": tr("Lane Changes"), + "rows": lc_rows, + "visible": True, + "height": self._section_block_height(self._section_height(len(lc_rows), ROW_HEIGHT)), + }) + + paus_rows: list[dict] = [{ + "target_id": "select:PauseLateralSpeed", + "title": tr("Pause Lateral Below"), + "subtitle": tr("Pause steering below the set speed."), + "get_value": lambda: f"{p.get_int('PauseLateralSpeed')} mph", + "pill_width": 140, + }] + if pos: + paus_rows.append({ + "target_id": "select:LateralResumeDelay", + "title": tr("Resume Delay"), + "subtitle": tr("Delay before lateral resumes after the turn signal is turned off. 0 = Off."), + "get_value": lambda: tr("Off") if p.get_float("LateralResumeDelay") == 0 + else f"{p.get_float('LateralResumeDelay'):.1f}s", + "pill_width": 120, + }) + sections.append({ + "title": tr("Pause Lateral"), + "rows": paus_rows, + "visible": True, + "height": self._section_block_height(self._section_height(len(paus_rows), ROW_HEIGHT)), + }) + + return sections + # ═══════════════════════════════════════════════════════════════ -# StarPilotLateralLayout — top-level hub with 3 tabs +# StarPilotLateralLayout — top-level hybrid panel # ═══════════════════════════════════════════════════════════════ class StarPilotLateralLayout(_SettingsPage): + SLIDER_COLOR = "#5B9BD5" + def __init__(self): super().__init__() + self._manager_view = SteeringManagerView(self) - self._sub_panels = { - "advanced_lateral": StarPilotAdvancedLateralLayout(), - } - self._wire_sub_panels() - self._build_view() + def _on_select(self, key: str): + if key == "PauseAOLOnBrake": + self._show_slider("PauseAOLOnBrake", 0, 100, unit=" mph") + elif key == "PauseLateralSpeed": + def on_speed_close(res, val): + if res == DialogResult.CONFIRM: + self._params.put_int("PauseLateralSpeed", int(val)) + self._params.put_bool("QOLLateral", int(val) > 0) + current = self._params.get_int("PauseLateralSpeed") + gui_app.push_widget(AetherSliderDialog( + tr("Pause Lateral Below"), 0, 100, 1, current, on_speed_close, + unit=" mph", color=self.SLIDER_COLOR)) + elif key == "LateralResumeDelay": + self._show_slider("LateralResumeDelay", 0.0, 5.0, step=0.1, unit="s", value_type="float") + elif key == "MinimumLaneChangeSpeed": + self._show_slider("MinimumLaneChangeSpeed", 0, 100, unit=" mph") + elif key == "LaneChangeTime": + self._show_slider("LaneChangeTime", 0.0, 5.0, step=0.1, unit="s", value_type="float") + elif key == "LaneDetectionWidth": + self._show_slider("LaneDetectionWidth", 0.0, 15.0, step=0.1, unit=" ft", value_type="float") + elif key == "LaneChangeSmoothing": + self._show_lane_smoothing() + elif key == "SteerDelay": + self._show_slider("SteerDelay", 0.01, 1.0, step=0.01, unit="s", value_type="float") + elif key == "SteerFriction": + self._show_slider("SteerFriction", 0.0, max(1.0, starpilot_state.car_state.friction * 1.5), step=0.01, value_type="float") + elif key == "SteerKP": + self._show_slider("SteerKP", max(0.01, starpilot_state.car_state.steerKp) * 0.5, max(0.01, starpilot_state.car_state.steerKp) * 1.5, step=0.01, value_type="float") + elif key == "SteerLatAccel": + self._show_slider("SteerLatAccel", max(0.01, starpilot_state.car_state.latAccelFactor) * 0.5, max(0.01, starpilot_state.car_state.latAccelFactor) * 1.5, step=0.01, value_type="float") + elif key == "SteerRatio": + self._show_slider("SteerRatio", max(0.01, starpilot_state.car_state.steerRatio) * 0.5, max(0.01, starpilot_state.car_state.steerRatio) * 1.5, step=0.01, value_type="float") - def _build_view(self): - tab_defs = [ - {"id": "steering", "title": tr_noop("Steering"), "subtitle": tr_noop("Steering modes")}, - {"id": "lane", "title": tr_noop("Lane"), "subtitle": tr_noop("Lane changes")}, - {"id": "tune", "title": tr_noop("Tune"), "subtitle": tr_noop("Advanced controls")}, - ] - - sections = [ - # ── Steering tab ── - SettingSection(tr_noop("Steering Modes"), [ - SettingRow("AlwaysOnLateral", "toggle", tr_noop("Always On Lateral"), - subtitle=tr_noop("Keep lateral control active even without openpilot engaged."), - get_state=lambda: self._params.get_bool("AlwaysOnLateral"), - set_state=lambda s: _confirm_reboot_toggle(self._params, "AlwaysOnLateral", s) if s else self._params.put_bool("AlwaysOnLateral", False)), - SettingRow("PauseAOLOnBrake", "value", tr_noop("Pause Below"), - subtitle="", - get_value=lambda: f"{self._params.get_int('PauseAOLOnBrake')} mph", - on_click=lambda: self._show_slider("PauseAOLOnBrake", 0, 100, unit=" mph"), - visible=lambda: self._params.get_bool("AlwaysOnLateral")), - SettingRow("QOLLateral", "toggle", tr_noop("Quality of Life"), - subtitle="", - get_state=lambda: self._params.get_bool("QOLLateral"), - set_state=lambda s: self._params.put_bool("QOLLateral", s)), - SettingRow("PauseLateralSpeed", "value", tr_noop("Pause Steering Below"), - subtitle="", - get_value=lambda: f"{self._params.get_int('PauseLateralSpeed')} mph", - on_click=lambda: self._show_slider("PauseLateralSpeed", 0, 100, unit=" mph"), - visible=lambda: self._params.get_bool("QOLLateral")), - SettingRow("PauseLateralOnSignal", "toggle", tr_noop("Turn Signal Only"), - subtitle=tr_noop("Only pause steering when the turn signal is active."), - get_state=lambda: self._params.get_bool("PauseLateralOnSignal"), - set_state=lambda s: self._params.put_bool("PauseLateralOnSignal", s), - visible=lambda: self._params.get_bool("QOLLateral") and self._params.get_int("PauseLateralSpeed") > 0), - SettingRow("LateralResumeDelay", "value", tr_noop("Lateral Resume Delay"), - subtitle=tr_noop("Delay before steering resumes after the turn signal turns off. Set to 0 to disable."), - get_value=lambda: "Off" if self._params.get_float("LateralResumeDelay") == 0 else f"{self._params.get_float('LateralResumeDelay'):.1f}s", - on_click=lambda: self._show_slider("LateralResumeDelay", 0.0, 5.0, step=0.1, unit=" s", value_type="float"), - visible=lambda: self._params.get_bool("QOLLateral") and self._params.get_int("PauseLateralSpeed") > 0 and self._params.get_bool("PauseLateralOnSignal")), - ], tab_key="steering"), - - # ── Lane tab ── - SettingSection("", [ - SettingRow("LaneChanges", "toggle", tr_noop("Lane Changes"), - subtitle="", - get_state=lambda: self._params.get_bool("LaneChanges"), - set_state=lambda s: self._params.put_bool("LaneChanges", s)), - SettingRow("NudgelessLaneChange", "toggle", tr_noop("Automatic Lane Changes"), - subtitle="", - get_state=lambda: self._params.get_bool("NudgelessLaneChange"), - set_state=lambda s: self._params.put_bool("NudgelessLaneChange", s), - visible=lambda: self._params.get_bool("LaneChanges")), - SettingRow("LaneChangeTime", "value", tr_noop("Lane Change Delay"), - subtitle="", - get_value=lambda: f"{self._params.get_float('LaneChangeTime'):.1f}s", - on_click=lambda: self._show_slider("LaneChangeTime", 0.0, 5.0, step=0.1, unit="s", value_type="float"), - visible=lambda: self._params.get_bool("LaneChanges") and self._params.get_bool("NudgelessLaneChange")), - SettingRow("MinimumLaneChangeSpeed", "value", tr_noop("Min Lane Change Speed"), - subtitle="", - get_value=lambda: f"{self._params.get_int('MinimumLaneChangeSpeed')} mph", - on_click=lambda: self._show_slider("MinimumLaneChangeSpeed", 0, 100, unit=" mph"), - visible=lambda: self._params.get_bool("LaneChanges")), - SettingRow("LaneDetectionWidth", "value", tr_noop("Minimum Lane Width"), - subtitle="", - get_value=lambda: f"{self._params.get_float('LaneDetectionWidth'):.1f} ft", - on_click=lambda: self._show_slider("LaneDetectionWidth", 0.0, 15.0, step=0.1, unit=" ft", value_type="float"), - visible=lambda: self._params.get_bool("LaneChanges") and self._params.get_bool("NudgelessLaneChange")), - SettingRow("OneLaneChange", "toggle", tr_noop("One Lane Change Per Signal"), - subtitle="", - get_state=lambda: self._params.get_bool("OneLaneChange"), - set_state=lambda s: self._params.put_bool("OneLaneChange", s), - visible=lambda: self._params.get_bool("LaneChanges")), - SettingRow("LaneChangeSmoothing", "value", tr_noop("Lane Change Smoothing"), - subtitle=tr_noop("How smoothly openpilot commits to a lane change. 10 is stock; lower values produce a gentler, more gradual maneuver."), - get_value=lambda: f"{self._params.get_int('LaneChangeSmoothing')}", - on_click=self._show_modal_pace_selector, - visible=lambda: self._params.get_bool("LaneChanges")), - ], tab_key="lane"), - - # ── Tune tab ── - SettingSection(tr_noop("Advanced Lateral Tuning"), [ - SettingRow("AdvancedLateralTune", "toggle", tr_noop("Advanced Lateral Tuning"), - subtitle=tr_noop("Advanced steering control changes to fine-tune how openpilot drives."), - get_state=lambda: self._params.get_bool("AdvancedLateralTune"), - set_state=lambda s: self._params.put_bool("AdvancedLateralTune", s)), - SettingRow("AdvancedConfigure", "value", tr_noop("Configure"), - subtitle=tr_noop("Adjust steering response, torque controller behavior, and auto-tuning controls."), - get_value=lambda: tr_noop("Settings"), - navigate_to="advanced_lateral", - enabled=lambda: self._params.get_bool("AdvancedLateralTune"), - disabled_label=tr_noop("Enable First")), - ], tab_key="tune"), - SettingSection(tr_noop("Lateral Tuning"), [ - SettingRow("LateralTune", "toggle", tr_noop("Lateral Tuning"), - subtitle=tr_noop("Miscellaneous steering control changes such as turn desires and NNFF modes."), - get_state=lambda: self._params.get_bool("LateralTune"), - set_state=lambda s: self._params.put_bool("LateralTune", s)), - SettingRow("TurnDesires", "toggle", tr_noop("Force Turn Desires Below Lane Change Speed"), - subtitle=tr_noop("Allow openpilot to follow turn intent below the minimum lane change speed when signaling."), - get_state=lambda: self._params.get_bool("TurnDesires"), - set_state=lambda s: self._params.put_bool("TurnDesires", s), - visible=lambda: self._params.get_bool("LateralTune")), - SettingRow("NavDesiresAllowed", "toggle", tr_noop("Use Route Desires"), - subtitle=tr_noop("Allow an active navigation route to request keep-left, keep-right, and low-speed turn desires."), - get_state=lambda: self._params.get_bool("NavDesiresAllowed"), - set_state=lambda s: self._params.put_bool("NavDesiresAllowed", s), - visible=lambda: self._params.get_bool("LateralTune")), - SettingRow("NNFF", "toggle", tr_noop("Neural Network Feedforward (NNFF)"), - subtitle=tr_noop("Use the full neural-network feedforward steering controller when available."), - get_state=lambda: self._params.get_bool("NNFF"), - set_state=lambda s: (self._params.put_bool("NNFF", s), s and self._params.put_bool("NNFFLite", False)), - visible=lambda: self._params.get_bool("LateralTune") and starpilot_state.car_state.hasNNFFLog and not starpilot_state.car_state.isAngleCar), - SettingRow("NNFFLite", "toggle", tr_noop("Neural Network Feedforward (NNFF) Lite"), - subtitle=tr_noop("Use the lightweight NNFF steering logic when the full model is off."), - get_state=lambda: self._params.get_bool("NNFFLite"), - set_state=lambda s: _confirm_reboot_toggle(self._params, "NNFFLite", s), - visible=lambda: self._params.get_bool("LateralTune") and not self._params.get_bool("NNFF") and not starpilot_state.car_state.isAngleCar), - ], tab_key="tune"), - ] - - self._manager_view = AetherSettingsView( - self, sections, - header_title=tr_noop("Steering"), - header_subtitle=tr_noop("Fine-tune lateral control, lane changes, and steering behavior."), - tab_defs=tab_defs, - panel_style=PANEL_STYLE, - metrics=COMPACT_PANEL_METRICS, - ) - - def _show_modal_pace_selector(self): + def _show_lane_smoothing(self): def on_close(res, val): if res == DialogResult.CONFIRM: self._params.put_int("LaneChangeSmoothing", int(val)) current = self._params.get_int("LaneChangeSmoothing") if self._params.get_int("LaneChangeSmoothing") > 0 else 10 - gui_app.push_widget(AetherSliderDialog(tr("Lane Change Smoothing"), 1, 10, 1, current, on_close, color=PANEL_STYLE.accent)) + gui_app.push_widget(AetherSliderDialog(tr("Lane Change Smoothing"), 1, 10, 1, current, on_close, + color=self.SLIDER_COLOR))