diff --git a/openpilot/selfdrive/ui/mici/onroad/augmented_road_view.py b/openpilot/selfdrive/ui/mici/onroad/augmented_road_view.py index 58ae692875..529bff9a45 100644 --- a/openpilot/selfdrive/ui/mici/onroad/augmented_road_view.py +++ b/openpilot/selfdrive/ui/mici/onroad/augmented_road_view.py @@ -259,11 +259,12 @@ class AugmentedRoadView(CameraView): gui_app.measure_frame_phase("model", self._model_renderer.render_with_lead, rect, lead_indicator, lead_info_mode, lead_info_metric, lateral_active=lateral_active, paint=paint) - def render_model_source_layer(self, rect: rl.Rectangle, *, show_long_indicator: bool = False) -> None: + def render_model_source_layer(self, rect: rl.Rectangle, *, show_long_indicator: bool | None = False) -> None: self._hud_renderer._update_state() self._hud_renderer._draw_model_source(rect) - self._long_indicator.set_should_draw(show_long_indicator) - self._long_indicator.render(rect) + if show_long_indicator is not None: + self._long_indicator.set_should_draw(show_long_indicator) + self._long_indicator.render(rect) def render_stock_confidence_layer(self, rect: rl.Rectangle, *, lateral_active: bool = False) -> None: self._confidence_ball.render_with_lateral(rect, lateral_active) diff --git a/openpilot/selfdrive/ui/mici/onroad/long_indicator.py b/openpilot/selfdrive/ui/mici/onroad/long_indicator.py index 1ff83082a2..e6e049a4ad 100644 --- a/openpilot/selfdrive/ui/mici/onroad/long_indicator.py +++ b/openpilot/selfdrive/ui/mici/onroad/long_indicator.py @@ -23,6 +23,10 @@ class LongIndicator(Widget): self._personality: int | None = None self._personality_changed_time = -HIGHLIGHT_TIME self._should_draw = False + self._sidebar = False + self._traffic_mode = False + self._sidebar_personality: int | None = None + self._sidebar_long_active: bool | None = None @staticmethod def _texture(name: str, width: int, height: int) -> rl.Texture: @@ -31,16 +35,29 @@ class LongIndicator(Widget): def set_should_draw(self, should_draw: bool): self._should_draw = should_draw + def render_sidebar(self, rect: rl.Rectangle, *, personality: int | None = None, + longitudinal_active: bool = False, traffic_mode: bool = False) -> None: + self._sidebar, self._traffic_mode = True, traffic_mode + self._sidebar_personality = personality + self._sidebar_long_active = longitudinal_active if personality is not None else None + try: + self.render(rect) + finally: + self._sidebar, self._traffic_mode = False, False + self._sidebar_personality = self._sidebar_long_active = None + def _render(self, rect: rl.Rectangle) -> None: sm = ui_state.sm - if sm.recv_frame['selfdriveState'] < ui_state.started_frame or not sm['selfdriveState'].enabled or not ui_state.has_longitudinal_control: + if self._sidebar_personality is None and (sm.recv_frame['selfdriveState'] < ui_state.started_frame or + not sm['selfdriveState'].enabled or not ui_state.has_longitudinal_control): self._personality = None self._personality_changed_time = -HIGHLIGHT_TIME self._alpha_filter.x = 0.0 return # hidden under alerts and set speed - visible = self._should_draw and sm['selfdriveState'].alertSize == log.SelfdriveState.AlertSize.none + visible = self._should_draw and (self._sidebar_personality is not None or + sm['selfdriveState'].alertSize == log.SelfdriveState.AlertSize.none) alpha = self._alpha_filter.update(visible) self._draw_lead_car(rect, alpha) self._draw_distance_bars(rect, alpha, visible) @@ -48,10 +65,12 @@ class LongIndicator(Widget): def _draw_lead_car(self, rect: rl.Rectangle, alpha: float) -> None: sm = ui_state.sm plan = sm['longitudinalPlan'] - has_lead = sm.alive['longitudinalPlan'] and plan.hasLead + has_lead = self._sidebar_long_active is not False and sm.alive['longitudinalPlan'] and plan.hasLead e2e = has_lead and plan.longitudinalPlanSource == log.LongitudinalPlan.LongitudinalPlanSource.e2e white_f, green_f = self._lead_car_filters + if self._sidebar_long_active is False: + white_f.x, green_f.x = 0.35, 0.0 white_alpha = white_f.update(0.0 if e2e else 0.9 if has_lead else 0.35) green_alpha = green_f.update(float(e2e)) @@ -62,7 +81,7 @@ class LongIndicator(Widget): def _draw_distance_bars(self, rect: rl.Rectangle, alpha: float, visible: bool) -> None: sm = ui_state.sm now = rl.get_time() - personality = sm['selfdriveState'].personality.raw + personality = self._sidebar_personality if self._sidebar_personality is not None else sm['selfdriveState'].personality.raw if self._personality is not None and personality != self._personality: self._personality_changed_time = now self._personality = personality @@ -72,18 +91,23 @@ class LongIndicator(Widget): highlight = now - self._personality_changed_time < HIGHLIGHT_TIME # blink at double the turn signal rate (2.67 Hz) while overriding the gas - overriding = any(e.name == log.OnroadEvent.EventName.gasPressedOverride for e in sm['onroadEvents']) + overriding = self._sidebar_long_active is not False and any( + e.name == log.OnroadEvent.EventName.gasPressedOverride for e in sm['onroadEvents']) blink = 0.35 / 0.9 if overriding and now % 0.375 > 0.1875 else 1.0 - count = personality + 1 + count = 1 if self._traffic_mode else personality + 1 for i, ((white, green), y, (active_f, green_f)) in enumerate(zip(self._txt_distance, (122, 136, 152), self._distance_filters, strict=True)): active = active_f.update(float(i < count)) - green_alpha = green_f.update(float(highlight and i == count - 1)) + green_alpha = green_f.update(float(highlight and i == count - 1 and not self._traffic_mode)) # only lit bars blink on override - self._draw_centered(white, rect, y, (0.35 * (1 - active) + 0.9 * (active - green_alpha) * blink) * alpha) + color = (200, 32, 48) if self._traffic_mode and i < count else (255, 255, 255) + self._draw_centered(white, rect, y, (0.35 * (1 - active) + 0.9 * (active - green_alpha) * blink) * alpha, color) self._draw_centered(green, rect, y, green_alpha * blink * alpha) - @staticmethod - def _draw_centered(texture: rl.Texture, rect: rl.Rectangle, y: float, alpha: float) -> None: - pos = rl.Vector2(rect.x + 46 - texture.width / 2, rect.y + y - texture.height / 2) - rl.draw_texture_ex(texture, pos, 0.0, 1.0, rl.Color(255, 255, 255, round(255 * alpha))) + def _draw_centered(self, texture: rl.Texture, rect: rl.Rectangle, y: float, alpha: float, + color: tuple[int, int, int] = (255, 255, 255)) -> None: + scale = min((rect.width - 4) / 76, (rect.height - 4) / 94) if self._sidebar else 1.0 + center_x = rect.x + rect.width / 2 if self._sidebar else rect.x + 46 + center_y = rect.y + rect.height / 2 + (y - 126) * scale if self._sidebar else rect.y + y + pos = rl.Vector2(center_x - texture.width * scale / 2, center_y - texture.height * scale / 2) + rl.draw_texture_ex(texture, pos, 0.0, scale, rl.Color(*color, round(255 * alpha))) diff --git a/openpilot/starpilot/ui/onroad.py b/openpilot/starpilot/ui/onroad.py index ce322d7b72..4b7136917a 100644 --- a/openpilot/starpilot/ui/onroad.py +++ b/openpilot/starpilot/ui/onroad.py @@ -354,6 +354,7 @@ class OnroadView: rail = rl.Rectangle(0, 0, 536, 240) if use_stock: stock_layer(rail, state) + self.compact_sidebar.render_personality(rail, state) else: self.compact_sidebar.render(rail, state) clip.begin_scissor_mode(0, 0, 476, 240) diff --git a/openpilot/starpilot/ui/onroad_compact_widgets.py b/openpilot/starpilot/ui/onroad_compact_widgets.py index 22104bb934..0582f733f8 100644 --- a/openpilot/starpilot/ui/onroad_compact_widgets.py +++ b/openpilot/starpilot/ui/onroad_compact_widgets.py @@ -226,6 +226,7 @@ class MiciSidebarWidgets: def __init__(self, fonts: BitmapFonts | None = None): self.fonts = fonts + self.personality_renderer = None self._confidence_filter = FirstOrderFilter(-0.5, 0.5, 1 / 60) self._confidence_drive_frame: int | None = None self._confidence_stamp_ns: int | None = None @@ -233,14 +234,22 @@ class MiciSidebarWidgets: def render(self, rect: rl.Rectangle, state: OnroadState) -> None: sidebar = rl.Rectangle(rect.x + rect.width - 60, rect.y, 60, rect.height) rl.draw_rectangle(int(sidebar.x), int(sidebar.y), int(sidebar.width), int(sidebar.height), rl.BLACK) - for key, render in (("model_confidence", self._confidence_ball), ("conditional_mode", self._conditional), - ("following_distance", self._personality)): + for key, render in (("model_confidence", self._confidence_ball), ("conditional_mode", self._conditional)): position = placement(state.customization, "compact", key) if not position["enabled"] or (position["x"] < sidebar.x and state.alert.size != AlertSize.NONE): continue bounds = rl.Rectangle(position["x"], position["y"], 60, 80) draw_widget_frame(bounds, state.customization, "compact", key) render(bounds, state) + self.render_personality(rect, state) + + def render_personality(self, rect: rl.Rectangle, state: OnroadState) -> None: + position = placement(state.customization, "compact", "following_distance") + if not position["enabled"] or (position["x"] < rect.x + rect.width - 60 and state.alert.size != AlertSize.NONE): + return + bounds = rl.Rectangle(position["x"], position["y"], 60, 80) + draw_widget_frame(bounds, state.customization, "compact", "following_distance") + self._personality(bounds, state) def _conditional(self, center: rl.Rectangle, state: OnroadState) -> None: conditional = conditional_status(state) @@ -349,16 +358,21 @@ class MiciSidebarWidgets: def _personality(self, rect: rl.Rectangle, state: OnroadState) -> None: cx, cy = rect.x + rect.width / 2, rect.y + rect.height / 2 - bottom_y, lane_top_y = cy + 24, cy - 24 - color = rl.Color(*rgba(state.customization, "text", "compact", "following_distance")) - _line(cx - 25, bottom_y, cx - 13, lane_top_y, color, 3) - _line(cx + 25, bottom_y, cx + 13, lane_top_y, color, 3) accent = rl.Color(200, 32, 48, 255) if state.traffic_mode else rl.Color(112, 192, 216, 255) - stack_count = 1 if state.traffic_mode else state.personality + 1 - for y, half_width in ((cy + 12, 18), (cy - 1, 14), (cy - 14, 10))[:stack_count]: - _line(cx - half_width, y, cx + half_width, y, accent, 5) - _line(cx - half_width, y, cx - half_width - 3, y + 7, accent, 4) - _line(cx + half_width, y, cx + half_width + 3, y + 7, accent, 4) + if self.personality_renderer is not None: + label = state.traffic_display is not None and state.traffic_display.state in ('active', 'paused', 'off') + bounds = rl.Rectangle(rect.x, rect.y, rect.width, rect.height - 14 if label else rect.height) + self.personality_renderer(bounds, state) + else: + bottom_y, lane_top_y = cy + 24, cy - 24 + color = rl.Color(*rgba(state.customization, "text", "compact", "following_distance")) + _line(cx - 25, bottom_y, cx - 13, lane_top_y, color, 3) + _line(cx + 25, bottom_y, cx + 13, lane_top_y, color, 3) + stack_count = 1 if state.traffic_mode else state.personality + 1 + for y, half_width in ((cy + 12, 18), (cy - 1, 14), (cy - 14, 10))[:stack_count]: + _line(cx - half_width, y, cx + half_width, y, accent, 5) + _line(cx - half_width, y, cx - half_width - 3, y + 7, accent, 4) + _line(cx + half_width, y, cx + half_width + 3, y + 7, accent, 4) if state.traffic_display is not None and self.fonts is not None and state.alert.size != AlertSize.FULL: short = {'active': 'TRF', 'paused': 'PAUSE', 'off': 'OFF'}.get(state.traffic_display.state) if short is None: diff --git a/openpilot/starpilot/ui/runtime_app.py b/openpilot/starpilot/ui/runtime_app.py index ba771ac0f5..d5f1d4c81b 100644 --- a/openpilot/starpilot/ui/runtime_app.py +++ b/openpilot/starpilot/ui/runtime_app.py @@ -177,10 +177,8 @@ class StarShellSession: if profile == Profile.COMPACT: self.view.onroad.stock_confidence_layer = lambda rect, state: render_stock_confidence(camera_owner, rect, state) self.view.onroad.stock_confidence_reset = camera_owner.reset_stock_confidence_layer - self.view.onroad.extra_overlays = lambda rect, state: camera_owner.render_model_source_layer( - rect, show_long_indicator=state.alert.size == AlertSize.NONE and - state.appearance.camera_view != CameraViewChoice.DRIVER and not state.reverse_driver_camera and - self.view.onroad.compact_hud._set_speed_alpha.x <= 1e-2) + self.view.onroad.extra_overlays = lambda rect, state: camera_owner.render_model_source_layer(rect, show_long_indicator=None) + self.view.onroad.compact_sidebar.personality_renderer = self._render_personality except Exception: self.fonts.close() raise @@ -292,6 +290,12 @@ class StarShellSession: fresh=monitor is not None and driver is not None, onroad=ui_state.is_onroad(), top_icons=top_icons) + def _render_personality(self, rect, state): + indicator = self.camera_owner._long_indicator + indicator.set_should_draw(state.alert.size == AlertSize.NONE) + indicator.render_sidebar(rect, personality=state.personality, + longitudinal_active=state.longitudinal_active, traffic_mode=state.traffic_mode) + def _favorite_actions(self): actions = mapped_actions(lambda page: self.feature_snapshot(page, favorite=True), self.feature_request, lambda: self.appearance_owner.snapshot(self.profile), self.appearance_owner.apply) diff --git a/openpilot/starpilot/ui/tests/test_chestnut_display.py b/openpilot/starpilot/ui/tests/test_chestnut_display.py index 163d99d5cf..045b00663a 100644 --- a/openpilot/starpilot/ui/tests/test_chestnut_display.py +++ b/openpilot/starpilot/ui/tests/test_chestnut_display.py @@ -21,14 +21,19 @@ def test_native_chestnut_status_accepts_typed_params(raw, expected): assert state.chestnut_active is expected -def test_custom_model_source_layer_uses_native_status_animation(): +@pytest.mark.parametrize('indicator', [False, None]) +def test_custom_model_source_layer_uses_native_status_animation(indicator): view = Mock(spec=AugmentedRoadView, _hud_renderer=Mock(), _long_indicator=Mock()) rect = rl.Rectangle(0, 0, 476, 240) - AugmentedRoadView.render_model_source_layer(view, rect) + AugmentedRoadView.render_model_source_layer(view, rect, show_long_indicator=indicator) view._hud_renderer._update_state.assert_called_once_with() view._hud_renderer._draw_model_source.assert_called_once_with(rect) - view._long_indicator.set_should_draw.assert_called_once_with(False) - view._long_indicator.render.assert_called_once_with(rect) + if indicator is None: + view._long_indicator.set_should_draw.assert_not_called() + view._long_indicator.render.assert_not_called() + else: + view._long_indicator.set_should_draw.assert_called_once_with(False) + view._long_indicator.render.assert_called_once_with(rect) @pytest.mark.parametrize('compiled', [False, True]) diff --git a/openpilot/starpilot/ui/tests/test_covered_camera.py b/openpilot/starpilot/ui/tests/test_covered_camera.py index 81e0872d76..a1ed8883ff 100644 --- a/openpilot/starpilot/ui/tests/test_covered_camera.py +++ b/openpilot/starpilot/ui/tests/test_covered_camera.py @@ -184,7 +184,7 @@ def make_session(monkeypatch, profile, camera): monkeypatch.setattr(runtime_app, 'ui_state', NS(params=object(), CP=None, sm={})) monkeypatch.delenv('SP_ONROAD_VISUAL_PREVIEW', raising=False) def view(*_, camera_layer, **__): - return NS(camera_layer=camera_layer, onroad=NS(unified_speed=NS(source_bounds=Mock()))) + return NS(camera_layer=camera_layer, onroad=NS(unified_speed=NS(source_bounds=Mock()), compact_sidebar=NS())) monkeypatch.setattr(runtime_app, 'ShellView', view) return runtime_app.StarShellSession(profile, camera) diff --git a/openpilot/starpilot/ui/tests/test_long_indicator.py b/openpilot/starpilot/ui/tests/test_long_indicator.py index f03e4be8bc..ba2d601e12 100644 --- a/openpilot/starpilot/ui/tests/test_long_indicator.py +++ b/openpilot/starpilot/ui/tests/test_long_indicator.py @@ -51,3 +51,74 @@ def test_indicator_tracks_lead_policy_and_yields_to_alerts(display): for _ in range(60): widget._render(rect) assert all(call.args[3] < .001 for call in widget._draw_centered.call_args_list[-8:]) + + +def test_stock_indicator_fits_bottom_right_and_moved_personality_slot(display, monkeypatch): + widget, _ = display + widget._draw_centered = indicator.LongIndicator._draw_centered.__get__(widget) + monkeypatch.setattr(widget, 'render', widget._render) + widget._txt_lead_car = (NS(width=35, height=27), NS(width=50, height=42)) + widget._txt_distance = [(NS(width=w, height=h), NS(width=gw, height=gh)) + for w, h, gw, gh in ((32, 7, 60, 35), (40, 9, 68, 37), (48, 11, 76, 39))] + draw = Mock() + monkeypatch.setattr(rl, 'draw_texture_ex', draw) + for rect in (rl.Rectangle(476, 160, 60, 80), rl.Rectangle(476, 160, 60, 66), rl.Rectangle(300, 20, 60, 80)): + draw.reset_mock() + widget.render_sidebar(rect) + assert draw.call_count == 8 + for call in draw.call_args_list: + texture, pos, _, scale, _ = call.args + assert rect.x <= pos.x and pos.x + texture.width * scale <= rect.x + rect.width + assert rect.y <= pos.y and pos.y + texture.height * scale <= rect.y + rect.height + assert not widget._sidebar + + +def test_stock_sidebar_preserves_traffic_accent_then_restores_personality(display, monkeypatch): + widget, _ = display + monkeypatch.setattr(widget, 'render', widget._render) + rect = rl.Rectangle(476, 160, 60, 80) + widget.render_sidebar(rect, traffic_mode=True) + first_bar = widget._draw_centered.call_args_list[-6] + assert first_bar.args[4] == (200, 32, 48) + for _ in range(60): + widget.render_sidebar(rect) + bars = widget._draw_centered.call_args_list[-6::2] + assert bars[1].args[3] > bars[2].args[3] + assert all(call.args[4] == (255, 255, 255) for call in bars) + + +@pytest.mark.parametrize('missing', ['longitudinal', 'engagement', 'drive']) +def test_sidebar_keeps_selected_personality_without_claiming_active_control(display, monkeypatch, missing): + widget, state = display + if missing == 'longitudinal': + state.has_longitudinal_control = False + elif missing == 'engagement': + state.sm['selfdriveState'].enabled = False + else: + state.started_frame = 13 + monkeypatch.setattr(widget, 'render', widget._render) + for _ in range(60): + widget.render_sidebar(rl.Rectangle(476, 160, 60, 80), personality=0) + draws = widget._draw_centered.call_args_list[-8:] + assert draws[0].args[3] == pytest.approx(.35, abs=.001) + assert draws[1].args[3] < .001 + assert draws[2].args[3] > .89 + assert draws[4].args[3] == pytest.approx(.35, abs=.001) + assert draws[6].args[3] == pytest.approx(.35, abs=.001) + + +def test_sidebar_disengagement_clears_active_lead_and_gas_override_immediately(display, monkeypatch): + widget, state = display + monkeypatch.setattr(widget, 'render', widget._render) + monkeypatch.setattr(indicator.rl, 'get_time', lambda: 1.1) + rect = rl.Rectangle(476, 160, 60, 80) + for _ in range(60): + widget.render_sidebar(rect, personality=1, longitudinal_active=True) + assert widget._draw_centered.call_args_list[-7].args[3] > .95 + state.sm['onroadEvents'] = [NS(name=log.OnroadEvent.EventName.gasPressedOverride)] + widget.render_sidebar(rect, personality=1, longitudinal_active=False) + draws = widget._draw_centered.call_args_list[-8:] + assert draws[0].args[3] == pytest.approx(.35, abs=.001) + assert draws[1].args[3] == 0.0 + assert draws[2].args[3] > .89 + assert draws[4].args[3] > .89