diff --git a/selfdrive/assets/icons_mici/body.png b/selfdrive/assets/icons_mici/body.png new file mode 100644 index 000000000..2f7bb779d --- /dev/null +++ b/selfdrive/assets/icons_mici/body.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6b45f88128430fb50ef51c8e08b8e2a1c8fbe0b5c3a08de9f5d9d59bc1edc82e +size 4545 diff --git a/selfdrive/ui/body/__init__.py b/selfdrive/ui/body/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/selfdrive/ui/body/animations.py b/selfdrive/ui/body/animations.py new file mode 100644 index 000000000..c40f7ecde --- /dev/null +++ b/selfdrive/ui/body/animations.py @@ -0,0 +1,278 @@ +from dataclasses import dataclass +from enum import Enum +import time + + +class AnimationMode(Enum): + ONCE_FORWARD = 1 + ONCE_FORWARD_BACKWARD = 2 + REPEAT_FORWARD = 3 + REPEAT_FORWARD_BACKWARD = 4 + + +@dataclass +class Animation: + frames: list[list[tuple[int, int]]] + starting_frames: list[list[tuple[int, int]]] | None = None # played once before the main loop + frame_duration: float = 0.15 # seconds each frame is shown + mode: AnimationMode = AnimationMode.REPEAT_FORWARD_BACKWARD + repeat_interval: float = 5.0 # seconds between animation restarts (only for REPEAT modes) + hold_end: float = 0.0 # seconds to hold the last frame before playing backward (only for *_BACKWARD modes) + left_turn_remove: list[tuple[int, int]] | None = None # dots to remove from frame when turning left + right_turn_remove: list[tuple[int, int]] | None = None # dots to remove from frame when turning right + + +# --- Animation Helper Functions --- + +def _mirror(dots: list[tuple[int, int]]) -> list[tuple[int, int]]: + """Mirror a component from the left side of the face to the right""" + return [(r, 15 - c) for r, c in dots] + + +def _mirror_no_flip(dots: list[tuple[int, int]]) -> list[tuple[int, int]]: + """Move a component to the mirrored position on the right half without flipping its shape.""" + min_c = min(c for _, c in dots) + max_c = max(c for _, c in dots) + return [(r, 15 - max_c - min_c + c) for r, c in dots] + + +def _shift(dots: list[tuple[int, int]], rc: tuple[int, int]) -> list[tuple[int, int]]: + dr, dc = rc + return [(r + dr, c + dc) for r, c in dots] + + +def _make_frame(left_eye: list[tuple[int, int]], right_eye: list[tuple[int, int]], + left_brow: list[tuple[int, int]], right_brow: list[tuple[int, int]], + mouth: list[tuple[int, int]]) -> list[tuple[int, int]]: + return left_eye + left_brow + right_eye + right_brow + mouth + + +# --- Animation Helper Components --- + +# Eyes (left side) +EYE_OPEN = [ + (2, 2), (2, 3), +(3, 1), (3, 2), (3, 3), (3, 4), +(4, 1), (4, 2), (4, 3), (4, 4), + (5, 2), (5, 3) +] +EYE_HALF = [ +(4, 1), (4, 2), (4, 3), (4, 4), + (5, 2), (5, 3) +] +EYE_CLOSED = [ +(4, 1), (4, 4), + (5, 2), (5, 3), +] +EYE_LEFT_LOOK = [ + (2, 2), (2, 3), +(3, 1), (3, 2), +(4, 1), (4, 2), + (5, 2), (5, 3), +] +EYE_RIGHT_LOOK = [ + (2, 2), (2, 3), + (3, 3), (3, 4), + (4, 3), (4, 4), + (5, 2), (5, 3), +] + +# Eyebrows (left side) +BROW_HIGH = [ + (0, 1), (0, 2), +(1, 0), +] +BROW_LOWERED = [ + (1, 1), (1, 2), +(2, 0) +] +BROW_STRAIGHT = [(1, 0), (1, 1), (1, 2)] +BROW_DOWN = [ +(0, 1), (0, 2), + (1, 3) +] + +# Mouths (centered, not mirrored) +MOUTH_SMILE = [ +(6, 6), (6, 9), + (7, 7), (7, 8), +] +MOUTH_NORMAL = [(7, 7), (7, 8)] +MOUTH_SAD = [ + (6, 7), (6, 8), +(7, 6), (7, 9) +] + +# --- Animations --- + +NORMAL = Animation( + frames=[ + _make_frame(EYE_OPEN, _mirror(EYE_OPEN), BROW_HIGH, _mirror(BROW_HIGH), MOUTH_SMILE), + _make_frame(EYE_HALF, _mirror(EYE_HALF), BROW_HIGH, _mirror(BROW_HIGH), MOUTH_SMILE), + _make_frame(EYE_CLOSED, _mirror(EYE_CLOSED), BROW_LOWERED, _mirror(BROW_LOWERED), MOUTH_SMILE), + ], + left_turn_remove=[ + (3, 3), (3, 4), + (4, 3), (4, 4), + ] + _mirror_no_flip([ + (3, 1), (3, 2), + (4, 1), (4, 2), + ]), + right_turn_remove=[ + (3, 1), (3, 2), + (4, 1), (4, 2), + ] + _mirror_no_flip([ + (3, 3), (3, 4), + (4, 3), (4, 4), + ]) +) + +ASLEEP = Animation( + frames=[ + _make_frame(EYE_CLOSED, _mirror(EYE_CLOSED), [], [], MOUTH_NORMAL), + ], +) + +SLEEPY = Animation( + frames=[ + _make_frame(EYE_CLOSED, _mirror(EYE_CLOSED), _shift(BROW_STRAIGHT, (1, 0)), [], MOUTH_NORMAL), + _make_frame(EYE_HALF, _mirror(EYE_CLOSED), BROW_LOWERED, [], MOUTH_NORMAL), + _make_frame(EYE_OPEN, _mirror(EYE_CLOSED), BROW_HIGH, [], MOUTH_NORMAL) + ], + frame_duration=0.25, + mode=AnimationMode.ONCE_FORWARD_BACKWARD, + repeat_interval=10, + hold_end=1.5, +) + +INQUISITIVE = Animation( + frames=[ + _make_frame(EYE_OPEN, _mirror(EYE_OPEN), BROW_HIGH, _mirror(BROW_HIGH), MOUTH_SMILE), + + _make_frame(EYE_LEFT_LOOK, _mirror(EYE_RIGHT_LOOK), BROW_HIGH, _mirror(BROW_HIGH), MOUTH_SMILE), + _make_frame(_shift(EYE_LEFT_LOOK, (0, -1)), _shift(_mirror(EYE_RIGHT_LOOK), (0, -1)), BROW_HIGH, _mirror(BROW_HIGH), MOUTH_SMILE), + _make_frame(_shift(EYE_LEFT_LOOK, (0, -1)), _shift(_mirror(EYE_RIGHT_LOOK), (0, -1)), BROW_HIGH, _mirror(BROW_HIGH), MOUTH_SMILE), + _make_frame(_shift(EYE_LEFT_LOOK, (0, -1)), _shift(_mirror(EYE_RIGHT_LOOK), (0, -1)), BROW_HIGH, _mirror(BROW_HIGH), MOUTH_SMILE), + _make_frame(EYE_LEFT_LOOK, _mirror(EYE_RIGHT_LOOK), BROW_HIGH, _mirror(BROW_HIGH), MOUTH_SMILE), + + _make_frame(EYE_RIGHT_LOOK, _mirror(EYE_LEFT_LOOK), BROW_HIGH, _mirror(BROW_HIGH), MOUTH_SMILE), + _make_frame(_shift(EYE_RIGHT_LOOK, (0, 1)), _shift(_mirror(EYE_LEFT_LOOK), (0, 1)), BROW_HIGH, _mirror(BROW_HIGH), MOUTH_SMILE), + _make_frame(_shift(EYE_RIGHT_LOOK, (0, 1)), _shift(_mirror(EYE_LEFT_LOOK), (0, 1)), BROW_HIGH, _mirror(BROW_HIGH), MOUTH_SMILE), + _make_frame(_shift(EYE_RIGHT_LOOK, (0, 1)), _shift(_mirror(EYE_LEFT_LOOK), (0, 1)), BROW_HIGH, _mirror(BROW_HIGH), MOUTH_SMILE), + _make_frame(EYE_RIGHT_LOOK, _mirror(EYE_LEFT_LOOK), BROW_HIGH, _mirror(BROW_HIGH), MOUTH_SMILE), + + _make_frame(EYE_OPEN, _mirror(EYE_OPEN), BROW_HIGH, _mirror(BROW_HIGH), MOUTH_SMILE), + ], + mode=AnimationMode.REPEAT_FORWARD, + frame_duration=0.15, + repeat_interval=10 +) + +WINK = Animation( + frames=[ + _make_frame(EYE_OPEN, _mirror(EYE_OPEN), BROW_HIGH, _mirror(BROW_HIGH), MOUTH_SMILE), + _make_frame(EYE_OPEN, _mirror(EYE_CLOSED), BROW_HIGH, _mirror(_shift(BROW_DOWN, (0, 2))), MOUTH_SMILE), + ], + mode=AnimationMode.ONCE_FORWARD_BACKWARD, + frame_duration=0.75, +) + + +# --- Face Animator Class --- + +class FaceAnimator: + def __init__(self, animation: Animation): + self._animation = animation + self._next: Animation | None = None + self._start_time = time.monotonic() + self._rewinding = False + self._rewind_start: float = 0.0 + self._rewind_from: int = 0 + self._seen_nonzero = False + + def set_animation(self, animation: Animation): + if animation is not self._animation: + self._next = animation + + def get_dots(self) -> list[tuple[int, int]]: + now = time.monotonic() + elapsed = now - self._start_time + + # Handle rewind for forward-only animations + if self._rewinding: + rewind_elapsed = now - self._rewind_start + frames_back = round(rewind_elapsed / self._animation.frame_duration) + frame_index = self._rewind_from - frames_back + if frame_index <= 0: + return self._switch_to_next(now) + return self._animation.frames[frame_index] + + # Play starting frames first (once) + starting = self._animation.starting_frames or [] + starting_duration = len(starting) * self._animation.frame_duration + if starting and elapsed < starting_duration: + frame_index = min(int(elapsed / self._animation.frame_duration), len(starting) - 1) + return starting[frame_index] + + # Main loop + loop_elapsed = elapsed - starting_duration if starting else elapsed + frame_index = _get_frame_index(self._animation, loop_elapsed, gap_first=bool(starting)) + + if frame_index != 0: + self._seen_nonzero = True + + if self._next is not None: + if frame_index == 0 and (len(self._animation.frames) == 1 or self._seen_nonzero): + return self._switch_to_next(now) + # No natural return to frame 0 — start rewinding + if self._animation.mode in (AnimationMode.ONCE_FORWARD, AnimationMode.REPEAT_FORWARD): + self._rewinding = True + self._rewind_start = now + self._rewind_from = frame_index + + return self._animation.frames[frame_index] + + def _switch_to_next(self, now: float) -> list[tuple[int, int]]: + self._animation = self._next + self._next = None + self._rewinding = False + self._seen_nonzero = False + self._start_time = now + return self._animation.frames[0] + + +def _get_frame_index(animation: Animation, elapsed: float, gap_first: bool = False) -> int: + """Get the current frame index given elapsed time and animation mode.""" + num_frames = len(animation.frames) + if num_frames == 1: + return 0 + + fd = animation.frame_duration + has_backward = animation.mode in (AnimationMode.ONCE_FORWARD_BACKWARD, AnimationMode.REPEAT_FORWARD_BACKWARD) + repeats = animation.mode in (AnimationMode.REPEAT_FORWARD, AnimationMode.REPEAT_FORWARD_BACKWARD) + + forward_duration = num_frames * fd + backward_frames = max(num_frames - 2, 0) if has_backward else 0 + hold = animation.hold_end if has_backward else 0.0 + cycle_duration = forward_duration + hold + backward_frames * fd + + if not repeats: + t = min(elapsed, cycle_duration) + else: + t = (elapsed + cycle_duration if gap_first else elapsed) % animation.repeat_interval + + # Forward phase + if t < forward_duration: + return min(int(t / fd), num_frames - 1) + t -= forward_duration + + # Hold at last frame + if t < hold: + return num_frames - 1 + t -= hold + + # Backward phase + if backward_frames and t < backward_frames * fd: + return num_frames - 2 - min(int(t / fd), backward_frames - 1) + + return 0 if has_backward else num_frames - 1 diff --git a/selfdrive/ui/body/layouts/__init__.py b/selfdrive/ui/body/layouts/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/selfdrive/ui/body/layouts/onroad.py b/selfdrive/ui/body/layouts/onroad.py new file mode 100644 index 000000000..d7e9f419c --- /dev/null +++ b/selfdrive/ui/body/layouts/onroad.py @@ -0,0 +1,93 @@ +import time +import pyray as rl + +from openpilot.system.ui.lib.application import gui_app, FontWeight +from openpilot.system.ui.widgets import Widget +from openpilot.system.ui.widgets.label import UnifiedLabel +from openpilot.selfdrive.ui.ui_state import ui_state +from openpilot.selfdrive.ui.body.animations import FaceAnimator, ASLEEP, INQUISITIVE, NORMAL, SLEEPY + +GRID_COLS = 16 +GRID_ROWS = 8 +DOT_RADIUS = 50 if gui_app.big_ui() else 10 + +IDLE_TIMEOUT = 30.0 # seconds of no joystick input before playing INQUISITIVE +IDLE_STEER_THRESH = 0.5 # degrees — below this counts as no input +IDLE_SPEED_THRESH = 0.01 # m/s — below this counts as no input + + +# This class is used both in BIG (tizi) and small (mici) UIs +class BodyLayout(Widget): + def __init__(self): + super().__init__() + self._animator = FaceAnimator(ASLEEP) + self._turning_left = False + self._turning_right = False + self._last_input_time = time.monotonic() + self._was_active = False + self._offroad_label = UnifiedLabel("turn on ignition to use", 95 if gui_app.big_ui() else 45, FontWeight.DISPLAY, + alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER, + alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE) + + def draw_dot_grid(self, rect: rl.Rectangle, dots: list[tuple[int, int]], color: rl.Color): + spacing = min(rect.height / GRID_ROWS, rect.width / GRID_COLS) + + grid_w = (GRID_COLS - 1) * spacing + grid_h = (GRID_ROWS - 1) * spacing + + offset_x = rect.x + (rect.width - grid_w) / 2 + offset_y = rect.y + (rect.height - grid_h) / 2 + + for row, col in dots: + x = int(offset_x + col * spacing) + y = int(offset_y + row * spacing) + rl.draw_circle(x, y, DOT_RADIUS, color) + + def _update_state(self): + super()._update_state() + + sm = ui_state.sm + + if ui_state.is_onroad(): + if not self._was_active: + self._last_input_time = time.monotonic() + self._was_active = True + + cs = sm['carState'] + has_input = abs(cs.steeringAngleDeg) > IDLE_STEER_THRESH or abs(cs.vEgo) > IDLE_SPEED_THRESH + if has_input: + self._last_input_time = time.monotonic() + + if time.monotonic() - self._last_input_time > IDLE_TIMEOUT: + self._animator.set_animation(INQUISITIVE) + else: + self._animator.set_animation(NORMAL) + else: + self._was_active = False + self._animator.set_animation(ASLEEP) + + steer = sm['testJoystick'].axes[1] if len(sm['testJoystick'].axes) > 1 else 0 + self._turning_left = steer <= -0.05 + self._turning_right = steer >= 0.05 + + # play animation on screen tap + def _handle_mouse_release(self, mouse_pos): + super()._handle_mouse_release(mouse_pos) + if not self._was_active: + self._animator.set_animation(SLEEPY) + + def _render(self, rect: rl.Rectangle): + dots = self._animator.get_dots() + animation = self._animator._animation + if self._turning_left and animation.left_turn_remove: + remove_set = set(animation.left_turn_remove) + dots = [d for d in dots if d not in remove_set] + elif self._turning_right and animation.right_turn_remove: + remove_set = set(animation.right_turn_remove) + dots = [d for d in dots if d not in remove_set] + self.draw_dot_grid(rect, dots, rl.WHITE) + + if ui_state.is_offroad(): + rl.draw_rectangle(int(self.rect.x), int(self.rect.y), int(self.rect.width), int(self.rect.height), rl.Color(0, 0, 0, 175)) + upper_half = rl.Rectangle(rect.x, rect.y, rect.width, rect.height / 2) + self._offroad_label.render(upper_half) diff --git a/selfdrive/ui/layouts/main.py b/selfdrive/ui/layouts/main.py index 15d44e24d..672f5463d 100644 --- a/selfdrive/ui/layouts/main.py +++ b/selfdrive/ui/layouts/main.py @@ -2,13 +2,14 @@ import pyray as rl from enum import IntEnum import cereal.messaging as messaging from openpilot.system.ui.lib.application import gui_app +from openpilot.system.ui.widgets import Widget from openpilot.selfdrive.ui.layouts.sidebar import Sidebar, SIDEBAR_WIDTH from openpilot.selfdrive.ui.layouts.home import HomeLayout from openpilot.selfdrive.ui.layouts.settings.settings import SettingsLayout, PanelType from openpilot.selfdrive.ui.onroad.augmented_road_view import AugmentedRoadView from openpilot.selfdrive.ui.ui_state import device, ui_state -from openpilot.system.ui.widgets import Widget from openpilot.selfdrive.ui.layouts.onboarding import OnboardingWindow +from openpilot.selfdrive.ui.body.layouts.onroad import BodyLayout class MainState(IntEnum): @@ -28,7 +29,9 @@ class MainLayout(Widget): self._prev_onroad = False # Initialize layouts - self._layouts = {MainState.HOME: HomeLayout(), MainState.SETTINGS: SettingsLayout(), MainState.ONROAD: AugmentedRoadView()} + self._home_layout = HomeLayout() + self._home_body_layout = BodyLayout() + self._layouts = {MainState.HOME: self._home_layout, MainState.SETTINGS: SettingsLayout(), MainState.ONROAD: AugmentedRoadView()} self._sidebar_rect = rl.Rectangle(0, 0, 0, 0) self._content_rect = rl.Rectangle(0, 0, 0, 0) @@ -54,14 +57,18 @@ class MainLayout(Widget): self._layouts[MainState.HOME]._setup_widget.set_open_settings_callback(lambda: self.open_settings(PanelType.FIREHOSE)) self._layouts[MainState.HOME].set_settings_callback(lambda: self.open_settings(PanelType.TOGGLES)) self._layouts[MainState.SETTINGS].set_callbacks(on_close=self._set_mode_for_state) - self._layouts[MainState.ONROAD].set_click_callback(self._on_onroad_clicked) + + for layout in (self._layouts[MainState.ONROAD], self._home_body_layout): + layout.set_click_callback(self._on_onroad_clicked) + device.add_interactive_timeout_callback(self._set_mode_for_state) + ui_state.add_on_body_changed_callbacks(self._on_body_changed) def _update_layout_rects(self): self._sidebar_rect = rl.Rectangle(self._rect.x, self._rect.y, SIDEBAR_WIDTH, self._rect.height) x_offset = SIDEBAR_WIDTH if self._sidebar.is_visible else 0 - self._content_rect = rl.Rectangle(self._rect.y + x_offset, self._rect.y, self._rect.width - x_offset, self._rect.height) + self._content_rect = rl.Rectangle(self._rect.x + x_offset, self._rect.y, self._rect.width - x_offset, self._rect.height) def _handle_onroad_transition(self): if ui_state.started != self._prev_onroad: @@ -70,6 +77,12 @@ class MainLayout(Widget): self._set_mode_for_state() def _set_mode_for_state(self): + # Don't go onroad if body, home is onroad + if ui_state.is_body: + self._set_current_layout(MainState.HOME) + self._sidebar.set_visible(not ui_state.ignition) + return + if ui_state.started: # Don't hide sidebar from interactive timeout if self._current_mode != MainState.ONROAD: @@ -101,6 +114,10 @@ class MainLayout(Widget): def _on_onroad_clicked(self): self._sidebar.set_visible(not self._sidebar.is_visible) + def _on_body_changed(self): + self._layouts[MainState.HOME] = self._home_body_layout if ui_state.is_body else self._home_layout + self._set_mode_for_state() + def _render_main_content(self): # Render sidebar if self._sidebar.is_visible: diff --git a/selfdrive/ui/mici/layouts/home.py b/selfdrive/ui/mici/layouts/home.py index 4bdff388b..cbe7ec29f 100644 --- a/selfdrive/ui/mici/layouts/home.py +++ b/selfdrive/ui/mici/layouts/home.py @@ -130,6 +130,7 @@ class MiciHomeLayout(Widget): self._experimental_icon = IconWidget("icons_mici/experimental_mode.png", (48, 48)) self._mic_icon = IconWidget("icons_mici/microphone.png", (32, 46)) + self._body_icon = IconWidget("icons_mici/body.png", (54, 37)) self._alerts_pill = AlertsPill() @@ -137,6 +138,7 @@ class MiciHomeLayout(Widget): IconWidget("icons_mici/settings.png", (48, 48), opacity=0.9), NetworkIcon(), self._experimental_icon, + self._body_icon, self._mic_icon, ], spacing=18) @@ -247,6 +249,7 @@ class MiciHomeLayout(Widget): # ***** Center-aligned bottom section icons ***** self._experimental_icon.set_visible(self._experimental_mode) self._mic_icon.set_visible(ui_state.recording_audio) + self._body_icon.set_visible(ui_state.is_body) footer_rect = rl.Rectangle(self.rect.x + HOME_PADDING, self.rect.y + self.rect.height - 48, self.rect.width - HOME_PADDING, 48) self._status_bar_layout.render(footer_rect) diff --git a/selfdrive/ui/mici/layouts/main.py b/selfdrive/ui/mici/layouts/main.py index 0d0f6308f..badfbe315 100644 --- a/selfdrive/ui/mici/layouts/main.py +++ b/selfdrive/ui/mici/layouts/main.py @@ -6,6 +6,7 @@ from openpilot.selfdrive.ui.mici.layouts.offroad_alerts import MiciOffroadAlerts from openpilot.selfdrive.ui.mici.onroad.augmented_road_view import AugmentedRoadView from openpilot.selfdrive.ui.ui_state import device, ui_state from openpilot.selfdrive.ui.mici.layouts.onboarding import OnboardingWindow +from openpilot.selfdrive.ui.body.layouts.onroad import BodyLayout from openpilot.system.ui.widgets import Widget from openpilot.system.ui.widgets.scroller import Scroller from openpilot.system.ui.lib.application import gui_app @@ -29,22 +30,25 @@ class MiciMainLayout(Scroller): self._home_layout = MiciHomeLayout() self._alerts_layout = MiciOffroadAlerts() self._settings_layout = SettingsLayout() - self._onroad_layout = AugmentedRoadView(bookmark_callback=self._on_bookmark_clicked) + self._car_onroad_layout = AugmentedRoadView(bookmark_callback=self._on_bookmark_clicked) + self._body_onroad_layout = BodyLayout() # Initialize widget rects - for widget in (self._home_layout, self._settings_layout, self._alerts_layout, self._onroad_layout): + for widget in (self._home_layout, self._alerts_layout, self._settings_layout, + self._car_onroad_layout, self._body_onroad_layout): # TODO: set parent rect and use it if never passed rect from render (like in Scroller) widget.set_rect(rl.Rectangle(0, 0, gui_app.width, gui_app.height)) self._scroller.add_widgets([ self._alerts_layout, self._home_layout, - self._onroad_layout, + self._car_onroad_layout, + self._body_onroad_layout, ]) self._scroller.set_reset_scroll_at_show(False) # Disable scrolling when onroad is interacting with bookmark - self._scroller.set_scrolling_enabled(lambda: not self._onroad_layout.is_swiping_left()) + self._scroller.set_scrolling_enabled(lambda: not self._car_onroad_layout.is_swiping_left()) # Set callbacks self._setup_callbacks() @@ -57,14 +61,22 @@ class MiciMainLayout(Scroller): if not self._onboarding_window.completed: gui_app.push_widget(self._onboarding_window) + @property + def _onroad_layout(self) -> Widget: + # For scroll_to + return self._body_onroad_layout if ui_state.is_body else self._car_onroad_layout + def _setup_callbacks(self): self._home_layout.set_callbacks( on_settings=lambda: gui_app.push_widget(self._settings_layout), on_alerts=lambda: self._scroll_to(self._alerts_layout), alert_count_callback=self._alerts_layout.active_alerts, ) - self._onroad_layout.set_click_callback(lambda: self._scroll_to(self._home_layout)) + for layout in (self._car_onroad_layout, self._body_onroad_layout): + layout.set_click_callback(lambda: self._scroll_to(self._home_layout)) + device.add_interactive_timeout_callback(self._on_interactive_timeout) + ui_state.add_on_body_changed_callbacks(self._on_body_changed) def _scroll_to(self, layout: Widget): layout_x = int(layout.rect.x) @@ -130,3 +142,7 @@ class MiciMainLayout(Scroller): user_bookmark = messaging.new_message('bookmarkButton') user_bookmark.valid = True self._pm.send('bookmarkButton', user_bookmark) + + def _on_body_changed(self): + self._car_onroad_layout.set_visible(not ui_state.is_body) + self._body_onroad_layout.set_visible(ui_state.is_body) diff --git a/selfdrive/ui/ui_state.py b/selfdrive/ui/ui_state.py index 30a656509..52426f510 100644 --- a/selfdrive/ui/ui_state.py +++ b/selfdrive/ui/ui_state.py @@ -13,6 +13,7 @@ from openpilot.system.ui.lib.application import gui_app from openpilot.system.hardware import HARDWARE, PC BACKLIGHT_OFFROAD = 65 if HARDWARE.get_device_type() == "mici" else 50 +PARAM_UPDATE_TIME = 5.0 class UIStatus(Enum): @@ -54,6 +55,7 @@ class UIState: "carOutput", "carControl", "liveParameters", + "testJoystick", "rawAudioData", ] ) @@ -77,15 +79,15 @@ class UIState: self.panda_type: log.PandaState.PandaType = log.PandaState.PandaType.unknown self.personality: log.LongitudinalPersonality = log.LongitudinalPersonality.standard self.has_longitudinal_control: bool = False + self.is_body: bool | None = None self.CP: car.CarParams | None = None self.light_sensor: float = -1.0 - self._param_update_time: float = 0.0 + self._param_update_time: float = -PARAM_UPDATE_TIME # Callbacks self._offroad_transition_callbacks: list[Callable[[], None]] = [] self._engaged_transition_callbacks: list[Callable[[], None]] = [] - - self.update_params() + self._on_body_changed_callbacks: list[Callable[[], None]] = [] def add_offroad_transition_callback(self, callback: Callable[[], None]): self._offroad_transition_callbacks.append(callback) @@ -93,6 +95,9 @@ class UIState: def add_engaged_transition_callback(self, callback: Callable[[], None]): self._engaged_transition_callbacks.append(callback) + def add_on_body_changed_callbacks(self, callback: Callable[[], None]): + self._on_body_changed_callbacks.append(callback) + @property def engaged(self) -> bool: return self.started and self.sm["selfdriveState"].enabled @@ -108,7 +113,7 @@ class UIState: self.sm.update(0) self._update_state() self._update_status() - if time.monotonic() - self._param_update_time > 5.0: + if time.monotonic() - self._param_update_time >= PARAM_UPDATE_TIME: self.update_params() device.update() @@ -180,6 +185,12 @@ class UIState: self.has_longitudinal_control = self.params.get_bool("AlphaLongitudinalEnabled") else: self.has_longitudinal_control = self.CP.openpilotLongitudinalControl + + if self.is_body != self.CP.notCar: + self.is_body = self.CP.notCar + for callback in self._on_body_changed_callbacks: + callback() + self._param_update_time = time.monotonic()