From 1e6b221d53ce22f3e68e4401baee8d0f883329d5 Mon Sep 17 00:00:00 2001 From: firestarsdog <229254897+firestarsdog@users.noreply.github.com> Date: Wed, 30 Sep 2026 02:23:29 -0400 Subject: [PATCH] push it --- .../onroad/starpilot/widgets/unified_speed.py | 49 ++++++-- .../ui/tests/test_unified_speed_widget.py | 116 +++++++++++++++++- 2 files changed, 152 insertions(+), 13 deletions(-) diff --git a/selfdrive/ui/onroad/starpilot/widgets/unified_speed.py b/selfdrive/ui/onroad/starpilot/widgets/unified_speed.py index 76ac795129..d130460b39 100644 --- a/selfdrive/ui/onroad/starpilot/widgets/unified_speed.py +++ b/selfdrive/ui/onroad/starpilot/widgets/unified_speed.py @@ -31,6 +31,9 @@ HEADER_ICON_SIZE = 34 HEADER_FONT_SIZE = 28 VALUE_FONT_SIZE = 96 UNIT_FONT_SIZE = 28 +PAUSE_ICON_WIDTH = 12 +PAUSE_ICON_HEIGHT = 14 +PAUSE_ICON_GAP = 8 OFFSET_FONT_SIZE = 22 OFFSET_PILL_HEIGHT = 30 CONFIRMATION_COLOR = rl.Color(188, 132, 255, 255) @@ -81,6 +84,7 @@ class UnifiedSpeedWidget(LayoutWidget): self._slc_enabled = False self._presentation: UnifiedSpeedPresentation | None = None self._show_max = False + self._pedal_override = False self._snapshot_frame: int | None = None def _refresh_snapshot(self) -> None: @@ -94,6 +98,11 @@ class UnifiedSpeedWidget(LayoutWidget): self.hud_renderer.is_cruise_available and not ui_state.starpilot_toggles.get("hide_max_speed", False) ) + self._pedal_override = ( + self.hud_renderer.is_cruise_set and ui_state.engaged and + ui_state.sm.valid.get("carState", False) and ui_state.sm.alive.get("carState", False) and + ui_state.sm.recv_frame["carState"] >= ui_state.started_frame and ui_state.sm["carState"].gasPressed + ) self._presentation = resolve_unified_speed( self._show_max, self.hud_renderer.is_cruise_set, self.hud_renderer.set_speed, self._slc_state, self._slc_enabled, ui_state.is_metric, @@ -158,17 +167,32 @@ class UnifiedSpeedWidget(LayoutWidget): rl.draw_rectangle_rounded_lines_ex(pill, roundness_for(pill, 17), 8, 2, OFFSET_COLOR) self._draw_centered_text(text, pill, y + (pill.height - text_size.y) / 2, OFFSET_FONT_SIZE, OFFSET_COLOR) - @staticmethod - def _max_header_color(active_side: str, cruise_set: bool) -> rl.Color: + def _draw_unit(self, bounds: rl.Rectangle, y: float) -> None: + text = tr(self._presentation.unit_text) + color = COLORS.WHITE_TRANSLUCENT + if self._pedal_override: + text_size = measure_text_cached(self._font_semi_bold, text, UNIT_FONT_SIZE) + text_shift = (PAUSE_ICON_WIDTH + PAUSE_ICON_GAP) / 2 + icon_x = bounds.x + (bounds.width - text_size.x) / 2 - text_shift + icon_y = y + (text_size.y - PAUSE_ICON_HEIGHT) / 2 + bar_width = PAUSE_ICON_WIDTH / 3 + for x in (icon_x, icon_x + 2 * bar_width): + rl.draw_rectangle_rec(rl.Rectangle(x, icon_y, bar_width, PAUSE_ICON_HEIGHT), OFFSET_COLOR) + bounds = rl.Rectangle(bounds.x + text_shift, bounds.y, bounds.width, bounds.height) + color = COLORS.DISENGAGED + self._draw_centered_text(text, bounds, y, UNIT_FONT_SIZE, color) + + def _max_header_color(self, active_side: str, cruise_set: bool) -> rl.Color: + if self._pedal_override: + return COLORS.DISENGAGED if cruise_set and ui_state.status == UIStatus.ENGAGED and active_side in ("max", "shared"): return COLORS.ENGAGED if cruise_set and ui_state.status in (UIStatus.DISENGAGED, UIStatus.OVERRIDE): return COLORS.DISENGAGED return COLORS.GREY - @staticmethod - def _limit_header_color(active_side: str, overridden: bool) -> rl.Color: - if overridden or ui_state.status in (UIStatus.DISENGAGED, UIStatus.OVERRIDE): + def _limit_header_color(self, active_side: str, overridden: bool) -> rl.Color: + if self._pedal_override or overridden or ui_state.status in (UIStatus.DISENGAGED, UIStatus.OVERRIDE): return COLORS.DISENGAGED if ui_state.status == UIStatus.ENGAGED and active_side in ("slc", "shared"): return COLORS.ENGAGED @@ -176,7 +200,7 @@ class UnifiedSpeedWidget(LayoutWidget): def _draw_active_emphasis(self, rect: rl.Rectangle) -> None: presentation = self._presentation - if presentation.mode == "merged" or ui_state.status != UIStatus.ENGAGED or presentation.active_side == "none": + if self._pedal_override or presentation.mode == "merged" or ui_state.status != UIStatus.ENGAGED or presentation.active_side == "none": return if presentation.mode in ("max_only", "limit_only"): bounds = rect @@ -222,6 +246,7 @@ class UnifiedSpeedWidget(LayoutWidget): def _render(self, rect: rl.Rectangle) -> None: presentation = self._presentation state = self._slc_state + speed_color = COLORS.DISENGAGED if self._pedal_override else COLORS.WHITE rl.draw_rectangle_rounded_lines_ex( rect, CONTROL_ROUNDNESS, CONTROL_SEGMENTS, 7, rl.Color(UNIFIED_ACCENT.r, UNIFIED_ACCENT.g, UNIFIED_ACCENT.b, 55), @@ -240,12 +265,12 @@ class UnifiedSpeedWidget(LayoutWidget): max_bounds = rl.Rectangle(rect.x, rect.y, rect.width / 2, rect.height) if presentation.mode in ("split", "merged") else rect limit_bounds = self._speed_limit_bounds(rect) if self._show_max or presentation.confirmation_pending: - max_color = COLORS.DARK_GREY if not self.hud_renderer.is_cruise_set else COLORS.WHITE + max_color = COLORS.DARK_GREY if not self.hud_renderer.is_cruise_set else speed_color max_label_color = self._max_header_color(presentation.active_side, self.hud_renderer.is_cruise_set) self._draw_header(max_bounds, "MAX SET", "speedometer", max_label_color) if presentation.mode != "merged": self._draw_centered_text(presentation.max_speed_text, max_bounds, rect.y + 75, VALUE_FONT_SIZE, max_color, bold=True) - self._draw_centered_text(tr(presentation.unit_text), max_bounds, rect.y + 204, UNIT_FONT_SIZE, COLORS.WHITE_TRANSLUCENT) + self._draw_unit(max_bounds, rect.y + 204) if limit_bounds is not None: icon_key = source_icon_key(presentation.source) @@ -253,16 +278,16 @@ class UnifiedSpeedWidget(LayoutWidget): label_color = self._limit_header_color(presentation.active_side, overridden) self._draw_header(limit_bounds, "SPEED LIMIT", icon_key, label_color) if presentation.mode != "merged": - self._draw_centered_text(presentation.posted_speed_text, limit_bounds, rect.y + 75, VALUE_FONT_SIZE, COLORS.WHITE, bold=True) + self._draw_centered_text(presentation.posted_speed_text, limit_bounds, rect.y + 75, VALUE_FONT_SIZE, speed_color, bold=True) if presentation.confirmation_pending: self._draw_centered_text(tr("PENDING"), limit_bounds, rect.y + 175, 25, CONFIRMATION_COLOR) elif presentation.offset_text is not None: self._draw_offset_pill(limit_bounds, presentation.offset_text, rect.y + 175) - self._draw_centered_text(tr(presentation.unit_text), limit_bounds, rect.y + 204, UNIT_FONT_SIZE, COLORS.WHITE_TRANSLUCENT) + self._draw_unit(limit_bounds, rect.y + 204) if presentation.mode == "merged": - self._draw_centered_text(presentation.effective_speed_text, rect, rect.y + 98, VALUE_FONT_SIZE, COLORS.WHITE, bold=True) - self._draw_centered_text(tr(presentation.unit_text), rect, rect.y + 204, UNIT_FONT_SIZE, COLORS.WHITE_TRANSLUCENT) + self._draw_centered_text(presentation.effective_speed_text, rect, rect.y + 98, VALUE_FONT_SIZE, speed_color, bold=True) + self._draw_unit(rect, rect.y + 204) if presentation.offset_text is not None: self._draw_offset_pill( limit_bounds, presentation.offset_text, rect.y + MERGED_SEPARATOR_Y - OFFSET_PILL_HEIGHT / 2, diff --git a/selfdrive/ui/tests/test_unified_speed_widget.py b/selfdrive/ui/tests/test_unified_speed_widget.py index 581fc59772..2140d710ae 100644 --- a/selfdrive/ui/tests/test_unified_speed_widget.py +++ b/selfdrive/ui/tests/test_unified_speed_widget.py @@ -17,6 +17,7 @@ def make_widget(mode="split", pending=False): widget._presentation = UnifiedSpeedPresentation(mode, "70", "65", "70", "+5", "mph", "Map Data", pending, "slc") widget._show_max = True widget._slc_state = None + widget._pedal_override = False widget.hud_renderer = SimpleNamespace(is_cruise_set=True) return widget @@ -208,7 +209,7 @@ def test_enabled_slc_stays_full_width_when_plan_is_stale(monkeypatch): widget._snapshot_frame = None widget.hud_renderer = SimpleNamespace(is_cruise_available=True, is_cruise_set=True, set_speed=70) monkeypatch.setattr(unified_speed, "ui_state", SimpleNamespace( - sm=SimpleNamespace(frame=1), starpilot_toggles={}, is_metric=False, + sm=SimpleNamespace(frame=1), starpilot_toggles={}, is_metric=False, engaged=False, )) monkeypatch.setattr(unified_speed, "_is_slc_enabled", lambda: True) monkeypatch.setattr(unified_speed, "_get_slc_state", lambda: None) @@ -217,6 +218,119 @@ def test_enabled_slc_stays_full_width_when_plan_is_stale(monkeypatch): assert widget._presentation.posted_speed_text == "–" +@pytest.fixture +def pedal_snapshot(monkeypatch): + class SubMaster(dict): + pass + + sm = SubMaster(carState=SimpleNamespace(gasPressed=True)) + sm.frame = 20 + sm.valid = {"carState": True} + sm.alive = {"carState": True} + sm.recv_frame = {"carState": 20} + ui = SimpleNamespace(sm=sm, started_frame=10, engaged=True, starpilot_toggles={}, is_metric=False) + widget = make_widget() + widget._snapshot_frame = None + widget.hud_renderer = SimpleNamespace(is_cruise_available=True, is_cruise_set=True, set_speed=70) + monkeypatch.setattr(unified_speed, "ui_state", ui) + monkeypatch.setattr(unified_speed, "_is_slc_enabled", lambda: True) + monkeypatch.setattr(unified_speed, "_get_slc_state", lambda: None) + return widget, ui + + +@pytest.mark.parametrize("gas,engaged,cruise_set,valid,alive,received,expected", [ + (True, True, True, True, True, 20, True), + (False, True, True, True, True, 20, False), + (True, False, True, True, True, 20, False), + (True, True, False, True, True, 20, False), + (True, True, True, False, True, 20, False), + (True, True, True, True, False, 20, False), + (True, True, True, True, True, 9, False), +]) +def test_pedal_override_requires_fresh_gas_and_engaged_cruise(pedal_snapshot, gas, engaged, cruise_set, valid, alive, received, expected): + widget, ui = pedal_snapshot + ui.sm["carState"].gasPressed = gas + ui.engaged = engaged + widget.hud_renderer.is_cruise_set = cruise_set + ui.sm.valid["carState"] = valid + ui.sm.alive["carState"] = alive + ui.sm.recv_frame["carState"] = received + widget._refresh_snapshot() + assert widget._pedal_override == expected + + +def test_pedal_cue_clears_on_release_with_a_persistent_slc_override(pedal_snapshot, monkeypatch): + widget, ui = pedal_snapshot + sm = ui.sm + state = { + "speed_conversion": CV.MS_TO_MPH, "accepted_speed_limit_ms": 65 * CV.MPH_TO_MS, + "effective_target_ms": 70 * CV.MPH_TO_MS, "offset_ms": 5 * CV.MPH_TO_MS, + "speed_limit_changed": False, "unconfirmed_valid": False, "presented_source": "Map Data", + "slc_is_limiting_max_set": False, "slc_overridden_speed": 80 * CV.MPH_TO_MS, + } + monkeypatch.setattr(unified_speed, "_get_slc_state", lambda: state) + widget._refresh_snapshot() + assert widget._pedal_override + presentation = widget._presentation + + sm["carState"].gasPressed = False + widget._refresh_snapshot() + assert widget._pedal_override + sm.frame += 1 + sm.recv_frame["carState"] = sm.frame + widget._refresh_snapshot() + assert not widget._pedal_override + assert widget._presentation == presentation + assert widget._slc_state["slc_overridden_speed"] > 0 + + +@pytest.mark.parametrize("mode", ["split", "merged", "max_only", "limit_only"]) +@pytest.mark.parametrize("unit", ["mph", "km/h"]) +def test_pedal_cue_mutes_targets_and_preserves_units_offsets_and_layout(monkeypatch, mode, unit): + widget = make_widget(mode) + widget._pedal_override = True + widget._font_semi_bold = None + widget._show_max = mode != "limit_only" + widget._presentation = replace(widget._presentation, unit_text=unit) + if mode in ("max_only", "limit_only"): + widget._rect.width = 250 + values, headers, pauses, offsets, lines = [], [], [], [], [] + monkeypatch.setattr(unified_speed, "ui_state", SimpleNamespace(status=unified_speed.UIStatus.ENGAGED)) + monkeypatch.setattr(unified_speed, "draw_control_card", lambda *args, **kwargs: None) + monkeypatch.setattr(unified_speed, "measure_text_cached", lambda *args: rl.Vector2(60, 28)) + monkeypatch.setattr(rl, "draw_rectangle_rounded_lines_ex", lambda *args: None) + monkeypatch.setattr(rl, "draw_rectangle_rec", lambda *args: pauses.append(args)) + monkeypatch.setattr(rl, "draw_line_ex", lambda *args: lines.append(args)) + monkeypatch.setattr(widget, "_draw_merged_separator", lambda *args: None) + monkeypatch.setattr(widget, "_draw_header", lambda bounds, text, icon, color: headers.append(color)) + monkeypatch.setattr(widget, "_draw_centered_text", lambda text, bounds, y, size, color, **kwargs: values.append((text, bounds, size, color))) + monkeypatch.setattr(widget, "_draw_offset_pill", lambda bounds, text, y: offsets.append(text)) + + widget._render(widget.rect) + speed_values = [value for value in values if value[2] == unified_speed.VALUE_FONT_SIZE] + unit_values = [value for value in values if value[2] == unified_speed.UNIT_FONT_SIZE] + expected_speeds = {"split": ["70", "65"], "merged": ["70"], "max_only": ["70"], "limit_only": ["65"]} + assert [value[0] for value in speed_values] == expected_speeds[mode] + assert all(value[3] == unified_speed.COLORS.DISENGAGED for value in speed_values + unit_values) + assert all(color == unified_speed.COLORS.DISENGAGED for color in headers) + assert [value[0] for value in unit_values] == [unit] * (2 if mode == "split" else 1) + assert len(pauses) == 2 * len(unit_values) + assert all(color == unified_speed.OFFSET_COLOR for _bounds, color in pauses) + assert offsets == ([] if mode == "max_only" else ["+5"]) + assert not any(line[2] == 3 for line in lines) + for index, value in enumerate(unit_values): + pause = pauses[index * 2][0] + assert pause.x == pytest.approx(value[1].x + (value[1].width - 60) / 2 - 20) + + values.clear() + pauses.clear() + widget._pedal_override = False + widget._render(widget.rect) + assert not pauses + assert all(value[3] == unified_speed.COLORS.WHITE for value in values if value[2] == unified_speed.VALUE_FONT_SIZE) + assert all(value[3] == unified_speed.COLORS.WHITE_TRANSLUCENT for value in values if value[2] == unified_speed.UNIT_FONT_SIZE) + + def test_split_merged_transitions_keep_the_same_footprint(monkeypatch): widget = make_widget("split") monkeypatch.setattr(widget, "_refresh_snapshot", lambda: None)