From f63ee3375a63601a9ecfb28988f3fa9af30a7010 Mon Sep 17 00:00:00 2001 From: firestar5683 <168790843+firestar5683@users.noreply.github.com> Date: Fri, 21 Aug 2026 13:21:20 -0500 Subject: [PATCH] Comover --- cereal/custom.capnp | 1 + .../opendbc/car/hyundai/carcontroller.py | 19 ++- opendbc_repo/opendbc/car/hyundai/interface.py | 7 +- .../opendbc/car/hyundai/tests/test_hyundai.py | 18 ++ opendbc_repo/opendbc/car/hyundai/values.py | 7 +- selfdrive/assets/.gitignore | 2 + selfdrive/assets/fonts/como-heavy.fnt | 17 ++ selfdrive/assets/fonts/como-heavy.otf | Bin 0 -> 76364 bytes selfdrive/assets/fonts/como-heavy.png | Bin 0 -> 24382 bytes selfdrive/assets/fonts/process.py | 6 +- .../lib/longitudinal_vehicle_tunes.py | 7 + .../tests/test_longitudinal_planner.py | 23 +++ .../tests/test_starpilot_acceleration.py | 26 ++- selfdrive/ui/layouts/home.py | 29 +++- selfdrive/ui/mici/layouts/home.py | 2 +- selfdrive/ui/onroad/starpilot/pulse_glide.py | 27 +++ .../onroad/starpilot/starpilot_onroad_view.py | 8 +- starpilot/common/starpilot_functions.py | 72 +++++--- starpilot/common/testing_grounds.py | 5 +- .../common/tests/test_starpilot_functions.py | 156 ++++++++++++++++++ .../common/tests/test_testing_grounds.py | 14 +- .../controls/lib/starpilot_acceleration.py | 35 +++- starpilot/controls/starpilot_card.py | 13 ++ starpilot/controls/starpilot_planner.py | 6 +- .../controls/tests/test_starpilot_card.py | 42 +++++ system/ui/lib/application.py | 6 + system/ui/lib/tests/test_application.py | 32 ++++ 27 files changed, 527 insertions(+), 53 deletions(-) create mode 100644 selfdrive/assets/fonts/como-heavy.fnt create mode 100644 selfdrive/assets/fonts/como-heavy.otf create mode 100644 selfdrive/assets/fonts/como-heavy.png create mode 100644 selfdrive/ui/onroad/starpilot/pulse_glide.py diff --git a/cereal/custom.capnp b/cereal/custom.capnp index c577ad906..a8ba066a5 100644 --- a/cereal/custom.capnp +++ b/cereal/custom.capnp @@ -220,6 +220,7 @@ struct StarPilotPlan @0xf98d843bfd7004a3 { disableThrottle @35 :Bool; trackingLead @36 :Bool; stopSignConfirmed @37 :Bool; + pulseGlideCoasting @38 :Bool; # developer-only P&G phase for on-road status UI } struct StarPilotRadarState @0xb86e6369214c01c8 { diff --git a/opendbc_repo/opendbc/car/hyundai/carcontroller.py b/opendbc_repo/opendbc/car/hyundai/carcontroller.py index 32a4c1097..670a150c0 100644 --- a/opendbc_repo/opendbc/car/hyundai/carcontroller.py +++ b/opendbc_repo/opendbc/car/hyundai/carcontroller.py @@ -9,10 +9,12 @@ from opendbc.car.common.conversions import Conversions as CV from opendbc.car.hyundai import hyundaicanfd, hyundaican from opendbc.car.hyundai.hyundaicanfd import CanBus from opendbc.car.hyundai.values import HyundaiFlags, Buttons, CarControllerParams, CAR, CANFD_ANGLE_LONGITUDINAL_CAR, \ - CANFD_RADAR_LIVE_LONGITUDINAL_CAR, kia_ev6_gt_line_longitudinal_tuning + CANFD_RADAR_LIVE_LONGITUDINAL_CAR, kia_ev6_gt_line_longitudinal_tuning, \ + KIA_EV6_GT_LINE_LONG_TUNING_TESTING_GROUND_ID from opendbc.car.interfaces import CarControllerBase from opendbc.car.vehicle_model import VehicleModel from openpilot.common.params import Params +from openpilot.starpilot.common.testing_grounds import testing_ground VisualAlert = structs.CarControl.HUDControl.VisualAlert LongCtrlState = structs.CarControl.Actuators.LongControlState @@ -79,7 +81,10 @@ BLINDSPOT_WARNING_SOUND_SAMPLES = 36 def egmp_dynamic_longitudinal_tuning(CP) -> bool: return CP.carFingerprint in (CAR.HYUNDAI_IONIQ_6, CAR.KIA_EV9, CAR.HYUNDAI_IONIQ_5_PE) or \ - kia_ev6_gt_line_longitudinal_tuning(CP.carFingerprint, getattr(CP, "carVin", "")) + kia_ev6_gt_line_longitudinal_tuning( + CP.carFingerprint, getattr(CP, "carVin", ""), + testing_ground.use(KIA_EV6_GT_LINE_LONG_TUNING_TESTING_GROUND_ID), + ) def get_canfd_scc_decel_step(CP) -> float: @@ -87,7 +92,10 @@ def get_canfd_scc_decel_step(CP) -> float: def should_reset_ev6_gt_line_longitudinal_tuning(CP, long_control_state: LongCtrlState) -> bool: - return kia_ev6_gt_line_longitudinal_tuning(CP.carFingerprint, getattr(CP, "carVin", "")) and \ + return kia_ev6_gt_line_longitudinal_tuning( + CP.carFingerprint, getattr(CP, "carVin", ""), + testing_ground.use(KIA_EV6_GT_LINE_LONG_TUNING_TESTING_GROUND_ID), + ) and \ long_control_state == LongCtrlState.off @@ -631,7 +639,10 @@ class CarController(CarControllerBase): use_egmp_dynamic_long_tuning = egmp_dynamic_longitudinal_tuning(self.CP) and self.long_active_ecu and \ actuators.longControlState in (LongCtrlState.starting, LongCtrlState.pid, LongCtrlState.stopping) - is_ev6_gt_line = kia_ev6_gt_line_longitudinal_tuning(self.CP.carFingerprint, getattr(self.CP, "carVin", "")) + is_ev6_gt_line = kia_ev6_gt_line_longitudinal_tuning( + self.CP.carFingerprint, getattr(self.CP, "carVin", ""), + testing_ground.use(KIA_EV6_GT_LINE_LONG_TUNING_TESTING_GROUND_ID), + ) is_ccnc_angle_long = self.CP.carFingerprint in CANFD_ANGLE_LONGITUDINAL_CAR if is_ccnc_angle_long and (self._ev9_long_tuning.stop_request or not CC.enabled or CC.cruiseControl.override): self._ioniq_6_long_tuning = reset_egmp_longitudinal_tuning(self._ioniq_6_long_tuning) diff --git a/opendbc_repo/opendbc/car/hyundai/interface.py b/opendbc_repo/opendbc/car/hyundai/interface.py index b75082688..c9aaffdec 100644 --- a/opendbc_repo/opendbc/car/hyundai/interface.py +++ b/opendbc_repo/opendbc/car/hyundai/interface.py @@ -12,13 +12,15 @@ from opendbc.car.hyundai.values import HyundaiFlags, CAR, CarControllerParams, \ CAN_CANFD_BLENDED_HDA2_LONGITUDINAL_CAR, \ HyundaiStarPilotSafetyFlags, \ hyundai_cancel_button_enables_cruise, \ - kia_ev6_gt_line_longitudinal_tuning + kia_ev6_gt_line_longitudinal_tuning, \ + KIA_EV6_GT_LINE_LONG_TUNING_TESTING_GROUND_ID from opendbc.car.hyundai.radar_interface import get_radar_track_config, radar_tracks_available from opendbc.car.interfaces import CarInterfaceBase, ACCEL_MIN from opendbc.car.disable_ecu import disable_ecu, ecu_log from opendbc.car.hyundai.carcontroller import CarController from opendbc.car.hyundai.carstate import CarState from opendbc.car.hyundai.radar_interface import RadarInterface +from openpilot.starpilot.common.testing_grounds import testing_ground ButtonType = structs.CarState.ButtonEvent.Type Ecu = structs.CarParams.Ecu @@ -106,7 +108,8 @@ class CarInterface(CarInterfaceBase): @staticmethod def apply_post_fingerprint_params(CP: structs.CarParams, candidate, fingerprint, car_fw) -> None: - if kia_ev6_gt_line_longitudinal_tuning(CP.carFingerprint, CP.carVin): + gt_line_testing_ground = testing_ground.use(KIA_EV6_GT_LINE_LONG_TUNING_TESTING_GROUND_ID) + if kia_ev6_gt_line_longitudinal_tuning(CP.carFingerprint, CP.carVin, gt_line_testing_ground): apply_kia_ev6_gt_line_longitudinal_params(CP) @staticmethod diff --git a/opendbc_repo/opendbc/car/hyundai/tests/test_hyundai.py b/opendbc_repo/opendbc/car/hyundai/tests/test_hyundai.py index 1670b0117..93dd1073c 100644 --- a/opendbc_repo/opendbc/car/hyundai/tests/test_hyundai.py +++ b/opendbc_repo/opendbc/car/hyundai/tests/test_hyundai.py @@ -1043,6 +1043,24 @@ class TestHyundaiFingerprint: assert reset_state.accel_last == pytest.approx(0.0) assert reset_state.long_control_state_last == LongCtrlState.off + def test_kia_ev6_gt_line_testing_ground_longitudinal_params(self, monkeypatch): + toggles = get_test_toggles() + CP = CarInterface.get_params(CAR.KIA_EV6, gen_empty_fingerprint(), [], True, False, False, toggles) + CP.carVin = "00000000000000000" + + monkeypatch.setattr( + "opendbc.car.hyundai.interface.testing_ground", + SimpleNamespace(use=lambda slot_id: slot_id == "5"), + ) + CarInterface.apply_post_fingerprint_params(CP, CAR.KIA_EV6, gen_empty_fingerprint(), []) + + assert CP.startAccel == pytest.approx(1.4) + assert CP.vEgoStarting == pytest.approx(0.5) + assert CP.longitudinalActuatorDelay == pytest.approx(0.35) + + assert kia_ev6_gt_line_longitudinal_tuning(CP.carFingerprint, CP.carVin, testing_ground_active=True) + assert not kia_ev6_gt_line_longitudinal_tuning(CAR.KIA_EV6_2025, CP.carVin, testing_ground_active=True) + def test_kia_ev6_non_gt_line_keeps_family_longitudinal_params(self): toggles = get_test_toggles() CP = CarInterface.get_params(CAR.KIA_EV6, gen_empty_fingerprint(), [], True, False, False, toggles) diff --git a/opendbc_repo/opendbc/car/hyundai/values.py b/opendbc_repo/opendbc/car/hyundai/values.py index eecc2ec7a..16eb84388 100644 --- a/opendbc_repo/opendbc/car/hyundai/values.py +++ b/opendbc_repo/opendbc/car/hyundai/values.py @@ -975,6 +975,7 @@ CAN_CANFD_BLENDED_HDA2_LONGITUDINAL_CAR = frozenset({ KIA_EV6_GT_LINE_LONG_TUNING_VDS_PREFIXES = frozenset({ "C4DLC", }) +KIA_EV6_GT_LINE_LONG_TUNING_TESTING_GROUND_ID = "5" ALT_BUS_LDA_BUTTON_CARS = frozenset() @@ -985,9 +986,9 @@ def hyundai_cancel_button_enables_cruise(car_fingerprint) -> bool: return car_fingerprint in CANCEL_BUTTON_ENABLE_CARS -def kia_ev6_gt_line_longitudinal_tuning(car_fingerprint, vin: str) -> bool: - return car_fingerprint == CAR.KIA_EV6 and isinstance(vin, str) and \ - len(vin) == 17 and vin[3:8] in KIA_EV6_GT_LINE_LONG_TUNING_VDS_PREFIXES +def kia_ev6_gt_line_longitudinal_tuning(car_fingerprint, vin: str, testing_ground_active: bool = False) -> bool: + vin_match = isinstance(vin, str) and len(vin) == 17 and vin[3:8] in KIA_EV6_GT_LINE_LONG_TUNING_VDS_PREFIXES + return car_fingerprint == CAR.KIA_EV6 and (vin_match or testing_ground_active) def get_platform_codes(fw_versions: list[bytes]) -> set[tuple[bytes, bytes | None]]: diff --git a/selfdrive/assets/.gitignore b/selfdrive/assets/.gitignore index 2d97f8b11..342359088 100644 --- a/selfdrive/assets/.gitignore +++ b/selfdrive/assets/.gitignore @@ -1,2 +1,4 @@ fonts/*.fnt fonts/*.png +!fonts/como-heavy.fnt +!fonts/como-heavy.png diff --git a/selfdrive/assets/fonts/como-heavy.fnt b/selfdrive/assets/fonts/como-heavy.fnt new file mode 100644 index 000000000..018abbb1b --- /dev/null +++ b/selfdrive/assets/fonts/como-heavy.fnt @@ -0,0 +1,17 @@ +info face="como-heavy" size=-200 bold=0 italic=0 charset="" unicode=1 stretchH=100 smooth=0 aa=1 padding=0,0,0,0 spacing=0,0 outline=0 +common lineHeight=200 base=200 scaleW=1024 scaleH=512 pages=1 packed=0 alphaChnl=0 redChnl=4 greenChnl=4 blueChnl=4 +page id=0 file="como-heavy.png" +chars count=13 +char id=32 x=6 y=6 width=39 height=200 xoffset=0 yoffset=0 xadvance=39 page=0 chnl=15 +char id=63 x=57 y=6 width=81 height=115 xoffset=0 yoffset=47 xadvance=83 page=0 chnl=15 +char id=80 x=150 y=6 width=100 height=113 xoffset=8 yoffset=49 xadvance=110 page=0 chnl=15 +char id=83 x=262 y=6 width=101 height=116 xoffset=4 yoffset=47 xadvance=108 page=0 chnl=15 +char id=97 x=375 y=6 width=88 height=85 xoffset=5 yoffset=78 xadvance=100 page=0 chnl=15 +char id=105 x=475 y=6 width=31 height=121 xoffset=6 yoffset=41 xadvance=43 page=0 chnl=15 +char id=108 x=518 y=6 width=29 height=119 xoffset=7 yoffset=43 xadvance=43 page=0 chnl=15 +char id=111 x=559 y=6 width=88 height=85 xoffset=5 yoffset=78 xadvance=98 page=0 chnl=15 +char id=112 x=659 y=6 width=88 height=115 xoffset=7 yoffset=78 xadvance=100 page=0 chnl=15 +char id=114 x=759 y=6 width=63 height=83 xoffset=7 yoffset=79 xadvance=69 page=0 chnl=15 +char id=115 x=834 y=6 width=77 height=85 xoffset=4 yoffset=78 xadvance=84 page=0 chnl=15 +char id=116 x=923 y=6 width=63 height=111 xoffset=6 yoffset=52 xadvance=70 page=0 chnl=15 +char id=122 x=6 y=218 width=79 height=81 xoffset=3 yoffset=80 xadvance=85 page=0 chnl=15 diff --git a/selfdrive/assets/fonts/como-heavy.otf b/selfdrive/assets/fonts/como-heavy.otf new file mode 100644 index 0000000000000000000000000000000000000000..426cb86ae7a10c4e97e0ca6c4590e0c21f997642 GIT binary patch literal 76364 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 Mb#??lG~a~$A4$?$`v3p{ literal 0 HcmV?d00001 diff --git a/selfdrive/assets/fonts/como-heavy.png b/selfdrive/assets/fonts/como-heavy.png new file mode 100644 index 0000000000000000000000000000000000000000..28b9b3584334faf063210f78d30e8e8a7d5eb98e GIT binary patch literal 24382 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

#|{}d^R`RR{Or=2O!D=$<` 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 0.0 + assert release_rate > 0.0 + + initial = planner.get_vehicle_far_follow_slew_target( + v_ego, prev_target=0.0, target=-0.6, output_should_stop=False, panic_bypass=False, + ) + smoothed = planner.get_vehicle_far_follow_slew_target( + v_ego, prev_target=initial, target=0.4, output_should_stop=False, panic_bypass=False, + ) + + assert initial == pytest.approx(-0.6) + assert smoothed == pytest.approx(initial + release_rate * planner.dt) + + def test_depart_release_hold_rejects_nearby_stopped_lead_conflict(): CP = CarInterface.get_non_essential_params(CAR.HONDA_CIVIC) planner = LongitudinalPlanner(CP) diff --git a/selfdrive/controls/tests/test_starpilot_acceleration.py b/selfdrive/controls/tests/test_starpilot_acceleration.py index e29fa451e..23836a5ec 100644 --- a/selfdrive/controls/tests/test_starpilot_acceleration.py +++ b/selfdrive/controls/tests/test_starpilot_acceleration.py @@ -1,3 +1,4 @@ +import math from types import SimpleNamespace import pytest @@ -8,6 +9,7 @@ from openpilot.starpilot.common.accel_profile import A_CRUISE_MAX_BP_CUSTOM, ACC from openpilot.starpilot.controls.lib.starpilot_acceleration import ( A_CRUISE_MIN_ECO, A_CRUISE_MIN_TRAFFIC, + PULSE_GLIDE_COAST_MIN_ACCEL, StarPilotAcceleration, get_max_accel_eco, get_max_accel_standard, @@ -55,9 +57,11 @@ def make_lead(status=False, d_rel=150.0, v_lead=0.0, a_lead_k=0.0): def make_sm(*, set_speed_kph=100.0, lead_one=None, lead_two=None, standstill=False, force_decel=False, - eco_gear=False, sport_gear=False, force_coast=False, pulse_and_glide=False, traffic_mode=False, v_ego_cluster=0.0): + eco_gear=False, sport_gear=False, force_coast=False, pulse_and_glide=False, traffic_mode=False, + v_ego_cluster=0.0, pitch=0.0): return { "carState": SimpleNamespace(vCruise=set_speed_kph, standstill=standstill, vEgoCluster=v_ego_cluster), + "carControl": SimpleNamespace(orientationNED=[0.0, pitch, 0.0]), "controlsState": SimpleNamespace(forceDecel=force_decel), "radarState": SimpleNamespace( leadOne=lead_one or make_lead(), @@ -222,7 +226,7 @@ def test_pulse_and_glide_coasts_at_set_speed_then_resumes_below_delta(): accel.update(set_speed, make_sm(set_speed_kph=100.0, pulse_and_glide=True), toggles) assert accel.pulse_glide_coasting is True assert accel.pulse_glide_target == pytest.approx(set_speed - delta) - assert accel.min_accel == pytest.approx(A_CRUISE_MIN_ECO) + assert accel.min_accel == pytest.approx(PULSE_GLIDE_COAST_MIN_ACCEL) accel.update((90.0 * CV.KPH_TO_MS) - 0.05, make_sm(set_speed_kph=100.0, pulse_and_glide=True), toggles) assert accel.pulse_glide_coasting is False @@ -232,7 +236,23 @@ def test_pulse_and_glide_coasts_at_set_speed_then_resumes_below_delta(): accel.update(99.8 * CV.KPH_TO_MS, make_sm(set_speed_kph=100.0, pulse_and_glide=True), toggles) assert accel.pulse_glide_coasting is True assert accel.pulse_glide_target == pytest.approx(set_speed - delta) - assert accel.min_accel == pytest.approx(A_CRUISE_MIN_ECO) + assert accel.min_accel == pytest.approx(PULSE_GLIDE_COAST_MIN_ACCEL) + + +def test_pulse_and_glide_pauses_on_steep_grade_then_resumes(): + set_speed = 100.0 * CV.KPH_TO_MS + delta = 10.0 * CV.KPH_TO_MS + accel = StarPilotAcceleration(FakePlanner(v_cruise=set_speed)) + toggles = make_toggles(pulse_glide_speed_delta=delta) + + accel.update(set_speed, make_sm(set_speed_kph=100.0, pulse_and_glide=True, pitch=math.radians(4.0)), toggles) + assert accel.pulse_glide_coasting is False + assert accel.pulse_glide_hill_paused is True + + accel.update(set_speed, make_sm(set_speed_kph=100.0, pulse_and_glide=True, pitch=math.radians(2.0)), toggles) + assert accel.pulse_glide_coasting is True + assert accel.pulse_glide_hill_paused is False + assert accel.min_accel == pytest.approx(PULSE_GLIDE_COAST_MIN_ACCEL) def test_pulse_and_glide_is_inert_when_disabled(): diff --git a/selfdrive/ui/layouts/home.py b/selfdrive/ui/layouts/home.py index bac83f2b7..5037b8a57 100644 --- a/selfdrive/ui/layouts/home.py +++ b/selfdrive/ui/layouts/home.py @@ -181,12 +181,31 @@ class HomeLayout(Widget): version_rect = rl.Rectangle(self.header_rect.x + self.header_rect.width - version_text_width, self.header_rect.y, version_text_width, self.header_rect.height) + brand_text = "StarPilot" + detail_text = self._version_text.removeprefix(brand_text) + brand_font = gui_app.font(FontWeight.BRAND) version_font_size = 48 - version_text_size = measure_text_cached(font, self._version_text, version_font_size) - if version_text_size.x > version_rect.width: - version_font_size = max(32, int(version_font_size * version_rect.width / version_text_size.x)) - gui_label(version_rect, self._version_text, version_font_size, rl.WHITE, font_weight=FontWeight.MEDIUM, - alignment=rl.GuiTextAlignment.TEXT_ALIGN_RIGHT) + + def _measure_header(font_size: int) -> tuple[rl.Vector2, rl.Vector2]: + return (measure_text_cached(brand_font, brand_text, font_size + 2), + measure_text_cached(font, detail_text, font_size)) + + brand_size, detail_size = _measure_header(version_font_size) + total_width = brand_size.x + detail_size.x + if total_width > version_rect.width: + version_font_size = max(32, int(version_font_size * version_rect.width / total_width)) + brand_size, detail_size = _measure_header(version_font_size) + total_width = brand_size.x + detail_size.x + + rendered_width = min(total_width, version_rect.width) + text_x = version_rect.x + version_rect.width - rendered_width + brand_rect = rl.Rectangle(text_x, version_rect.y, min(brand_size.x, rendered_width), version_rect.height) + gui_label(brand_rect, brand_text, version_font_size + 2, rl.WHITE, font_weight=FontWeight.BRAND, elide_right=False) + + detail_width = max(0.0, rendered_width - brand_rect.width) + if detail_text and detail_width > 0: + detail_rect = rl.Rectangle(brand_rect.x + brand_rect.width, version_rect.y, detail_width, version_rect.height) + gui_label(detail_rect, detail_text, version_font_size, rl.WHITE, font_weight=FontWeight.MEDIUM) def _render_home_content(self): self._render_left_column() diff --git a/selfdrive/ui/mici/layouts/home.py b/selfdrive/ui/mici/layouts/home.py index dbe126734..3b3ca354a 100644 --- a/selfdrive/ui/mici/layouts/home.py +++ b/selfdrive/ui/mici/layouts/home.py @@ -173,7 +173,7 @@ class MiciHomeLayout(Widget): self._mic_icon, ], spacing=18) - self._openpilot_label = UnifiedLabel("starpilot", font_size=96, font_weight=FontWeight.DISPLAY, max_width=480, wrap_text=False) + self._openpilot_label = UnifiedLabel("StarPilot", font_size=96, font_weight=FontWeight.BRAND, max_width=480, wrap_text=False) self._version_label = UnifiedLabel("", font_size=36, font_weight=FontWeight.ROMAN, max_width=480, wrap_text=False) self._large_version_label = UnifiedLabel("", font_size=64, text_color=rl.GRAY, font_weight=FontWeight.ROMAN, max_width=480, wrap_text=False) self._date_label = UnifiedLabel("", font_size=36, text_color=rl.GRAY, font_weight=FontWeight.ROMAN, max_width=480, wrap_text=False) diff --git a/selfdrive/ui/onroad/starpilot/pulse_glide.py b/selfdrive/ui/onroad/starpilot/pulse_glide.py new file mode 100644 index 000000000..fe90fc023 --- /dev/null +++ b/selfdrive/ui/onroad/starpilot/pulse_glide.py @@ -0,0 +1,27 @@ +import pyray as rl + +from openpilot.system.ui.lib.application import FontWeight, gui_app +from openpilot.system.ui.lib.text_measure import draw_text_with_shadow, measure_text_cached + + +PULSE_COLOR = rl.Color(52, 190, 112, 255) +GLIDE_COLOR = rl.Color(65, 155, 235, 255) + + +def render_pulse_glide(rect: rl.Rectangle, coasting: bool) -> None: + """Render the developer-only P&G phase badge beside the standard HUD badges.""" + border = GLIDE_COLOR if coasting else PULSE_COLOR + label = "GLIDE" if coasting else "PULSE" + font = gui_app.font(FontWeight.BOLD) + font_size = 27 + text_size = measure_text_cached(font, label, font_size) + + rl.draw_rectangle_rounded(rect, 0.3, 10, rl.Color(0, 0, 0, 166)) + rl.draw_rectangle_rounded_lines_ex(rect, 0.3, 10, 4, border) + draw_text_with_shadow( + font, + label, + rl.Vector2(rect.x + (rect.width - text_size.x) / 2, rect.y + (rect.height - text_size.y) / 2), + font_size, + rl.WHITE, + ) diff --git a/selfdrive/ui/onroad/starpilot/starpilot_onroad_view.py b/selfdrive/ui/onroad/starpilot/starpilot_onroad_view.py index 5aca1c42b..0125c3cf6 100644 --- a/selfdrive/ui/onroad/starpilot/starpilot_onroad_view.py +++ b/selfdrive/ui/onroad/starpilot/starpilot_onroad_view.py @@ -14,6 +14,7 @@ from openpilot.selfdrive.ui.onroad.starpilot.widgets import ( ) from openpilot.selfdrive.ui.onroad.starpilot.stopping_point import render_stopping_point from openpilot.selfdrive.ui.onroad.starpilot.pause_indicators import render_lateral_paused, render_longitudinal_paused +from openpilot.selfdrive.ui.onroad.starpilot.pulse_glide import render_pulse_glide from openpilot.selfdrive.ui.onroad.starpilot.pip_sidecam import PipSideCamera from openpilot.selfdrive.ui.onroad.starpilot.favorite_radial_menu import FavoriteRadialMenu from openpilot.selfdrive.ui.onroad.starpilot.weather_icon import render_weather_icon @@ -342,6 +343,7 @@ class StarPilotOnroadView(AugmentedRoadView): # Check pause/CEM states starpilot_car_state = ui_state.sm["starpilotCarState"] if ui_state.sm.valid.get("starpilotCarState", False) else None + plan = ui_state.sm["starpilotPlan"] if ui_state.sm.valid.get("starpilotPlan", False) else None lateral_paused = starpilot_car_state.pauseLateral if starpilot_car_state else False longitudinal_paused = (starpilot_car_state.pauseLongitudinal or starpilot_car_state.forceCoast) if starpilot_car_state else False @@ -352,6 +354,8 @@ class StarPilotOnroadView(AugmentedRoadView): active_badges.append("lateral_paused") if longitudinal_paused: active_badges.append("longitudinal_paused") + if starpilot_car_state and starpilot_car_state.pulseAndGlide: + active_badges.append("pulse_glide") # Dimensions badge_w = 120 @@ -377,9 +381,11 @@ class StarPilotOnroadView(AugmentedRoadView): render_lateral_paused(badge_rect) elif badge == "longitudinal_paused": render_longitudinal_paused(badge_rect) + elif badge == "pulse_glide": + pulse_glide_coasting = bool(getattr(plan, "pulseGlideCoasting", False)) if plan else False + render_pulse_glide(badge_rect, pulse_glide_coasting) # 2. Render Weather (on the opposite side of DM icon) - plan = ui_state.sm["starpilotPlan"] if ui_state.sm.valid.get("starpilotPlan", False) else None if plan and plan.weatherId != 0: weather_w = 120 weather_h = 120 diff --git a/starpilot/common/starpilot_functions.py b/starpilot/common/starpilot_functions.py index 0d6724680..603d398c3 100644 --- a/starpilot/common/starpilot_functions.py +++ b/starpilot/common/starpilot_functions.py @@ -2,6 +2,7 @@ import dataclasses import json import requests +import tempfile import threading import time @@ -34,6 +35,10 @@ from openpilot.starpilot.common.starpilot_variables import ( StarPilotVariables, get_starpilot_toggles ) +BOOT_LOGO_JPEG_PATH = Path("/usr/comma/bg.jpg") +BOOT_LOGO_PNG_PATH = Path("/usr/comma/bg.png") +BOOT_LOGO_MAGIC_PATH = Path("/usr/comma/magic.py") + def seed_desktop_theme_assets(): params = Params() @@ -163,8 +168,6 @@ def update_boot_logo(starpilot=False, stock=False, selected_logo=None): if HARDWARE.get_device_type() == "pc": return - boot_logo_location = Path("/usr/comma/bg.jpg") - if starpilot: target_logo = Path(BASEDIR) / "starpilot/assets/other_images/starpilot_boot_logo.jpg" if selected_logo: @@ -184,30 +187,57 @@ def update_boot_logo(starpilot=False, stock=False, selected_logo=None): print(f"Error: Target logo file not found at {target_logo}") return - source_logo = target_logo - staged_logo = Path("/tmp/starpilot_boot_logo.jpg") try: from PIL import Image - with Image.open(target_logo) as img: - # weston.service always writes a JPEG copy of /usr/comma/bg.jpg; make sure - # the source image is already RGB JPEG to avoid startup failure on RGBA assets. - if img.format != "JPEG" or img.mode != "RGB": - img.convert("RGB").save(staged_logo, format="JPEG", quality=95) - source_logo = staged_logo + with tempfile.TemporaryDirectory(prefix="starpilot_boot_logo_") as staging_dir: + staging_path = Path(staging_dir) + staged_jpeg = staging_path / "bg.jpg" + staged_png = staging_path / "bg.png" + + with Image.open(target_logo) as img: + normalized_logo = img.convert("RGB") + if normalized_logo.width >= normalized_logo.height: + landscape_logo = normalized_logo + weston_logo = normalized_logo.transpose(Image.Transpose.ROTATE_270) + else: + weston_logo = normalized_logo + landscape_logo = normalized_logo.transpose(Image.Transpose.ROTATE_90) + + magic_uses_jpeg = False + try: + magic_uses_jpeg = BOOT_LOGO_JPEG_PATH.as_posix() in BOOT_LOGO_MAGIC_PATH.read_text() + except OSError: + pass + + (landscape_logo if magic_uses_jpeg else weston_logo).save(staged_jpeg, format="JPEG", quality=95) + landscape_logo.save(staged_png, format="PNG") + + logo_variants = [(staged_jpeg, BOOT_LOGO_JPEG_PATH)] + if BOOT_LOGO_PNG_PATH.is_file(): + logo_variants.append((staged_png, BOOT_LOGO_PNG_PATH)) + + pending_updates = [ + (source, destination) + for source, destination in logo_variants + if not destination.is_file() or destination.read_bytes() != source.read_bytes() + ] + if not pending_updates: + return + + mount_options = run_cmd(["findmnt", "-n", "-o", "OPTIONS", "/"], "Successfully retrieved mount options", "Failed to retrieve mount options") + if mount_options is None: + return + if run_cmd(["sudo", "mount", "-o", "remount,rw", "/"], "Successfully remounted / as read-write", "Failed to remount /") is None: + return + + try: + for source, destination in pending_updates: + run_cmd(["sudo", "cp", source, destination], f"Successfully replaced boot logo at {destination}", f"Failed to replace boot logo at {destination}") + finally: + run_cmd(["sudo", "mount", "-o", f"remount,{mount_options}", "/"], "Successfully restored / mount options", "Failed to restore / mount options") except Exception as error: print(f"Error normalizing boot logo {target_logo}: {error}") - if target_logo.suffix.lower() not in {".jpg", ".jpeg"}: - print("Skipping boot logo update to keep weston startup stable.") - return - - current_logo = boot_logo_location.read_bytes() if boot_logo_location.is_file() else b"" - desired_logo = source_logo.read_bytes() - if current_logo != desired_logo: - mount_options = run_cmd(["findmnt", "-n", "-o", "OPTIONS", "/"], "Successfully retrieved mount options", "Failed to retrieve mount options") - run_cmd(["sudo", "mount", "-o", "remount,rw", "/"], "Successfully remounted / as read-write", "Failed to remount /") - run_cmd(["sudo", "cp", source_logo, boot_logo_location], "Successfully replaced boot logo", "Failed to replace boot logo") - run_cmd(["sudo", "mount", "-o", f"remount,{mount_options}", "/"], "Successfully restored / mount options", "Failed to restore / mount options") MAPS_DOWNLOAD_PROGRESS_PARAM = "MapsDownloadProgress" diff --git a/starpilot/common/testing_grounds.py b/starpilot/common/testing_grounds.py index de8f65ee1..8047cb1dd 100644 --- a/starpilot/common/testing_grounds.py +++ b/starpilot/common/testing_grounds.py @@ -63,9 +63,10 @@ TESTING_GROUNDS_SLOT_DEFINITIONS = ( }, { "id": TESTING_GROUND_5, - "name": "Unused", - "description": "Unused slot.", + "name": "EV6 GT-Line Long", + "description": "Kia EV6 GT-Line longitudinal tuning sandbox.", "aLabel": "A - Installed tune", + "bLabel": "B - EV6 GT-Line long tune", }, { "id": TESTING_GROUND_6, diff --git a/starpilot/common/tests/test_starpilot_functions.py b/starpilot/common/tests/test_starpilot_functions.py index 39025b7f5..8e19f9896 100644 --- a/starpilot/common/tests/test_starpilot_functions.py +++ b/starpilot/common/tests/test_starpilot_functions.py @@ -1,3 +1,8 @@ +import shutil + +import pytest +from PIL import Image + from openpilot.starpilot.common import connect_server as cs from openpilot.starpilot.common import starpilot_functions as sf @@ -27,6 +32,157 @@ class FakeThreadManager: return False +@pytest.mark.parametrize("extension,image_format", [("jpg", "JPEG"), ("png", "PNG")]) +def test_update_boot_logo_writes_agnos_jpeg_and_png(monkeypatch, tmp_path, extension, image_format): + themes_path = tmp_path / "themes" + custom_logo = themes_path / "bootlogos" / f"custom.{extension}" + custom_logo.parent.mkdir(parents=True) + Image.new("RGBA" if image_format == "PNG" else "RGB", (24, 12), (12, 34, 56, 255)).save(custom_logo, format=image_format) + + jpeg_destination = tmp_path / "usr" / "comma" / "bg.jpg" + png_destination = tmp_path / "usr" / "comma" / "bg.png" + jpeg_destination.parent.mkdir(parents=True) + jpeg_destination.write_bytes(b"old jpeg") + png_destination.write_bytes(b"old png") + + commands = [] + + def fake_run_cmd(command, *_args, **_kwargs): + commands.append(command) + if command[0] == "findmnt": + return "ro,relatime" + if command[:2] == ["sudo", "cp"]: + shutil.copy2(command[2], command[3]) + return "" + + monkeypatch.setattr(sf.HARDWARE, "get_device_type", lambda: "mici") + monkeypatch.setattr(sf, "THEME_SAVE_PATH", themes_path) + monkeypatch.setattr(sf, "BOOT_LOGO_JPEG_PATH", jpeg_destination) + monkeypatch.setattr(sf, "BOOT_LOGO_PNG_PATH", png_destination) + monkeypatch.setattr(sf, "BOOT_LOGO_MAGIC_PATH", tmp_path / "usr" / "comma" / "magic.py") + monkeypatch.setattr(sf, "run_cmd", fake_run_cmd) + + sf.update_boot_logo(starpilot=True, selected_logo="custom") + + with Image.open(jpeg_destination) as jpeg_logo: + assert jpeg_logo.format == "JPEG" + assert jpeg_logo.mode == "RGB" + assert jpeg_logo.size == (12, 24) + with Image.open(png_destination) as png_logo: + assert png_logo.format == "PNG" + assert png_logo.mode == "RGB" + assert png_logo.size == (24, 12) + + assert [command for command in commands if command[:2] == ["sudo", "mount"]] == [ + ["sudo", "mount", "-o", "remount,rw", "/"], + ["sudo", "mount", "-o", "remount,ro,relatime", "/"], + ] + assert len([command for command in commands if command[:2] == ["sudo", "cp"]]) == 2 + + +def test_update_boot_logo_does_not_create_png_on_legacy_agnos(monkeypatch, tmp_path): + themes_path = tmp_path / "themes" + custom_logo = themes_path / "bootlogos" / "custom.png" + custom_logo.parent.mkdir(parents=True) + Image.new("RGB", (24, 12), (12, 34, 56)).save(custom_logo, format="PNG") + + jpeg_destination = tmp_path / "usr" / "comma" / "bg.jpg" + png_destination = tmp_path / "usr" / "comma" / "bg.png" + jpeg_destination.parent.mkdir(parents=True) + jpeg_destination.write_bytes(b"old jpeg") + + def fake_run_cmd(command, *_args, **_kwargs): + if command[0] == "findmnt": + return "ro,relatime" + if command[:2] == ["sudo", "cp"]: + shutil.copy2(command[2], command[3]) + return "" + + monkeypatch.setattr(sf.HARDWARE, "get_device_type", lambda: "tici") + monkeypatch.setattr(sf, "THEME_SAVE_PATH", themes_path) + monkeypatch.setattr(sf, "BOOT_LOGO_JPEG_PATH", jpeg_destination) + monkeypatch.setattr(sf, "BOOT_LOGO_PNG_PATH", png_destination) + monkeypatch.setattr(sf, "BOOT_LOGO_MAGIC_PATH", tmp_path / "usr" / "comma" / "magic.py") + monkeypatch.setattr(sf, "run_cmd", fake_run_cmd) + + sf.update_boot_logo(starpilot=True, selected_logo="custom") + + with Image.open(jpeg_destination) as jpeg_logo: + assert jpeg_logo.format == "JPEG" + assert jpeg_logo.size == (12, 24) + assert not png_destination.exists() + + +def test_update_boot_logo_rotates_legacy_stock_for_raylib(monkeypatch, tmp_path): + stock_logo = tmp_path / "starpilot" / "assets" / "other_images" / "stock_bg.jpg" + stock_logo.parent.mkdir(parents=True) + Image.new("RGB", (12, 24), (12, 34, 56)).save(stock_logo, format="JPEG") + + comma_path = tmp_path / "usr" / "comma" + jpeg_destination = comma_path / "bg.jpg" + png_destination = comma_path / "bg.png" + comma_path.mkdir(parents=True) + jpeg_destination.write_bytes(b"old jpeg") + png_destination.write_bytes(b"old png") + + def fake_run_cmd(command, *_args, **_kwargs): + if command[0] == "findmnt": + return "ro,relatime" + if command[:2] == ["sudo", "cp"]: + shutil.copy2(command[2], command[3]) + return "" + + monkeypatch.setattr(sf.HARDWARE, "get_device_type", lambda: "mici") + monkeypatch.setattr(sf, "BASEDIR", str(tmp_path)) + monkeypatch.setattr(sf, "BOOT_LOGO_JPEG_PATH", jpeg_destination) + monkeypatch.setattr(sf, "BOOT_LOGO_PNG_PATH", png_destination) + monkeypatch.setattr(sf, "BOOT_LOGO_MAGIC_PATH", comma_path / "magic.py") + monkeypatch.setattr(sf, "run_cmd", fake_run_cmd) + + sf.update_boot_logo(stock=True) + + with Image.open(jpeg_destination) as jpeg_logo: + assert jpeg_logo.size == (12, 24) + with Image.open(png_destination) as png_logo: + assert png_logo.size == (24, 12) + + +def test_update_boot_logo_uses_landscape_jpeg_for_current_magic(monkeypatch, tmp_path): + themes_path = tmp_path / "themes" + custom_logo = themes_path / "bootlogos" / "custom.png" + custom_logo.parent.mkdir(parents=True) + Image.new("RGB", (12, 24), (12, 34, 56)).save(custom_logo, format="PNG") + + comma_path = tmp_path / "usr" / "comma" + jpeg_destination = comma_path / "bg.jpg" + png_destination = comma_path / "bg.png" + magic_path = comma_path / "magic.py" + comma_path.mkdir(parents=True) + jpeg_destination.write_bytes(b"old jpeg") + magic_path.write_text(f'BACKGROUND = "{jpeg_destination.as_posix()}"\n') + + def fake_run_cmd(command, *_args, **_kwargs): + if command[0] == "findmnt": + return "ro,relatime" + if command[:2] == ["sudo", "cp"]: + shutil.copy2(command[2], command[3]) + return "" + + monkeypatch.setattr(sf.HARDWARE, "get_device_type", lambda: "mici") + monkeypatch.setattr(sf, "THEME_SAVE_PATH", themes_path) + monkeypatch.setattr(sf, "BOOT_LOGO_JPEG_PATH", jpeg_destination) + monkeypatch.setattr(sf, "BOOT_LOGO_PNG_PATH", png_destination) + monkeypatch.setattr(sf, "BOOT_LOGO_MAGIC_PATH", magic_path) + monkeypatch.setattr(sf, "run_cmd", fake_run_cmd) + + sf.update_boot_logo(starpilot=True, selected_logo="custom") + + with Image.open(jpeg_destination) as jpeg_logo: + assert jpeg_logo.format == "JPEG" + assert jpeg_logo.size == (24, 12) + assert not png_destination.exists() + + def test_automatic_update_requests_guarded_reboot(monkeypatch): params = FakeParams({ "UpdaterState": "idle", diff --git a/starpilot/common/tests/test_testing_grounds.py b/starpilot/common/tests/test_testing_grounds.py index db4447e06..d457f5626 100644 --- a/starpilot/common/tests/test_testing_grounds.py +++ b/starpilot/common/tests/test_testing_grounds.py @@ -1,23 +1,21 @@ import json -import pytest - from openpilot.starpilot.common import testing_grounds as tg -@pytest.mark.parametrize("hidden_slot_id", [tg.TESTING_GROUND_5]) -def test_hidden_testing_ground_selection_is_migrated(tmp_path, monkeypatch, hidden_slot_id): +def test_invalid_testing_ground_selection_is_migrated(tmp_path, monkeypatch): + invalid_slot_id = "99" state_path = tmp_path / "slots.json" state_path.write_text(json.dumps({ "schemaVersion": tg.TESTING_GROUNDS_SCHEMA_VERSION, - "activeSlot": hidden_slot_id, + "activeSlot": invalid_slot_id, "activeVariant": tg.TESTING_GROUND_TEST_VARIANT, }), encoding="utf-8") monkeypatch.setattr(tg, "TESTING_GROUNDS_STATE_PATH", state_path) monkeypatch.setattr(tg, "_CACHE_LAST_REFRESH", 0.0) monkeypatch.setattr(tg, "_CACHE_LAST_MTIME_NS", -1) - monkeypatch.setattr(tg, "_VISIBLE_TESTING_GROUND_IDS", tuple(slot_id for slot_id in tg.TESTING_GROUND_IDS if slot_id != hidden_slot_id)) + monkeypatch.setattr(tg, "_VISIBLE_TESTING_GROUND_IDS", tuple(tg.TESTING_GROUND_IDS)) monkeypatch.setattr(tg, "_DEFAULT_ACTIVE_SLOT", tg.TESTING_GROUND_1) monkeypatch.setattr(tg, "_CACHE_ACTIVE_SLOT", tg._DEFAULT_ACTIVE_SLOT) monkeypatch.setattr(tg, "_CACHE_ACTIVE_VARIANT", tg.DEFAULT_TESTING_GROUND_VARIANT) @@ -32,11 +30,11 @@ def test_hidden_testing_ground_selection_is_migrated(tmp_path, monkeypatch, hidd assert payload["activeVariant"] == tg.DEFAULT_TESTING_GROUND_VARIANT -def test_hidden_slot_invalid_variant_is_migrated_off_slot(tmp_path, monkeypatch): +def test_invalid_slot_variant_is_migrated_off_slot(tmp_path, monkeypatch): state_path = tmp_path / "slots.json" state_path.write_text(json.dumps({ "schemaVersion": tg.TESTING_GROUNDS_SCHEMA_VERSION, - "activeSlot": tg.TESTING_GROUND_5, + "activeSlot": "99", "activeVariant": "C", }), encoding="utf-8") diff --git a/starpilot/controls/lib/starpilot_acceleration.py b/starpilot/controls/lib/starpilot_acceleration.py index d359a08df..f3c4ee05f 100644 --- a/starpilot/controls/lib/starpilot_acceleration.py +++ b/starpilot/controls/lib/starpilot_acceleration.py @@ -1,4 +1,6 @@ #!/usr/bin/env python3 +import math + import numpy as np from openpilot.common.constants import CV @@ -79,6 +81,9 @@ RELEVANT_LEAD_MIN_BRAKE = -0.4 PULSE_GLIDE_MIN_TARGET_SPEED = 5.0 PULSE_GLIDE_MIN_LOWER_SPEED = 3.0 PULSE_GLIDE_HYSTERESIS = 0.25 +PULSE_GLIDE_COAST_MIN_ACCEL = -0.03 +PULSE_GLIDE_HILL_ENTER_PITCH = math.radians(3.0) +PULSE_GLIDE_HILL_EXIT_PITCH = math.radians(2.5) # Drive mode -> profile mapping used by the map_acceleration / map_deceleration toggles. GEAR_STATE_PROFILES = { @@ -153,11 +158,37 @@ class StarPilotAcceleration: self.last_gear_state = "init" self.pulse_glide_coasting = False self.pulse_glide_target = None + self.pulse_glide_hill_paused = False + + def _update_pulse_glide_hill_pause(self, sm): + try: + orientation_ned = sm["carControl"].orientationNED + if len(orientation_ned) < 2: + return self.pulse_glide_hill_paused + abs_pitch = abs(float(orientation_ned[1])) + except (KeyError, IndexError, TypeError, ValueError, AttributeError): + return self.pulse_glide_hill_paused + + if not math.isfinite(abs_pitch): + return self.pulse_glide_hill_paused + + if self.pulse_glide_hill_paused: + if abs_pitch <= PULSE_GLIDE_HILL_EXIT_PITCH: + self.pulse_glide_hill_paused = False + elif abs_pitch >= PULSE_GLIDE_HILL_ENTER_PITCH: + self.pulse_glide_hill_paused = True + + return self.pulse_glide_hill_paused def _update_pulse_glide(self, v_ego, sm, starpilot_toggles): self.pulse_glide_target = None pulse_glide_enabled = bool(getattr(sm["starpilotCarState"], "pulseAndGlide", False)) if not pulse_glide_enabled: + self.pulse_glide_coasting = False + self.pulse_glide_hill_paused = False + return False + + if self._update_pulse_glide_hill_pause(sm): self.pulse_glide_coasting = False return False @@ -254,8 +285,10 @@ class StarPilotAcceleration: self.max_accel -= self.max_accel * self.starpilot_planner.starpilot_weather.reduce_acceleration pulse_glide_coasting = self._update_pulse_glide(v_ego, sm, starpilot_toggles) - if sm["starpilotCarState"].forceCoast or pulse_glide_coasting: + if sm["starpilotCarState"].forceCoast: self.min_accel = A_CRUISE_MIN_ECO + elif pulse_glide_coasting: + self.min_accel = PULSE_GLIDE_COAST_MIN_ACCEL elif sm["starpilotCarState"].trafficModeEnabled: self.min_accel = A_CRUISE_MIN_TRAFFIC elif starpilot_toggles.map_deceleration and (eco_gear or sport_gear): diff --git a/starpilot/controls/starpilot_card.py b/starpilot/controls/starpilot_card.py index afd3a5a05..0d24a6401 100644 --- a/starpilot/controls/starpilot_card.py +++ b/starpilot/controls/starpilot_card.py @@ -1,4 +1,5 @@ #!/usr/bin/env python3 +from opendbc.car import structs from opendbc.car.chrysler.values import pacifica_hybrid_aol_requires_set_press from opendbc.car.hyundai.values import CAR as HYUNDAI_CAR, HyundaiFlags from opendbc.safety import ALTERNATIVE_EXPERIENCE @@ -276,6 +277,18 @@ class StarPilotCard: elif not self.distancePressed_previously: self.gap_counter = 0 + distance_released = not starpilotCarState.distancePressed and self.distancePressed_previously + has_distance_release = any( + self._button_type_raw(be) == int(ButtonType.gapAdjustCruise) and not be.pressed + for be in carState.buttonEvents + ) + if getattr(self.CP, "carFingerprint", None) == HYUNDAI_CAR.HYUNDAI_ELANTRA_HEV_2024 and \ + distance_released and not has_distance_release: + carState.buttonEvents = [ + *carState.buttonEvents, + structs.CarState.ButtonEvent(pressed=False, type=ButtonType.gapAdjustCruise), + ] + self.distancePressed_previously = starpilotCarState.distancePressed if not starpilotCarState.distancePressed and 1 <= self.gap_counter < self.long_press_threshold: diff --git a/starpilot/controls/starpilot_planner.py b/starpilot/controls/starpilot_planner.py index e9b37bc6e..650bdecb1 100644 --- a/starpilot/controls/starpilot_planner.py +++ b/starpilot/controls/starpilot_planner.py @@ -329,7 +329,11 @@ class StarPilotPlanner: starpilotPlan.cscTraining = self.starpilot_vcruise.csc.enable_training starpilotPlan.desiredFollowDistance = int(self.starpilot_following.desired_follow_distance) - starpilotPlan.disableThrottle = self.starpilot_following.disable_throttle + starpilotPlan.disableThrottle = ( + self.starpilot_following.disable_throttle or + self.starpilot_acceleration.pulse_glide_coasting + ) + starpilotPlan.pulseGlideCoasting = self.starpilot_acceleration.pulse_glide_coasting starpilotPlan.trackingLead = self.tracking_lead conditional_experimental_mode = False diff --git a/starpilot/controls/tests/test_starpilot_card.py b/starpilot/controls/tests/test_starpilot_card.py index 0666c0cae..c390c2273 100644 --- a/starpilot/controls/tests/test_starpilot_card.py +++ b/starpilot/controls/tests/test_starpilot_card.py @@ -219,6 +219,48 @@ def make_wrapped_button_event(button_type, pressed): return SimpleNamespace(type=SimpleNamespace(raw=int(button_type)), pressed=pressed) +@pytest.mark.parametrize( + ("car_fingerprint", "expect_normalized_release"), + ( + (spc.HYUNDAI_CAR.HYUNDAI_ELANTRA_HEV_2024, True), + (spc.HYUNDAI_CAR.HYUNDAI_ELANTRA_2024, False), + ), +) +def test_distance_release_normalization_is_limited_to_reported_elantra_hybrid( + monkeypatch, tmp_path, car_fingerprint, expect_normalized_release, +): + monkeypatch.setattr(spc, "Params", FakeParams) + monkeypatch.setattr(spc, "is_FrogsGoMoo", lambda: False) + monkeypatch.setattr(spc, "ERROR_LOGS_PATH", tmp_path) + + card = spc.StarPilotCard( + SimpleNamespace(brand="hyundai", carFingerprint=car_fingerprint), + SimpleNamespace(alternativeExperience=0), + ) + toggles = make_toggles( + experimental_mode_via_distance=False, + bookmark_via_distance=False, + force_coast_via_distance=False, + pulse_and_glide_via_distance=False, + pause_lateral_via_distance=False, + pause_longitudinal_via_distance=False, + switchback_mode_via_distance=False, + ) + sm = make_sm() + starpilot_car_state = SimpleNamespace(distancePressed=True) + + card.update(make_car_state(), starpilot_car_state, sm, toggles) + + starpilot_car_state.distancePressed = False + car_state = make_car_state(button_events=[SimpleNamespace(type=spc.ButtonType.unknown, pressed=False)]) + card.update(car_state, starpilot_car_state, sm, toggles) + + assert any( + be.type == spc.ButtonType.gapAdjustCruise and not be.pressed + for be in car_state.buttonEvents + ) is expect_normalized_release + + def test_honda_lkas_button_can_toggle_always_on_lateral(monkeypatch, tmp_path): monkeypatch.setattr(spc, "Params", FakeParams) monkeypatch.setattr(spc, "is_FrogsGoMoo", lambda: False) diff --git a/system/ui/lib/application.py b/system/ui/lib/application.py index cacc59869..1c13c4015 100644 --- a/system/ui/lib/application.py +++ b/system/ui/lib/application.py @@ -155,6 +155,7 @@ class FontWeight(StrEnum): BOLD = "Inter-Bold.fnt" SEMI_BOLD = "Inter-SemiBold.fnt" UNIFONT = "unifont.fnt" + BRAND = "como-heavy.fnt" # Small UI fonts DISPLAY_REGULAR = "Inter-Regular.fnt" @@ -165,6 +166,11 @@ class FontWeight(StrEnum): def font_fallback(font: rl.Font) -> rl.Font: """Fall back to unifont for languages that require it.""" if multilang.requires_unifont(): + try: + if font.texture.id == gui_app.font(FontWeight.BRAND).texture.id: + return font + except (AttributeError, KeyError): + pass return gui_app.font(FontWeight.UNIFONT) return font diff --git a/system/ui/lib/tests/test_application.py b/system/ui/lib/tests/test_application.py index d8e5e0458..511701737 100644 --- a/system/ui/lib/tests/test_application.py +++ b/system/ui/lib/tests/test_application.py @@ -1,3 +1,6 @@ +from importlib.resources import as_file +from types import SimpleNamespace + from openpilot.system.ui.lib import application @@ -35,3 +38,32 @@ def test_burn_in_shift_transitions_between_positions(monkeypatch): midpoint = app._burn_in_shift(109.0) assert midpoint == (-1.0, 0.0) assert app._burn_in_shift(110.0) == (-2.0, 0.0) + + +def test_brand_font_assets_include_wordmark_glyphs(): + with as_file(application.FONT_DIR.joinpath("como-heavy.fnt")) as font_path: + lines = font_path.read_text().splitlines() + + glyphs = {} + for line in lines: + if not line.startswith("char id="): + continue + fields = dict(field.split("=", 1) for field in line.split() if "=" in field) + glyphs[int(fields["id"])] = (int(fields["width"]), int(fields["height"])) + + for char in set("StarPilot"): + assert glyphs[ord(char)][0] > 0 + assert glyphs[ord(char)][1] > 0 + + +def test_brand_font_is_not_replaced_by_language_fallback(monkeypatch): + brand_font = SimpleNamespace(texture=SimpleNamespace(id=1)) + unifont = SimpleNamespace(texture=SimpleNamespace(id=2)) + monkeypatch.setattr(application.multilang, "requires_unifont", lambda: True) + monkeypatch.setattr(application.gui_app, "font", lambda weight: { + application.FontWeight.BRAND: brand_font, + application.FontWeight.UNIFONT: unifont, + }[weight]) + + assert application.font_fallback(brand_font) is brand_font + assert application.font_fallback(SimpleNamespace(texture=SimpleNamespace(id=3))) is unifont