From 9d87c4c8cb4483b43f7f1236b498e8ee2754f9a3 Mon Sep 17 00:00:00 2001 From: firestarsdog <229254897+firestarsdog@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:25:43 -0400 Subject: [PATCH] UI Pass --- .../ui/onroad/starpilot/slc_speed_limit.py | 48 ++-- .../starpilot/unified_speed_presentation.py | 54 ++-- .../onroad/starpilot/widgets/unified_speed.py | 82 ++++-- .../tests/test_unified_speed_presentation.py | 88 +++++-- .../ui/tests/test_unified_speed_widget.py | 241 +++++++++++++++++- 5 files changed, 417 insertions(+), 96 deletions(-) diff --git a/selfdrive/ui/onroad/starpilot/slc_speed_limit.py b/selfdrive/ui/onroad/starpilot/slc_speed_limit.py index f3703b6634..fc035741df 100644 --- a/selfdrive/ui/onroad/starpilot/slc_speed_limit.py +++ b/selfdrive/ui/onroad/starpilot/slc_speed_limit.py @@ -75,8 +75,21 @@ def _speed_limit_pulse_color(base: rl.Color, alpha: int) -> rl.Color: # ── State ───────────────────────────────────────────────────────────── +def _is_slc_enabled() -> bool: + toggles = getattr(ui_state, "starpilot_toggles", {}) + if "speed_limit_controller" in toggles: + return bool(toggles["speed_limit_controller"]) + return ui_state.ui_params.get_bool("SpeedLimitController") + + def _get_slc_state(): """Extract SLC state from SubMaster. Returns dict or None if stale/hidden.""" + slc_enabled = _is_slc_enabled() + params = ui_state.ui_params + if not (slc_enabled or params.get_bool("ShowSpeedLimits")): + _pulse.clear() + return None + sm = ui_state.sm if sm.recv_frame["starpilotPlan"] < ui_state.started_frame: _pulse.clear() @@ -84,15 +97,10 @@ def _get_slc_state(): plan = sm["starpilotPlan"] speed_limit_changed = plan.speedLimitChanged + presented_source = getattr(plan, 'slcPresentedSpeedLimitSource', '') - params = ui_state.ui_params - show_slc = params.get_bool("ShowSpeedLimits") unconfirmed_valid = plan.unconfirmedSlcSpeedLimit > 1 - if not show_slc: - _pulse.clear() - return None - speed_conversion = CV.MS_TO_KPH if ui_state.is_metric else CV.MS_TO_MPH dashboard_sl = sm["starpilotCarState"].dashboardSpeedLimit if sm.valid.get("starpilotCarState", False) else 0.0 vision_enabled = params.get_bool("VisionSpeedLimitDetection") @@ -105,9 +113,6 @@ def _get_slc_state(): # The pulse uses the accepted raw limit, so unit changes cannot retrigger it. _tick_pulse(plan.slcSpeedLimitSource, plan.slcSpeedLimit) - toggles = getattr(ui_state, 'starpilot_toggles', {}) - slc_enabled = toggles.get('speed_limit_controller') if 'speed_limit_controller' in toggles else params.get_bool('SpeedLimitController') - return { 'accepted_speed_limit_ms': plan.slcSpeedLimit, # Match the control target's non-negative base before cluster compensation. @@ -115,9 +120,11 @@ def _get_slc_state(): 'offset_ms': plan.slcSpeedLimitOffset, 'slc_overridden_speed': plan.slcOverriddenSpeed, 'speed_limit_source': plan.slcSpeedLimitSource, - 'presented_source': getattr(plan, 'slcPresentedSpeedLimitSource', plan.slcSpeedLimitSource), - 'slc_enabled': bool(slc_enabled), - 'slc_is_limiting_max_set': bool(getattr(plan, 'slcIsLimitingMaxSet', False)), + # Older publishers/replays decode the new Text field as "", rather than omitting the attribute. + 'presented_source': presented_source or plan.slcSpeedLimitSource, + 'slc_enabled': slc_enabled, + # Both UI fields were added together; older plans have no published limiting state. + 'slc_is_limiting_max_set': bool(getattr(plan, 'slcIsLimitingMaxSet', False)) if presented_source else None, 'unconfirmed_speed_limit': max(0.0, plan.unconfirmedSlcSpeedLimit * speed_conversion), 'unconfirmed_valid': unconfirmed_valid, 'speed_limit_changed': speed_limit_changed, @@ -251,10 +258,19 @@ def _draw_source_icon(icon_key: str, x: float, y: float, size: float, color: rl. ) rl.draw_circle_v(pin_center, size * 0.09, _SOURCE_PANEL_BG) elif icon_key == "dashboard": - dashboard_scale = 1.22 + # The Dashboard speed-limit source is a vehicle glyph, distinct from Max Set's gauge. + body = rl.Rectangle(x + size * 0.10, y + size * 0.43, size * 0.80, size * 0.29) + rl.draw_rectangle_rounded_lines_ex(body, 0.30, 8, stroke, color) + rl.draw_line_ex(rl.Vector2(x + size * 0.25, body.y), rl.Vector2(x + size * 0.36, y + size * 0.27), stroke, color) + rl.draw_line_ex(rl.Vector2(x + size * 0.36, y + size * 0.27), rl.Vector2(x + size * 0.68, y + size * 0.27), stroke, color) + rl.draw_line_ex(rl.Vector2(x + size * 0.68, y + size * 0.27), rl.Vector2(x + size * 0.79, body.y), stroke, color) + for wheel_x in (x + size * 0.27, x + size * 0.73): + rl.draw_circle_v(rl.Vector2(wheel_x, y + size * 0.75), size * 0.07, color) + elif icon_key == "speedometer": + gauge_scale = 1.22 pivot = rl.Vector2(cx, cy + size * 0.17) - inner_radius = size * 0.27 * dashboard_scale - outer_radius = size * 0.34 * dashboard_scale + inner_radius = size * 0.27 * gauge_scale + outer_radius = size * 0.34 * gauge_scale ring_segments = max(24, int(size * 0.25)) rl.draw_ring(pivot, inner_radius, outer_radius, 190, 350, ring_segments, color) cap_radius = (outer_radius - inner_radius) / 2 @@ -279,7 +295,7 @@ def _draw_source_icon(icon_key: str, x: float, y: float, size: float, color: rl. stroke, color, ) - rl.draw_circle_v(pivot, max(2.0, size * 0.06 * dashboard_scale), color) + rl.draw_circle_v(pivot, max(2.0, size * 0.06 * gauge_scale), color) def _draw_sources_bubble_empty_state(panel_rect: rl.Rectangle) -> None: diff --git a/selfdrive/ui/onroad/starpilot/unified_speed_presentation.py b/selfdrive/ui/onroad/starpilot/unified_speed_presentation.py index 588e6d7f54..8eab3336a1 100644 --- a/selfdrive/ui/onroad/starpilot/unified_speed_presentation.py +++ b/selfdrive/ui/onroad/starpilot/unified_speed_presentation.py @@ -17,30 +17,44 @@ class UnifiedSpeedPresentation: def resolve_unified_speed(show_max: bool, cruise_set: bool, max_speed: float, - slc_state: dict | None, is_metric: bool) -> UnifiedSpeedPresentation: + slc_state: dict | None, slc_enabled: bool, is_metric: bool) -> UnifiedSpeedPresentation: """Compare the rounded values the driver sees; ignore override speed for layout.""" unit = "km/h" if is_metric else "mph" max_text = str(round(max_speed)) if cruise_set else "–" - if slc_state is None: - return UnifiedSpeedPresentation("max_only", max_text, "–", "–", None, unit, "None", False, "max" if cruise_set else "none") + posted_text = effective_text = "–" + offset_text = None + source = "None" + pending = has_limit = slc_is_limiting = False + if slc_state is not None: + conversion = slc_state['speed_conversion'] + accepted = slc_state['accepted_speed_limit_ms'] + pending = bool(slc_state['speed_limit_changed'] and slc_state['unconfirmed_valid']) + source = slc_state['presented_source'] + has_limit = (source not in ("", "None") and accepted > 1) or pending + if has_limit: + posted_text = str(round(slc_state['unconfirmed_speed_limit'])) if pending else str(round(accepted * conversion)) + effective = slc_state['effective_target_ms'] + slc_is_limiting = slc_state['slc_is_limiting_max_set'] + if slc_is_limiting is None: + slc_is_limiting = cruise_set and accepted > 1 and 0 < effective * conversion < max_speed + effective_text = str(round(effective * conversion)) if effective > 0 else "–" + offset_display = round(slc_state['offset_ms'] * conversion) + offset_text = f"{offset_display:+d}" if offset_display else None + else: + source = "None" - conversion = slc_state['speed_conversion'] - accepted = slc_state['accepted_speed_limit_ms'] - effective = slc_state['effective_target_ms'] - candidate = slc_state['unconfirmed_speed_limit'] - pending = bool(slc_state['speed_limit_changed'] and slc_state['unconfirmed_valid']) - source = slc_state['presented_source'] - has_limit = (source not in ("", "None") and accepted > 1) or pending - if not has_limit: - return UnifiedSpeedPresentation("max_only", max_text, "–", "–", None, unit, "None", False, "max" if cruise_set else "none") + # Max-only is valid only when SLC is disabled. + if pending: + mode = "split" + elif slc_enabled: + mode = "merged" if show_max and cruise_set and has_limit and max_text == effective_text else "split" if show_max else "limit_only" + elif has_limit: + mode = "split" if show_max else "limit_only" + else: + mode = "max_only" - posted_text = str(round(candidate)) if pending else str(round(accepted * conversion)) - effective_text = str(round(effective * conversion)) if effective > 0 else "–" - offset_display = round(slc_state['offset_ms'] * conversion) - offset_text = f"{offset_display:+d}" if offset_display else None - merged = bool(show_max and cruise_set and slc_state['slc_enabled'] and not pending and max_text == effective_text) - mode = "split" if pending else "merged" if merged else "split" if show_max else "limit_only" - active_side = "none" if slc_state['slc_overridden_speed'] else "shared" if merged else ( - "slc" if slc_state['slc_is_limiting_max_set'] else "max" if (show_max or pending) and cruise_set else "none" + active_side = "none" if slc_state is not None and slc_state['slc_overridden_speed'] else "shared" if mode == "merged" else ( + "slc" if slc_enabled and cruise_set and slc_is_limiting else + "max" if (show_max or pending) and cruise_set else "none" ) return UnifiedSpeedPresentation(mode, max_text, posted_text, effective_text, offset_text, unit, source, pending, active_side) diff --git a/selfdrive/ui/onroad/starpilot/widgets/unified_speed.py b/selfdrive/ui/onroad/starpilot/widgets/unified_speed.py index 4b5b5b510d..fcb962b867 100644 --- a/selfdrive/ui/onroad/starpilot/widgets/unified_speed.py +++ b/selfdrive/ui/onroad/starpilot/widgets/unified_speed.py @@ -9,14 +9,13 @@ from openpilot.common.params import Params from openpilot.selfdrive.ui.ui_state import ui_state, UIStatus from openpilot.selfdrive.ui.onroad.hud_renderer import COLORS from openpilot.selfdrive.ui.onroad.starpilot.slc_speed_limit import ( - _draw_source_icon, _draw_sources_bubble, _get_slc_state, _speed_limit_pulse_color, source_icon_key, + _draw_source_icon, _draw_sources_bubble, _get_slc_state, _is_slc_enabled, _speed_limit_pulse_color, source_icon_key, ) from openpilot.selfdrive.ui.onroad.starpilot.unified_speed_presentation import ( UnifiedSpeedPresentation, resolve_unified_speed, ) from openpilot.selfdrive.ui.onroad.starpilot.widget_style import ( - CONTROL_BG, CONTROL_BORDER, CONTROL_BORDER_WIDTH, CONTROL_ROUNDNESS, - CONTROL_SEGMENTS, draw_control_card, roundness_for, + CONTROL_BG, CONTROL_ROUNDNESS, CONTROL_SEGMENTS, draw_control_card, roundness_for, ) from openpilot.selfdrive.ui.onroad.starpilot.widgets.base import LayoutWidget from openpilot.system.ui.lib.application import gui_app, FontWeight @@ -34,6 +33,7 @@ UNIT_FONT_SIZE = 28 OFFSET_FONT_SIZE = 25 CONFIRMATION_COLOR = rl.Color(188, 132, 255, 255) OFFSET_COLOR = rl.Color(188, 156, 226, 220) +UNIFIED_ACCENT = rl.Color(160, 96, 230, 230) class UnifiedSpeedWidget(LayoutWidget): @@ -45,6 +45,7 @@ class UnifiedSpeedWidget(LayoutWidget): self._font_semi_bold = gui_app.font(FontWeight.SEMI_BOLD) self._font_bold = gui_app.font(FontWeight.BOLD) self._slc_state: dict | None = None + self._slc_enabled = False self._presentation: UnifiedSpeedPresentation | None = None self._show_max = False self._snapshot_frame: int | None = None @@ -54,6 +55,7 @@ class UnifiedSpeedWidget(LayoutWidget): if frame is not None and frame == self._snapshot_frame: return self._snapshot_frame = frame + self._slc_enabled = _is_slc_enabled() self._slc_state = _get_slc_state() self._show_max = ( self.hud_renderer.is_cruise_available and @@ -61,7 +63,7 @@ class UnifiedSpeedWidget(LayoutWidget): ) self._presentation = resolve_unified_speed( self._show_max, self.hud_renderer.is_cruise_set, self.hud_renderer.set_speed, - self._slc_state, ui_state.is_metric, + self._slc_state, self._slc_enabled, ui_state.is_metric, ) @property @@ -99,23 +101,26 @@ class UnifiedSpeedWidget(LayoutWidget): def _draw_header(self, bounds: rl.Rectangle, text: str, icon_key: str | None, label_color: rl.Color) -> None: text = tr(text) - text_size = measure_text_cached(self._font_semi_bold, text, HEADER_FONT_SIZE) - slot_gap = 9 - group_width = HEADER_ICON_SIZE + slot_gap + text_size.x + font_size = HEADER_FONT_SIZE + icon_width = HEADER_ICON_SIZE + 9 if icon_key else 0 + while font_size > 16 and measure_text_cached(self._font_semi_bold, text, font_size).x + icon_width > bounds.width - 24: + font_size -= 1 + text_size = measure_text_cached(self._font_semi_bold, text, font_size) + group_width = icon_width + text_size.x group_x = bounds.x + (bounds.width - group_width) / 2 icon_y = bounds.y + 20 if icon_key: _draw_source_icon(icon_key, group_x, icon_y, HEADER_ICON_SIZE, rl.WHITE) rl.draw_text_ex( self._font_semi_bold, text, - rl.Vector2(group_x + HEADER_ICON_SIZE + slot_gap, icon_y + (HEADER_ICON_SIZE - text_size.y) / 2), - HEADER_FONT_SIZE, 0, label_color, + rl.Vector2(group_x + icon_width, icon_y + (HEADER_ICON_SIZE - text_size.y) / 2), + font_size, 0, label_color, ) def _draw_offset_pill(self, bounds: rl.Rectangle, text: str, y: float) -> None: text_size = measure_text_cached(self._font_semi_bold, text, OFFSET_FONT_SIZE) width = max(56.0, text_size.x + 20.0) - pill = rl.Rectangle(bounds.x + (bounds.width - width) / 2, y, width, 34) + pill = rl.Rectangle(bounds.x + (bounds.width - width) / 2, y, width, 30) rl.draw_rectangle_rounded(pill, roundness_for(pill, 17), 8, rl.Color(32, 20, 45, 170)) 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) @@ -138,7 +143,7 @@ class UnifiedSpeedWidget(LayoutWidget): def _draw_active_emphasis(self, rect: rl.Rectangle) -> None: presentation = self._presentation - if ui_state.status != UIStatus.ENGAGED or presentation.active_side == "none": + if 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 @@ -151,29 +156,52 @@ class UnifiedSpeedWidget(LayoutWidget): rl.draw_line_ex( rl.Vector2(bounds.x + 18, rect.y + 65), rl.Vector2(bounds.x + bounds.width - 18, rect.y + 65), - 3, COLORS.ENGAGED, + 3, UNIFIED_ACCENT, ) + def _draw_merged_separator(self, rect: rl.Rectangle) -> None: + center = rect.x + rect.width / 2 + shelf_y = rect.y + 76 + valley_y = shelf_y + 12 + color = rl.Color(UNIFIED_ACCENT.r, UNIFIED_ACCENT.g, UNIFIED_ACCENT.b, 170) + rl.draw_line_ex(rl.Vector2(rect.x + 18, shelf_y), rl.Vector2(center - 34, shelf_y), 2, color) + rl.draw_spline_segment_bezier_cubic( + rl.Vector2(center - 34, shelf_y), rl.Vector2(center - 19, shelf_y), + rl.Vector2(center - 23, valley_y), rl.Vector2(center - 7, valley_y), 2, color, + ) + rl.draw_line_ex(rl.Vector2(center - 7, valley_y), rl.Vector2(center + 7, valley_y), 2, color) + rl.draw_spline_segment_bezier_cubic( + rl.Vector2(center + 7, valley_y), rl.Vector2(center + 23, valley_y), + rl.Vector2(center + 19, shelf_y), rl.Vector2(center + 34, shelf_y), 2, color, + ) + rl.draw_line_ex(rl.Vector2(center + 34, shelf_y), rl.Vector2(rect.x + rect.width - 18, shelf_y), 2, color) + def _draw_speed_limit_border(self, rect: rl.Rectangle, right: rl.Rectangle, color: rl.Color) -> None: # Clip the shared rounded outline so only the Speed Limit side changes. rl.begin_scissor_mode(int(right.x), int(rect.y), int(right.width + 1), int(rect.height + 1)) try: - rl.draw_rectangle_rounded_lines_ex(rect, CONTROL_ROUNDNESS, CONTROL_SEGMENTS, CONTROL_BORDER_WIDTH, color) + rl.draw_rectangle_rounded_lines_ex(rect, CONTROL_ROUNDNESS, CONTROL_SEGMENTS, 3, color) finally: rl.end_scissor_mode() - if right.x > rect.x: + if self._presentation.mode == "split": rl.draw_line_ex(rl.Vector2(right.x, rect.y + 8), rl.Vector2(right.x, rect.y + rect.height - 8), 3, color) def _render(self, rect: rl.Rectangle) -> None: presentation = self._presentation state = self._slc_state - draw_control_card(rect, fill=CONTROL_BG, border=CONTROL_BORDER) + rl.draw_rectangle_rounded_lines_ex( + rect, CONTROL_ROUNDNESS, CONTROL_SEGMENTS, 7, + rl.Color(UNIFIED_ACCENT.r, UNIFIED_ACCENT.g, UNIFIED_ACCENT.b, 55), + ) + draw_control_card(rect, fill=CONTROL_BG, border=UNIFIED_ACCENT, border_width=2) if presentation.mode == "split": divider_x = rect.x + rect.width / 2 rl.draw_line_ex( rl.Vector2(divider_x, rect.y + 8), rl.Vector2(divider_x, rect.y + rect.height - 8), - 2, CONTROL_BORDER, + 2, rl.Color(UNIFIED_ACCENT.r, UNIFIED_ACCENT.g, UNIFIED_ACCENT.b, 110), ) + elif presentation.mode == "merged": + self._draw_merged_separator(rect) self._draw_active_emphasis(rect) max_bounds = rl.Rectangle(rect.x, rect.y, rect.width / 2, rect.height) if presentation.mode in ("split", "merged") else rect @@ -181,10 +209,10 @@ class UnifiedSpeedWidget(LayoutWidget): 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_label_color = self._max_header_color(presentation.active_side, self.hud_renderer.is_cruise_set) - self._draw_header(max_bounds, "MAX SET", "dashboard", max_label_color) + 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 + 79, VALUE_FONT_SIZE, max_color, bold=True) - self._draw_centered_text(tr(presentation.unit_text), max_bounds, rect.y + 210, UNIT_FONT_SIZE, COLORS.WHITE_TRANSLUCENT) + 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) if limit_bounds is not None: icon_key = source_icon_key(presentation.source) @@ -192,18 +220,18 @@ 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 + 79, VALUE_FONT_SIZE, COLORS.WHITE, bold=True) + self._draw_centered_text(presentation.posted_speed_text, limit_bounds, rect.y + 75, VALUE_FONT_SIZE, COLORS.WHITE, 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 + 171) - self._draw_centered_text(tr(presentation.unit_text), limit_bounds, rect.y + 210, UNIT_FONT_SIZE, COLORS.WHITE_TRANSLUCENT) + 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) if presentation.mode == "merged": - self._draw_centered_text(presentation.effective_speed_text, rect, rect.y + 84, VALUE_FONT_SIZE, COLORS.WHITE, bold=True) - self._draw_centered_text(tr(presentation.unit_text), rect, rect.y + 210, UNIT_FONT_SIZE, COLORS.WHITE_TRANSLUCENT) + 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) if presentation.offset_text is not None: - self._draw_offset_pill(limit_bounds, presentation.offset_text, rect.y + 70) + self._draw_offset_pill(limit_bounds, presentation.offset_text, rect.y + 69) if presentation.confirmation_pending and limit_bounds is not None: intensity = (1.0 + math.sin(2.0 * math.pi * rl.get_time())) / 2.0 @@ -212,8 +240,8 @@ class UnifiedSpeedWidget(LayoutWidget): self._draw_speed_limit_border(rect, limit_bounds, pulse) else: if limit_bounds is not None and state is not None: - vision_color = _speed_limit_pulse_color(CONTROL_BORDER, CONTROL_BORDER.a) - if (vision_color.r, vision_color.g, vision_color.b) != (CONTROL_BORDER.r, CONTROL_BORDER.g, CONTROL_BORDER.b): + vision_color = _speed_limit_pulse_color(UNIFIED_ACCENT, UNIFIED_ACCENT.a) + if (vision_color.r, vision_color.g, vision_color.b) != (UNIFIED_ACCENT.r, UNIFIED_ACCENT.g, UNIFIED_ACCENT.b): self._draw_speed_limit_border(rect, limit_bounds, vision_color) if state is not None and ui_state.ui_params.get_bool("SpeedLimitSources"): _draw_sources_bubble(state, rect) diff --git a/selfdrive/ui/tests/test_unified_speed_presentation.py b/selfdrive/ui/tests/test_unified_speed_presentation.py index 4e92cbe15f..b3c21ce462 100644 --- a/selfdrive/ui/tests/test_unified_speed_presentation.py +++ b/selfdrive/ui/tests/test_unified_speed_presentation.py @@ -26,10 +26,11 @@ def slc_state(posted_mph=65, offset_mph=0, source="Map Data", *, pending_mph=0, (80, 65, 5, "split"), (70, 70, 0, "merged"), (70, 65, 5, "merged"), + (70, 65, 4, "split"), (65, 70, -5, "merged"), ]) def test_split_and_merge_use_effective_accepted_limit(max_speed, posted, offset, expected_mode): - result = resolve_unified_speed(True, True, max_speed, slc_state(posted, offset), False) + result = resolve_unified_speed(True, True, max_speed, slc_state(posted, offset), True, False) assert result.mode == expected_mode assert result.posted_speed_text == str(posted) assert result.effective_speed_text == str(posted + offset) @@ -38,7 +39,7 @@ def test_split_and_merge_use_effective_accepted_limit(max_speed, posted, offset, def test_pending_candidate_forces_split_without_replacing_accepted_target(): state = slc_state(65, 5, source="Vision", pending_mph=75) - result = resolve_unified_speed(True, True, 70, state, False) + result = resolve_unified_speed(True, True, 70, state, True, False) assert result.mode == "split" assert result.confirmation_pending assert result.posted_speed_text == "75" @@ -48,67 +49,90 @@ def test_pending_candidate_forces_split_without_replacing_accepted_target(): def test_resolution_after_confirmation_uses_same_equality_rule(): state = slc_state(65, 5, pending_mph=75) - assert resolve_unified_speed(True, True, 70, state, False).mode == "split" + assert resolve_unified_speed(True, True, 70, state, True, False).mode == "split" state["speed_limit_changed"] = state["unconfirmed_valid"] = False - assert resolve_unified_speed(True, True, 70, state, False).mode == "merged" - assert resolve_unified_speed(True, True, 80, state, False).mode == "split" + assert resolve_unified_speed(True, True, 70, state, True, False).mode == "merged" + assert resolve_unified_speed(True, True, 80, state, True, False).mode == "split" def test_source_change_and_override_do_not_change_layout(): state = slc_state(65, 5) - assert resolve_unified_speed(True, True, 70, state, False).mode == "merged" + assert resolve_unified_speed(True, True, 70, state, True, False).mode == "merged" + state["presented_source"] = "Vision" + result = resolve_unified_speed(True, True, 70, state, True, False) + assert result.mode == "merged" + assert result.source == "Vision" state["presented_source"] = "Dashboard" state["slc_overridden_speed"] = 40.0 - result = resolve_unified_speed(True, True, 70, state, False) + result = resolve_unified_speed(True, True, 70, state, True, False) assert result.mode == "merged" assert result.source == "Dashboard" def test_source_target_change_recomputes_layout_independently(): state = slc_state(65, 5, source="Map Data") - assert resolve_unified_speed(True, True, 70, state, False).mode == "merged" + assert resolve_unified_speed(True, True, 70, state, True, False).mode == "merged" state.update(slc_state(55, 5, source="Vision")) - result = resolve_unified_speed(True, True, 70, state, False) + result = resolve_unified_speed(True, True, 70, state, True, False) assert result.mode == "split" assert result.source == "Vision" def test_active_side_uses_published_control_semantic(): state = slc_state(65, 5, limiting=True) - assert resolve_unified_speed(True, True, 80, state, False).active_side == "slc" + assert resolve_unified_speed(True, True, 80, state, True, False).active_side == "slc" state["slc_is_limiting_max_set"] = False - assert resolve_unified_speed(True, True, 80, state, False).active_side == "max" + assert resolve_unified_speed(True, True, 80, state, True, False).active_side == "max" state["slc_overridden_speed"] = 40.0 - assert resolve_unified_speed(True, True, 80, state, False).active_side == "none" + assert resolve_unified_speed(True, True, 80, state, True, False).active_side == "none" def test_display_only_speed_limit_stays_split(): - result = resolve_unified_speed(True, True, 70, slc_state(70, enabled=False), False) + result = resolve_unified_speed(True, True, 70, slc_state(70, enabled=False), False, False) assert result.mode == "split" def test_disabled_confirmation_does_not_force_split(): state = slc_state(65, 5, pending_mph=75) state["speed_limit_changed"] = False - assert resolve_unified_speed(True, True, 70, state, False).mode == "merged" + assert resolve_unified_speed(True, True, 70, state, True, False).mode == "merged" def test_missing_limit_never_renders_zero_or_a_stale_source(): - result = resolve_unified_speed(True, True, 70, slc_state(0, source="None"), False) - assert result.mode == "max_only" + result = resolve_unified_speed(True, True, 70, slc_state(0, source="None"), True, False) + assert result.mode == "split" assert result.posted_speed_text == "–" + assert result.effective_speed_text == "–" assert result.source == "None" +def test_missing_limit_with_slc_disabled_allows_max_only(): + result = resolve_unified_speed(True, True, 70, slc_state(0, source="None", enabled=False), False, False) + assert result.mode == "max_only" + + +@pytest.mark.parametrize("show_max,expected_mode", [(True, "split"), (False, "limit_only")]) +def test_stale_slc_data_keeps_speed_limit_region(show_max, expected_mode): + result = resolve_unified_speed(show_max, True, 70, None, True, False) + assert result.mode == expected_mode + assert result.posted_speed_text == "–" + assert result.effective_speed_text == "–" + assert result.source == "None" + + +def test_missing_data_with_slc_disabled_uses_max_only(): + assert resolve_unified_speed(True, True, 70, None, False, False).mode == "max_only" + + def test_persisted_previous_limit_without_source_remains_visible(): - result = resolve_unified_speed(True, True, 70, slc_state(45, source="Previous Limit"), False) + result = resolve_unified_speed(True, True, 70, slc_state(45, source="Previous Limit"), True, False) assert result.mode == "split" assert result.posted_speed_text == "45" assert result.source == "Previous Limit" def test_low_limit_with_large_negative_offset_preserves_configured_offset(): - result = resolve_unified_speed(True, True, 70, slc_state(5, -99), False) + result = resolve_unified_speed(True, True, 70, slc_state(5, -99), True, False) assert result.mode == "split" assert result.posted_speed_text == "5" assert result.effective_speed_text == "–" @@ -117,7 +141,7 @@ def test_low_limit_with_large_negative_offset_preserves_configured_offset(): def test_metric_and_rounding_follow_the_displayed_value(): state = slc_state(65.4, 4.4, metric=True) - result = resolve_unified_speed(True, True, 70, state, True) + result = resolve_unified_speed(True, True, 70, state, True, True) assert result.mode == "merged" assert result.posted_speed_text == "65" assert result.offset_text == "+4" @@ -126,17 +150,37 @@ def test_metric_and_rounding_follow_the_displayed_value(): def test_invisible_fraction_does_not_keep_card_split(): state = slc_state(65.1, 5.2) - result = resolve_unified_speed(True, True, 70.4, state, False) + result = resolve_unified_speed(True, True, 70.4, state, True, False) assert result.mode == "merged" def test_hidden_max_still_shows_posted_limit(): - result = resolve_unified_speed(False, True, 70, slc_state(65), False) + result = resolve_unified_speed(False, True, 70, slc_state(65), True, False) + assert result.mode == "limit_only" + result = resolve_unified_speed(False, True, 70, slc_state(65, enabled=False), False, False) assert result.mode == "limit_only" def test_confirmation_forces_split_even_when_max_is_hidden(): - result = resolve_unified_speed(False, True, 70, slc_state(65, pending_mph=75), False) + result = resolve_unified_speed(False, True, 70, slc_state(65, pending_mph=75), True, False) assert result.mode == "split" assert result.max_speed_text == "70" assert result.confirmation_pending + + +def test_offset_max_pending_and_source_transitions_recompute_mode(): + state = slc_state(65) + assert resolve_unified_speed(True, True, 70, state, True, False).mode == "split" + state.update(slc_state(65, 5)) + assert resolve_unified_speed(True, True, 70, state, True, False).mode == "merged" + assert resolve_unified_speed(True, True, 75, state, True, False).mode == "split" + state.update(slc_state(65, 5, pending_mph=75)) + assert resolve_unified_speed(True, True, 70, state, True, False).mode == "split" + state.update(slc_state(65, 5)) + assert resolve_unified_speed(True, True, 70, state, True, False).mode == "merged" + state.update(slc_state(0, source="None")) + missing = resolve_unified_speed(True, True, 70, state, True, False) + assert missing.mode == "split" + assert missing.posted_speed_text == "–" + state.update(slc_state(65, 5)) + assert resolve_unified_speed(True, True, 70, state, True, False).mode == "merged" diff --git a/selfdrive/ui/tests/test_unified_speed_widget.py b/selfdrive/ui/tests/test_unified_speed_widget.py index 80446dfe91..2eded1d175 100644 --- a/selfdrive/ui/tests/test_unified_speed_widget.py +++ b/selfdrive/ui/tests/test_unified_speed_widget.py @@ -1,8 +1,13 @@ from types import SimpleNamespace +from dataclasses import replace import pyray as rl +import pytest -from openpilot.selfdrive.ui.onroad.starpilot.unified_speed_presentation import UnifiedSpeedPresentation +from cereal import custom +from openpilot.common.constants import CV +from openpilot.selfdrive.ui.onroad.starpilot import slc_speed_limit +from openpilot.selfdrive.ui.onroad.starpilot.unified_speed_presentation import UnifiedSpeedPresentation, resolve_unified_speed from openpilot.selfdrive.ui.onroad.starpilot.widgets import unified_speed @@ -32,6 +37,17 @@ def test_confirmation_touch_only_accepts_on_speed_limit_side(monkeypatch): assert writes == [("SpeedLimitAccepted", True)] +def test_merged_speed_limit_side_toggles_sources(monkeypatch): + widget = make_widget("merged") + writes = [] + params = SimpleNamespace(get_bool=lambda _key: False, put_bool=lambda key, value: writes.append((key, value))) + monkeypatch.setattr(unified_speed, "ui_state", SimpleNamespace(ui_params=params)) + widget._handle_mouse_press(rl.Vector2(100, 150)) + assert writes == [] + widget._handle_mouse_press(rl.Vector2(400, 150)) + assert writes == [("SpeedLimitSources", True)] + + def test_diagnostic_sources_can_be_dismissed_from_max_only_card(monkeypatch): widget = make_widget("max_only") widget._slc_state = {} @@ -43,37 +59,240 @@ def test_diagnostic_sources_can_be_dismissed_from_max_only_card(monkeypatch): def test_right_border_overlay_is_clipped_to_speed_limit_side(monkeypatch): - widget = make_widget() events = [] monkeypatch.setattr(unified_speed.rl, "begin_scissor_mode", lambda *args: events.append(("begin", args))) monkeypatch.setattr(unified_speed.rl, "draw_rectangle_rounded_lines_ex", lambda *args: events.append(("outline", args))) monkeypatch.setattr(unified_speed.rl, "draw_line_ex", lambda *args: events.append(("divider", args))) monkeypatch.setattr(unified_speed.rl, "end_scissor_mode", lambda: events.append(("end",))) - rect = widget.rect - right = widget._speed_limit_bounds(rect) - widget._draw_speed_limit_border(rect, right, rl.Color(188, 132, 255, 200)) - assert events[0] == ("begin", (290, 75, 261, 251)) - assert [event[0] for event in events] == ["begin", "outline", "end", "divider"] + for mode, expected in (("split", ["begin", "outline", "end", "divider"]), + ("merged", ["begin", "outline", "end"])): + events.clear() + widget = make_widget(mode) + rect = widget.rect + right = widget._speed_limit_bounds(rect) + widget._draw_speed_limit_border(rect, right, rl.Color(188, 132, 255, 200)) + assert events[0] == ("begin", (290, 75, 261, 251)) + assert [event[0] for event in events] == expected def test_split_and_merged_draw_one_card_with_both_headers(monkeypatch): cards = [] + lines = [] monkeypatch.setattr(unified_speed, "draw_control_card", lambda *args, **kwargs: cards.append(args[0])) monkeypatch.setattr(unified_speed, "ui_state", SimpleNamespace(status=unified_speed.UIStatus.DISENGAGED, ui_params=SimpleNamespace(get_bool=lambda _key: False))) - monkeypatch.setattr(unified_speed.rl, "draw_line_ex", lambda *args: None) + monkeypatch.setattr(unified_speed.rl, "draw_line_ex", lambda *args: lines.append(args)) + monkeypatch.setattr(unified_speed.rl, "draw_rectangle_rounded_lines_ex", lambda *args: None) for mode in ("split", "merged"): + lines.clear() widget = make_widget(mode) headers = [] + values = [] + separators = [] + offsets = [] monkeypatch.setattr(widget, "_draw_header", lambda _bounds, text, icon, _color, rows=headers: rows.append((text, icon))) - monkeypatch.setattr(widget, "_draw_centered_text", lambda *args, **kwargs: None) - monkeypatch.setattr(widget, "_draw_offset_pill", lambda *args: None) + monkeypatch.setattr(widget, "_draw_centered_text", lambda text, *args, rows=values, **kwargs: rows.append(text)) + monkeypatch.setattr(widget, "_draw_offset_pill", lambda bounds, text, y, rows=offsets: rows.append((bounds, text, y))) + monkeypatch.setattr(widget, "_draw_merged_separator", lambda _rect, rows=separators: rows.append(True)) monkeypatch.setattr(widget, "_draw_active_emphasis", lambda *args: None) widget._render(widget.rect) - assert headers == [("MAX SET", "dashboard"), ("SPEED LIMIT", "map")] + assert headers == [("MAX SET", "speedometer"), ("SPEED LIMIT", "map")] + assert separators == ([True] if mode == "merged" else []) + assert sum(line[0].x == line[1].x == 290 for line in lines) == (1 if mode == "split" else 0) + assert values == (["70", "mph"] if mode == "merged" else ["70", "mph", "65", "mph"]) + assert offsets[0][0].x == 290 + assert offsets[0][2] == (144 if mode == "merged" else 250) assert len(cards) == 2 +def test_merged_draws_effective_speed_once_and_skips_active_line(monkeypatch): + widget = make_widget("merged") + widget._presentation = replace(widget._presentation, max_speed_text="71", effective_speed_text="70", active_side="shared") + monkeypatch.setattr(unified_speed, "ui_state", SimpleNamespace(status=unified_speed.UIStatus.ENGAGED, + ui_params=SimpleNamespace(get_bool=lambda _key: False))) + monkeypatch.setattr(unified_speed, "draw_control_card", lambda *args, **kwargs: None) + monkeypatch.setattr(unified_speed.rl, "draw_rectangle_rounded_lines_ex", lambda *args: None) + lines = [] + monkeypatch.setattr(unified_speed.rl, "draw_line_ex", lambda *args: lines.append(args)) + monkeypatch.setattr(widget, "_draw_merged_separator", lambda _rect: None) + monkeypatch.setattr(widget, "_draw_header", lambda *args: None) + monkeypatch.setattr(widget, "_draw_offset_pill", lambda *args: None) + values = [] + monkeypatch.setattr(widget, "_draw_centered_text", lambda text, *args, **kwargs: values.append(text)) + widget._render(widget.rect) + assert values == ["70", "mph"] + assert lines == [] + + +def test_merged_separator_has_shallow_center_dip(monkeypatch): + widget = make_widget("merged") + segments = [] + monkeypatch.setattr(unified_speed.rl, "draw_line_ex", lambda *args: segments.append(("line", args))) + monkeypatch.setattr(unified_speed.rl, "draw_spline_segment_bezier_cubic", lambda *args: segments.append(("curve", args))) + widget._draw_merged_separator(widget.rect) + assert [segment[0] for segment in segments] == ["line", "curve", "line", "curve", "line"] + assert segments[0][1][0].y == widget.rect.y + 76 + assert segments[2][1][0].y == widget.rect.y + 88 + + +def test_enabled_slc_stays_full_width_when_plan_is_stale(monkeypatch): + widget = make_widget("split") + 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, + )) + monkeypatch.setattr(unified_speed, "_is_slc_enabled", lambda: True) + monkeypatch.setattr(unified_speed, "_get_slc_state", lambda: None) + assert widget.get_size() == (520.0, 250.0) + assert widget.is_visible + assert widget._presentation.posted_speed_text == "–" + + +def test_split_merged_transitions_keep_the_same_footprint(monkeypatch): + widget = make_widget("split") + monkeypatch.setattr(widget, "_refresh_snapshot", lambda: None) + sizes = [] + for mode in ("split", "merged", "split", "merged"): + widget._presentation = replace(widget._presentation, mode=mode) + sizes.append(widget.get_size()) + assert sizes == [(520.0, 250.0)] * 4 + + +@pytest.fixture +def slc_ui(monkeypatch): + class Params(dict): + def get_bool(self, key): + return bool(self.get(key)) + + def get(self, key, encoding=None): + return super().get(key) + + class SubMaster(dict): + recv_frame = {"starpilotPlan": 10} + valid = {"starpilotCarState": True} + + plan = custom.StarPilotPlan.new_message( + slcSpeedLimit=30 * CV.MPH_TO_MS, slcSpeedLimitOffset=0.0, slcSpeedLimitSource="Map Data", + slcOverriddenSpeed=0.0, slcMapSpeedLimit=30 * CV.MPH_TO_MS, slcMapboxSpeedLimit=0.0, + slcNextSpeedLimit=0.0, unconfirmedSlcSpeedLimit=0.0, speedLimitChanged=False, + ) + sm = SubMaster(starpilotPlan=plan, starpilotCarState=SimpleNamespace(dashboardSpeedLimit=0.0)) + sm.recv_frame = sm.recv_frame.copy() + params = Params(SpeedLimitController=True, ShowSpeedLimits=False) + ui = SimpleNamespace( + sm=sm, started_frame=10, is_metric=False, ui_params=params, starpilot_toggles={}, + params_memory=SimpleNamespace(get_float=lambda _key: 0.0), + ) + monkeypatch.setattr(slc_speed_limit, "ui_state", ui) + monkeypatch.setattr(slc_speed_limit, "starpilot_state", SimpleNamespace(car_state=SimpleNamespace(hasDashSpeedLimits=True))) + monkeypatch.setattr(slc_speed_limit, "_tick_pulse", lambda *args: None) + return ui + + +def test_slc_state_extraction_respects_feature_and_display_toggles(slc_ui): + assert slc_speed_limit._is_slc_enabled() + assert slc_speed_limit._get_slc_state()["slc_enabled"] + slc_ui.starpilot_toggles["speed_limit_controller"] = False + assert not slc_speed_limit._is_slc_enabled() + assert slc_speed_limit._get_slc_state() is None + slc_ui.ui_params["ShowSpeedLimits"] = True + assert not slc_speed_limit._get_slc_state()["slc_enabled"] + slc_ui.starpilot_toggles["speed_limit_controller"] = True + slc_ui.sm.recv_frame["starpilotPlan"] = 9 + assert slc_speed_limit._get_slc_state() is None + + +@pytest.mark.parametrize("presented_source,expected_source,expected_speed", [ + ("", "Map Data", "30"), + ("Map Data", "Map Data", "30"), + ("None", "None", "–"), + ("Previous Limit", "Previous Limit", "30"), + ("Vision", "Vision", "30"), +]) +def test_serialized_plan_source_defaults_and_explicit_values(slc_ui, presented_source, expected_source, expected_speed): + message = slc_ui.sm["starpilotPlan"] + if presented_source: + message.slcPresentedSpeedLimitSource = presented_source + # Replay decodes older plans with a present but empty Text attribute. + with custom.StarPilotPlan.from_bytes(message.to_bytes()) as plan: + slc_ui.sm["starpilotPlan"] = plan + state = slc_speed_limit._get_slc_state() + result = resolve_unified_speed(True, True, 35, state, True, False) + assert result.source == expected_source + assert result.posted_speed_text == expected_speed + assert result.mode == "split" + + +def test_legacy_replay_limit_and_offset_merge_with_max_set(slc_ui): + message = slc_ui.sm["starpilotPlan"] + message.slcSpeedLimitOffset = 5 * CV.MPH_TO_MS + with custom.StarPilotPlan.from_bytes(message.to_bytes()) as plan: + slc_ui.sm["starpilotPlan"] = plan + result = resolve_unified_speed(True, True, 35, slc_speed_limit._get_slc_state(), True, False) + assert (result.source, result.posted_speed_text, result.effective_speed_text) == ("Map Data", "30", "35") + assert (result.mode, result.offset_text) == ("merged", "+5") + + +@pytest.mark.parametrize("presented_source,limiting,max_speed,enabled,overridden,expected_side,line_x", [ + ("", False, 40, True, False, "slc", 308), + ("", False, 34, True, False, "max", 48), + ("", False, 35, True, False, "shared", None), + ("", False, 40, False, False, "max", 48), + ("", False, 40, True, True, "none", None), + ("Map Data", False, 40, True, False, "max", 48), + ("Map Data", True, 40, True, False, "slc", 308), +]) +def test_active_underline_with_legacy_and_current_plans(slc_ui, monkeypatch, presented_source, limiting, + max_speed, enabled, overridden, expected_side, line_x): + message = slc_ui.sm["starpilotPlan"] + message.slcSpeedLimitOffset = 5 * CV.MPH_TO_MS + message.slcPresentedSpeedLimitSource = presented_source + message.slcIsLimitingMaxSet = limiting + message.slcOverriddenSpeed = 40 * CV.MPH_TO_MS if overridden else 0.0 + slc_ui.starpilot_toggles["speed_limit_controller"] = enabled + slc_ui.ui_params["ShowSpeedLimits"] = True + with custom.StarPilotPlan.from_bytes(message.to_bytes()) as plan: + slc_ui.sm["starpilotPlan"] = plan + presentation = resolve_unified_speed(True, True, max_speed, slc_speed_limit._get_slc_state(), enabled, False) + assert presentation.active_side == expected_side + + widget = make_widget(presentation.mode) + widget._presentation = presentation + monkeypatch.setattr(unified_speed, "ui_state", SimpleNamespace(status=unified_speed.UIStatus.ENGAGED)) + lines = [] + monkeypatch.setattr(unified_speed.rl, "draw_line_ex", lambda *args: lines.append(args)) + widget._draw_active_emphasis(widget.rect) + if line_x is None: + assert lines == [] + else: + assert len(lines) == 1 + assert (lines[0][0].x, lines[0][0].y, lines[0][1].x) == (line_x, 140, line_x + 224) + assert lines[0][3] == unified_speed.UNIFIED_ACCENT + + +def test_legacy_plan_without_active_source_does_not_use_diagnostic_map_limit(slc_ui): + message = slc_ui.sm["starpilotPlan"] + message.slcSpeedLimitSource = "None" + with custom.StarPilotPlan.from_bytes(message.to_bytes()) as plan: + slc_ui.sm["starpilotPlan"] = plan + state = slc_speed_limit._get_slc_state() + assert round(state["map_sl"]) == 30 + result = resolve_unified_speed(True, True, 35, state, True, False) + assert (result.source, result.posted_speed_text, result.mode) == ("None", "–", "split") + + +def test_legacy_pending_candidate_remains_visible_without_active_source(slc_ui): + message = slc_ui.sm["starpilotPlan"] + message.slcSpeedLimitSource = "None" + message.unconfirmedSlcSpeedLimit = 45 * CV.MPH_TO_MS + message.speedLimitChanged = True + with custom.StarPilotPlan.from_bytes(message.to_bytes()) as plan: + slc_ui.sm["starpilotPlan"] = plan + result = resolve_unified_speed(True, True, 35, slc_speed_limit._get_slc_state(), True, False) + assert (result.posted_speed_text, result.mode, result.confirmation_pending) == ("45", "split", True) + + def test_header_colors_preserve_engaged_disengaged_and_override_semantics(monkeypatch): widget = make_widget() colors = unified_speed.COLORS