From 32381eb182fe69b24f226c86f68e0df27b50b11e Mon Sep 17 00:00:00 2001 From: firestar5683 <168790843+firestar5683@users.noreply.github.com> Date: Sun, 13 Sep 2026 14:04:45 -0500 Subject: [PATCH] Reapply "joplin" This reverts commit e517a8354181eccf2ca5867e0f91bd1cb002244b. --- common/params_keys.h | 1 + opendbc_repo/opendbc/car/gm/gmcan.py | 15 +- opendbc_repo/opendbc/car/gm/tests/test_gm.py | 118 ++++++++++++-- selfdrive/ui/mici/onroad/hud_renderer.py | 7 +- selfdrive/ui/onroad/exp_button.py | 16 +- selfdrive/ui/tests/test_aethergauge.py | 17 +++ .../ui/tests/test_rivian_lateral_mode.py | 29 ++++ starpilot/car/ford/lateral.py | 75 ++++++++- starpilot/car/ford/tests/test_lateral.py | 144 ++++++++++++++++++ .../common/assets/device_settings_layout.json | 10 ++ .../the_galaxy/assets/components/main.css | 20 ++- .../navigation/navigation_destination.css | 11 +- .../the_galaxy/assets/components/router.js | 2 +- .../the_galaxy/assets/components/sidebar.css | 61 ++++---- .../the_galaxy/assets/components/sidebar.js | 66 +++++++- .../system/the_galaxy/assets/js/utils.js | 2 + .../system/the_galaxy/templates/index.html | 10 +- .../tests/test_device_settings_layout.py | 9 ++ .../tests/test_frontend_module_graph.py | 24 +++ 19 files changed, 567 insertions(+), 70 deletions(-) diff --git a/common/params_keys.h b/common/params_keys.h index 899b8c76bc..73c441163f 100644 --- a/common/params_keys.h +++ b/common/params_keys.h @@ -632,6 +632,7 @@ inline static std::unordered_map keys = { {"SafeModeBackup", {PERSISTENT, JSON, "{}", "{}"}}, {"SetSpeedLimit", {PERSISTENT, BOOL, "0", "0", 1}}, {"SetSpeedOffset", {PERSISTENT, FLOAT, "0.0", "0.0", 2, SETTINGS_SIMPLE}}, + {"ShowBrakeStatus", {PERSISTENT, BOOL, "0", "0", 2, SETTINGS_SIMPLE}}, {"ShowCEMStatus", {PERSISTENT, BOOL, "1", "0", 2, SETTINGS_SIMPLE}}, {"ShowCCMStatus", {PERSISTENT, BOOL, "0", "0", 2}}, {"ShowCPU", {PERSISTENT, BOOL, "0", "0", 3}}, diff --git a/opendbc_repo/opendbc/car/gm/gmcan.py b/opendbc_repo/opendbc/car/gm/gmcan.py index f9259a2c51..a5f97ce22b 100644 --- a/opendbc_repo/opendbc/car/gm/gmcan.py +++ b/opendbc_repo/opendbc/car/gm/gmcan.py @@ -349,7 +349,20 @@ def _create_volt_cc_spam_command(CS, actuators, ms_convert, lead_visible): deadband_mph = VOLT_CC_LEAD_REQUEST_DEADBAND_MPH if lead_visible else VOLT_CC_FREE_REQUEST_DEADBAND_MPH request_deadband = deadband_mph * (CV.MPH_TO_KPH if ms_convert == CV.MS_TO_KPH else 1.0) - if abs(requested_setpoint - speed_setpoint) <= request_deadband: + target_setpoint = None + v_cruise_kph = float(getattr(CS.out, "vCruise", 0.0)) + if 0.0 < v_cruise_kph < 255.0: + is_metric = ms_convert == CV.MS_TO_KPH + target_setpoint = int(round(v_cruise_kph if is_metric else v_cruise_kph * CV.KPH_TO_MPH)) + + moving_toward_target = target_setpoint is not None and ( + (accel > 0.0 and speed_setpoint < target_setpoint) or + (accel < 0.0 and speed_setpoint > target_setpoint) + ) + if target_setpoint is not None and accel > 0.0 and speed_setpoint >= target_setpoint: + return CruiseButtons.INIT, float("inf") + + if not moving_toward_target and abs(requested_setpoint - speed_setpoint) <= request_deadband: return CruiseButtons.INIT, float("inf") if accel == 0.0: diff --git a/opendbc_repo/opendbc/car/gm/tests/test_gm.py b/opendbc_repo/opendbc/car/gm/tests/test_gm.py index 90ddbda323..e590858777 100644 --- a/opendbc_repo/opendbc/car/gm/tests/test_gm.py +++ b/opendbc_repo/opendbc/car/gm/tests/test_gm.py @@ -947,7 +947,7 @@ class TestGMCarController: assert len(msgs) == 1 - def test_volt_cc_redneck_holds_when_pseudo_speed_request_is_within_deadband(self): + def test_volt_cc_redneck_does_not_raise_stock_setpoint_above_max(self): packer = CANPacker(DBC[CAR.CHEVROLET_VOLT_CC][Bus.pt]) controller = SimpleNamespace(frame=int(2.0 / DT_CTRL), last_button_frame=0, apply_speed=0, malibu_button_phase=0) cs = SimpleNamespace( @@ -960,7 +960,7 @@ class TestGMCarController: buttons_counter=2, out=SimpleNamespace( vEgo=100.0 * CV.KPH_TO_MS, - cruiseState=SimpleNamespace(speed=99.0 * CV.KPH_TO_MS), + cruiseState=SimpleNamespace(speed=100.0 * CV.KPH_TO_MS), vCruise=100.0, ), ) @@ -970,11 +970,61 @@ class TestGMCarController: ) assert msgs == [] - assert controller.apply_speed == 99 + assert controller.apply_speed == 100 - def test_volt_cc_redneck_uses_smaller_request_deadband_with_lead(self): + def test_volt_cc_redneck_tracks_max_inside_request_deadband(self): packer = CANPacker(DBC[CAR.CHEVROLET_VOLT_CC][Bus.pt]) - controller = SimpleNamespace(frame=int(2.0 / DT_CTRL), last_button_frame=0, apply_speed=0, malibu_button_phase=0) + controller = SimpleNamespace(frame=int(3.0 / DT_CTRL), last_button_frame=0, apply_speed=0, malibu_button_phase=0) + cs = SimpleNamespace( + CP=SimpleNamespace( + carFingerprint=CAR.CHEVROLET_VOLT_CC, + flags=GMFlags.NO_CAMERA.value, + networkLocation=structs.CarParams.NetworkLocation.gateway, + minEnableSpeed=0.0, + ), + buttons_counter=2, + out=SimpleNamespace( + vEgo=52.0 * CV.KPH_TO_MS, + cruiseState=SimpleNamespace(speed=52.0 * CV.KPH_TO_MS), + vCruise=60.0, + ), + ) + + msgs = gmcan.create_gm_cc_spam_command( + packer, controller, cs, SimpleNamespace(accel=0.1), SimpleNamespace(is_metric=True), + ) + + assert len(msgs) == 1 + assert controller.apply_speed == 53 + + def test_volt_cc_redneck_tracks_max_down_inside_request_deadband(self): + packer = CANPacker(DBC[CAR.CHEVROLET_VOLT_CC][Bus.pt]) + controller = SimpleNamespace(frame=int(3.0 / DT_CTRL), last_button_frame=0, apply_speed=0, malibu_button_phase=0) + cs = SimpleNamespace( + CP=SimpleNamespace( + carFingerprint=CAR.CHEVROLET_VOLT_CC, + flags=GMFlags.NO_CAMERA.value, + networkLocation=structs.CarParams.NetworkLocation.gateway, + minEnableSpeed=0.0, + ), + buttons_counter=2, + out=SimpleNamespace( + vEgo=68.0 * CV.KPH_TO_MS, + cruiseState=SimpleNamespace(speed=68.0 * CV.KPH_TO_MS), + vCruise=60.0, + ), + ) + + msgs = gmcan.create_gm_cc_spam_command( + packer, controller, cs, SimpleNamespace(accel=-0.1), SimpleNamespace(is_metric=True), + ) + + assert len(msgs) == 1 + assert controller.apply_speed == 67 + + def test_volt_cc_redneck_holds_small_decel_request_at_max(self): + packer = CANPacker(DBC[CAR.CHEVROLET_VOLT_CC][Bus.pt]) + controller = SimpleNamespace(frame=int(3.0 / DT_CTRL), last_button_frame=0, apply_speed=0, malibu_button_phase=0) cs = SimpleNamespace( CP=SimpleNamespace( carFingerprint=CAR.CHEVROLET_VOLT_CC, @@ -985,17 +1035,67 @@ class TestGMCarController: buttons_counter=2, out=SimpleNamespace( vEgo=100.0 * CV.KPH_TO_MS, - cruiseState=SimpleNamespace(speed=99.0 * CV.KPH_TO_MS), + cruiseState=SimpleNamespace(speed=100.0 * CV.KPH_TO_MS), vCruise=100.0, ), ) msgs = gmcan.create_gm_cc_spam_command( - packer, controller, cs, SimpleNamespace(accel=0.5), SimpleNamespace(is_metric=True), lead_visible=True, + packer, controller, cs, SimpleNamespace(accel=-0.1), SimpleNamespace(is_metric=True), + ) + + assert msgs == [] + assert controller.apply_speed == 100 + + def test_volt_cc_redneck_holds_medium_decel_request_at_max_without_lead(self): + packer = CANPacker(DBC[CAR.CHEVROLET_VOLT_CC][Bus.pt]) + controller = SimpleNamespace(frame=int(3.0 / DT_CTRL), last_button_frame=0, apply_speed=0, malibu_button_phase=0) + cs = SimpleNamespace( + CP=SimpleNamespace( + carFingerprint=CAR.CHEVROLET_VOLT_CC, + flags=GMFlags.NO_CAMERA.value, + networkLocation=structs.CarParams.NetworkLocation.gateway, + minEnableSpeed=0.0, + ), + buttons_counter=2, + out=SimpleNamespace( + vEgo=100.0 * CV.KPH_TO_MS, + cruiseState=SimpleNamespace(speed=100.0 * CV.KPH_TO_MS), + vCruise=100.0, + ), + ) + + msgs = gmcan.create_gm_cc_spam_command( + packer, controller, cs, SimpleNamespace(accel=-0.5), SimpleNamespace(is_metric=True), lead_visible=False, + ) + + assert msgs == [] + assert controller.apply_speed == 100 + + def test_volt_cc_redneck_brakes_for_lead_inside_free_road_deadband(self): + packer = CANPacker(DBC[CAR.CHEVROLET_VOLT_CC][Bus.pt]) + controller = SimpleNamespace(frame=int(3.0 / DT_CTRL), last_button_frame=0, apply_speed=0, malibu_button_phase=0) + cs = SimpleNamespace( + CP=SimpleNamespace( + carFingerprint=CAR.CHEVROLET_VOLT_CC, + flags=GMFlags.NO_CAMERA.value, + networkLocation=structs.CarParams.NetworkLocation.gateway, + minEnableSpeed=0.0, + ), + buttons_counter=2, + out=SimpleNamespace( + vEgo=100.0 * CV.KPH_TO_MS, + cruiseState=SimpleNamespace(speed=100.0 * CV.KPH_TO_MS), + vCruise=100.0, + ), + ) + + msgs = gmcan.create_gm_cc_spam_command( + packer, controller, cs, SimpleNamespace(accel=-0.5), SimpleNamespace(is_metric=True), lead_visible=True, ) assert len(msgs) == 1 - assert controller.apply_speed == 100 + assert controller.apply_speed == 99 def test_volt_cc_redneck_accelerates_when_pseudo_speed_request_exceeds_deadband(self): packer = CANPacker(DBC[CAR.CHEVROLET_VOLT_CC][Bus.pt]) @@ -1043,7 +1143,7 @@ class TestGMCarController: out=SimpleNamespace( vEgo=50.7 * CV.KPH_TO_MS, cruiseState=SimpleNamespace(speed=49.0 * CV.KPH_TO_MS), - vCruise=50.0, + vCruise=49.0, ), ) diff --git a/selfdrive/ui/mici/onroad/hud_renderer.py b/selfdrive/ui/mici/onroad/hud_renderer.py index 49dfd0e2fb..982f615155 100644 --- a/selfdrive/ui/mici/onroad/hud_renderer.py +++ b/selfdrive/ui/mici/onroad/hud_renderer.py @@ -8,6 +8,7 @@ from openpilot.selfdrive.ui.onroad.starpilot.rivian_lateral_mode import rivian_l from openpilot.selfdrive.ui.mici.onroad.speed_limit_utils import resolve_display_speed_limit_ms from openpilot.selfdrive.ui.onroad.starpilot.navigation_card import NavigationCardRenderer from openpilot.selfdrive.ui.ui_state import ui_state, UIStatus +from openpilot.selfdrive.ui.onroad.exp_button import get_wheel_tint from openpilot.system.ui.lib.application import gui_app, FontWeight from openpilot.system.ui.lib.utils import draw_circle_gradient_compat from openpilot.system.ui.lib.multilang import tr @@ -194,7 +195,11 @@ class HudRenderer(Widget): controls_state = sm['controlsState'] car_state = sm['carState'] rivian_lateral_mode.update() - self._wheel_tint = rivian_lateral_mode.wheel_tint + self._wheel_tint = get_wheel_tint( + getattr(car_state, "brakePressed", False), + rivian_lateral_mode.wheel_tint, + ui_state.ui_params.get_bool("ShowBrakeStatus"), + ) v_cruise_cluster = car_state.vCruiseCluster set_speed = ( diff --git a/selfdrive/ui/onroad/exp_button.py b/selfdrive/ui/onroad/exp_button.py index f80609787b..f158881cd7 100644 --- a/selfdrive/ui/onroad/exp_button.py +++ b/selfdrive/ui/onroad/exp_button.py @@ -11,6 +11,13 @@ from openpilot.starpilot.common.experimental_state import ( ) +BRAKE_WHEEL_COLOR = rl.Color(255, 0, 0, 255) + + +def get_wheel_tint(brake_pressed: bool, mode_tint: rl.Color | None, brake_status_enabled: bool) -> rl.Color | None: + return BRAKE_WHEEL_COLOR if brake_status_enabled and brake_pressed else mode_tint + + class ExpButton(Widget): def __init__(self, button_size: int, icon_size: int): super().__init__() @@ -102,8 +109,13 @@ class ExpButton(Widget): texture = self._txt_exp if exp_mode else self._txt_wheel color = self._white_color tint = None - if self.wheel_tint is not None: - tint = rl.Color(self.wheel_tint.r, self.wheel_tint.g, self.wheel_tint.b, self._white_color.a) + wheel_tint = get_wheel_tint( + getattr(ui_state.sm["carState"], "brakePressed", False), + self.wheel_tint, + self._params.get_bool("ShowBrakeStatus"), + ) + if wheel_tint is not None: + tint = rl.Color(wheel_tint.r, wheel_tint.g, wheel_tint.b, self._white_color.a) rl.draw_circle(center_x, center_y, self._rect.width / 2, self._bg_color) if tint is not None: diff --git a/selfdrive/ui/tests/test_aethergauge.py b/selfdrive/ui/tests/test_aethergauge.py index 5b0389b0a7..ef0b12c76e 100644 --- a/selfdrive/ui/tests/test_aethergauge.py +++ b/selfdrive/ui/tests/test_aethergauge.py @@ -41,6 +41,8 @@ from openpilot.selfdrive.ui.onroad.starpilot.aethergauge import ( _is_lead, _is_stop_light, _lead_data, + _to_display_distance, + _to_display_speed, ) from openpilot.starpilot.common.experimental_state import CEStatus @@ -50,6 +52,7 @@ aethergauge.ui_state = mock_ui_state @pytest.fixture(autouse=True) def reset_ui_state(): mock_ui_state.sm.reset() + mock_ui_state.is_metric = False mock_ui_state.conditional_status = CEStatus["OFF"] mock_ui_state.starpilot_toggles.update({ "conditional_experimental_mode": True, @@ -135,6 +138,20 @@ def test_is_stop_light(): assert not _is_stop_light() +@pytest.mark.parametrize( + ("is_metric", "expected_distance", "expected_speed"), + [ + (False, (33, "ft"), (22, "mph")), + (True, (10, "m"), (36, "km/h")), + ], +) +def test_aether_gauge_uses_display_units(is_metric, expected_distance, expected_speed): + mock_ui_state.is_metric = is_metric + + assert _to_display_distance(10.0) == expected_distance + assert _to_display_speed(10.0) == expected_speed + + def test_is_curve_speed_follows_csc_activation_without_mode_gate(monkeypatch): monkeypatch.setattr(aethergauge, "_csc_state", lambda: {"active": True, "curvature": 0.002}) assert _is_curve_speed() diff --git a/selfdrive/ui/tests/test_rivian_lateral_mode.py b/selfdrive/ui/tests/test_rivian_lateral_mode.py index 403fc83692..80e34435ef 100644 --- a/selfdrive/ui/tests/test_rivian_lateral_mode.py +++ b/selfdrive/ui/tests/test_rivian_lateral_mode.py @@ -293,3 +293,32 @@ def test_non_mici_wheel_icon_uses_rivian_tint(monkeypatch): assert len(draws["textures"]) == 1 texture_color = draws["textures"][0][-1] assert (texture_color.r, texture_color.g, texture_color.b, texture_color.a) == (0x4D, 0x9D, 0xFF, 255) + + +def test_non_mici_wheel_icon_turns_red_when_brakes_are_pressed(monkeypatch): + module, draws = load_exp_button(monkeypatch) + button = module.ExpButton(192, 144) + button.wheel_tint = FakeColor(0x4D, 0x9D, 0xFF, 255) + module.ui_state.ui_params.get_bool = lambda key, *args, **kwargs: key == "ShowBrakeStatus" + module.ui_state.sm["carState"].brakePressed = True + button._update_state() + + button._render(FakeRectangle(0, 0, 192, 192)) + + assert len(draws["textures"]) == 1 + texture_color = draws["textures"][0][-1] + assert (texture_color.r, texture_color.g, texture_color.b, texture_color.a) == (255, 0, 0, 255) + + +def test_non_mici_wheel_icon_brake_tint_is_disabled_by_default(monkeypatch): + module, draws = load_exp_button(monkeypatch) + button = module.ExpButton(192, 144) + button.wheel_tint = FakeColor(0x4D, 0x9D, 0xFF, 255) + module.ui_state.sm["carState"].brakePressed = True + button._update_state() + + button._render(FakeRectangle(0, 0, 192, 192)) + + assert len(draws["textures"]) == 1 + texture_color = draws["textures"][0][-1] + assert (texture_color.r, texture_color.g, texture_color.b, texture_color.a) == (0x4D, 0x9D, 0xFF, 255) diff --git a/starpilot/car/ford/lateral.py b/starpilot/car/ford/lateral.py index ac7f0f505f..33d51d2a80 100644 --- a/starpilot/car/ford/lateral.py +++ b/starpilot/car/ford/lateral.py @@ -35,9 +35,19 @@ STEER_DT = CarControllerParams.STEER_STEP * DT_CTRL CURVATURE_LOOKAHEAD_MIN = 0.20 CURVATURE_LOOKAHEAD_MAX = 0.40 MACH_E_TURN_IN_LOOKAHEAD_EXTRA = 0.80 +MACH_E_LOW_SPEED_TURN_IN_LOOKAHEAD_EXTRA = 1.60 +MACH_E_LOW_SPEED_TURN_IN_START_SPEED = 2.0 +MACH_E_LOW_SPEED_TURN_IN_FULL_SPEED = 3.0 +MACH_E_LOW_SPEED_TURN_IN_MAX_SPEED = 9.0 +MACH_E_LOW_SPEED_TURN_IN_FADE_SPEED = 12.0 MACH_E_TURN_IN_MIN_CURVATURE = 0.002 MACH_E_TURN_IN_FULL_CURVATURE = 0.008 MACH_E_TURN_IN_LAG_CURVATURE = 0.006 +MACH_E_DIRECTION_CHANGE_MIN_SPEED = 9.0 +MACH_E_DIRECTION_CHANGE_MIN_PREVIEW_CURVATURE = 0.0005 +MACH_E_DIRECTION_CHANGE_FULL_PREVIEW_CURVATURE = 0.002 +MACH_E_DIRECTION_CHANGE_MIN_LAG_CURVATURE = 0.0008 +MACH_E_DIRECTION_CHANGE_FULL_LAG_CURVATURE = 0.0015 FORD_CURVATURE_LOOKAHEAD = { CAR.FORD_EXPLORER_MK6: 0.20, } @@ -167,10 +177,11 @@ class FordLateralController: def _current_curvature(CS) -> float: return -CS.out.yawRate / max(CS.out.vEgoRaw, 0.1) - def _blend_and_scale(self, desired: float, predicted: float, v_ego: float, current: float = 0.0) -> tuple[float, int]: + def _blend_and_scale(self, desired: float, predicted: float, v_ego: float, current: float = 0.0, + allow_opposite_preview: bool = False) -> tuple[float, int]: blend = float(np.interp(abs(desired), [0.0, 0.001], [self.curvature_blend_low, self.curvature_blend_high])) if self.CP.carFingerprint in FORD_CONSERVATIVE_PREVIEW_CARS: - if desired * predicted <= 0.0: + if desired * predicted <= 0.0 and not allow_opposite_preview: blend = 0.0 elif current * predicted > 0.0 and abs(current) > abs(desired) and abs(predicted) > abs(desired): blend *= abs(desired) / abs(predicted) @@ -205,6 +216,37 @@ class FordLateralController: )) return curvature_weight * lag_weight + @staticmethod + def _turn_in_lookahead_extra(v_ego: float) -> float: + return float(np.interp( + v_ego, + [MACH_E_LOW_SPEED_TURN_IN_START_SPEED, MACH_E_LOW_SPEED_TURN_IN_FULL_SPEED, + MACH_E_LOW_SPEED_TURN_IN_MAX_SPEED, MACH_E_LOW_SPEED_TURN_IN_FADE_SPEED], + [MACH_E_TURN_IN_LOOKAHEAD_EXTRA, MACH_E_LOW_SPEED_TURN_IN_LOOKAHEAD_EXTRA, + MACH_E_LOW_SPEED_TURN_IN_LOOKAHEAD_EXTRA, MACH_E_TURN_IN_LOOKAHEAD_EXTRA], + )) + + def _direction_change_preview_weight(self, desired: float, preview: float, current: float) -> float: + if self.CP.carFingerprint not in FORD_CONSERVATIVE_PREVIEW_CARS: + return 0.0 + if desired * preview >= 0.0 or desired * self.desired_curvature_last <= 0.0: + return 0.0 + if (abs(desired) >= abs(self.desired_curvature_last) or desired * current <= 0.0 or + abs(current) <= abs(desired)): + return 0.0 + + preview_weight = float(np.interp( + abs(preview), + [MACH_E_DIRECTION_CHANGE_MIN_PREVIEW_CURVATURE, MACH_E_DIRECTION_CHANGE_FULL_PREVIEW_CURVATURE], + [0.0, 1.0], + )) + lag_weight = float(np.interp( + abs(current) - abs(desired), + [MACH_E_DIRECTION_CHANGE_MIN_LAG_CURVATURE, MACH_E_DIRECTION_CHANGE_FULL_LAG_CURVATURE], + [0.0, 1.0], + )) + return preview_weight * lag_weight + def _manual_turn(self, CC, CS) -> bool: if not CC.latActive: self.human_turn.reset() @@ -268,13 +310,30 @@ class FordLateralController: lookahead = self._curvature_lookahead() predicted = self._predicted_curvature(v_ego, lookahead) desired = float(actuators.curvature) + allow_opposite_preview = False if self.CP.carFingerprint in FORD_CONSERVATIVE_PREVIEW_CARS: - turn_in_predicted = self._predicted_curvature(v_ego, lookahead + MACH_E_TURN_IN_LOOKAHEAD_EXTRA) - turn_in_weight = self._turn_in_preview_weight(desired, turn_in_predicted, current) - if turn_in_weight > 0.0: - turn_in_target = float(np.copysign(max(abs(desired), abs(turn_in_predicted)), desired)) - predicted = float(np.interp(turn_in_weight, [0.0, 1.0], [predicted, turn_in_target])) - requested, precision = self._blend_and_scale(desired, predicted, v_ego, current) + direction_change_predicted = self._predicted_curvature(v_ego, lookahead + MACH_E_TURN_IN_LOOKAHEAD_EXTRA) + direction_change_weight = 0.0 + if v_ego > MACH_E_DIRECTION_CHANGE_MIN_SPEED and not CS.out.steeringPressed and not self._lane_change()[0]: + direction_change_weight = self._direction_change_preview_weight(desired, direction_change_predicted, current) + if direction_change_weight > 0.0: + predicted = float(np.interp(direction_change_weight, [0.0, 1.0], [predicted, direction_change_predicted])) + allow_opposite_preview = True + else: + turn_in_predicted = direction_change_predicted + turn_in_lookahead_extra = self._turn_in_lookahead_extra(v_ego) + if (turn_in_lookahead_extra > MACH_E_TURN_IN_LOOKAHEAD_EXTRA and + desired * self.desired_curvature_last >= 0.0 and + abs(desired) > abs(self.desired_curvature_last)): + low_speed_turn_in_predicted = self._predicted_curvature(v_ego, lookahead + turn_in_lookahead_extra) + if (desired * low_speed_turn_in_predicted > 0.0 and + abs(low_speed_turn_in_predicted) > abs(turn_in_predicted)): + turn_in_predicted = low_speed_turn_in_predicted + turn_in_weight = self._turn_in_preview_weight(desired, turn_in_predicted, current) + if turn_in_weight > 0.0: + turn_in_target = float(np.copysign(max(abs(desired), abs(turn_in_predicted)), desired)) + predicted = float(np.interp(turn_in_weight, [0.0, 1.0], [predicted, turn_in_target])) + requested, precision = self._blend_and_scale(desired, predicted, v_ego, current, allow_opposite_preview) self.desired_curvature_last = desired if v_ego > 9.0: diff --git a/starpilot/car/ford/tests/test_lateral.py b/starpilot/car/ford/tests/test_lateral.py index a583b0ba5e..f8ecc56b41 100644 --- a/starpilot/car/ford/tests/test_lateral.py +++ b/starpilot/car/ford/tests/test_lateral.py @@ -170,6 +170,110 @@ def test_mach_e_turn_in_preview_is_not_carried_into_unwind(controller): desired=0.008, preview=0.009, current=0.004) == 0.0 +@pytest.mark.parametrize("speed,expected", ( + (1.0, 0.80), + (2.0, 0.80), + (2.5, 1.20), + (3.0, 1.60), + (8.0, 1.60), + (9.0, 1.60), + (10.5, 1.20), + (12.0, 0.80), + (15.0, 0.80), +)) +def test_mach_e_turn_in_lookahead_extra_fades_by_speed(controller, speed, expected): + assert controller._turn_in_lookahead_extra(speed) == pytest.approx(expected) + + +@pytest.mark.parametrize("sign", (1.0, -1.0)) +def test_mach_e_direction_change_preview_leads_a_lagging_unwind(controller, sign): + controller.CP.carFingerprint = CAR.FORD_MUSTANG_MACH_E_MK1 + controller.desired_curvature_last = sign * 0.002 + + weight = controller._direction_change_preview_weight( + desired=sign * 0.0015, preview=-sign * 0.002, current=sign * 0.003) + + assert weight == pytest.approx(1.0) + + +@pytest.mark.parametrize("desired,preview,current,last", ( + (0.0015, 0.002, 0.003, 0.002), # no predicted direction change + (0.002, -0.002, 0.003, 0.0015), # desired curvature is still rising + (0.0015, -0.002, -0.001, 0.002), # vehicle already changed direction + (0.0015, -0.002, 0.0022, 0.002), # measured unwind lag is too small +)) +def test_mach_e_direction_change_preview_rejects_unrelated_states( + controller, desired, preview, current, last): + controller.CP.carFingerprint = CAR.FORD_MUSTANG_MACH_E_MK1 + controller.desired_curvature_last = last + + assert controller._direction_change_preview_weight(desired, preview, current) == 0.0 + + +def test_mach_e_direction_change_preview_can_cross_the_current_desired_path(controller): + controller.CP.carFingerprint = CAR.FORD_MUSTANG_MACH_E_MK1 + + requested, _ = controller._blend_and_scale( + 0.0015, -0.002, 15.0, current=0.003, allow_opposite_preview=True) + + assert requested == pytest.approx(0.0001) + + +def test_mach_e_direction_change_preview_uses_far_path_when_unwind_lags(controller, monkeypatch): + controller.CP.carFingerprint = CAR.FORD_MUSTANG_MACH_E_MK1 + controller.sm["liveDelay"].lateralDelay = 0.4 + controller.desired_curvature_last = 0.002 + blend_inputs = [] + monkeypatch.setattr( + controller, "_predicted_curvature", + lambda _v_ego, lookahead: 0.002 if lookahead < 1.0 else -0.002, + ) + monkeypatch.setattr( + controller, "_blend_and_scale", + lambda desired, predicted, v_ego, current, allow_opposite_preview=False: + blend_inputs.append((desired, predicted, v_ego, current, allow_opposite_preview)) or (0.0, 1), + ) + + controller.update( + SimpleNamespace(latActive=True), car_state(speed=15.0, curvature=0.003), + SimpleNamespace(curvature=0.0015), + ) + + assert blend_inputs == [(pytest.approx(0.0015), pytest.approx(-0.002), pytest.approx(15.0), + pytest.approx(0.003), True)] + + +@pytest.mark.parametrize("speed,steering_pressed,lane_change", ( + (8.0, False, False), + (9.0, False, False), + (15.0, True, False), + (15.0, False, True), +)) +def test_mach_e_direction_change_preview_is_bypassed_outside_its_operating_state( + controller, monkeypatch, speed, steering_pressed, lane_change): + controller.CP.carFingerprint = CAR.FORD_MUSTANG_MACH_E_MK1 + controller.desired_curvature_last = 0.002 + monkeypatch.setattr(controller, "_predicted_curvature", lambda _v_ego, lookahead: -0.002) + monkeypatch.setattr(controller, "_lane_change", lambda: (lane_change, 2 if lane_change else 0)) + monkeypatch.setattr( + controller, "_direction_change_preview_weight", + lambda *_args: pytest.fail("direction-change preview must be bypassed"), + ) + + controller.update( + SimpleNamespace(latActive=True), + car_state(speed=speed, curvature=0.003, steering_pressed=steering_pressed), + SimpleNamespace(curvature=0.0015), + ) + + +def test_non_mach_e_direction_change_preview_is_unchanged(controller): + controller.desired_curvature_last = 0.002 + + assert controller._direction_change_preview_weight( + desired=0.0015, preview=-0.002, current=0.003) == 0.0 + + def test_mach_e_turn_in_preview_uses_extra_model_horizon(controller, monkeypatch): controller.CP.carFingerprint = CAR.FORD_MUSTANG_MACH_E_MK1 controller.sm["liveDelay"].lateralDelay = 0.4 @@ -183,9 +287,49 @@ def test_mach_e_turn_in_preview_uses_extra_model_horizon(controller, monkeypatch SimpleNamespace(curvature=0.010), ) + assert lookaheads == [pytest.approx(0.4), pytest.approx(1.2), pytest.approx(2.0)] + + +def test_mach_e_turn_in_preview_keeps_existing_horizon_above_fade_speed(controller, monkeypatch): + controller.CP.carFingerprint = CAR.FORD_MUSTANG_MACH_E_MK1 + controller.sm["liveDelay"].lateralDelay = 0.4 + controller.desired_curvature_last = 0.007 + lookaheads = [] + monkeypatch.setattr(controller, "_predicted_curvature", + lambda _v_ego, lookahead: lookaheads.append(lookahead) or 0.012) + + controller.update( + SimpleNamespace(latActive=True), car_state(speed=15.0, curvature=0.002), + SimpleNamespace(curvature=0.010), + ) + assert lookaheads == [pytest.approx(0.4), pytest.approx(1.2)] +def test_mach_e_low_speed_turn_in_preview_cannot_weaken_existing_preview(controller, monkeypatch): + controller.CP.carFingerprint = CAR.FORD_MUSTANG_MACH_E_MK1 + controller.sm["liveDelay"].lateralDelay = 0.4 + controller.desired_curvature_last = 0.007 + blend_inputs = [] + + def predicted_curvature(_v_ego, lookahead): + return {0.4: 0.006, 1.2: 0.010, 2.0: 0.004}[round(lookahead, 1)] + + monkeypatch.setattr(controller, "_predicted_curvature", predicted_curvature) + monkeypatch.setattr( + controller, "_blend_and_scale", + lambda desired, predicted, v_ego, current, allow_opposite_preview=False: + blend_inputs.append((desired, predicted)) or (0.0, 1), + ) + + controller.update( + SimpleNamespace(latActive=True), car_state(speed=8.0, curvature=0.002), + SimpleNamespace(curvature=0.008), + ) + + assert blend_inputs == [(pytest.approx(0.008), pytest.approx(0.010))] + + def test_non_mach_e_does_not_request_extra_model_horizon(controller, monkeypatch): controller.sm["liveDelay"].lateralDelay = 0.4 lookaheads = [] diff --git a/starpilot/common/assets/device_settings_layout.json b/starpilot/common/assets/device_settings_layout.json index 4342fdf9ee..1663c31c27 100644 --- a/starpilot/common/assets/device_settings_layout.json +++ b/starpilot/common/assets/device_settings_layout.json @@ -2839,6 +2839,16 @@ "parent_key": "CustomUI", "settings_tier": "simple" }, + { + "key": "ShowBrakeStatus", + "label": "Show Brake Status", + "description": "Tint the on-screen steering-wheel icon red while the car reports that the brake pedal is pressed.", + "picker_description": "Tints the on-screen steering-wheel icon red while braking.", + "data_type": "bool", + "ui_type": "toggle", + "galaxy_only": true, + "settings_tier": "simple" + }, { "key": "ModelUI", "label": "Model UI", diff --git a/starpilot/system/the_galaxy/assets/components/main.css b/starpilot/system/the_galaxy/assets/components/main.css index d93a2a886c..ab7bf4f5f0 100644 --- a/starpilot/system/the_galaxy/assets/components/main.css +++ b/starpilot/system/the_galaxy/assets/components/main.css @@ -187,12 +187,18 @@ body { .content { color: var(--main-fg); flex-grow: 1; - margin-left: var(--sidebar-width, 250px); + margin-left: 0; overflow-y: auto; padding-bottom: var(--padding-xl); padding-left: var(--padding-lg); } +@media only screen and (min-width: 768px) { + html.galaxy-sidebar-pinned .content { + margin-left: var(--sidebar-width, 250px); + } +} + .embedded #sidebar, .embedded #sidebar_shell, .embedded #sidebarUnderlay { @@ -310,7 +316,7 @@ a { /* ――― Snackbar wrapper ――― */ #snackbar_wrapper { bottom: var(--snackbar-offset, 30px); - left: var(--sidebar-width); + left: 0; margin: 0 auto; position: fixed; right: 0; @@ -318,6 +324,12 @@ a { z-index: var(--z-overlay); } +@media only screen and (min-width: 768px) { + html.galaxy-sidebar-pinned #snackbar_wrapper { + left: var(--sidebar-width); + } +} + /* ――― Animations ――― */ @keyframes fadein { from { @@ -342,7 +354,7 @@ a { } /* ――― Breakpoint overrides ――― */ -@media only screen and (max-width: var(--breakpoint-md)) { +@media only screen and (max-width: 767px) { .content { margin-left: 0; } @@ -435,4 +447,4 @@ a { ::-webkit-scrollbar-thumb { background-color: var(--thumb-color); border-radius: var(--border-radius-sm); -} \ No newline at end of file +} diff --git a/starpilot/system/the_galaxy/assets/components/navigation/navigation_destination.css b/starpilot/system/the_galaxy/assets/components/navigation/navigation_destination.css index 9ce50aaf6e..79e1ebdd96 100644 --- a/starpilot/system/the_galaxy/assets/components/navigation/navigation_destination.css +++ b/starpilot/system/the_galaxy/assets/components/navigation/navigation_destination.css @@ -251,9 +251,16 @@ .map-wrapper { height: 100vh; - left: var(--sidebar-width); + left: 0; position: absolute; - width: calc(100vw - var(--sidebar-width)); + width: 100vw; +} + +@media only screen and (min-width: 768px) { + html.galaxy-sidebar-pinned .map-wrapper { + left: var(--sidebar-width); + width: calc(100vw - var(--sidebar-width)); + } } .mapboxgl-popup-content { diff --git a/starpilot/system/the_galaxy/assets/components/router.js b/starpilot/system/the_galaxy/assets/components/router.js index bfcc56fed3..97d21ac13a 100644 --- a/starpilot/system/the_galaxy/assets/components/router.js +++ b/starpilot/system/the_galaxy/assets/components/router.js @@ -16,7 +16,7 @@ import { NavKeys } from "/assets/components/navigation/navigation_keys.js?v=app- import { RouteRecordings } from "/assets/components/recordings/dashcam_routes.js" import { SettingsView } from "/assets/components/settings.js?v=router-cycle-fix-5" import { ScreenRecordings } from "/assets/components/recordings/screen_recordings.js" -import { Sidebar } from "/assets/components/sidebar.js?v=controllers-nav-1" +import { Sidebar } from "/assets/components/sidebar.js?v=sidebar-pin-2" import { SentryMode } from "/assets/components/tools/sentry.js" import { SpeedLimits } from "/assets/components/tools/speed_limits.js" import { ModelManager } from "/assets/components/tools/model_manager.js?v=20260906a" diff --git a/starpilot/system/the_galaxy/assets/components/sidebar.css b/starpilot/system/the_galaxy/assets/components/sidebar.css index 9f627227d6..e845336d3e 100644 --- a/starpilot/system/the_galaxy/assets/components/sidebar.css +++ b/starpilot/system/the_galaxy/assets/components/sidebar.css @@ -1,11 +1,14 @@ #menu_button { align-items: center; background-color: var(--main-fg); + border: 0; border-radius: 50%; bottom: var(--border-radius-xl); + cursor: pointer; display: flex; height: 50px; justify-content: center; + padding: 0; position: fixed; right: var(--border-radius-xl); width: 50px; @@ -70,11 +73,12 @@ font-size: var(--border-radius-xl); height: 100dvh; justify-content: space-between; - left: 0; + left: calc(-1 * var(--sidebar-width)); min-width: var(--sidebar-width); overflow-y: auto; position: fixed; top: 0; + transition: var(--transition-fast); width: var(--sidebar-width); z-index: var(--z-sidebar); } @@ -194,10 +198,29 @@ margin: 0; } +.sidebar-pin-button { + background: transparent; + border: 0; + color: var(--text-muted); + cursor: pointer; + font-size: var(--font-size-lg); + margin-left: auto; + padding: var(--padding-xs); +} + +.sidebar-pin-button:hover, +.sidebar-pin-button:focus-visible { + color: var(--text-color); +} + .sidebar.visible { left: 0; } +.sidebar.pinned { + left: 0; +} + .sidebar_header a:last-of-type { color: var(--text-muted); font-size: var(--font-size-xs); @@ -230,15 +253,13 @@ transform: var(--hover-scale-sm); } -@media only screen and (max-width: 768px) and (orientation: portrait) { +@media only screen and (max-width: 767px) { #sidebarUnderlay { display: block; } .sidebar { - left: calc(-1 * var(--sidebar-width)); overflow-y: scroll; - transition: var(--transition-fast); } .sidebar .menu_section>li>a { @@ -254,34 +275,10 @@ @media only screen and (min-width: 768px) { #menu_button { + display: flex; + } + + html.galaxy-sidebar-pinned #menu_button { display: none; } } - -@media only screen and (max-width: var(--breakpoint-md)) { - #sidebarUnderlay { - display: block; - } - - .sidebar { - left: calc(-1 * var(--sidebar-width)); - overflow-y: scroll; - transition: var(--transition-fast); - } - - .sidebar .menu_section>li>a { - font-size: var(--padding-sm); - } - - .sidebar .title { - flex-direction: row; - font-size: var(--border-radius-xl); - margin: 0; - } -} - -@media only screen and (min-width: var(--breakpoint-md)) { - #menu_button { - display: none; - } -} \ No newline at end of file diff --git a/starpilot/system/the_galaxy/assets/components/sidebar.js b/starpilot/system/the_galaxy/assets/components/sidebar.js index 1a5cb77c6b..35d0cacd30 100644 --- a/starpilot/system/the_galaxy/assets/components/sidebar.js +++ b/starpilot/system/the_galaxy/assets/components/sidebar.js @@ -37,6 +37,40 @@ const MENU_ITEMS = { }; let galaxyDeveloperMode = false; +const SIDEBAR_PINNED_KEY = "galaxySidebarPinned"; +let sidebarPinnedPreference = null; + +function defaultSidebarPinned() { + try { + return window.matchMedia?.("(min-width: 768px)")?.matches ?? window.innerWidth >= 768; + } catch { + return false; + } +} + +function sidebarPinned() { + if (sidebarPinnedPreference !== null) return sidebarPinnedPreference; + let stored; + try { + stored = window.localStorage?.getItem(SIDEBAR_PINNED_KEY); + } catch { + stored = null; + } + if (stored === "true" || stored === "false") { + sidebarPinnedPreference = stored === "true"; + return sidebarPinnedPreference; + } + return defaultSidebarPinned(); +} + +function setSidebarPinned(pinned) { + sidebarPinnedPreference = Boolean(pinned); + try { + window.localStorage?.setItem(SIDEBAR_PINNED_KEY, String(sidebarPinnedPreference)); + } catch { + } + document.documentElement.classList.toggle("galaxy-sidebar-pinned", sidebarPinnedPreference); +} function matchesPath(currentPath, link) { if (link === "/") return currentPath === "/"; @@ -92,14 +126,29 @@ function bindSidebarHandlers() { if (!menuButton || !underlay) return; - if (!window.__theGalaxySidebarMenuBound) { - window.__theGalaxySidebarMenuBound = true; + const pinButton = document.getElementById("sidebar_pin_button"); + pinButton?.addEventListener("click", () => { + const pinned = !sidebarPinned(); + setSidebarPinned(pinned); + const sidebar = document.getElementById("sidebar"); + hideSidebar(); + sidebar?.classList.toggle("pinned", pinned); + pinButton.setAttribute("aria-pressed", String(pinned)); + pinButton.setAttribute("title", pinned ? "Unpin navigation" : "Pin navigation"); + pinButton.querySelector("i")?.classList.toggle("bi-pin-angle-fill", pinned); + pinButton.querySelector("i")?.classList.toggle("bi-pin-angle", !pinned); + }); + + if (menuButton.dataset.boundClick !== "1") { + menuButton.dataset.boundClick = "1"; menuButton.addEventListener("click", () => { const sidebar = document.getElementById("sidebar"); const currentUnderlay = document.getElementById("sidebarUnderlay"); if (!sidebar || !currentUnderlay) return; - sidebar.classList.toggle("visible"); - currentUnderlay.classList.toggle("hidden"); + if (sidebar.classList.contains("pinned")) return; + const open = sidebar.classList.toggle("visible"); + currentUnderlay.classList.toggle("hidden", !open); + menuButton.setAttribute("aria-expanded", String(open)); }); } @@ -132,19 +181,25 @@ function renderSidebarIntoShell(currentPath) { if (!shell) return; const activePath = currentPath || window.location.pathname; + const pinned = sidebarPinned(); const sectionsMarkup = Object.entries(MENU_ITEMS) .map(([section, links]) => buildSectionMarkup(section, links, activePath)) .join(""); shell.innerHTML = ` -