diff --git a/common/params_keys.h b/common/params_keys.h index 7e6ecc9f28..6c7e63ebf9 100644 --- a/common/params_keys.h +++ b/common/params_keys.h @@ -625,6 +625,10 @@ inline static std::unordered_map keys = { {"RotatingWheel", {PERSISTENT, BOOL, "1", "0", 1, SETTINGS_SIMPLE}}, {"ScreenBrightness", {PERSISTENT, INT, "101", "101", 2, SETTINGS_SIMPLE}}, {"ScreenBrightnessOnroad", {PERSISTENT, INT, "101", "101", 2, SETTINGS_SIMPLE}}, + {"ScreenBrightnessManual", {PERSISTENT, INT, "100", "100", 2, SETTINGS_SIMPLE}}, + {"ScreenBrightnessOffset", {PERSISTENT, INT, "0", "0", 2, SETTINGS_SIMPLE}}, + {"ScreenBrightnessOnroadManual", {PERSISTENT, INT, "100", "100", 2, SETTINGS_SIMPLE}}, + {"ScreenBrightnessOnroadOffset", {PERSISTENT, INT, "0", "0", 2, SETTINGS_SIMPLE}}, {"ScreenManagement", {PERSISTENT, BOOL, "1", "0", 1, SETTINGS_SIMPLE}}, {"ScreenRecorder", {PERSISTENT, BOOL, "1", "0", 2, SETTINGS_SIMPLE}}, {"ScreenTimeout", {PERSISTENT, INT, "30", "30", 2, SETTINGS_SIMPLE}}, @@ -696,6 +700,18 @@ inline static std::unordered_map keys = { {"StandardJerkSpeed", {PERSISTENT, FLOAT, "100.0", "100.0", 3}}, {"StandardJerkSpeedDecrease", {PERSISTENT, FLOAT, "100.0", "100.0", 3}}, {"StandbyMode", {PERSISTENT, BOOL, "0", "0", 1, SETTINGS_SIMPLE}}, + {"StandbyButtonPressTime", {CLEAR_ON_MANAGER_START | DONT_LOG, INT, "0", "0"}}, + {"StandbyWakeTouch", {PERSISTENT, BOOL, "1", "1", 2, SETTINGS_SIMPLE}}, + {"StandbyWakeDriveState", {PERSISTENT, BOOL, "1", "1", 2, SETTINGS_SIMPLE}}, + {"StandbyWakeButton", {PERSISTENT, BOOL, "0", "0", 2, SETTINGS_SIMPLE}}, + {"StandbyWakeEngage", {PERSISTENT, BOOL, "1", "1", 2, SETTINGS_SIMPLE}}, + {"StandbyWakeDisengage", {PERSISTENT, BOOL, "1", "1", 2, SETTINGS_SIMPLE}}, + {"StandbyWakeInfoAlert", {PERSISTENT, BOOL, "1", "1", 2, SETTINGS_SIMPLE}}, + {"StandbyWakeWarningAlert", {PERSISTENT, BOOL, "1", "1", 2, SETTINGS_SIMPLE}}, + {"StandbyWakeCriticalAlert", {PERSISTENT, BOOL, "1", "1", 2, SETTINGS_SIMPLE}}, + {"StandbyWakeTurnSignal", {PERSISTENT, BOOL, "0", "0", 2, SETTINGS_SIMPLE}}, + {"StandbyWakeBrake", {PERSISTENT, BOOL, "0", "0", 2, SETTINGS_SIMPLE}}, + {"StandbyWakeAccelerator", {PERSISTENT, BOOL, "0", "0", 2, SETTINGS_SIMPLE}}, {"StartAccel", {PERSISTENT, FLOAT, "0.0", "0.0", 3}}, {"StartAccelStock", {PERSISTENT, FLOAT, "0.0", "0.0", 3}}, {"StartupMessageBottom", {PERSISTENT, STRING, "Always keep hands on wheel and eyes on road", "Always keep hands on wheel and eyes on road", 0}}, diff --git a/selfdrive/ui/layouts/settings/starpilot/screen_controls.py b/selfdrive/ui/layouts/settings/starpilot/screen_controls.py new file mode 100644 index 0000000000..e536cde135 --- /dev/null +++ b/selfdrive/ui/layouts/settings/starpilot/screen_controls.py @@ -0,0 +1,157 @@ +from __future__ import annotations + +import pyray as rl + +from openpilot.selfdrive.ui.layouts.settings.starpilot.aethergrid import ( + AetherAdjustorRow, + AetherInlineRangeControl, + AetherSegmentedControl, + DEFAULT_PANEL_STYLE, + RowToggleTile, + draw_rounded_fill, + draw_rounded_stroke, + draw_text_fit_common, + wrap_text, +) +from openpilot.starpilot.common.screen_settings import brightness_preferences, set_brightness_mode, write_screen_setting +from openpilot.system.ui.lib.application import FontWeight, gui_app +from openpilot.system.ui.lib.multilang import tr +from openpilot.system.ui.widgets import Widget +from openpilot.system.ui.widgets.label import gui_label +from openpilot.system.ui.widgets.confirm_dialog import alert_dialog +from openpilot.selfdrive.ui.lib.screen_settings import try_screen_setting + + +def show_screen_save_error(): + gui_app.push_widget(alert_dialog(tr("Unable to save screen settings. Please try again."))) + + +class BrightnessAdjustorRow(AetherAdjustorRow): + def __init__(self, *args, params, brightness_key: str, **kwargs): + self._params = params + self._brightness_key = brightness_key + super().__init__(*args, **kwargs) + + def formatted_value(self) -> str: + prefs = brightness_preferences(self._params, self._brightness_key) + if prefs["mode"] == "auto": + return tr("Auto") + f" {prefs['offset']:+d}%" + return tr("Manual") + f" {prefs['manual']}%" + + +class WakeToggleRow(RowToggleTile): + """Show the wake description; the standard compact toggle row only shows its state.""" + + def _render(self, rect: rl.Rectangle): + active = self.get_state() + self._animate_plate(rl.get_frame_time()) + face, accent = self._render_hud_background(rect, self._active_color, self._glow) + x, width = face.x + 24, face.width - 48 + draw_text_fit_common(self._font, self.title, rl.Vector2(x, face.y + 14), width - 150, 32, color=rl.WHITE) + state = tr("Enabled") if active else tr("Disabled") + gui_label( + rl.Rectangle(x + width - 135, face.y + 12, 135, 38), + state, + 24, + accent if active else rl.Color(170, 175, 185, 255), + FontWeight.MEDIUM, + alignment=rl.GuiTextAlignment.TEXT_ALIGN_RIGHT, + ) + lines = wrap_text(self._font_desc, self.desc, width, 22, max_lines=3) + for index, line in enumerate(lines): + draw_text_fit_common(self._font_desc, line, rl.Vector2(x, face.y + 54 + index * 24), width, 22, color=rl.Color(190, 195, 205, 255)) + + +class AetherBrightnessDialog(Widget): + """Separate persisted mode and value controls; all brightness output stays in Device.""" + + def __init__(self, params, key: str, title: str): + super().__init__() + self._params, self._key, self._title = params, key, title + self._done_rect = rl.Rectangle(0, 0, 0, 0) + self._done_pressed = False + self._mode_control = self._child(AetherSegmentedControl([tr("Auto"), tr("Manual")], self._mode_index, self._set_mode, style=DEFAULT_PANEL_STYLE)) + prefs = brightness_preferences(params, key) + self._offset = self._child( + AetherInlineRangeControl( + -30, + 30, + 1, + prefs["offset"], + lambda value: None, + on_commit=lambda value: self._save_value(self._key + "Offset", int(value)), + unit="%", + labels={value: f"{value:+d}%" for value in range(-30, 31)}, + ) + ) + self._manual = self._child( + AetherInlineRangeControl(0, 100, 1, prefs["manual"], lambda value: None, on_commit=lambda value: self._save_value(self._key, int(value)), unit="%") + ) + self._offset.set_visible(lambda: self._mode_index() == 0) + self._manual.set_visible(lambda: self._mode_index() == 1) + + def _mode_index(self) -> int: + return 0 if brightness_preferences(self._params, self._key)["mode"] == "auto" else 1 + + def _set_mode(self, index: int): + saved = try_screen_setting(lambda: set_brightness_mode(self._params, self._key, "auto" if index == 0 else "manual"), show_screen_save_error) + if not saved: + self._refresh_controls() + return saved + + def _save_value(self, key: str, value: int): + saved = try_screen_setting(lambda: write_screen_setting(self._params, key, value), show_screen_save_error) + if not saved: + self._refresh_controls() + return saved + + def _refresh_controls(self): + prefs = brightness_preferences(self._params, self._key) + for control, value in ((self._offset, prefs["offset"]), (self._manual, prefs["manual"])): + control.reset_interaction() + control.set_value(value) + + def _handle_mouse_press(self, mouse_pos): + self._done_pressed = rl.check_collision_point_rec(mouse_pos, self._done_rect) + + def _handle_mouse_release(self, mouse_pos): + if self._done_pressed and rl.check_collision_point_rec(mouse_pos, self._done_rect): + gui_app.pop_widget() + self._done_pressed = False + + def _render(self, rect: rl.Rectangle): + rl.draw_rectangle(0, 0, gui_app.width, gui_app.height, rl.Color(0, 0, 0, 180)) + card = rl.Rectangle(rect.x + 60, rect.y + 60, rect.width - 120, rect.height - 120) + draw_rounded_fill(card, rl.Color(10, 12, 16, 255), radius_px=35) + draw_rounded_stroke(card, rl.Color(255, 255, 255, 35), radius_px=35) + inset, width = card.x + 80, card.width - 160 + gui_label(rl.Rectangle(inset, card.y + 50, width - 250, 80), self._title, 60, rl.WHITE, FontWeight.BOLD) + self._done_rect = rl.Rectangle(card.x + card.width - 290, card.y + 35, 230, 110) + draw_rounded_fill(self._done_rect, DEFAULT_PANEL_STYLE.accent, radius_px=20) + gui_label(self._done_rect, tr("Done"), 46, rl.WHITE, FontWeight.BOLD, alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER) + self._mode_control.render(rl.Rectangle(inset, card.y + 175, width, 125)) + + prefs = brightness_preferences(self._params, self._key) + auto = prefs["mode"] == "auto" + control = self._offset if auto else self._manual + if not control.is_interacting: + control.set_value(prefs["offset"] if auto else prefs["manual"]) + value = int(control.current_val) + title = tr("Auto Brightness Offset") if auto else tr("Manual Brightness") + description = ( + tr("Adjust automatic brightness from -30% to +30%. 0% keeps the normal automatic level.") if auto else tr("Set a fixed brightness from 0 to 100%.") + ) + gui_label(rl.Rectangle(inset, card.y + 355, width, 70), title, 46, rl.WHITE, FontWeight.MEDIUM) + gui_label(rl.Rectangle(inset, card.y + 435, width, 75), f"{value:+d}%" if auto else f"{value}%", 66, DEFAULT_PANEL_STYLE.accent, FontWeight.BOLD) + control.render(rl.Rectangle(inset, card.y + card.height - 245, width, 100)) + endpoint_y = card.y + card.height - 153 + gui_label(rl.Rectangle(inset + 84, endpoint_y, 300, 30), "-30%" if auto else "0%", 26, rl.Color(160, 165, 175, 255), FontWeight.NORMAL) + gui_label( + rl.Rectangle(inset + width - 384, endpoint_y, 300, 30), + "+30%" if auto else "100%", + 26, + rl.Color(160, 165, 175, 255), + FontWeight.NORMAL, + alignment=rl.GuiTextAlignment.TEXT_ALIGN_RIGHT, + ) + gui_label(rl.Rectangle(inset, card.y + card.height - 115, width, 70), description, 28, rl.Color(180, 185, 195, 255), FontWeight.NORMAL) diff --git a/selfdrive/ui/layouts/settings/starpilot/system_settings.py b/selfdrive/ui/layouts/settings/starpilot/system_settings.py index 05f3605f92..6ce04432a4 100644 --- a/selfdrive/ui/layouts/settings/starpilot/system_settings.py +++ b/selfdrive/ui/layouts/settings/starpilot/system_settings.py @@ -57,6 +57,13 @@ from openpilot.selfdrive.ui.layouts.settings.starpilot.aethergrid import ( TOGGLE_ROW_HEIGHT, ) from openpilot.starpilot.common import param_profiles +from openpilot.starpilot.common.screen_settings import ( + BRIGHTNESS_KEYS, SCREEN_WAKE_DESCRIPTIONS, SCREEN_WAKE_OPTIONS, brightness_preferences, write_screen_setting, +) +from openpilot.selfdrive.ui.layouts.settings.starpilot.screen_controls import ( + AetherBrightnessDialog, BrightnessAdjustorRow, WakeToggleRow, show_screen_save_error, +) +from openpilot.selfdrive.ui.lib.screen_settings import try_screen_setting from openpilot.starpilot.common.connect_server import prepare_konik_server_switch from openpilot.starpilot.common.starpilot_variables import EXCLUDED_KEYS as STARPILOT_EXCLUDED_KEYS, TOGGLE_BACKUPS, update_starpilot_toggles @@ -135,7 +142,7 @@ class SystemSettingsManagerView(PanelManagerView): hours: f"{hours} " + (tr("hour") if hours == 1 else tr("hours")) for hours in range(1, 31) } - brightness_labels = {101: tr("Auto"), 0: tr("Off")} + brightness_labels = {0: tr("Off")} self._slider_specs: dict[str, dict[str, Any]] = { "ScreenBrightness": { @@ -144,11 +151,11 @@ class SystemSettingsManagerView(PanelManagerView): "unit": "%", "labels": brightness_labels, "min": 0, - "max": 101, + "max": 100, "step": 1, "live": True, - "presets": [0, 25, 50, 75, 101], - "get": lambda: float(self._controller._params.get_int("ScreenBrightness")), + "presets": [0, 25, 50, 75, 100], + "get": lambda: float(brightness_preferences(self._controller._params, "ScreenBrightness")["manual"]), "set": lambda v: self._controller._set_brightness("ScreenBrightness", v), }, "ScreenBrightnessOnroad": { @@ -157,17 +164,17 @@ class SystemSettingsManagerView(PanelManagerView): "unit": "%", "labels": brightness_labels, "min": 0, - "max": 101, + "max": 100, "step": 1, "live": True, - "presets": [0, 35, 60, 80, 101], - "get": lambda: float(self._controller._params.get_int("ScreenBrightnessOnroad")), + "presets": [0, 25, 50, 75, 100], + "get": lambda: float(brightness_preferences(self._controller._params, "ScreenBrightnessOnroad")["manual"]), "set": lambda v: self._controller._set_brightness("ScreenBrightnessOnroad", int(v)), }, "ScreenTimeout": { - "title": tr("Offroad Screen Timeout"), - "subtitle": "", - "unit": "s", + "title": tr("Offroad Timeout"), + "subtitle": tr("Screen sleeps while parked."), + "unit": " seconds", "labels": {}, "min": 5, "max": 60, @@ -178,9 +185,9 @@ class SystemSettingsManagerView(PanelManagerView): "set": lambda v: self._set_timeout("ScreenTimeout", v), }, "ScreenTimeoutOnroad": { - "title": tr("Onroad Screen Timeout"), - "subtitle": "", - "unit": "s", + "title": tr("Onroad Timeout"), + "subtitle": tr("Standby sleeps the screen after this many seconds."), + "unit": " seconds", "labels": {}, "min": 5, "max": 60, @@ -222,8 +229,10 @@ class SystemSettingsManagerView(PanelManagerView): return lambda active: self._show_system_slider(k) if active else None for key, spec in self._slider_specs.items(): + row_type = BrightnessAdjustorRow if key in BRIGHTNESS_KEYS else AetherAdjustorRow + row_kwargs = {"params": self._controller._params, "brightness_key": key} if key in BRIGHTNESS_KEYS else {} adjustor = self._child( - AetherAdjustorRow( + row_type( spec["title"], spec["subtitle"], spec["min"], @@ -239,6 +248,7 @@ class SystemSettingsManagerView(PanelManagerView): set_active=make_set_active(key), style=PANEL_STYLE, color=PANEL_STYLE.accent, + **row_kwargs, ) ) adjustor.set_touch_valid_callback(lambda: self._scroll_panel.is_touch_valid()) @@ -247,7 +257,7 @@ class SystemSettingsManagerView(PanelManagerView): self._toggle_defs = [ { "title": tr("Standby Mode"), - "subtitle": "", + "subtitle": tr("Only selected events wake the screen. Choose wake events below."), "get_state": lambda: self._controller._params.get_bool("StandbyMode"), "set_state": lambda v: self._controller._params.put_bool("StandbyMode", v), }, @@ -299,6 +309,17 @@ class SystemSettingsManagerView(PanelManagerView): }, ] + self._wake_toggle_defs = [ + { + "title": tr("Wake: {}").format(tr(label)), + "subtitle": tr(SCREEN_WAKE_DESCRIPTIONS[key]), + "wake": True, + "get_state": lambda k=key, d=default: self._controller._params.get_bool(k, default=d), + "set_state": lambda value, k=key: self._save_wake_setting(k, value), + } + for key, label, default in SCREEN_WAKE_OPTIONS + ] + self._standby_visible = self._controller._params.get_bool("StandbyMode") self._basics_tile_grid_h = 0.0 if self.PANEL_STYLE.toggle_row_mode: @@ -309,8 +330,7 @@ class SystemSettingsManagerView(PanelManagerView): tile = self._make_toggle_tile(toggle_def) self._connectivity_tile_grid.add_tile(tile) self.register_page_grid(self._connectivity_tile_grid) - page_size = self._compute_page_size(TOGGLE_ROW_HEIGHT) - self._set_toggle_pages([self._toggle_defs[i:i+page_size] for i in range(0, len(self._toggle_defs), page_size)]) + self._refresh_toggle_pages() self._drive_mode_control = self._child( AetherSegmentedControl( @@ -322,6 +342,29 @@ class SystemSettingsManagerView(PanelManagerView): ) ) + def _save_wake_setting(self, key: str, value: bool): + return try_screen_setting(lambda: write_screen_setting(self._controller._params, key, value), show_screen_save_error) + + def _make_toggle_tile(self, definition: dict): + if definition.get("wake"): + return WakeToggleRow(title=definition["title"], desc=definition["subtitle"], + get_state=definition["get_state"], set_state=definition["set_state"], bg_color=self.PANEL_STYLE.accent) + return super()._make_toggle_tile(definition) + + def _display_keys(self) -> list[str]: + standby = self._controller._params.get_bool("StandbyMode") + return [key for key in self._display_slider_keys if key != "ScreenTimeoutOnroad" or standby] + + def _visible_toggle_defs(self) -> list[dict]: + if self._controller._params.get_bool("StandbyMode"): + return self._toggle_defs[:1] + self._wake_toggle_defs + self._toggle_defs[1:] + return self._toggle_defs + + def _refresh_toggle_pages(self): + definitions = self._visible_toggle_defs() + page_size = self._compute_page_size(TOGGLE_ROW_HEIGHT) + self._set_toggle_pages([definitions[i:i + page_size] for i in range(0, len(definitions), page_size)]) + def _tab_subtitle(self, tab_id: str) -> str: if tab_id == "basics": return tr("{} controls + {} toggles").format( @@ -342,6 +385,9 @@ class SystemSettingsManagerView(PanelManagerView): return f"{int(current_val)}{spec['unit']}" def _show_system_slider(self, key: str): + if key in BRIGHTNESS_KEYS: + gui_app.push_widget(AetherBrightnessDialog(self._controller._params, key, self._slider_specs[key]["title"])) + return spec = self._slider_specs[key] original_val = spec["get"]() @@ -489,17 +535,21 @@ class SystemSettingsManagerView(PanelManagerView): draw_custom_icon("first_aid", icon_x, icon_y, s, icon_color) def _measure_content_height(self, width: float) -> float: - display_h = self._slider_section_height(self._display_slider_keys, width) + GROUP_TOP_INSET + GROUP_HEADER_TOTAL_HEIGHT + standby = self._controller._params.get_bool("StandbyMode") + if standby != self._standby_visible: + self._standby_visible = standby + self._refresh_toggle_pages() + display_h = self._slider_section_height(self._display_keys(), width) + GROUP_TOP_INSET + GROUP_HEADER_TOTAL_HEIGHT power_h = self._slider_section_height(self._power_slider_keys, width) + GROUP_TOP_INSET + GROUP_HEADER_TOTAL_HEIGHT if self._uses_two_columns(width): column_w = self._column_width(width) # Reset custom heights to calculate natural measurements first - for key in self._display_slider_keys + self._power_slider_keys: + for key in self._display_keys() + self._power_slider_keys: self._adjustor_rows[key].custom_row_height = None - display_container_h = self._slider_section_height(self._display_slider_keys, column_w) + display_container_h = self._slider_section_height(self._display_keys(), column_w) power_container_h = self._slider_section_height(self._power_slider_keys, column_w) left_overhead = GROUP_TOP_INSET + 2 * GROUP_HEADER_TOTAL_HEIGHT + SECTION_GAP @@ -522,7 +572,7 @@ class SystemSettingsManagerView(PanelManagerView): if max_container_h < max_natural_h: scale_f = max_container_h / left_natural_content_h row_h = max(80.0, float(AETHER_LIST_METRICS.adjustor_row_height) * scale_f) - for key in self._display_slider_keys + self._power_slider_keys: + for key in self._display_keys() + self._power_slider_keys: self._adjustor_rows[key].custom_row_height = row_h self._system_max_container_h = max_container_h @@ -530,7 +580,7 @@ class SystemSettingsManagerView(PanelManagerView): return self._compute_two_column_height(max_container_h) else: # Ensure defaults are restored in single column mode - for key in self._display_slider_keys + self._power_slider_keys: + for key in self._display_keys() + self._power_slider_keys: self._adjustor_rows[key].custom_row_height = None tiles_content_h = self.measure_page_grid_height(self._connectivity_tile_grid, width - 24) return self._stacked_section_height([display_h, power_h, tiles_content_h + 24]) @@ -555,8 +605,8 @@ class SystemSettingsManagerView(PanelManagerView): current_y = y + GROUP_TOP_INSET current_y = draw_group_header(x + 24, current_y, column_w - 48, tr("Display")) - for index, key in enumerate(self._display_slider_keys): - current_y = self._draw_slider_row(rl.Rectangle(x, current_y, column_w, 0), key, is_last=index == len(self._display_slider_keys) - 1) + for index, key in enumerate(self._display_keys()): + current_y = self._draw_slider_row(rl.Rectangle(x, current_y, column_w, 0), key, is_last=index == len(self._display_keys()) - 1) current_y += SECTION_GAP @@ -570,7 +620,7 @@ class SystemSettingsManagerView(PanelManagerView): self._system_max_container_h, columns=tg_cols) return - y = self._draw_slider_section(y, x, width, tr("Display"), self._display_slider_keys) + y = self._draw_slider_section(y, x, width, tr("Display"), self._display_keys()) y += SECTION_GAP y = self._draw_slider_section(y, x, width, tr("Power"), self._power_slider_keys) y += SECTION_GAP @@ -978,13 +1028,7 @@ class StarPilotSystemLayout(_SettingsPage): self._on_reset_stock() def _set_brightness(self, key, val): - self._params.put_int(key, int(val)) - if not ui_state.started and key == "ScreenBrightness": - if hasattr(HARDWARE, 'set_screen_brightness'): - HARDWARE.set_screen_brightness(int(val)) - elif ui_state.started and key == "ScreenBrightnessOnroad": - if hasattr(HARDWARE, 'set_screen_brightness'): - HARDWARE.set_screen_brightness(int(val)) + return try_screen_setting(lambda: write_screen_setting(self._params, key, int(val)), show_screen_save_error) def _get_konik_state(self): if Path("/data/not_vetted").exists(): diff --git a/selfdrive/ui/lib/screen_settings.py b/selfdrive/ui/lib/screen_settings.py new file mode 100644 index 0000000000..930bc1aa6d --- /dev/null +++ b/selfdrive/ui/lib/screen_settings.py @@ -0,0 +1,14 @@ +"""Keep native screen controls usable after a rejected or failed settings write.""" + +from collections.abc import Callable + +from openpilot.common.params import UnknownKeyName + + +def try_screen_setting(write: Callable[[], object], on_error: Callable[[], None]) -> bool: + try: + write() + except (OSError, ValueError, UnknownKeyName): + on_error() + return False + return True diff --git a/selfdrive/ui/mici/layouts/settings/device.py b/selfdrive/ui/mici/layouts/settings/device.py index c761be204e..4e855fac6d 100644 --- a/selfdrive/ui/mici/layouts/settings/device.py +++ b/selfdrive/ui/mici/layouts/settings/device.py @@ -14,6 +14,7 @@ from openpilot.selfdrive.ui.mici.widgets.dialog import BigDialog, BigConfirmatio from openpilot.selfdrive.ui.mici.widgets.pairing_dialog import PairingDialog, get_pairing_backend_name, get_pairing_host from openpilot.selfdrive.ui.mici.onroad.driver_camera_dialog import DriverCameraDialog from openpilot.selfdrive.ui.mici.layouts.onboarding import TrainingGuide, TermsPage +from openpilot.selfdrive.ui.mici.layouts.settings.screen import ScreenSettingsLayoutMici from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos from openpilot.system.ui.lib.multilang import tr from openpilot.system.ui.widgets import Widget @@ -271,6 +272,8 @@ class DeviceLayoutMici(NavScroller): self._connect_server_btn = ConnectServerBigButton() self._simple_mode_btn = BigParamControl("simple mode", "SimpleMode") + screen_settings_btn = BigButton("screen settings") + screen_settings_btn.set_click_callback(lambda: gui_app.push_widget(ScreenSettingsLayoutMici())) driver_cam_btn = BigButton("driver\ncamera preview", "", gui_app.texture("icons_mici/settings/device/cameras.png", 64, 64)) driver_cam_btn.set_click_callback(lambda: gui_app.push_widget(DriverCameraDialog())) @@ -288,6 +291,7 @@ class DeviceLayoutMici(NavScroller): self._connect_server_btn, PairBigButton(), self._simple_mode_btn, + screen_settings_btn, review_training_guide_btn, driver_cam_btn, reset_driver_monitoring_btn, diff --git a/selfdrive/ui/mici/layouts/settings/screen.py b/selfdrive/ui/mici/layouts/settings/screen.py new file mode 100644 index 0000000000..bbbf5bf956 --- /dev/null +++ b/selfdrive/ui/mici/layouts/settings/screen.py @@ -0,0 +1,340 @@ +from __future__ import annotations + +from collections.abc import Callable +import pyray as rl + +from openpilot.common.params import Params +from openpilot.selfdrive.ui.mici.widgets.button import BigButton, BigToggle, GreyBigButton +from openpilot.selfdrive.ui.mici.widgets.dialog import BigDialog, BigMultiOptionDialog +from openpilot.starpilot.common.screen_settings import ( + BRIGHTNESS_KEYS, + SCREEN_INT_KEYS, + SCREEN_WAKE_DESCRIPTIONS, + SCREEN_WAKE_KEYS, + SCREEN_WAKE_OPTIONS, + brightness_preferences, + set_brightness_mode, + write_screen_setting, +) +from openpilot.system.ui.lib.application import FontWeight, gui_app +from openpilot.system.ui.widgets.label import gui_label +from openpilot.system.ui.widgets.nav_widget import NavWidget +from openpilot.system.ui.widgets.scroller import NavScroller +from openpilot.system.ui.lib.multilang import tr +from openpilot.selfdrive.ui.lib.screen_settings import try_screen_setting + + +def show_screen_save_error(): + gui_app.push_widget(BigDialog("", tr("Unable to save screen settings. Please try again."))) + + +class ScreenModeDialogMici(BigMultiOptionDialog): + def __init__(self, *args, current_option: Callable[[], str], **kwargs): + super().__init__(*args, **kwargs) + self._current_option = current_option + + def _confirm_selection(self): + if self._right_btn_callback is None or self._right_btn_callback(): + self.dismiss() + else: + self._selected_option = self._default_option = self._current_option() + self._on_option_selected(self._selected_option) + + +class ScreenSliderMici(NavWidget): + """A numeric slider sized for the comma4 display, with swipe-down back navigation.""" + + BACK_TOUCH_AREA_PERCENTAGE = 0.30 + + def __init__( + self, + title: str, + minimum: int, + maximum: int, + step: int, + value: int, + unit: str, + on_change: Callable[[int], bool | None], + read_value: Callable[[], int] | None = None, + ): + super().__init__() + self._title, self._minimum, self._maximum, self._step = title, minimum, maximum, step + self._value, self._unit, self._on_change = value, unit, on_change + self._committed_value = value + self._read_value = read_value + self._track = rl.Rectangle(0, 0, 1, 1) + self._minus = rl.Rectangle(0, 0, 0, 0) + self._plus = rl.Rectangle(0, 0, 0, 0) + self._pressed = None + self._dragging = False + + def _value_from_x(self, x: float) -> int: + fraction = max(0.0, min(1.0, (x - self._track.x) / max(1.0, self._track.width))) + value = self._minimum + round(fraction * (self._maximum - self._minimum) / self._step) * self._step + return max(self._minimum, min(self._maximum, int(value))) + + def _handle_mouse_press(self, mouse_pos): + if self.is_dismissing: + return + self._pressed = None + if rl.check_collision_point_rec(mouse_pos, self._minus): + self._pressed = "minus" + elif rl.check_collision_point_rec(mouse_pos, self._plus): + self._pressed = "plus" + elif rl.check_collision_point_rec(mouse_pos, rl.Rectangle(self._track.x, self._track.y - 35, self._track.width, 80)): + self._dragging = True + self._value = self._value_from_x(mouse_pos.x) + + def _handle_mouse_event(self, mouse_event): + super()._handle_mouse_event(mouse_event) + if self._dragging and mouse_event.left_down: + self._value = self._value_from_x(mouse_event.pos.x) + + def _handle_mouse_release(self, mouse_pos): + changed = self._dragging + if self._pressed == "minus" and rl.check_collision_point_rec(mouse_pos, self._minus): + self._value = max(self._minimum, self._value - self._step) + changed = True + elif self._pressed == "plus" and rl.check_collision_point_rec(mouse_pos, self._plus): + self._value = min(self._maximum, self._value + self._step) + changed = True + self._dragging, self._pressed = False, None + if changed and not self.is_dismissing: + if self._on_change(self._value) is False: + self._value = self._read_value() if self._read_value is not None else self._committed_value + self._committed_value = self._value + else: + self._committed_value = self._value + + def _render(self, rect: rl.Rectangle): + color = rl.Color(175, 150, 255, 255) + gui_label(rl.Rectangle(rect.x + 25, rect.y + 22, rect.width - 50, 45), self._title, 34, rl.WHITE, FontWeight.BOLD) + value = f"{self._value:+d}{self._unit}" if self._minimum < 0 else f"{self._value}{self._unit}" + gui_label(rl.Rectangle(rect.x + 25, rect.y + 72, rect.width - 50, 48), value, 42, color, FontWeight.BOLD, alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER) + y = rect.y + rect.height - 75 + self._minus = rl.Rectangle(rect.x + 20, y - 20, 60, 65) + self._plus = rl.Rectangle(rect.x + rect.width - 80, y - 20, 60, 65) + self._track = rl.Rectangle(rect.x + 105, y + 10, max(1, rect.width - 210), 8) + for button, label in ((self._minus, "-"), (self._plus, "+")): + rl.draw_rectangle_rounded(button, 0.3, 6, rl.Color(50, 50, 55, 255)) + gui_label(button, label, 42, rl.WHITE, FontWeight.BOLD, alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER) + rl.draw_rectangle_rounded(self._track, 1.0, 8, rl.Color(65, 65, 70, 255)) + fraction = (self._value - self._minimum) / max(1, self._maximum - self._minimum) + fill = rl.Rectangle(self._track.x, self._track.y, self._track.width * fraction, self._track.height) + if fill.width > 0: + rl.draw_rectangle_rounded(fill, 1.0, 8, color) + rl.draw_circle_v(rl.Vector2(self._track.x + fill.width, self._track.y + 4), 18, color) + for x, endpoint, alignment in ( + (self._track.x, self._minimum, rl.GuiTextAlignment.TEXT_ALIGN_LEFT), + (self._track.x + self._track.width - 140, self._maximum, rl.GuiTextAlignment.TEXT_ALIGN_RIGHT), + ): + endpoint_text = f"{endpoint:+d}{self._unit}" if self._minimum < 0 else f"{endpoint}{self._unit}" + gui_label(rl.Rectangle(x, y + 38, 140, 26), endpoint_text, 20, rl.Color(160, 160, 170, 255), FontWeight.NORMAL, alignment=alignment) + + +class ScreenValueButton(BigButton): + def __init__(self, title: str, params, get_spec: Callable[[], dict]): + super().__init__(title) + self._params, self._get_spec = params, get_spec + self.set_click_callback(self._show_slider) + self.refresh() + + def refresh(self): + spec = self._get_spec() + value = int(spec["value"]) + self.set_text(spec.get("label", self.text)) + self.set_value(f"{value:+d}{spec['unit']}" if spec["min"] < 0 else f"{value}{spec['unit']}") + + def _show_slider(self): + spec = self._get_spec() + gui_app.push_widget( + ScreenSliderMici( + spec["title"], + spec["min"], + spec["max"], + spec.get("step", 1), + spec["value"], + spec["unit"], + lambda value: self._save(spec["key"], value), + read_value=lambda: self._read_saved_value(spec["key"]), + ) + ) + + def _read_saved_value(self, key: str) -> int: + if key in BRIGHTNESS_KEYS: + return brightness_preferences(self._params, key)["manual"] + if key.endswith("Offset"): + return brightness_preferences(self._params, key.removesuffix("Offset"))["offset"] + return self._params.get_int(key, return_default=True) + + def _save(self, key: str, value: int): + def write(): + if key in SCREEN_INT_KEYS: + write_screen_setting(self._params, key, value) + else: + self._params.put_int(key, value) + + saved = try_screen_setting(write, show_screen_save_error) + self.refresh() + return saved + + +class BrightnessLayoutMici(NavScroller): + def __init__(self, key: str, title: str): + super().__init__() + self._params, self._key, self._title = Params(), key, title + self._mode = BigButton(title, "") + self._mode.set_click_callback(self._show_mode_selector) + self._value = ScreenValueButton("", self._params, self._slider_spec) + self._explanation = GreyBigButton("", "") + self._scroller.add_widgets([self._mode, self._value, self._explanation]) + self._refresh() + + def _slider_spec(self) -> dict: + prefs = brightness_preferences(self._params, self._key) + if prefs["mode"] == "auto": + return { + "key": self._key + "Offset", + "title": "auto brightness offset", + "label": "auto offset", + "min": -30, + "max": 30, + "value": prefs["offset"], + "unit": "%", + } + return {"key": self._key, "title": "manual brightness", "label": "manual brightness", "min": 0, "max": 100, "value": prefs["manual"], "unit": "%"} + + def _show_mode_selector(self): + prefs = brightness_preferences(self._params, self._key) + holder = {} + + def confirm(): + saved = try_screen_setting(lambda: set_brightness_mode(self._params, self._key, holder["dialog"].get_selected_option().lower()), show_screen_save_error) + self._refresh() + return saved + + dialog = ScreenModeDialogMici( + options=["Auto", "Manual"], + default=prefs["mode"].title(), + right_btn_callback=confirm, + current_option=lambda: brightness_preferences(self._params, self._key)["mode"].title(), + ) + holder["dialog"] = dialog + gui_app.push_widget(dialog) + + def _refresh(self): + prefs = brightness_preferences(self._params, self._key) + self._mode.set_value(prefs["mode"].title()) + self._value.refresh() + self._explanation.set_value( + "Auto offset: -30% to +30%. 0% keeps normal brightness." if prefs["mode"] == "auto" else "Manual sets a fixed brightness from 0 to 100%." + ) + + def show_event(self): + super().show_event() + self._refresh() + + def _update_state(self): + super()._update_state() + self._refresh() + + +class ScreenToggleMici(BigToggle): + def __init__(self, title: str, params, key: str, default: bool = False): + self._params, self._key, self._default = params, key, default + super().__init__(title, toggle_callback=self._save) + self.refresh() + + def _save(self, value: bool): + def write(): + if self._key in SCREEN_WAKE_KEYS: + write_screen_setting(self._params, self._key, value) + else: + self._params.put_bool(self._key, value) + + saved = try_screen_setting(write, show_screen_save_error) + self.refresh() + return saved + + def refresh(self): + self.set_checked(self._params.get_bool(self._key, default=self._default)) + + +class ScreenWakeToggleMici(ScreenToggleMici): + """Keep each wake condition and its full explanation on one small-screen card.""" + + LABEL_HORIZONTAL_PADDING = 22 + LABEL_VERTICAL_PADDING = 14 + + def __init__(self, title: str, params, key: str, default: bool): + super().__init__(title, params, key, default) + self.set_value(SCREEN_WAKE_DESCRIPTIONS[key]) + self._sub_label.set_line_height(1.0) + + def _get_label_font_size(self): + return 26 + + def _width_hint(self) -> int: + return int(self._rect.width - self.LABEL_HORIZONTAL_PADDING * 2 - self._txt_enabled_toggle.width) + + def _draw_content(self, btn_y: float): + width = self._width_hint() + available = self._rect.height - self.LABEL_VERTICAL_PADDING * 2 - self._label.get_content_height(width) + for size in range(22, 17, -1): + self._sub_label.set_font_size(size) + if self._sub_label.get_content_height(width) <= available: + break + super()._draw_content(btn_y) + + +class ScreenSettingsLayoutMici(NavScroller): + def __init__(self): + super().__init__() + self._params = Params() + management = ScreenToggleMici("screen controls", self._params, "ScreenManagement", True) + offroad = BigButton("offroad brightness") + offroad.set_click_callback(lambda: gui_app.push_widget(BrightnessLayoutMici("ScreenBrightness", "offroad brightness"))) + onroad = BigButton("onroad brightness") + onroad.set_click_callback(lambda: gui_app.push_widget(BrightnessLayoutMici("ScreenBrightnessOnroad", "onroad brightness"))) + offroad_timeout = self._timeout_button("ScreenTimeout", "offroad timeout") + self._onroad_timeout = self._timeout_button("ScreenTimeoutOnroad", "onroad timeout") + standby = ScreenToggleMici("standby mode", self._params, "StandbyMode") + self._wake_controls = [ScreenWakeToggleMici("wake: " + label.lower(), self._params, key, default) for key, label, default in SCREEN_WAKE_OPTIONS] + explanation = GreyBigButton("", "Standby sleeps the screen onroad. Only selected wake events wake it.") + self._refresh_controls = [management, offroad_timeout, self._onroad_timeout, standby, *self._wake_controls] + for control in [offroad, onroad, offroad_timeout, self._onroad_timeout, standby, *self._wake_controls]: + control.set_enabled(lambda: self._params.get_bool("ScreenManagement", default=True)) + self._scroller.add_widgets([management, offroad, onroad, offroad_timeout, standby, self._onroad_timeout, *self._wake_controls, explanation]) + self._refresh() + + def _timeout_button(self, key: str, title: str) -> ScreenValueButton: + return ScreenValueButton( + title, + self._params, + lambda: { + "key": key, + "title": title, + "label": title, + "min": 5, + "max": 60, + "step": 5, + "value": self._params.get_int(key, return_default=True), + "unit": " seconds", + }, + ) + + def _refresh(self): + for control in self._refresh_controls: + control.refresh() + standby = self._params.get_bool("StandbyMode") + self._onroad_timeout.set_visible(standby) + for control in self._wake_controls: + control.set_visible(standby) + + def show_event(self): + super().show_event() + self._refresh() + + def _update_state(self): + super()._update_state() + self._refresh() diff --git a/selfdrive/ui/tests/test_device_screen_settings.py b/selfdrive/ui/tests/test_device_screen_settings.py index 5094b09382..329f478e55 100644 --- a/selfdrive/ui/tests/test_device_screen_settings.py +++ b/selfdrive/ui/tests/test_device_screen_settings.py @@ -7,6 +7,9 @@ class FakeParams: def __init__(self, **values): self.values = values + def get(self, key): + return self.values.get(key) + def get_bool(self, key, **_kwargs): return bool(self.values.get(key, False)) @@ -31,6 +34,7 @@ def make_device(monkeypatch, **overrides): values.update(overrides) state = SimpleNamespace( ui_params=FakeParams(**values), + params_memory=FakeParams(), status=ui_state_module.UIStatus.DISENGAGED, started=False, ignition=False, @@ -165,7 +169,7 @@ def test_standby_wakes_for_visible_alert(monkeypatch): state.ignition = True device._ignition = True device._interaction_time = now - 1 - device._visible_onroad_alert = lambda: True + device._active_standby_alerts = lambda: {"StandbyWakeInfoAlert"} device._update_wakefulness() diff --git a/selfdrive/ui/tests/test_native_screen_controls.py b/selfdrive/ui/tests/test_native_screen_controls.py new file mode 100644 index 0000000000..5cd0c29aed --- /dev/null +++ b/selfdrive/ui/tests/test_native_screen_controls.py @@ -0,0 +1,209 @@ +"""Native UI behavior tests without loading device-only raylib/msgq libraries.""" + +import ast +import importlib.util +from pathlib import Path +from types import SimpleNamespace +import pytest +from openpilot.selfdrive.ui.lib.screen_settings import try_screen_setting + +ROOT = Path(__file__).resolve().parents[3] + + +def method(path, cls, name, **namespace): + assert (ROOT / path).exists(), f'Missing native screen implementation {path}' + tree = ast.parse((ROOT / path).read_text()) + class_node = next((node for node in tree.body if isinstance(node, ast.ClassDef) and node.name == cls), None) + assert class_node is not None, f'Missing native screen control {cls}' + node = next((node for node in class_node.body if isinstance(node, ast.FunctionDef) and node.name == name), None) + assert node is not None, f'Missing native screen behavior {cls}.{name}' + module = ast.Module(body=[ast.ImportFrom(module='__future__', names=[ast.alias(name='annotations')], level=0), node], type_ignores=[]) + namespace.setdefault('try_screen_setting', try_screen_setting) + namespace.setdefault('show_screen_save_error', lambda: pytest.fail('Unexpected native save error')) + exec(compile(ast.fix_missing_locations(module), str(ROOT / path), 'exec'), namespace) + return namespace[name] + + +SYSTEM = 'selfdrive/ui/layouts/settings/starpilot/system_settings.py' +MICi = 'selfdrive/ui/mici/layouts/settings/screen.py' + + +def real_params(directory): + spec = importlib.util.spec_from_file_location('native_test_params', ROOT / 'common/params.py') + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module.Params(directory) + + +class TestNativeScreenControls: + @pytest.fixture(autouse=True) + def params_directory(self, tmp_path): + self._params_directory = tmp_path + + def _real_params(self): + return real_params(str(self._params_directory)) + + def test_mici_toggle_refresh_uses_real_boolean_getter_signature(self): + refresh = method(MICi, 'ScreenToggleMici', 'refresh') + params = self._real_params() + values = [] + control = SimpleNamespace(_params=params, _key='StandbyWakeBrake', _default=False, set_checked=values.append) + refresh(control) + params.put_bool('StandbyWakeBrake', True) + refresh(control) + assert values == [False, True] + + def test_mici_screen_controls_enabled_uses_real_boolean_getter(self): + tree = ast.parse((ROOT / MICi).read_text()) + screen_class = next(node for node in tree.body if isinstance(node, ast.ClassDef) and node.name == 'ScreenSettingsLayoutMici') + enabled_call = next( + node for node in ast.walk(screen_class) if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) and (node.func.attr == 'set_enabled') + ) + params = self._real_params() + enabled = eval(compile(ast.Expression(enabled_call.args[0]), 'native-enable-callback', 'eval'), {'self': SimpleNamespace(_params=params)}) + assert enabled() + params.put_bool('ScreenManagement', False) + assert not enabled() + + def test_big_ui_wake_callbacks_use_real_boolean_getter_and_keep_choices_independent(self): + tree = ast.parse((ROOT / SYSTEM).read_text()) + system_class = next(node for node in tree.body if isinstance(node, ast.ClassDef) and node.name == 'SystemSettingsManagerView') + assignment = next( + node + for node in ast.walk(system_class) + if isinstance(node, ast.Assign) and any(isinstance(target, ast.Attribute) and target.attr == '_wake_toggle_defs' for target in node.targets) + ) + params = self._real_params() + view = SimpleNamespace(_controller=SimpleNamespace(_params=params)) + namespace = { + 'self': view, + 'tr': lambda text: text, + 'SCREEN_WAKE_DESCRIPTIONS': {'StandbyWakeEngage': 'Wake when engaged.', 'StandbyWakeBrake': 'Wake when braking.'}, + 'SCREEN_WAKE_OPTIONS': [('StandbyWakeEngage', 'Engagement', True), ('StandbyWakeBrake', 'Brake', False)], + } + exec(compile(ast.Module(body=[assignment], type_ignores=[]), 'native-wake-callbacks', 'exec'), namespace) + assert [option['get_state']() for option in view._wake_toggle_defs] == [True, False] + assert [option['subtitle'] for option in view._wake_toggle_defs] == ['Wake when engaged.', 'Wake when braking.'] + params.put_bool('StandbyWakeBrake', True) + params.put_bool('StandbyWakeEngage', False) + assert [option['get_state']() for option in view._wake_toggle_defs] == [False, True] + + def test_mici_screen_toggles_save_existing_and_new_settings(self): + helper_spec = importlib.util.spec_from_file_location('screen_settings', ROOT / 'starpilot/common/screen_settings.py') + helper = importlib.util.module_from_spec(helper_spec) + helper_spec.loader.exec_module(helper) + save = method(MICi, 'ScreenToggleMici', '_save', SCREEN_WAKE_KEYS=helper.SCREEN_WAKE_KEYS, write_screen_setting=helper.write_screen_setting) + params = self._real_params() + for key in ('ScreenManagement', 'StandbyMode', 'StandbyWakeBrake'): + save(SimpleNamespace(_params=params, _key=key, refresh=lambda: None), True) + assert [params.get_bool(key) for key in ('ScreenManagement', 'StandbyMode', 'StandbyWakeBrake')] == [True, True, True] + + def test_mici_value_save_remembers_brightness_and_accepts_existing_timeout(self): + helper_spec = importlib.util.spec_from_file_location('screen_settings', ROOT / 'starpilot/common/screen_settings.py') + helper = importlib.util.module_from_spec(helper_spec) + helper_spec.loader.exec_module(helper) + save = method(MICi, 'ScreenValueButton', '_save', SCREEN_INT_KEYS=helper.SCREEN_INT_KEYS, write_screen_setting=helper.write_screen_setting) + params = self._real_params() + params.put_int('ScreenBrightness', 101) + view = SimpleNamespace(_params=params, refresh=lambda: None) + save(view, 'ScreenBrightness', 38) + save(view, 'ScreenTimeout', 25) + assert [params.get_int(key) for key in ('ScreenBrightness', 'ScreenBrightnessManual', 'ScreenTimeout')] == [38, 38, 25] + + def test_big_ui_offroad_timeout_remains_visible_when_standby_is_off(self): + get_keys = method(SYSTEM, 'SystemSettingsManagerView', '_display_keys') + params = SimpleNamespace(get_bool=lambda key: False) + view = SimpleNamespace( + _controller=SimpleNamespace(_params=params), _display_slider_keys=['ScreenBrightness', 'ScreenBrightnessOnroad', 'ScreenTimeout', 'ScreenTimeoutOnroad'] + ) + assert get_keys(view) == ['ScreenBrightness', 'ScreenBrightnessOnroad', 'ScreenTimeout'] + params.get_bool = lambda key: True + assert get_keys(view) == view._display_slider_keys + + def test_big_ui_standby_reveals_wake_choices_without_losing_other_toggles(self): + get_defs = method(SYSTEM, 'SystemSettingsManagerView', '_visible_toggle_defs') + params = SimpleNamespace(get_bool=lambda key: False) + view = SimpleNamespace( + _controller=SimpleNamespace(_params=params), + _toggle_defs=[{'title': 'Standby'}, {'title': 'Uploads'}], + _wake_toggle_defs=[{'title': 'Engagement'}, {'title': 'Brake'}], + ) + assert get_defs(view) == [{'title': 'Standby'}, {'title': 'Uploads'}] + params.get_bool = lambda key: True + assert get_defs(view) == [{'title': 'Standby'}, {'title': 'Engagement'}, {'title': 'Brake'}, {'title': 'Uploads'}] + + def test_mici_brightness_slider_selects_offset_or_manual_with_correct_bounds(self): + for mode, want in [('auto', ('ScreenBrightnessOnroadOffset', -30, 30, -25, '%')), ('manual', ('ScreenBrightnessOnroad', 0, 100, 73, '%'))]: + get_spec = method( + MICi, 'BrightnessLayoutMici', '_slider_spec', brightness_preferences=lambda params, key, mode=mode: {'mode': mode, 'manual': 73, 'offset': -25} + ) + view = SimpleNamespace(_params=object(), _key='ScreenBrightnessOnroad') + spec = get_spec(view) + assert (spec['key'], spec['min'], spec['max'], spec['value'], spec['unit']) == want + + def test_big_ui_auto_summary_uses_signed_percent_without_affecting_manual(self): + for mode, offset, want in [('auto', 25, 'Auto +25%'), ('auto', -25, 'Auto -25%'), ('manual', 0, 'Manual 73%')]: + formatted_value = method( + 'selfdrive/ui/layouts/settings/starpilot/screen_controls.py', + 'BrightnessAdjustorRow', + 'formatted_value', + brightness_preferences=lambda params, key, mode=mode, offset=offset: {'mode': mode, 'manual': 73, 'offset': offset}, + tr=lambda text: text, + ) + assert formatted_value(SimpleNamespace(_params=object(), _brightness_key='ScreenBrightness')) == want + + def test_mici_value_card_signs_offset_only_when_both_modes_use_percent(self): + refresh = method(MICi, 'ScreenValueButton', 'refresh') + for minimum, value, want in [(-30, 25, '+25%'), (-30, -25, '-25%'), (-30, 0, '+0%'), (0, 73, '73%')]: + values = [] + spec = {'min': minimum, 'value': value, 'unit': '%'} + button = SimpleNamespace(_get_spec=lambda spec=spec: spec, text='brightness', set_text=lambda text: None, set_value=values.append) + refresh(button) + assert values == [want] + + def test_big_ui_manual_selection_uses_new_default_and_preserves_remembered_value(self): + helper_spec = importlib.util.spec_from_file_location('screen_settings', ROOT / 'starpilot/common/screen_settings.py') + helper = importlib.util.module_from_spec(helper_spec) + helper_spec.loader.exec_module(helper) + choose_mode = method( + 'selfdrive/ui/layouts/settings/starpilot/screen_controls.py', 'AetherBrightnessDialog', '_set_mode', set_brightness_mode=helper.set_brightness_mode + ) + params = self._real_params() + view = SimpleNamespace(_params=params, _key='ScreenBrightnessOnroad') + choose_mode(view, 1) + assert params.get_int('ScreenBrightnessOnroad') == 100 + params.put_int('ScreenBrightnessOnroad', 41) + choose_mode(view, 0) + choose_mode(view, 1) + assert params.get_int('ScreenBrightnessOnroad') == 41 + + def test_mici_standby_controls_visibility_without_changing_saved_values(self): + refresh = method(MICi, 'ScreenSettingsLayoutMici', '_refresh') + + class Control: + visible = None + + def refresh(self): + pass + + def set_visible(self, value): + self.visible = value + + params = SimpleNamespace(get_bool=lambda key: False) + timeout = Control() + triggers = [Control(), Control()] + view = SimpleNamespace(_params=params, _refresh_controls=[], _onroad_timeout=timeout, _wake_controls=triggers) + refresh(view) + assert not timeout.visible + assert all(control.visible is False for control in triggers) + params.get_bool = lambda key: True + refresh(view) + assert timeout.visible + assert all(control.visible is True for control in triggers) + + def test_mici_numeric_slider_clamps_and_snaps_to_allowed_steps(self): + value_from_x = method(MICi, 'ScreenSliderMici', '_value_from_x') + slider = SimpleNamespace(_track=SimpleNamespace(x=50, width=400), _minimum=-30, _maximum=30, _step=1) + assert [value_from_x(slider, x) for x in (0, 50, 150, 250, 449, 500)] == [-30, -30, -15, 0, 30, 30] + slider._minimum, slider._maximum, slider._step = (5, 60, 5) + assert [value_from_x(slider, x) for x in (0, 122, 250, 500)] == [5, 15, 35, 60] diff --git a/selfdrive/ui/tests/test_native_screen_save_errors.py b/selfdrive/ui/tests/test_native_screen_save_errors.py new file mode 100644 index 0000000000..cdd6ec856c --- /dev/null +++ b/selfdrive/ui/tests/test_native_screen_save_errors.py @@ -0,0 +1,249 @@ +"""Exercise registered native save callbacks when the shared writer rejects a save.""" + +import os +from contextlib import nullcontext +from types import SimpleNamespace +import pytest +from unittest.mock import patch # noqa: TID251 - standard-library mocking inside pytest tests + +os.environ.setdefault('SP_HEADLESS_TEST', '1') +from openpilot.common.params import Params, UnknownKeyName +from openpilot.selfdrive.ui.layouts.settings.starpilot import screen_controls as big +from openpilot.selfdrive.ui.mici.layouts.settings import screen as mici + + +class Range: + def __init__(self, *args, **kwargs): + self.current_val = args[3] if len(args) > 3 else 0 + self.commit = kwargs.get('on_commit') + self.is_interacting = False + + def set_visible(self, value): + pass + + def set_value(self, value): + self.current_val = value + + def reset_interaction(self): + self.is_interacting = False + + +class TestNativeScreenSaveErrors: + @pytest.fixture(autouse=True) + def setup(self, tmp_path): + self.params = Params(str(tmp_path)) + self.params.put_int('ScreenBrightness', 101) + self.params.put_int('ScreenBrightnessManual', 37) + self.params.put_int('ScreenBrightnessOffset', -12) + self.params.put_bool('StandbyWakeBrake', False) + self.errors = [] + with ( + patch.object(big, 'show_screen_save_error', lambda: self.errors.append('save failed')), + patch.object(mici, 'show_screen_save_error', lambda: self.errors.append('save failed')), + ): + yield + + def assert_recoverable(self, action): + try: + result = action() + except (OSError, ValueError) as error: + pytest.fail(f'Save error escaped the native callback: {error}') + assert self.errors == ['save failed'] + return result + + def big_dialog(self): + with patch.object(big, 'AetherInlineRangeControl', Range), patch.object(big, 'AetherSegmentedControl', Range): + return big.AetherBrightnessDialog(self.params, 'ScreenBrightness', 'Offroad brightness') + + def test_c3_failed_mode_save_preserves_auto_and_manual_memory(self): + dialog = self.big_dialog() + with patch.object(big, 'set_brightness_mode', side_effect=OSError('Screen settings busy')): + self.assert_recoverable(lambda: dialog._set_mode(1)) + assert dialog._mode_index() == 0 + assert self.params.get_int('ScreenBrightnessManual') == 37 + + def test_c3_failed_auto_selection_preserves_manual_brightness(self): + self.params.put_int('ScreenBrightness', 37) + dialog = self.big_dialog() + with patch.object(big, 'set_brightness_mode', side_effect=ValueError('Invalid setting')): + self.assert_recoverable(lambda: dialog._set_mode(0)) + assert dialog._mode_index() == 1 + assert self.params.get_int('ScreenBrightness') == 37 + + def test_c3_registered_slider_callbacks_restore_saved_values(self): + for control_name, saved in (('_offset', -12), ('_manual', 37)): + self.errors.clear() + self.params.put_int('ScreenBrightness', 37 if control_name == '_manual' else 101) + dialog = self.big_dialog() + control = getattr(dialog, control_name) + control.current_val = 25 + with patch.object(big, 'write_screen_setting', side_effect=OSError('disk full')): + self.assert_recoverable(lambda control=control: control.commit(25)) + assert control.current_val == saved + assert self.params.get_int('ScreenBrightnessManual') == 37 + assert self.params.get_int('ScreenBrightnessOffset') == -12 + + def test_c3_registered_wake_toggle_recovers_from_failed_save(self): + import ast + from types import MethodType, SimpleNamespace + from openpilot.selfdrive.ui.tests.test_native_screen_controls import ROOT, SYSTEM, method + from openpilot.starpilot.common.screen_settings import SCREEN_WAKE_DESCRIPTIONS, SCREEN_WAKE_OPTIONS + + def fail_write(params, key, value): + raise OSError("Screen settings busy") + + namespace = {"write_screen_setting": fail_write, "show_screen_save_error": lambda: self.errors.append("save failed")} + save = method(SYSTEM, "SystemSettingsManagerView", "_save_wake_setting", **namespace) + view = SimpleNamespace(_controller=SimpleNamespace(_params=self.params)) + view._save_wake_setting = MethodType(save, view) + tree = ast.parse((ROOT / SYSTEM).read_text()) + cls = next(node for node in tree.body if isinstance(node, ast.ClassDef) and node.name == "SystemSettingsManagerView") + definition = next( + node.value + for node in ast.walk(cls) + if isinstance(node, ast.Assign) and any(isinstance(target, ast.Attribute) and target.attr == "_wake_toggle_defs" for target in node.targets) + ) + definitions = eval( + compile(ast.Expression(definition), "wake-controls", "eval"), + dict(namespace, self=view, tr=lambda value: value, SCREEN_WAKE_OPTIONS=SCREEN_WAKE_OPTIONS, SCREEN_WAKE_DESCRIPTIONS=SCREEN_WAKE_DESCRIPTIONS), + ) + brake = next(control for control in definitions if "Brake pedal" in control["title"]) + self.assert_recoverable(lambda: brake["set_state"](True)) + assert brake["get_state"]() is False + assert self.params.get_bool("StandbyWakeBrake") is False + + @pytest.mark.parametrize("error_type", [OSError, ValueError, UnknownKeyName]) + def test_c4_failed_wake_toggle_restores_checked_state(self, error_type): + control = mici.ScreenToggleMici.__new__(mici.ScreenToggleMici) + control._params, control._key, control._default = (self.params, 'StandbyWakeBrake', False) + control._checked = True + with patch.object(mici, 'write_screen_setting', side_effect=error_type('Screen settings busy')): + self.assert_recoverable(lambda: control._save(True)) + assert not control._checked + assert not self.params.get_bool('StandbyWakeBrake') + + @pytest.mark.parametrize('key,minimum,saved', [('ScreenBrightnessOffset', -30, -12), ('ScreenBrightness', 0, 37)]) + def test_c4_slider_failure_restores_visible_value_and_keeps_slider_open(self, key, minimum, saved): + self.params.put_int('ScreenBrightness', 37 if key == 'ScreenBrightness' else 101) + button = mici.ScreenValueButton.__new__(mici.ScreenValueButton) + button._params = self.params + button.refresh = lambda: None + slider = mici.ScreenSliderMici("brightness", minimum, 30 if minimum < 0 else 100, 1, saved, "%", lambda value: button._save(key, value)) + slider._value, slider._dragging = 25, True + with patch.object(mici, 'write_screen_setting', side_effect=OSError('Screen settings busy')): + self.assert_recoverable(lambda: slider._handle_mouse_release(None)) + assert slider._value == saved + assert not slider.is_dismissing + assert self.params.get_int(key) == saved + + @pytest.mark.parametrize('initial,selected,saved_mode', [(101, 'Manual', 'Auto'), (37, 'Auto', 'Manual')]) + @pytest.mark.parametrize('fail_save', [True, False]) + def test_c4_mode_confirmation_dismisses_only_after_successful_save(self, initial, selected, saved_mode, fail_save): + self.params.put_int('ScreenBrightness', initial) + layout = mici.BrightnessLayoutMici.__new__(mici.BrightnessLayoutMici) + layout._params, layout._key = (self.params, 'ScreenBrightness') + shown_modes = [] + layout._mode = SimpleNamespace(set_value=shown_modes.append) + layout._value = SimpleNamespace(refresh=lambda: None) + layout._explanation = SimpleNamespace(set_value=lambda value: None) + dialogs = [] + base = getattr(mici, 'ScreenModeDialogMici', mici.BigMultiOptionDialog) + + class Dialog: + def __init__(self, options, default, right_btn_callback, **kwargs): + self._right_btn_callback = right_btn_callback + self._selected_option = selected + self._default_option = default + self._current_option = kwargs.get('current_option', lambda: default) + self.dismissed = False + + def get_selected_option(self): + return self._selected_option + + def dismiss(self, callback=None): + self.dismissed = True + if callback: + callback() + + def _on_option_selected(self, option): + self._selected_option = option + + with ( + patch.object(mici, 'BigMultiOptionDialog', Dialog), + patch.object(mici, 'ScreenModeDialogMici', Dialog, create=True), + patch.object(mici.gui_app, 'push_widget', dialogs.append), + ): + layout._show_mode_selector() + dialog = dialogs[0] + with patch.object(mici, 'set_brightness_mode', side_effect=OSError('Screen settings busy')) if fail_save else nullcontext(): + if fail_save: + self.assert_recoverable(lambda: base._confirm_selection(dialog)) + else: + base._confirm_selection(dialog) + assert self.errors == [] + assert dialog.dismissed is not fail_save + assert dialog.get_selected_option() == (saved_mode if fail_save else selected) + assert shown_modes[-1] == (saved_mode if fail_save else selected) + assert self.params.get_int('ScreenBrightness') == (initial if fail_save else 37 if selected == 'Manual' else 101) + assert self.params.get_int('ScreenBrightnessManual') == 37 + + @pytest.mark.parametrize("interface", ["c3", "c4"]) + def test_native_controls_recover_when_another_save_holds_the_lock(self, interface): + from openpilot.starpilot.common.screen_settings import _screen_write_transaction + + if interface == "c3": + control = self.big_dialog() + + def action(): + return control._set_mode(1) + else: + control = mici.ScreenToggleMici.__new__(mici.ScreenToggleMici) + control._params, control._key, control._default = self.params, "StandbyWakeBrake", False + control._checked = True + + def action(): + return control._save(True) + + with _screen_write_transaction(self.params): + self.assert_recoverable(action) + assert self.params.get_int("ScreenBrightness") == 101 + assert self.params.get_int("ScreenBrightnessManual") == 37 + assert self.params.get_bool("StandbyWakeBrake") is False + if interface == "c3": + assert control._mode_index() == 0 + else: + assert control._checked is False + + @pytest.mark.parametrize("key,initial,external", [("ScreenBrightnessOffset", -12, 19), ("ScreenBrightness", 37, 73)]) + def test_c4_rejected_save_reads_latest_persisted_slider_value_before_retry(self, key, initial, external): + if key == "ScreenBrightness": + mici.write_screen_setting(self.params, key, initial) + button = mici.ScreenValueButton.__new__(mici.ScreenValueButton) + button._params = self.params + button.refresh = lambda: None + button._get_spec = lambda: { + "key": key, + "title": "brightness", + "value": initial, + "min": -30 if key.endswith("Offset") else 0, + "max": 30 if key.endswith("Offset") else 100, + "unit": "%", + } + dialogs = [] + with patch.object(mici.gui_app, "push_widget", dialogs.append): + button._show_slider() + slider = dialogs[0] + # A second client changes this value while the native slider is open. + mici.write_screen_setting(self.params, key, external) + if key == "ScreenBrightness": + mici.set_brightness_mode(self.params, key, "auto") + slider._value, slider._dragging = 25, True + with patch.object(mici, "write_screen_setting", side_effect=OSError("Screen settings busy")): + self.assert_recoverable(lambda: slider._handle_mouse_release(None)) + assert slider._value == external + assert slider._committed_value == external + assert not slider.is_dismissing + slider._value, slider._dragging = external + 1, True + slider._handle_mouse_release(None) + assert self.params.get_int(key) == external + 1 + assert slider._committed_value == external + 1 diff --git a/selfdrive/ui/ui_state.py b/selfdrive/ui/ui_state.py index 6fc17569d5..4e9ac30fe9 100644 --- a/selfdrive/ui/ui_state.py +++ b/selfdrive/ui/ui_state.py @@ -13,7 +13,10 @@ from openpilot.selfdrive.ui.lib.prime_state import PrimeState from openpilot.selfdrive.ui.lib.ui_param_cache import shared_ui_params from openpilot.system.ui.lib.application import gui_app from openpilot.starpilot.common.lateral_only_experimental import lateral_only_experimental_available -from openpilot.system.hardware import HARDWARE, PC +from openpilot.system.hardware import HARDWARE, PC, TICI +from openpilot.starpilot.common.screen_settings import ( + StandbyWakeTracker, standby_alert_wake_key, brightness_preferences, calculate_screen_brightness, enabled_wake_keys, standby_button_press_time, +) BACKLIGHT_OFFROAD = 65 if HARDWARE.get_device_type() == "mici" else 50 @@ -301,6 +304,8 @@ class Device: def __init__(self): self._ignition = False + self._started = ui_state.started + self._last_button_press = standby_button_press_time(ui_state.params_memory) self._interaction_time: float = -1 self._override_interactive_timeout: int | None = None self._interactive_timeout_callbacks: list[Callable] = [] @@ -315,7 +320,10 @@ class Device: self._screen_timeout = 30 self._screen_timeout_onroad = 30 self._standby_mode = False - self._last_status = ui_state.status + self._screen_offset = 0 + self._screen_offset_onroad = 0 + self._wake_keys = frozenset() + self._wake_tracker = StandbyWakeTracker() self._refresh_screen_settings(force=True) self._offroad_brightness: int = BACKLIGHT_OFFROAD @@ -330,11 +338,11 @@ class Device: def set_override_interactive_timeout(self, timeout: int | None) -> None: # Override the interactive timeout duration temporarily self._override_interactive_timeout = timeout - self._reset_interactive_timeout() + self.reset_interactive_timeout() @property def interactive_timeout(self) -> int: - if self._override_interactive_timeout is not None: + if self._override_interactive_timeout is not None and not ((ui_state.started or ui_state.ignition) and self._standby_mode): return self._override_interactive_timeout timeout_onroad = self._screen_timeout_onroad @@ -351,7 +359,9 @@ class Device: self._interaction_time = time.monotonic() + self.interactive_timeout def reset_interactive_timeout(self) -> None: - self._reset_interactive_timeout() + # Page lifecycle callbacks must not wake Standby without a selected event. + if not ((ui_state.started or ui_state.ignition) and self._standby_mode): + self._reset_interactive_timeout() def add_interactive_timeout_callback(self, callback: Callable): self._interactive_timeout_callbacks.append(callback) @@ -378,6 +388,9 @@ class Device: self._screen_timeout, self._screen_timeout_onroad, self._standby_mode, + self._screen_offset, + self._screen_offset_onroad, + self._wake_keys, ) self._screen_management = self._params.get_bool("ScreenManagement") @@ -387,12 +400,17 @@ class Device: self._screen_timeout = self._params.get_int("ScreenTimeout", return_default=True) self._screen_timeout_onroad = self._params.get_int("ScreenTimeoutOnroad", return_default=True) self._standby_mode = self._params.get_bool("StandbyMode") + self._screen_offset = brightness_preferences(self._params, "ScreenBrightness")["offset"] + self._screen_offset_onroad = brightness_preferences(self._params, "ScreenBrightnessOnroad")["offset"] + self._wake_keys = frozenset(enabled_wake_keys(self._params)) else: self._screen_brightness = 101 self._screen_brightness_onroad = 101 self._screen_timeout = 30 self._screen_timeout_onroad = 30 self._standby_mode = False + self._screen_offset = self._screen_offset_onroad = 0 + self._wake_keys = frozenset() self._screen_settings_refresh_time = now current = ( @@ -402,8 +420,11 @@ class Device: self._screen_timeout, self._screen_timeout_onroad, self._standby_mode, + self._screen_offset, + self._screen_offset_onroad, + self._wake_keys, ) - if previous != current and self._interaction_time > 0: + if previous != current and self._interaction_time > 0 and not ((ui_state.started or ui_state.ignition) and (previous[5] or self._standby_mode)): self._reset_interactive_timeout() def set_offroad_brightness(self, brightness: int | None): @@ -434,29 +455,31 @@ class Device: clipped_brightness = float(np.interp(clipped_brightness, [0, 1], [30, 100])) - brightness = round(self._brightness_filter.update(clipped_brightness)) - if not self._awake: - brightness = 0 - elif ui_state.started and self._standby_mode and time.monotonic() > self._interaction_time: - brightness = 0 - elif ui_state.started and self._screen_brightness_onroad != 101: - brightness = max(5, self._screen_brightness_onroad) if time.monotonic() <= self._interaction_time else self._screen_brightness_onroad - elif not ui_state.started and self._screen_brightness != 101: - brightness = self._screen_brightness - - return brightness + automatic = self._brightness_filter.update(clipped_brightness) + interactive = time.monotonic() <= self._interaction_time + return calculate_screen_brightness( + automatic, + self._screen_brightness_onroad if ui_state.started else self._screen_brightness, + self._screen_offset_onroad if ui_state.started else self._screen_offset, + interactive=interactive, + awake=self._awake, + standby_timed_out=(ui_state.started or ui_state.ignition) and self._standby_mode and not interactive, + ) def _update_wakefulness(self): # Handle interactive timeout - ignition_state_changed = ui_state.ignition != self._ignition - self._ignition = ui_state.ignition + drive_state_changed = ui_state.ignition != self._ignition or ui_state.started != self._started + standby_active = self._standby_mode and (ui_state.started or self._started or ui_state.ignition or self._ignition) + self._ignition, self._started = ui_state.ignition, ui_state.started - status_changed = ui_state.status != self._last_status and ui_state.status != UIStatus.OVERRIDE - self._last_status = ui_state.status - wake_for_onroad_event = (ui_state.started and self._standby_mode and self._screen_brightness_onroad != 0 and - (status_changed or self._visible_onroad_alert())) - - if ignition_state_changed or any(ev.left_down for ev in gui_app.mouse_events) or wake_for_onroad_event: + events = self._wake_input_events() | self._active_standby_alerts() + touched = any(ev.left_down for ev in gui_app.mouse_events) + if touched: + events.add("StandbyWakeTouch") + if drive_state_changed: + events.add("StandbyWakeDriveState") + should_wake = bool(events & self._wake_keys) if standby_active else touched or drive_state_changed + if should_wake: self._reset_interactive_timeout() interaction_timeout = time.monotonic() > self._interaction_time @@ -471,25 +494,53 @@ class Device: self._set_awake(keep_display_awake) @staticmethod - def _visible_onroad_alert() -> bool: - if not ui_state.started: - return False - + def _fresh_message(name): sm = ui_state.sm try: - selfdrive_state = sm["selfdriveState"] - if selfdrive_state.alertSize != log.SelfdriveState.AlertSize.none: - return True - if selfdrive_state.alertStatus != log.SelfdriveState.AlertStatus.normal: - return True - except Exception: - pass + return sm[name] if sm.alive[name] and sm.valid[name] else None + except (KeyError, AttributeError): + return None + def _wake_input_events(self): + button_time = standby_button_press_time(ui_state.params_memory) + external_press = button_time > self._last_button_press and 0 <= time.monotonic() - button_time / 1e9 < 2 + self._last_button_press = button_time + if not ui_state.started: + events = self._wake_tracker.update() + if external_press: + events.add("StandbyWakeButton") + return events + selfdrive_state = self._fresh_message("selfdriveState") + car_state = self._fresh_message("carState") + gear = str(car_state.gearShifter) if car_state is not None else None + events = self._wake_tracker.update( + engaged=bool(selfdrive_state.enabled) if selfdrive_state is not None else None, + turn_signal=(int(car_state.leftBlinker) | (int(car_state.rightBlinker) << 1)) if car_state is not None else None, + brake=bool(car_state.brakePressed) if car_state is not None else None, + accelerator=bool(car_state.gasPressed) if car_state is not None else None, + drive_state=gear if gear not in ("unknown", "0") else None, + ) + if external_press: + events.add("StandbyWakeButton") + return events + + def _active_standby_alerts(self): + if not ui_state.started: + return set() + sm = ui_state.sm try: - starpilot_state = sm["starpilotSelfdriveState"] - return getattr(starpilot_state.alertSize, "raw", 0) != 0 - except Exception: - return False + key = standby_alert_wake_key( + sm["selfdriveState"], sm["starpilotSelfdriveState"], + now=time.monotonic(), started_time=ui_state.started_time, started_frame=ui_state.started_frame, + updated=sm.updated["selfdriveState"], recv_frame=sm.recv_frame["selfdriveState"], recv_time=sm.recv_time["selfdriveState"], + primary_fresh=self._fresh_message("selfdriveState") is not None, + secondary_fresh=self._fresh_message("starpilotSelfdriveState") is not None, + tici=TICI, mici=HARDWARE.get_device_type() == "mici", + hide_alerts=getattr(ui_state, "starpilot_toggles", {}).get("hide_alerts", False), + ) + except (KeyError, AttributeError): + return set() + return {key} if key is not None else set() def _set_awake(self, on: bool): if on != self._awake: diff --git a/starpilot/common/assets/device_settings_layout.json b/starpilot/common/assets/device_settings_layout.json index 0ee696c875..233fde05bb 100644 --- a/starpilot/common/assets/device_settings_layout.json +++ b/starpilot/common/assets/device_settings_layout.json @@ -4683,22 +4683,30 @@ { "key": "ScreenBrightness", "label": "Screen Brightness (Offroad)", - "description": "The screen brightness while not driving.", + "description": "Use automatic brightness with an adjustable offset, or Manual to set a fixed brightness while parked.", "data_type": "int", "ui_type": "numeric", - "step": 1.0, + "galaxy_ui_type": "brightness", + "step": 1, "parent_key": "ScreenManagement", - "settings_tier": "simple" + "settings_tier": "simple", + "default": 101, + "min": 0, + "max": 101 }, { "key": "ScreenBrightnessOnroad", "label": "Screen Brightness (Onroad)", - "description": "The screen brightness while driving.", + "description": "Choose Auto to follow ambient light with an adjustable offset, or Manual to set a fixed brightness while driving.", "data_type": "int", "ui_type": "numeric", - "step": 1.0, + "galaxy_ui_type": "brightness", + "step": 1, "parent_key": "ScreenManagement", - "settings_tier": "simple" + "settings_tier": "simple", + "default": 101, + "min": 0, + "max": 101 }, { "key": "ScreenRecorder", @@ -4713,35 +4721,196 @@ { "key": "ScreenTimeout", "label": "Screen Timeout (Offroad)", - "description": "How long the screen stays on after being tapped while not driving.", + "description": "How long the parked screen stays awake after a touch.", "data_type": "int", "ui_type": "numeric", "min": 5.0, "max": 60.0, "step": 5.0, "parent_key": "ScreenManagement", - "settings_tier": "simple" + "settings_tier": "simple", + "unit": " seconds" }, { "key": "ScreenTimeoutOnroad", "label": "Screen Timeout (Onroad)", - "description": "How long the screen stays on after being tapped while driving.", + "description": "How long the driving screen stays awake before Standby Mode turns it off.", "data_type": "int", "ui_type": "numeric", "min": 5.0, "max": 60.0, "step": 5.0, - "parent_key": "ScreenManagement", - "settings_tier": "simple" + "parent_key": "StandbyMode", + "settings_tier": "simple", + "unit": " seconds", + "visible_when_all_true": [ + "ScreenManagement", + "StandbyMode" + ] }, { "key": "StandbyMode", "label": "Standby Mode", - "description": "Turn the screen off while driving and automatically wake it up for alerts or engagement state changes.", - "picker_description": "Turns the screen off while driving and wakes for alerts.", + "description": "Turn the driving screen off after the onroad timeout. While Standby Mode is active, only the selected wake events turn it back on.", + "picker_description": "Turns the driving screen off and wakes it only for selected events.", "data_type": "bool", "ui_type": "toggle", "parent_key": "ScreenManagement", + "settings_tier": "simple", + "is_parent_toggle": true + }, + { + "key": "StandbyWakeEngage", + "label": "Engagement", + "description": "Wake the screen from Standby when driving assistance engages.", + "data_type": "bool", + "ui_type": "toggle", + "default": true, + "parent_key": "StandbyMode", + "visible_when_all_true": [ + "ScreenManagement", + "StandbyMode" + ], + "settings_tier": "simple" + }, + { + "key": "StandbyWakeDisengage", + "label": "Disengagement", + "description": "Wake the screen from Standby when driving assistance disengages.", + "data_type": "bool", + "ui_type": "toggle", + "default": true, + "parent_key": "StandbyMode", + "visible_when_all_true": [ + "ScreenManagement", + "StandbyMode" + ], + "settings_tier": "simple" + }, + { + "key": "StandbyWakeInfoAlert", + "label": "Informational alerts", + "description": "Wake the screen from Standby and keep it awake while an informational alert is displayed.", + "data_type": "bool", + "ui_type": "toggle", + "default": true, + "parent_key": "StandbyMode", + "visible_when_all_true": [ + "ScreenManagement", + "StandbyMode" + ], + "settings_tier": "simple" + }, + { + "key": "StandbyWakeWarningAlert", + "label": "Warning alerts", + "description": "Wake the screen from Standby and keep it awake while a warning alert is displayed.", + "data_type": "bool", + "ui_type": "toggle", + "default": true, + "parent_key": "StandbyMode", + "visible_when_all_true": [ + "ScreenManagement", + "StandbyMode" + ], + "settings_tier": "simple" + }, + { + "key": "StandbyWakeCriticalAlert", + "label": "Critical / takeover alerts", + "description": "Wake the screen from Standby and keep it awake while a critical or takeover alert is displayed.", + "data_type": "bool", + "ui_type": "toggle", + "default": true, + "parent_key": "StandbyMode", + "visible_when_all_true": [ + "ScreenManagement", + "StandbyMode" + ], + "settings_tier": "simple" + }, + { + "key": "StandbyWakeTurnSignal", + "label": "Turn signals", + "description": "Wake the screen from Standby when a turn signal is activated.", + "data_type": "bool", + "ui_type": "toggle", + "default": false, + "parent_key": "StandbyMode", + "visible_when_all_true": [ + "ScreenManagement", + "StandbyMode" + ], + "settings_tier": "simple" + }, + { + "key": "StandbyWakeBrake", + "label": "Brake pedal", + "description": "Wake the screen from Standby when the brake pedal is pressed.", + "data_type": "bool", + "ui_type": "toggle", + "default": false, + "parent_key": "StandbyMode", + "visible_when_all_true": [ + "ScreenManagement", + "StandbyMode" + ], + "settings_tier": "simple" + }, + { + "key": "StandbyWakeAccelerator", + "label": "Accelerator pedal", + "description": "Wake the screen from Standby when the accelerator pedal is pressed.", + "data_type": "bool", + "ui_type": "toggle", + "default": false, + "parent_key": "StandbyMode", + "visible_when_all_true": [ + "ScreenManagement", + "StandbyMode" + ], + "settings_tier": "simple" + }, + { + "key": "StandbyWakeTouch", + "label": "Touch screen", + "description": "Wake the screen from Standby when the screen is touched.", + "data_type": "bool", + "ui_type": "toggle", + "default": true, + "parent_key": "StandbyMode", + "visible_when_all_true": [ + "ScreenManagement", + "StandbyMode" + ], + "settings_tier": "simple" + }, + { + "key": "StandbyWakeDriveState", + "label": "Car drive state changed", + "description": "Wake the screen from Standby when the gear changes between Park, Reverse, Neutral or Drive, or when ignition or driving state changes.", + "data_type": "bool", + "ui_type": "toggle", + "default": true, + "parent_key": "StandbyMode", + "visible_when_all_true": [ + "ScreenManagement", + "StandbyMode" + ], + "settings_tier": "simple" + }, + { + "key": "StandbyWakeButton", + "label": "Bluetooth or steering wheel button", + "description": "Wake the screen from Standby when a connected Bluetooth or USB controller button, or a supported steering wheel button, is pressed.", + "data_type": "bool", + "ui_type": "toggle", + "default": false, + "parent_key": "StandbyMode", + "visible_when_all_true": [ + "ScreenManagement", + "StandbyMode" + ], "settings_tier": "simple" } ] diff --git a/starpilot/common/screen_settings.py b/starpilot/common/screen_settings.py new file mode 100644 index 0000000000..4c435ad6bc --- /dev/null +++ b/starpilot/common/screen_settings.py @@ -0,0 +1,260 @@ +"""Shared screen preferences and wake policy for native UI and Galaxy. + +The existing brightness value 101 continues to encode Auto for older clients. +Manual values and offsets are independent for the driving and parked screens. +""" +from contextlib import contextmanager +import fcntl +import math +import os +from pathlib import Path +from threading import RLock + +from openpilot.common.params import UnknownKeyName + +BRIGHTNESS_KEYS = ('ScreenBrightness', 'ScreenBrightnessOnroad') +SCREEN_INT_KEYS = frozenset(key + suffix for key in BRIGHTNESS_KEYS for suffix in ('', 'Manual', 'Offset')) +STANDBY_BUTTON_PRESS_PARAM = 'StandbyButtonPressTime' +SCREEN_WAKE_OPTIONS = ( + ('StandbyWakeTouch', 'Touch screen', True), + ('StandbyWakeDriveState', 'Car drive state changed', True), + ('StandbyWakeButton', 'Bluetooth or steering wheel button', False), + ('StandbyWakeEngage', 'Engagement', True), + ('StandbyWakeDisengage', 'Disengagement', True), + ('StandbyWakeInfoAlert', 'Informational alerts', True), + ('StandbyWakeWarningAlert', 'Warning alerts', True), + ('StandbyWakeCriticalAlert', 'Critical / takeover alerts', True), + ('StandbyWakeTurnSignal', 'Turn signals', False), + ('StandbyWakeBrake', 'Brake pedal', False), + ('StandbyWakeAccelerator', 'Accelerator pedal', False), +) +SCREEN_WAKE_DESCRIPTIONS = { + 'StandbyWakeTouch': 'Wake the screen from Standby when you touch it.', + 'StandbyWakeDriveState': 'Wake the screen from Standby when the gear, ignition or driving state changes.', + 'StandbyWakeButton': 'Wake the screen from Standby when a connected Bluetooth or USB controller, or a supported steering wheel button, is pressed.', + 'StandbyWakeEngage': 'Wake the screen from Standby when StarPilot engages.', + 'StandbyWakeDisengage': 'Wake the screen from Standby when StarPilot disengages.', + 'StandbyWakeInfoAlert': 'Wake the screen from Standby and keep it awake while an informational alert is displayed.', + 'StandbyWakeWarningAlert': 'Wake the screen from Standby and keep it awake while a warning alert is displayed.', + 'StandbyWakeCriticalAlert': 'Wake the screen from Standby and keep it awake while a critical or takeover alert is displayed.', + 'StandbyWakeTurnSignal': 'Wake the screen from Standby when a turn signal is activated or its direction changes.', + 'StandbyWakeBrake': 'Wake the screen from Standby when the brake pedal is pressed.', + 'StandbyWakeAccelerator': 'Wake the screen from Standby when the accelerator pedal is pressed.', +} +SCREEN_WAKE_KEYS = frozenset(key for key, _, _ in SCREEN_WAKE_OPTIONS) +SCREEN_SETTING_KEYS = SCREEN_INT_KEYS | SCREEN_WAKE_KEYS +_WRITE_LOCK = RLock() + + +def _raw(params, key): + try: + return params.get(key) + except (UnknownKeyName, KeyError, TypeError, ValueError): + # Older native registries may not yet know new settings during an update. + return None + + +def _integer(params, key, default, minimum, maximum): + try: + value = float(_raw(params, key)) + if not math.isfinite(value): + return default + return min(maximum, max(minimum, round(value))) + except (TypeError, ValueError, OverflowError): + return default + + +def brightness_preferences(params, key): + if key not in BRIGHTNESS_KEYS: + raise ValueError('Unknown brightness setting') + value = _integer(params, key, 101, 0, 101) + return { + 'mode': 'auto' if value == 101 else 'manual', + 'manual': _integer(params, key + 'Manual', 100, 0, 100) if value == 101 else value, + 'offset': _integer(params, key + 'Offset', 0, -30, 30), + } + + +def _boolean(raw, default=False): + if raw is None: + return default + if isinstance(raw, bytes): + return raw.strip().lower() in (b'1', b'true') + if isinstance(raw, str): + return raw.strip().lower() in ('1', 'true') + return bool(raw) + + +def enabled_wake_keys(params): + return {key for key, _, default in SCREEN_WAKE_OPTIONS if _boolean(_raw(params, key), default)} + + +@contextmanager +def _screen_write_transaction(params): + # Keep the lock outside the key directory, where Params clearing cannot remove + # it. Include the prefix so isolated Params stores do not block each other. + directory = Path(params.get_param_path()) + lock_path = directory.parent / f'.screen_settings.{directory.name}.lock' + with _WRITE_LOCK: + fd = os.open(lock_path, os.O_CREAT | os.O_RDWR | os.O_CLOEXEC, 0o660) + try: + # A competing save must report busy instead of blocking the UI thread. + fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + invalidate = getattr(params, 'invalidate', None) + if invalidate is not None: + invalidate() + try: + yield + finally: + # UIParamCache must not retain a pre-transaction value after success or + # rollback; Galaxy writes may have changed it before we took the lock. + if invalidate is not None: + invalidate() + finally: + os.close(fd) + + +def write_screen_setting(params, key, value): + if key not in SCREEN_SETTING_KEYS: + raise ValueError('Unknown screen setting') + if key in SCREEN_WAKE_KEYS: + if type(value) is not bool: + raise ValueError('Wake selections must be booleans') + else: + lo, hi = (-30, 30) if key.endswith('Offset') else (0, 101 if key in BRIGHTNESS_KEYS else 100) + if type(value) not in (int, float) or not math.isfinite(value) or int(value) != value or not lo <= value <= hi: + raise ValueError(f'{key} must be a whole number from {lo} to {hi}') + value = int(value) + + with _screen_write_transaction(params): + return _write_screen_setting(params, key, value) + + +def _write_screen_setting(params, key, value): + changes = {} + if key in BRIGHTNESS_KEYS: + current = _integer(params, key, 101, 0, 101) + if value != 101: + changes[key + 'Manual'] = value + elif current != 101: + changes[key + 'Manual'] = current + changes[key] = value + before = {k: _raw(params, k) for k in changes} + applied = [] + try: + for changed_key, changed_value in changes.items(): + put = params.put_bool if changed_key in SCREEN_WAKE_KEYS else params.put_int + put(changed_key, changed_value) + applied.append(changed_key) + actual = _raw(params, changed_key) + if changed_key in SCREEN_WAKE_KEYS: + matches = (changed_key in enabled_wake_keys(params)) == changed_value + else: + matches = actual is not None and _integer(params, changed_key, -999, -100, 101) == changed_value + if not matches: + raise OSError('Screen setting write did not persist') + except Exception: + for changed_key in reversed(applied): + try: + previous = before[changed_key] + if previous is None: + params.remove(changed_key) + elif changed_key in SCREEN_WAKE_KEYS: + params.put_bool(changed_key, _boolean(previous)) + else: + params.put_int(changed_key, int(previous)) + except Exception: + pass + raise + return changes + + +def set_brightness_mode(params, key, mode): + if mode not in ('auto', 'manual'): + raise ValueError('Brightness mode must be Auto or Manual') + if key not in BRIGHTNESS_KEYS: + raise ValueError('Unknown brightness setting') + with _screen_write_transaction(params): + preferences = brightness_preferences(params, key) + return _write_screen_setting(params, key, 101 if mode == 'auto' else preferences['manual']) + + +def calculate_screen_brightness(automatic, manual, offset=0, *, interactive=False, awake=True, standby_timed_out=False): + if not awake or standby_timed_out: + return 0 + target = automatic * (1 + min(30, max(-30, offset)) / 100) if manual == 101 else manual + target = min(100, max(0, round(target))) + # A tap or selected wake event must make a deliberately dark screen usable. + return max(5, target) if manual == 101 or interactive else target + + +def alert_wake_key(alert): + status = str(getattr(alert, 'alertStatus', 'normal')) + size = str(getattr(alert, 'alertSize', 'none')) + if size in ('none', '0'): + return None + if status in ('critical', '2'): + return 'StandbyWakeCriticalAlert' + if status in ('userPrompt', '1'): + return 'StandbyWakeWarningAlert' + if size not in ('none', '0'): + return 'StandbyWakeInfoAlert' + return None + + +def standby_alert_wake_key(primary, secondary, *, now, started_time, started_frame, updated, recv_frame, recv_time, + primary_fresh, secondary_fresh, tici, mici, hide_alerts=False): + """Resolve the current renderer alert without constructing a UI widget. + + Both renderers generate startup/unresponsive alerts before reading normal + messages. C4 keeps the reboot alert critical; C3 renders it informational. + Raw stale messages never wake Standby, even if still cached by the renderer. + """ + waiting_for_startup = recv_frame < started_frame + if not updated: + if waiting_for_startup and now - started_time > 5: + return 'StandbyWakeInfoAlert' + missing = now - recv_time + if tici and not waiting_for_startup and missing > 5: + if getattr(primary, 'enabled', False) and missing - 5 < 10: + return 'StandbyWakeCriticalAlert' + return 'StandbyWakeCriticalAlert' if mici else 'StandbyWakeInfoAlert' + + if waiting_for_startup: + return None + # Primary alerts take display precedence, even when that category is disabled. + if alert_wake_key(primary) is not None: + alert = primary if primary_fresh else None + else: + alert = secondary if secondary_fresh else None + if not mici and hide_alerts and str(getattr(alert, 'alertStatus', 'normal')) in ('normal', '0'): + return None + return alert_wake_key(alert) + + +class StandbyWakeTracker: + """Detect new driver inputs; missing samples reset each input's history.""" + def __init__(self): + self.previous = {} + + def update(self, *, engaged=None, turn_signal=None, brake=None, accelerator=None, drive_state=None): + current = dict(engaged=engaged, turn_signal=turn_signal, brake=brake, accelerator=accelerator, drive_state=drive_state) + events = set() + for key, value in current.items(): + previous = self.previous.get(key) + if value is not None and previous is not None and value != previous: + if key == 'engaged': + events.add('StandbyWakeEngage' if value else 'StandbyWakeDisengage') + elif key == 'drive_state': + events.add('StandbyWakeDriveState') + elif value: + events.add({'turn_signal': 'StandbyWakeTurnSignal', 'brake': 'StandbyWakeBrake', 'accelerator': 'StandbyWakeAccelerator'}[key]) + self.previous = current + return events + + +def standby_button_press_time(params): + try: + return max(0, int(_raw(params, STANDBY_BUTTON_PRESS_PARAM) or 0)) + except (TypeError, ValueError, OverflowError): + return 0 diff --git a/starpilot/common/tests/test_screen_alert_renderer_wakes.py b/starpilot/common/tests/test_screen_alert_renderer_wakes.py new file mode 100644 index 0000000000..f26a967c2d --- /dev/null +++ b/starpilot/common/tests/test_screen_alert_renderer_wakes.py @@ -0,0 +1,113 @@ +"""Compare wake policy with real renderer methods without constructing widgets.""" +import ast +from enum import IntEnum +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from openpilot.starpilot.common.screen_settings import alert_wake_key, standby_alert_wake_key + + +class ProtoEnum(IntEnum): + @property + def raw(self): + return int(self) + + +class Size(ProtoEnum): + none = 0 + small = 1 + mid = 2 + full = 3 + + +class Status(ProtoEnum): + normal = 0 + userPrompt = 1 + critical = 2 + starpilot = 3 + + +def message(): + return SimpleNamespace(enabled=True, alertSize=Size.none, alertStatus=Status.normal, + alertText1='', alertText2='', alertType='', alertHudVisual=0) + + +def renderer_method(mici, state, sm, tici): + root = Path(__file__).resolve().parents[3] + source = root / ('selfdrive/ui/mici/onroad/alert_renderer.py' if mici else 'selfdrive/ui/onroad/alert_renderer.py') + tree = ast.parse(source.read_text()) + constants = {'ALERT_STARTUP_PENDING', 'ALERT_CRITICAL_TIMEOUT', 'ALERT_CRITICAL_REBOOT', + 'SELFDRIVE_STATE_TIMEOUT', 'SELFDRIVE_UNRESPONSIVE_TIMEOUT'} + body = [node for node in tree.body if isinstance(node, ast.Assign) and any( + isinstance(target, ast.Name) and target.id in constants for target in node.targets)] + renderer = next(node for node in tree.body if isinstance(node, ast.ClassDef) and node.name == 'AlertRenderer') + body.append(next(node for node in renderer.body if isinstance(node, ast.FunctionDef) and node.name == 'get_alert')) + namespace = dict(Alert=SimpleNamespace, AlertSize=Size, AlertStatus=Status, + custom=SimpleNamespace(StarPilotSelfdriveState=SimpleNamespace(AlertSize=Size)), + messaging=SimpleNamespace(SubMaster=object), tr=lambda text: text, ui_state=state, + time=SimpleNamespace(monotonic=lambda: 100), TICI=tici) + exec(compile(ast.Module(body=body, type_ignores=[]), str(source), 'exec'), namespace) + return namespace['get_alert'](SimpleNamespace(), sm) + + +@pytest.mark.parametrize('mici', [False, True]) +@pytest.mark.parametrize('scenario', [ + 'startup', 'startup_boundary', 'takeover', 'timeout_boundary', 'reboot', 'reboot_boundary', 'reboot_disengaged', + 'nonhardware', 'updated_old_timestamp', 'info', 'warning', 'critical', 'no_size', 'secondary', + 'primary_precedence', 'hidden_normal', 'visible_starpilot', 'hidden_generated_startup', +]) +def test_wake_category_matches_actual_renderer_alert(mici, scenario): + class Messages(dict): + pass + sm = Messages(selfdriveState=message(), starpilotSelfdriveState=message()) + sm.updated = {'selfdriveState': True} + sm.recv_frame = {'selfdriveState': 6} + sm.recv_time = {'selfdriveState': 100} + state = SimpleNamespace(started_time=90, started_frame=5, starpilot_toggles={}) + tici = True + primary = sm['selfdriveState'] + secondary = sm['starpilotSelfdriveState'] + if scenario in ('startup', 'startup_boundary', 'hidden_generated_startup'): + sm.updated['selfdriveState'] = False + sm.recv_frame['selfdriveState'] = 4 + if scenario == 'startup_boundary': + state.started_time = 95 + if scenario == 'hidden_generated_startup': + state.starpilot_toggles['hide_alerts'] = True + elif scenario in ('takeover', 'timeout_boundary', 'reboot', 'reboot_boundary', 'reboot_disengaged', 'nonhardware', 'updated_old_timestamp'): + sm.updated['selfdriveState'] = scenario == 'updated_old_timestamp' + sm.recv_time['selfdriveState'] = {'timeout_boundary': 95, 'reboot': 84, 'reboot_boundary': 85}.get(scenario, 94) + primary.enabled = scenario != 'reboot_disengaged' + tici = scenario != 'nonhardware' + elif scenario == 'secondary': + secondary.alertSize = Size.small + elif scenario == 'visible_starpilot': + secondary.alertSize, secondary.alertStatus = Size.small, Status.starpilot + state.starpilot_toggles['hide_alerts'] = True + else: + primary.alertSize = Size.none if scenario == 'no_size' else Size.small + primary.alertStatus = (Status.userPrompt if scenario == 'warning' else + Status.critical if scenario in ('critical', 'no_size', 'primary_precedence') else Status.normal) + if scenario == 'primary_precedence': + secondary.alertSize = Size.small + if scenario == 'hidden_normal': + state.starpilot_toggles['hide_alerts'] = True + rendered = renderer_method(mici, state, sm, tici) + expected = alert_wake_key(SimpleNamespace(alertSize=rendered.size, alertStatus=rendered.status)) if rendered else None + actual = standby_alert_wake_key(primary, secondary, now=100, + started_time=state.started_time, started_frame=state.started_frame, + updated=sm.updated['selfdriveState'], recv_frame=sm.recv_frame['selfdriveState'], recv_time=sm.recv_time['selfdriveState'], + primary_fresh=True, secondary_fresh=True, tici=tici, mici=mici, hide_alerts=state.starpilot_toggles.get('hide_alerts', False)) + assert actual == expected + + +@pytest.mark.parametrize('primary_stale', [False, True]) +def test_stale_raw_alert_is_ignored_before_generated_timeout(primary_stale): + primary, secondary = message(), message() + alert = primary if primary_stale else secondary + alert.alertStatus, alert.alertSize = Status.critical, Size.full + assert standby_alert_wake_key(primary, secondary, now=100, started_time=90, started_frame=5, + updated=False, recv_frame=6, recv_time=99, primary_fresh=not primary_stale, secondary_fresh=primary_stale, + tici=True, mici=True) is None diff --git a/starpilot/common/tests/test_screen_device_runtime.py b/starpilot/common/tests/test_screen_device_runtime.py new file mode 100644 index 0000000000..2ef5a030a4 --- /dev/null +++ b/starpilot/common/tests/test_screen_device_runtime.py @@ -0,0 +1,351 @@ +"""Run the real Device class without opening a display or native IPC sockets.""" +import ast +from collections.abc import Callable +from enum import Enum +from pathlib import Path +from types import SimpleNamespace + +import numpy as np +import pytest + +from openpilot.common.filter_simple import FirstOrderFilter +from openpilot.starpilot.common import screen_settings as screen + + +class Params: + def __init__(self, values): + self.values = values + def get(self, key): + return self.values.get(key) + def get_int(self, key, **kwargs): + return int(self.values.get(key, 101 if 'Brightness' in key else 30)) + def get_bool(self, key): + return bool(self.values.get(key, False)) + + +class Messages(dict): + def __init__(self): + super().__init__(selfdriveState=SimpleNamespace(enabled=False, alertSize='none', alertStatus='normal'), + starpilotSelfdriveState=SimpleNamespace(alertSize='none', alertStatus='normal'), + carState=SimpleNamespace(leftBlinker=False, rightBlinker=False, brakePressed=False, gasPressed=False, + gearShifter='drive', buttonEvents=[])) + self.updated = dict.fromkeys(self, True) + self.recv_frame = dict.fromkeys(self, 1) + self.recv_time = dict.fromkeys(self, 100) + self.logMonoTime = dict.fromkeys(self, 1) + self.valid = dict.fromkeys(self, True) + self.alive = dict.fromkeys(self, True) + + +def make_device(*, device_type="tici", **settings): + status = Enum('UIStatus', 'DISENGAGED ENGAGED OVERRIDE') + state = SimpleNamespace(started=True, ignition=True, status=status.DISENGAGED, light_sensor=-1, started_time=0, started_frame=0, starpilot_toggles={}, + params_memory=Params({}), ui_params=Params({'ScreenManagement': True, 'ScreenBrightness': 101, + 'ScreenBrightnessOnroad': 101, 'StandbyMode': True, **settings}), sm=Messages()) + app = SimpleNamespace(target_fps=20, big_ui=lambda: False, mouse_events=[], set_should_render=lambda value: None) + env = {**vars(screen), 'ui_state': state, 'gui_app': app, 'UIStatus': status, 'BACKLIGHT_OFFROAD': 65, + 'np': np, 'time': SimpleNamespace(monotonic=lambda: 100), 'FirstOrderFilter': FirstOrderFilter, + 'Callable': Callable, 'HARDWARE': SimpleNamespace(set_display_power=lambda value: None, get_device_type=lambda: device_type), + 'cloudlog': SimpleNamespace(debug=lambda value: None), 'PC': False, 'TICI': True} + source = Path(__file__).resolve().parents[3] / 'selfdrive/ui/ui_state.py' + node = next(n for n in ast.parse(source.read_text()).body if isinstance(n, ast.ClassDef) and n.name == 'Device') + exec(compile(ast.Module(body=[node], type_ignores=[]), str(source), 'exec'), env) + device = env['Device']() + device._brightness_filter = SimpleNamespace(update=lambda value: value) + device._ignition = True + device._interaction_time = 90 + return device, state, app + + +def test_driving_and_parked_offsets_apply_independently(): + device, state, _ = make_device(StandbyMode=False, ScreenBrightnessOffset=-20, ScreenBrightnessOnroadOffset=15) + assert device._calculate_brightness() == 75 + state.started = False + assert device._calculate_brightness() == 52 + + +def test_offset_is_ignored_in_manual_and_when_screen_settings_disabled(): + device, _, _ = make_device(StandbyMode=False, ScreenBrightnessOnroad=22, ScreenBrightnessOnroadOffset=50) + assert device._calculate_brightness() == 22 + device._params.values['ScreenManagement'] = False + device._refresh_screen_settings(force=True) + assert device._calculate_brightness() == 65 + + +def test_parked_zero_can_be_woken_by_touch_then_times_out_without_standby(): + device, state, app = make_device(StandbyMode=False, ScreenBrightness=0) + state.started = state.ignition = device._ignition = False + assert device._calculate_brightness() == 0 + app.mouse_events = [SimpleNamespace(left_down=True)] + device._update_wakefulness() + assert device._calculate_brightness() == 5 + app.mouse_events = [] + device._interaction_time = 90 + device._update_wakefulness() + assert not device.awake + assert device._calculate_brightness() == 0 + + +@pytest.mark.parametrize('key,field,value', [ + ('StandbyWakeInfoAlert', 'alertSize', 'small'), + ('StandbyWakeWarningAlert', 'alertStatus', 'userPrompt'), + ('StandbyWakeCriticalAlert', 'alertStatus', 'critical'), +]) +def test_only_selected_alert_categories_wake(key, field, value): + settings = dict.fromkeys(screen.SCREEN_WAKE_KEYS, False) + device, state, _ = make_device(**settings) + state.sm['selfdriveState'].alertSize = 'small' + setattr(state.sm['selfdriveState'], field, value) + device._update_wakefulness() + assert device._calculate_brightness() == 0 + device._params.values[key] = True + device._refresh_screen_settings(force=True) + device._interaction_time = 90 + device._update_wakefulness() + assert device._calculate_brightness() == 65 + + +@pytest.mark.parametrize('key,field', [ + ('StandbyWakeBrake', 'brakePressed'), ('StandbyWakeAccelerator', 'gasPressed'), ('StandbyWakeTurnSignal', 'leftBlinker'), +]) +def test_selected_driver_input_wakes_once_and_can_sleep_while_held(key, field): + device, state, _ = make_device(**dict.fromkeys(screen.SCREEN_WAKE_KEYS, False)) + device._params.values[key] = True + device._refresh_screen_settings(force=True) + device._update_wakefulness() + setattr(state.sm['carState'], field, True) + device._update_wakefulness() + assert device._calculate_brightness() == 65 + device._interaction_time = 90 + device._update_wakefulness() + assert device._calculate_brightness() == 0 + + +def test_unselected_engagement_and_stale_alerts_do_not_wake(): + device, state, _ = make_device(**dict.fromkeys(screen.SCREEN_WAKE_KEYS, False)) + device._update_wakefulness() + state.sm['selfdriveState'].enabled = True + state.status = type(state.status).ENGAGED + device._update_wakefulness() + assert device._calculate_brightness() == 0 + device._params.values['StandbyWakeCriticalAlert'] = True + device._refresh_screen_settings(force=True) + device._interaction_time = 90 + state.sm['selfdriveState'].alertStatus = 'critical' + state.sm['selfdriveState'].alertSize = 'full' + state.sm.alive['selfdriveState'] = False + device._update_wakefulness() + assert device._calculate_brightness() == 0 + + +def test_selected_event_recovers_manual_zero_brightness(): + device, state, _ = make_device(ScreenBrightnessOnroad=0) + state.sm['selfdriveState'].alertSize = 'full' + state.sm['selfdriveState'].alertStatus = 'critical' + device._update_wakefulness() + assert device._calculate_brightness() == 5 + + +def test_runtime_limits_legacy_offsets_to_thirty_percent(): + device, state, _ = make_device(StandbyMode=False, ScreenBrightnessOffset=-100, ScreenBrightnessOnroadOffset=-100) + assert device._calculate_brightness() == 46 + state.started = False + assert device._calculate_brightness() == 46 + + +@pytest.mark.parametrize('key', sorted(screen.SCREEN_WAKE_KEYS)) +@pytest.mark.parametrize('selected', [False, True]) +def test_every_wake_choice_controls_its_own_event(key, selected): + settings = dict.fromkeys(screen.SCREEN_WAKE_KEYS, False) + settings[key] = selected + device, state, app = make_device(**settings) + device._update_wakefulness() # Seed signals; startup is not a driver event. + if key == 'StandbyWakeEngage': + state.sm['selfdriveState'].enabled = True + elif key == 'StandbyWakeDisengage': + state.sm['selfdriveState'].enabled = True + device._update_wakefulness() + state.sm['selfdriveState'].enabled = False + elif key.endswith('Alert'): + alert = state.sm['selfdriveState'] + alert.alertSize = 'small' + alert.alertStatus = {'StandbyWakeInfoAlert': 'normal', 'StandbyWakeWarningAlert': 'userPrompt', 'StandbyWakeCriticalAlert': 'critical'}[key] + elif key in ('StandbyWakeBrake', 'StandbyWakeAccelerator', 'StandbyWakeTurnSignal'): + field = {'StandbyWakeBrake': 'brakePressed', 'StandbyWakeAccelerator': 'gasPressed', 'StandbyWakeTurnSignal': 'leftBlinker'}[key] + setattr(state.sm['carState'], field, True) + elif key == 'StandbyWakeTouch': + app.mouse_events = [SimpleNamespace(left_down=True)] + elif key == 'StandbyWakeDriveState': + state.sm['carState'].gearShifter = 'reverse' + elif key == 'StandbyWakeButton': + state.params_memory.values['StandbyButtonPressTime'] = 99_500_000_000 + else: + pytest.fail('No stimulus for wake option ' + key) + device._interaction_time = 90 + device._update_wakefulness() + assert (device._calculate_brightness() > 0) is selected + + +@pytest.mark.parametrize('selected', [False, True]) +def test_external_button_press_is_fresh_and_consumed_once(selected): + settings = dict.fromkeys(screen.SCREEN_WAKE_KEYS, False) + settings['StandbyWakeButton'] = selected + device, state, _ = make_device(**settings) + device._update_wakefulness() + state.params_memory.values['StandbyButtonPressTime'] = 99_500_000_000 + device._update_wakefulness() + assert (device._calculate_brightness() > 0) is selected + device._interaction_time = 90 + device._update_wakefulness() + assert device._calculate_brightness() == 0 + state.params_memory.values['StandbyButtonPressTime'] = 95_000_000_000 + device._update_wakefulness() + assert device._calculate_brightness() == 0 + + +@pytest.mark.parametrize('selected', [False, True]) +def test_ignition_changes_obey_drive_state_choice(selected): + settings = dict.fromkeys(screen.SCREEN_WAKE_KEYS, False) + settings['StandbyWakeDriveState'] = selected + device, state, _ = make_device(**settings) + state.ignition = False + device._update_wakefulness() + assert (device._calculate_brightness() > 0) is selected + + +def test_settings_and_programmatic_timeouts_cannot_bypass_standby_choices(): + device, _, _ = make_device(**dict.fromkeys(screen.SCREEN_WAKE_KEYS, False)) + device._params.values['ScreenBrightnessOnroadOffset'] = 20 + device._refresh_screen_settings(force=True) + assert device._interaction_time == 90 + device.reset_interactive_timeout() + device.set_override_interactive_timeout(300) + assert device._interaction_time == 90 + assert device.interactive_timeout == 30 + assert device._calculate_brightness() == 0 + + +def test_stale_button_messages_and_releases_do_not_wake(): + device, state, _ = make_device(StandbyWakeButton=True) + device._update_wakefulness() + state.sm['carState'].buttonEvents = [SimpleNamespace(pressed=False, type='accelCruise')] + state.sm.logMonoTime['carState'] += 1 + device._update_wakefulness() + assert device._calculate_brightness() == 0 + state.sm['carState'].buttonEvents[0].pressed = True + state.sm.logMonoTime['carState'] += 1 + state.sm.alive['carState'] = False + device._update_wakefulness() + state.sm.alive['carState'] = True + device._update_wakefulness() + assert device._calculate_brightness() == 0 + + +@pytest.mark.parametrize('selected', [False, True]) +def test_entering_ignition_before_onroad_obeys_drive_state_selection(selected): + device, state, _ = make_device(**{**dict.fromkeys(screen.SCREEN_WAKE_KEYS, False), 'StandbyWakeDriveState': selected}) + state.started = state.ignition = device._started = device._ignition = False + device._update_wakefulness() + assert not device.awake + state.ignition = True + device._update_wakefulness() + assert (device._calculate_brightness() > 0) is selected + state.started = True + device._update_wakefulness() + assert (device._calculate_brightness() > 0) is selected + + +def test_consumed_button_press_is_not_replayed_between_ui_frames(): + device, state, _ = make_device(StandbyWakeButton=True) + device._update_wakefulness() + state.params_memory.values['StandbyButtonPressTime'] = 99_500_000_000 + device._update_wakefulness() + assert device._calculate_brightness() > 0 + device._interaction_time = 90 + device._update_wakefulness() + assert device._calculate_brightness() == 0 + + + +@pytest.mark.parametrize('operation', ['settings_change', 'public_reset', 'timeout_override']) +def test_force_offroad_cannot_wake_through_settings_or_timeout_overrides(operation): + device, state, _ = make_device(**dict.fromkeys(screen.SCREEN_WAKE_KEYS, False)) + device._update_wakefulness() + state.started = False + assert state.ignition + device._update_wakefulness() + assert device._calculate_brightness() == 0 + if operation == 'settings_change': + device._params.values['ScreenBrightnessOffset'] = 15 + device._refresh_screen_settings(force=True) + elif operation == 'public_reset': + device.reset_interactive_timeout() + else: + device.set_override_interactive_timeout(300) + device._update_wakefulness() + assert device._interaction_time == 90 + assert device.interactive_timeout == 30 + assert device._calculate_brightness() == 0 + + +@pytest.mark.parametrize('device_type', ['tici', 'mici']) +@pytest.mark.parametrize('fallback', ['startup', 'takeover', 'reboot']) +@pytest.mark.parametrize('selected', [False, True]) +def test_rendered_system_alerts_obey_their_selected_category(device_type, fallback, selected): + category = 'StandbyWakeCriticalAlert' if fallback == 'takeover' or (fallback == 'reboot' and device_type == 'mici') else 'StandbyWakeInfoAlert' + settings = dict.fromkeys(screen.SCREEN_WAKE_KEYS, False) + settings[category] = selected + device, state, _ = make_device(device_type=device_type, **settings) + state.started_frame, state.started_time = 5, 90 + state.sm.updated['selfdriveState'] = False + state.sm.alive['selfdriveState'] = False + state.sm.recv_frame['selfdriveState'] = 1 if fallback == 'startup' else 6 + state.sm.recv_time['selfdriveState'] = 84 if fallback == 'reboot' else 94 + state.sm['selfdriveState'].enabled = True + # Generated fallback alerts are returned before the normal hide-alerts filter. + state.starpilot_toggles['hide_alerts'] = True + device._update_wakefulness() + assert device._active_standby_alerts() == {category} + assert (device._calculate_brightness() > 0) is selected + + +def test_alert_with_no_displayed_size_does_not_wake(): + device, state, _ = make_device(StandbyWakeCriticalAlert=True) + state.sm['selfdriveState'].alertStatus = 'critical' + state.sm['selfdriveState'].alertSize = 'none' + device._update_wakefulness() + assert device._active_standby_alerts() == set() + assert device._calculate_brightness() == 0 + + +def test_hidden_secondary_alert_does_not_bypass_primary_category_selection(): + settings = dict.fromkeys(screen.SCREEN_WAKE_KEYS, False) + settings['StandbyWakeInfoAlert'] = True + device, state, _ = make_device(**settings) + state.sm['selfdriveState'].alertStatus = 'critical' + state.sm['selfdriveState'].alertSize = 'full' + state.sm['starpilotSelfdriveState'].alertSize = 'small' + device._update_wakefulness() + assert device._active_standby_alerts() == {'StandbyWakeCriticalAlert'} + assert device._calculate_brightness() == 0 + + +@pytest.mark.parametrize('device_type', ['tici', 'mici']) +def test_normal_alert_hide_setting_matches_active_renderer(device_type): + device, state, _ = make_device(device_type=device_type) + state.starpilot_toggles['hide_alerts'] = True + state.sm['selfdriveState'].alertSize = 'small' + device._update_wakefulness() + assert (device._calculate_brightness() > 0) is (device_type == 'mici') + + +@pytest.mark.parametrize('selected', [False, True]) +def test_bluetooth_wake_during_ignition_only_standby(selected): + settings = {**dict.fromkeys(screen.SCREEN_WAKE_KEYS, False), 'StandbyWakeButton': selected} + device, state, _ = make_device(**settings) + state.started = device._started = False + state.ignition = device._ignition = True + device._update_wakefulness() + state.params_memory.values['StandbyButtonPressTime'] = 99_500_000_000 + device._update_wakefulness() + assert (device._calculate_brightness() > 0) is selected diff --git a/starpilot/common/tests/test_screen_settings.py b/starpilot/common/tests/test_screen_settings.py new file mode 100644 index 0000000000..fe4cb7915a --- /dev/null +++ b/starpilot/common/tests/test_screen_settings.py @@ -0,0 +1,152 @@ +import importlib +from pathlib import Path +from tempfile import TemporaryDirectory +from types import SimpleNamespace + +import pytest + + +def screen(): + return importlib.import_module('openpilot.starpilot.common.screen_settings') + + +class Params: + def __init__(self, values=None): + self.values = dict(values or {}) + self.fail_key = None + self.directory = TemporaryDirectory(prefix='screen-settings-test-') + + def get_param_path(self): + return str(Path(self.directory.name) / 'd') + + def get(self, key): + return self.values.get(key) + + def put_int(self, key, value): + if key == self.fail_key: + raise OSError('simulated write failure') + self.values[key] = value + + def put_bool(self, key, value): + self.put_int(key, value) + + def remove(self, key): + self.values.pop(key, None) + + +@pytest.mark.parametrize('key', ['ScreenBrightness', 'ScreenBrightnessOnroad']) +def test_new_defaults_auto_zero_offset_and_remembered_manual(key): + assert screen().brightness_preferences(Params(), key) == {'mode': 'auto', 'manual': 100, 'offset': 0} + + +def test_mode_switches_preserve_each_manual_level_independently(): + params = Params({'ScreenBrightness': 21, 'ScreenBrightnessOnroad': 76}) + screen().set_brightness_mode(params, 'ScreenBrightness', 'auto') + screen().set_brightness_mode(params, 'ScreenBrightnessOnroad', 'auto') + assert params.values['ScreenBrightness'] == 101 + assert params.values['ScreenBrightnessOnroad'] == 101 + screen().set_brightness_mode(params, 'ScreenBrightness', 'manual') + screen().set_brightness_mode(params, 'ScreenBrightnessOnroad', 'manual') + assert params.values['ScreenBrightness'] == 21 + assert params.values['ScreenBrightnessOnroad'] == 76 + + +def test_manual_zero_is_remembered_and_auto_offset_is_retained(): + params = Params({'ScreenBrightness': 101, 'ScreenBrightnessOffset': -12}) + screen().write_screen_setting(params, 'ScreenBrightness', 0) + screen().set_brightness_mode(params, 'ScreenBrightness', 'auto') + assert screen().brightness_preferences(params, 'ScreenBrightness') == {'mode': 'auto', 'manual': 0, 'offset': -12} + screen().set_brightness_mode(params, 'ScreenBrightness', 'manual') + assert params.values['ScreenBrightness'] == 0 + + +@pytest.mark.parametrize('key,value', [ + ('ScreenBrightness', -1), ('ScreenBrightness', 102), ('ScreenBrightness', True), + ('ScreenBrightness', 3.5), ('ScreenBrightnessOffset', -31), ('ScreenBrightnessOffset', float('nan')), + ('ScreenBrightnessOnroadOffset', 31), ('ScreenBrightnessManual', 101), + ('StandbyWakeCriticalAlert', 'false'), ('StandbyWakeBrake', 1), ('StandbyWakeUnknown', True), +]) +def test_invalid_values_are_rejected_without_any_writes(key, value): + params = Params({'ScreenBrightness': 101}) + with pytest.raises(ValueError): + screen().write_screen_setting(params, key, value) + assert params.values == {'ScreenBrightness': 101} + + +def test_failed_primary_write_does_not_change_display_mode(): + params = Params({'ScreenBrightness': 25, 'ScreenBrightnessManual': 80}) + params.fail_key = 'ScreenBrightness' + with pytest.raises(OSError): + screen().set_brightness_mode(params, 'ScreenBrightness', 'auto') + assert params.values['ScreenBrightness'] == 25 + + +@pytest.mark.parametrize('automatic,manual,offset,expected', [ + (65, 101, 10, 72), (50, 101, -15, 42), (95, 101, 25, 100), (30, 101, -40, 21), + (40, 101, -30, 28), (0, 101, -30, 5), (2, 101, 30, 5), + (80, 23, 50, 23), (80, 0, -40, 0), (20, 100, 100, 100), +]) +def test_offset_only_affects_auto_and_output_is_bounded(automatic, manual, offset, expected): + assert screen().calculate_screen_brightness(automatic, manual, offset) == expected + + +@pytest.mark.parametrize('manual,offset', [(0, 0)]) +def test_zero_brightness_can_temporarily_be_seen_after_waking(manual, offset): + assert screen().calculate_screen_brightness(30, manual, offset, interactive=True) == 5 + assert screen().calculate_screen_brightness(30, manual, offset, interactive=False) == 0 + + +def test_sleep_and_standby_override_positive_offsets(): + assert screen().calculate_screen_brightness(65, 101, 100, awake=False, interactive=True) == 0 + assert screen().calculate_screen_brightness(65, 101, 100, standby_timed_out=True) == 0 + + +@pytest.mark.parametrize('status,size,expected', [ + ('normal', 'none', None), ('normal', 'small', 'StandbyWakeInfoAlert'), + ('userPrompt', 'small', 'StandbyWakeWarningAlert'), ('critical', 'full', 'StandbyWakeCriticalAlert'), + ('critical', 'none', None), +]) +def test_alerts_are_classified_by_priority(status, size, expected): + assert screen().alert_wake_key(SimpleNamespace(alertStatus=status, alertSize=size)) == expected + + +def test_defaults_preserve_alert_and_engagement_waking_only(): + enabled = screen().enabled_wake_keys(Params()) + assert enabled == {'StandbyWakeEngage', 'StandbyWakeDisengage', 'StandbyWakeInfoAlert', 'StandbyWakeWarningAlert', + 'StandbyWakeCriticalAlert', 'StandbyWakeTouch', 'StandbyWakeDriveState'} + assert 'StandbyWakeCriticalAlert' not in screen().enabled_wake_keys(Params({'StandbyWakeCriticalAlert': False})) + + +def test_vehicle_triggers_are_edges_not_continuous_pedal_or_signal_states(): + tracker = screen().StandbyWakeTracker() + assert tracker.update(engaged=False, turn_signal=0, brake=False, accelerator=False) == set() + assert tracker.update(engaged=True, turn_signal=1, brake=True, accelerator=True) == { + 'StandbyWakeEngage', 'StandbyWakeTurnSignal', 'StandbyWakeBrake', 'StandbyWakeAccelerator'} + assert tracker.update(engaged=True, turn_signal=1, brake=True, accelerator=True) == set() + assert tracker.update(engaged=False, turn_signal=2, brake=False, accelerator=False) == {'StandbyWakeDisengage', 'StandbyWakeTurnSignal'} + + +def test_stale_vehicle_data_does_not_create_wake_events_when_it_returns(): + tracker = screen().StandbyWakeTracker() + tracker.update(engaged=False, turn_signal=0, brake=False, accelerator=False) + assert tracker.update() == set() + assert tracker.update(engaged=True, turn_signal=1, brake=True, accelerator=True) == set() + + +@pytest.mark.parametrize('key', ['ScreenBrightness', 'ScreenBrightnessOnroad']) +@pytest.mark.parametrize('stored,expected', [(-100, -30), (100, 30), (-30, -30), (30, 30)]) +def test_saved_offsets_are_limited_without_rewriting_preferences(key, stored, expected): + params = Params({key + 'Offset': stored}) + assert screen().brightness_preferences(params, key)['offset'] == expected + assert params.values[key + 'Offset'] == stored + + +@pytest.mark.parametrize('offset', [-30, 30]) +def test_offset_limits_can_be_saved_in_both_contexts(offset): + params = Params() + for key in ('ScreenBrightnessOffset', 'ScreenBrightnessOnroadOffset'): + assert screen().write_screen_setting(params, key, offset) == {key: offset} + + +def test_legacy_excessive_offset_is_clamped_in_brightness_calculation(): + assert screen().calculate_screen_brightness(65, 101, -100) == 46 diff --git a/starpilot/common/tests/test_screen_settings_api.py b/starpilot/common/tests/test_screen_settings_api.py new file mode 100644 index 0000000000..0465e6f514 --- /dev/null +++ b/starpilot/common/tests/test_screen_settings_api.py @@ -0,0 +1,64 @@ +"""Exercise the real Galaxy settings handler with an in-memory Params store.""" +import ast +from pathlib import Path + +from flask import Flask, jsonify, request +import pytest + +from test_screen_settings import Params + + +def client_for(params): + source = Path(__file__).resolve().parents[3] / 'starpilot/system/the_galaxy/the_galaxy.py' + setup = next(n for n in ast.parse(source.read_text()).body if isinstance(n, ast.FunctionDef) and n.name == 'setup') + route = next(n for n in setup.body if isinstance(n, ast.FunctionDef) and n.name == 'get_param') + app = Flask(__name__) + env = dict(app=app, request=request, jsonify=jsonify, params=params, update_starpilot_toggles=lambda: None, + LONGITUDINAL_MODE_KEYS=set(), PERSONALITY_PROFILES_PARAM='', PERSONALITY_PARKED_PARAM_KEYS=set(), + PERSONALITY_PROFILE_ENABLE_PARAM_KEYS=set(), FAVORITE_SLOTS_PARAM='', MODEL_SMOOTHING_KEYS=set(), + PERSONALITY_ADVANCED_PARAM_KEYS=set(), PERSONALITY_FOLLOW_PARAM_KEYS=set(), + _get_param_type_info=lambda: (set(), {})) + exec(compile(ast.Module(body=[route], type_ignores=[]), str(source), 'exec'), env) + return app.test_client() + + +def test_mode_write_returns_remembered_manual_value(): + params = Params({'ScreenBrightnessOnroad': 32}) + response = client_for(params).put('/api/params', json={'key': 'ScreenBrightnessOnroad', 'value': 101}) + assert response.status_code == 200 + assert response.json['updated'] == {'ScreenBrightnessOnroad': 101, 'ScreenBrightnessOnroadManual': 32} + + +@pytest.mark.parametrize('key,value,expected', [('ScreenBrightnessOffset', -25, -25), ('ScreenBrightnessOnroad', 0, 0), + ('StandbyWakeCriticalAlert', False, False)]) +def test_screen_values_are_validated_and_return_typed_readback(key, value, expected): + params = Params() + response = client_for(params).put('/api/params', json={'key': key, 'value': value}) + assert response.status_code == 200 + assert response.json['updated'][key] == expected + assert params.values[key] == expected + + +@pytest.mark.parametrize('key,value', [ + ('ScreenBrightnessOffset', 31), ('ScreenBrightnessOnroadOffset', -31), ('StandbyWakeEngage', 'false'), ('StandbyWakeFake', True), +]) +def test_invalid_screen_requests_return_400_without_writes(key, value): + params = Params() + response = client_for(params).put('/api/params', json={'key': key, 'value': value}) + assert response.status_code == 400 + assert not params.values + + +def test_native_write_failure_is_reported_instead_of_success(): + params = Params() + params.fail_key = 'ScreenBrightnessOffset' + response = client_for(params).put('/api/params', json={'key': 'ScreenBrightnessOffset', 'value': 10}) + assert response.status_code == 503 + + +def test_galaxy_failed_wake_write_reports_error_and_restores_selection(): + from test_screen_settings_transactions import SilentOnceParams, galaxy_params + params = SilentOnceParams({'StandbyWakeCriticalAlert': True}) + response = client_for(galaxy_params(params)).put('/api/params', json={'key': 'StandbyWakeCriticalAlert', 'value': False}) + assert response.status_code == 503 + assert params.values == {'StandbyWakeCriticalAlert': True} diff --git a/starpilot/common/tests/test_screen_settings_transactions.py b/starpilot/common/tests/test_screen_settings_transactions.py new file mode 100644 index 0000000000..04638b9f13 --- /dev/null +++ b/starpilot/common/tests/test_screen_settings_transactions.py @@ -0,0 +1,139 @@ +"""Exercise screen writes across the real Galaxy/cache wrappers and processes.""" +import ast +import multiprocessing +from pathlib import Path + +import pytest + +from openpilot.selfdrive.ui.lib.ui_param_cache import UIParamCache +from openpilot.starpilot.common.screen_settings import brightness_preferences, set_brightness_mode, write_screen_setting + +from test_screen_settings import Params + + +def galaxy_params(params): + source = Path(__file__).resolve().parents[3] / 'starpilot/system/the_galaxy/the_galaxy.py' + node = next(node for node in ast.parse(source.read_text()).body + if isinstance(node, ast.ClassDef) and node.name == 'ParamsCompat') + namespace = {} + exec(compile(ast.Module(body=[node], type_ignores=[]), str(source), 'exec'), namespace) + return namespace['ParamsCompat'](params) + + +class SilentOnceParams(Params): + def __init__(self, values): + super().__init__(values) + self.fail_once = True + + def put_bool(self, key, value): + if self.fail_once: + self.fail_once = False + return # Native Params does not propagate C++ putBool's error return. + super().put_bool(key, value) + + +def test_galaxy_failed_wake_write_preserves_enabled_selection(): + params = SilentOnceParams({'StandbyWakeCriticalAlert': True}) + with pytest.raises(OSError): + write_screen_setting(galaxy_params(params), 'StandbyWakeCriticalAlert', False) + assert params.values == {'StandbyWakeCriticalAlert': True} + + +def test_cached_native_mode_change_preserves_a_newer_galaxy_manual_value(): + params = Params({'ScreenBrightness': 25, 'ScreenBrightnessManual': 25}) + cache = UIParamCache(params, ttl=60) + assert brightness_preferences(cache, 'ScreenBrightness')['manual'] == 25 + write_screen_setting(galaxy_params(params), 'ScreenBrightness', 70) + set_brightness_mode(cache, 'ScreenBrightness', 'auto') + assert params.values == {'ScreenBrightness': 101, 'ScreenBrightnessManual': 70} + set_brightness_mode(cache, 'ScreenBrightness', 'manual') + assert brightness_preferences(cache, 'ScreenBrightness')['manual'] == 70 + + +class SharedParams: + """Manager-backed typed values and a shared path; no device Params are opened.""" + def __init__(self, values, directory, operation=None, paused=None, release=None): + self.values, self.directory = values, directory + self.operation, self.paused, self.release = operation, paused, release + self.did_pause = False + self.did_fail = False + + def get_param_path(self): + return self.directory + + def pause(self): + self.did_pause = True + self.paused.set() + assert self.release.wait(5), 'Parent did not release the test transaction' + + def get(self, key, **kwargs): + value = self.values.get(key) + if self.operation == 'snapshot' and key == 'ScreenBrightness' and not self.did_pause: + self.pause() + return value + + def put_int(self, key, value): + if self.operation == 'rollback' and key == 'ScreenBrightness': + if not self.did_fail: + self.did_fail = True + return + if not self.did_pause: + self.pause() + self.values[key] = value + + def remove(self, key): + self.values.pop(key, None) + + +def write_in_other_process(values, directory, operation, paused, release, outcomes): + params = galaxy_params(SharedParams(values, directory, operation, paused, release)) + try: + if operation == 'snapshot': + set_brightness_mode(params, 'ScreenBrightness', 'auto') + else: + write_screen_setting(params, 'ScreenBrightness', 60) + except OSError: + outcomes.put('failed') + else: + outcomes.put('saved') + + +@pytest.mark.parametrize('operation', ['snapshot', 'rollback']) +def test_other_process_cannot_write_during_snapshot_or_rollback(tmp_path, operation): + context = multiprocessing.get_context('spawn') + with context.Manager() as manager: + values = manager.dict(ScreenBrightness=25, ScreenBrightnessManual=25) + directory = str(tmp_path / 'd') + paused, release, outcomes = context.Event(), context.Event(), context.Queue() + writer = context.Process(target=write_in_other_process, args=(values, directory, operation, paused, release, outcomes)) + writer.start() + try: + assert paused.wait(5), 'Other process did not reach the intended transaction step' + native = UIParamCache(SharedParams(values, directory)) + # The competing UI must report busy before changing either parameter. + with pytest.raises(BlockingIOError): + write_screen_setting(native, 'ScreenBrightness', 70) + finally: + release.set() + writer.join(5) + if writer.is_alive(): + writer.terminate() + writer.join(5) + assert writer.exitcode == 0 + assert outcomes.get(timeout=5) == ('saved' if operation == 'snapshot' else 'failed') + assert dict(values) == {'ScreenBrightness': 101 if operation == 'snapshot' else 25, 'ScreenBrightnessManual': 25} + # A later retry succeeds and its remembered value survives mode switches. + write_screen_setting(native, 'ScreenBrightness', 70) + set_brightness_mode(native, 'ScreenBrightness', 'auto') + set_brightness_mode(native, 'ScreenBrightness', 'manual') + assert dict(values) == {'ScreenBrightness': 70, 'ScreenBrightnessManual': 70} + outcomes.close() + outcomes.join_thread() + + +def test_lock_creation_failure_leaves_values_unchanged(tmp_path): + values = {'ScreenBrightness': 25, 'ScreenBrightnessManual': 25} + params = SharedParams(values, str(tmp_path / 'unavailable' / 'd')) + with pytest.raises(OSError): + write_screen_setting(params, 'ScreenBrightness', 101) + assert values == {'ScreenBrightness': 25, 'ScreenBrightnessManual': 25} diff --git a/starpilot/docs/screen_settings.md b/starpilot/docs/screen_settings.md new file mode 100644 index 0000000000..8d9197d9e3 --- /dev/null +++ b/starpilot/docs/screen_settings.md @@ -0,0 +1,70 @@ +# Screen brightness and Standby + +Screen Management provides independent driving and parked brightness preferences in New Galaxy and the native comma 3/3X and comma 4 interfaces. + +## Brightness + +Auto remains the default. It uses the existing automatic brightness calculation and applies an offset from -30% to +30% of that result. For example, a normal automatic value of 40% with a -30% offset produces 28%, not 10%. The final automatic value is clamped to 5–100% while awake. Standby and normal parked sleep can still deliberately blank the screen. + +The existing onroad calculation follows camera exposure and filters changes. Parked Auto retains the existing base level: 50% on comma 3/3X and 65% on comma 4, with existing screen overrides still applied. This feature does not add an offroad ambient-light sensor. + +Manual allows 0–100% and starts at 100% when there is no previous manual choice. Each context remembers its own manual value when switched to Auto. Existing manual selections remain selected. A selected wake event temporarily makes manual 0% visible at 5%. + +New Galaxy shows a mode selector and the slider for that mode. Standby uses the normal Galaxy toggle styling and an enabled-only Manage/Close submenu containing the onroad timeout and wake choices. Native settings provide the same preferences using their existing screen sizes and navigation patterns. + +## Timeouts and wake choices + +Both timeout readouts use seconds, with a 5–60 second range and 5 second steps. The parked timeout controls normal offroad sleep. The onroad timeout and wake choices are visible when Standby is enabled; the onroad timeout also remains the internal temporary-visibility duration for manual 0%. + +During Standby, only selected conditions reset the wake timer. Changing settings, opening a page, or requesting a page-specific timeout does not independently wake it. Once Standby ends, ordinary parked touch and drive-transition behavior applies again. + +| Wake choice | Default | Trigger | +| --- | --- | --- | +| Touch screen | On | Screen touch | +| Car drive state changed | On | Gear, ignition or onroad state changes | +| Bluetooth or steering wheel button | Off | Connected Bluetooth/USB controller button or supported vehicle button press | +| Engagement | On | Assistance becomes enabled | +| Disengagement | On | Assistance becomes disabled | +| Informational alerts | On | A displayed informational alert | +| Warning alerts | On | A displayed warning alert | +| Critical / takeover alerts | On | A displayed critical or takeover alert | +| Turn signals | Off | Signal activation or direction change | +| Brake pedal | Off | Brake press | +| Accelerator pedal | Off | Accelerator press | + +Selected alerts keep the screen awake while displayed. Category selection follows the comma 3/4 renderers, including generated startup and unresponsive-system alerts and primary-alert precedence. Hidden or stale raw alerts do not bypass selection. Pedals, signals and buttons use transitions, so holding one does not continually refresh the timer. Disabling an alert wake controls the screen only; it does not change the underlying alert or sound. + +Vehicle button coverage depends on the car interface. Generic button events are drained through a nonconflating carState subscription because a single-frame event can disappear between UI frames. Tesla Model 3/Y additionally use a passive subscriber to the existing UI_warning/scrollWheelPressed signal. The Tesla signal detects left/right/down presses, not wheel rotation or a second overlapping press while its aggregate pressed bit remains set. Neither observer transmits CAN nor changes driving button events. Controller actions retain their separate enable toggle; an unmapped button can wake the screen without executing an action. + +## Persistence and compatibility + +The existing brightness keys retain 101 as Auto and 0–100 as Manual. Four additional persistent integers store manual memory and relative offsets. Eleven persistent booleans store wake selections. StandbyButtonPressTime carries fresh button timestamps in RAM, clears on manager start, and is excluded from logging. + +Native UI and Galaxy writes use one shared validator and an advisory nonblocking file lock outside the Params key directory. Snapshot, write, readback and rollback run within that transaction; UI caches invalidate inside and after it. A busy or failed save is reported and can be retried. Other direct Params writers must use the shared helper to participate in this transaction contract. + +The Params registry source must be included in the normal device build before installation. Source changes alone do not update an existing compiled native registry. + +## Focused verification + +From a configured Linux checkout with the project Python dependencies, including the compiled opendbc parser/packer: + +```sh +PYTHONPATH=. python -m pytest -q -c /dev/null --confcutdir=starpilot/common/tests \ + starpilot/common/tests/test_screen_*.py \ + selfdrive/ui/tests/test_native_screen_controls.py \ + selfdrive/ui/tests/test_native_screen_save_errors.py \ + starpilot/system/wheel_controls/tests + +PYTHONPATH=. python -m pytest -q -c /dev/null --confcutdir=starpilot/system/the_galaxy/tests \ + starpilot/system/the_galaxy/tests/test_device_settings_frontend.py \ + starpilot/system/the_galaxy/tests/test_device_settings_layout.py \ + starpilot/system/the_galaxy/tests/test_ui_vue_frontend.py \ + starpilot/system/the_galaxy/tests/test_frontend_module_graph.py + +node starpilot/system/the_galaxy/tests/test_screen_settings_dom.cjs +node starpilot/system/the_galaxy/tests/test_tesla_can_wake_frontend.cjs +``` + +The DOM test requires Playwright and Chromium. PLAYWRIGHT_MODULE and CHROMIUM_EXECUTABLE can point to an existing installation; GALAXY_DOM_SCREENSHOT optionally saves previews. It loads the real Vue components with synthetic API responses and no device writes. The Python commands bypass unrelated manager-wide fixtures and explicitly include starpilot tests, which are outside the repository's default testpaths. A fully built environment can additionally run selfdrive/ui/tests/test_device_screen_settings.py through the normal pytest configuration. + +Automated tests cover every wake choice enabled and disabled, freshness, held inputs, generated-alert parity, minimum brightness, write failures, cross-process saves, native controls, browser interactions and existing controller actions. Physical screen readability and actual car input coverage still require checks on the relevant hardware. diff --git a/starpilot/system/the_galaxy/assets/mobile/css/material.css b/starpilot/system/the_galaxy/assets/mobile/css/material.css index a7c25e3948..5d5321c9db 100644 --- a/starpilot/system/the_galaxy/assets/mobile/css/material.css +++ b/starpilot/system/the_galaxy/assets/mobile/css/material.css @@ -2275,6 +2275,15 @@ button.gx-chip:hover { .gx-monitor tbody tr:hover { background:var(--surface); } @media(max-width:600px) { .gx-monitor__summary { grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; } .gx-monitor__metric { padding:12px; } .gx-monitor__metric strong { font-size:22px; } } +/* Screen settings: separate brightness modes. */ +.gx-brightness__readout { + align-items: baseline; + display: flex; + flex-wrap: wrap; + gap: var(--sp-2); + justify-content: space-between; +} +.gx-brightness__readout label { color: var(--text-muted); font-size: var(--fs-sm); } /* GalaxySelect: the themed dropdown used for every select in the app. */ .gx-select { display: inline-flex; position: relative; vertical-align: middle; min-width: 0; max-width: 100%; } .gx-select.gx-field { padding: 0; background: var(--surface-container-high); } diff --git a/starpilot/system/the_galaxy/assets/mobile/js/api.js b/starpilot/system/the_galaxy/assets/mobile/js/api.js index 698e32b195..e71869b779 100644 --- a/starpilot/system/the_galaxy/assets/mobile/js/api.js +++ b/starpilot/system/the_galaxy/assets/mobile/js/api.js @@ -54,7 +54,7 @@ export const api = { async getLayout() { const data = await request(LAYOUT_URL, { cache: "no-store" }) return (data || []) - .map((section) => ({ ...section, params: (section.params || []).filter((p) => p.key !== "Model") })) + .map((section) => ({ ...section, params: (section.params || []).filter((p) => p.key !== "Model").map((p) => ({ ...p, ui_type: p.galaxy_ui_type || p.ui_type })) })) .filter((section) => (section.params || []).length > 0) }, diff --git a/starpilot/system/the_galaxy/assets/mobile/js/components/GalaxyToggleCard.js b/starpilot/system/the_galaxy/assets/mobile/js/components/GalaxyToggleCard.js index 259fc89c95..493c42f235 100644 --- a/starpilot/system/the_galaxy/assets/mobile/js/components/GalaxyToggleCard.js +++ b/starpilot/system/the_galaxy/assets/mobile/js/components/GalaxyToggleCard.js @@ -4,6 +4,7 @@ import { normalizeHexColor, numericBounds, numericEpsilon, snapNumericToBoundsAndStep, resolveVehicleUnitParam, stepPrecision, } from "../params.js" +import { ScreenBrightnessControl } from "./ScreenBrightnessControl.js" import { FavoritesEditor } from "./FavoritesEditor.js" import { t } from "../i18n.js" @@ -15,7 +16,7 @@ const FINE_SCRUB_JITTER_PX = 4 export const GalaxyToggleCard = { name: "GalaxyToggleCard", - components: { FavoritesEditor }, + components: { FavoritesEditor, ScreenBrightnessControl }, props: { param: { type: Object, required: true }, value: { default: undefined }, @@ -269,7 +270,9 @@ export const GalaxyToggleCard = { this.clearHoldTimer() }, template: ` -
+ +
{{ tr(displayParam.label, displayParam.label) }} diff --git a/starpilot/system/the_galaxy/assets/mobile/js/components/ScreenBrightnessControl.js b/starpilot/system/the_galaxy/assets/mobile/js/components/ScreenBrightnessControl.js new file mode 100644 index 0000000000..ecb889b169 --- /dev/null +++ b/starpilot/system/the_galaxy/assets/mobile/js/components/ScreenBrightnessControl.js @@ -0,0 +1,140 @@ +import { api, showSnackbar } from "../api.js" +import { t } from "../i18n.js" + +function boundedInt(value, min, max, fallback) { + if (value === undefined || value === null || value === "") return fallback + const number = Number(value) + return Number.isFinite(number) ? Math.min(max, Math.max(min, Math.round(number))) : fallback +} + +export const ScreenBrightnessControl = { + name: "ScreenBrightnessControl", + props: { + param: { type: Object, required: true }, + value: { default: undefined }, + values: { type: Object, default: () => ({}) }, + locked: { type: Boolean, default: false }, + lockMessage: { type: String, default: "This setting can only be changed while parked." }, + }, + emits: ["change"], + data() { + const brightness = boundedInt(this.value, 0, 101, 101) + return { + brightness, + manual: brightness <= 100 ? brightness : boundedInt(this.values[`${this.param.key}Manual`], 0, 100, 100), + offset: boundedInt(this.values[`${this.param.key}Offset`], -30, 30, 0), + preview: null, + interacting: false, + updating: false, + } + }, + computed: { + mode() { return this.brightness === 101 ? "auto" : "manual" }, + manualKey() { return `${this.param.key}Manual` }, + offsetKey() { return `${this.param.key}Offset` }, + savedManual() { return this.values[this.manualKey] }, + savedOffset() { return this.values[this.offsetKey] }, + controlId() { return `gx-${this.param.key}` }, + sliderValue() { return this.preview ?? (this.mode === "auto" ? this.offset : this.brightness) }, + sliderReadout() { + return `${this.mode === "auto" && this.sliderValue > 0 ? "+" : ""}${this.sliderValue}%` + }, + }, + watch: { + value(value) { + if (this.updating || this.interacting) return + this.brightness = boundedInt(value, 0, 101, 101) + if (this.brightness <= 100) this.manual = this.brightness + }, + savedManual(value) { + if (!this.updating && !this.interacting) this.manual = boundedInt(value, 0, 100, 100) + }, + savedOffset(value) { + if (!this.updating && (!this.interacting || this.mode !== "auto")) this.offset = boundedInt(value, -30, 30, 0) + }, + }, + methods: { + tr(key) { return t(key, key) }, + async commit(key, value) { + if (this.locked || this.updating) return + const previous = { brightness: this.brightness, manual: this.manual, offset: this.offset } + const patch = { [key]: value } + if (key === this.param.key) { + this.brightness = value + if (value <= 100) this.manual = value + patch[this.manualKey] = this.manual + } else { + this.offset = value + } + this.updating = true + this.$emit("change", patch) + try { + const data = await api.updateParam({ key, value }) + const updated = { ...patch, ...(data?.updated || {}) } + if (this.param.key in updated) this.brightness = boundedInt(updated[this.param.key], 0, 101, 101) + if (this.manualKey in updated) this.manual = boundedInt(updated[this.manualKey], 0, 100, 100) + if (this.offsetKey in updated) this.offset = boundedInt(updated[this.offsetKey], -30, 30, 0) + this.$emit("change", updated) + showSnackbar(this.tr("Screen settings updated.")) + } catch (error) { + Object.assign(this, previous) + this.$emit("change", key === this.param.key + ? { [this.param.key]: previous.brightness, [this.manualKey]: previous.manual } + : { [this.offsetKey]: previous.offset }) + showSnackbar(error?.message || this.tr("Unable to save screen settings."), "error") + } finally { + // Let the parent's optimistic patch/readback settle before accepting prop updates. + await this.$nextTick() + this.updating = false + } + }, + async onModeChange(event) { + const next = event.target.value === "auto" ? 101 : this.manual + if (next !== this.brightness) await this.commit(this.param.key, next) + event.target.value = this.mode + }, + beginInteract() { if (!this.locked && !this.updating) this.interacting = true }, + onSliderInput(event) { + if (this.locked || this.updating) return + this.interacting = true + this.preview = boundedInt(event.target.value, this.mode === "auto" ? -30 : 0, this.mode === "auto" ? 30 : 100, 0) + }, + async onSliderCommit(event) { + const auto = this.mode === "auto" + const next = boundedInt(event.target.value, auto ? -30 : 0, auto ? 30 : 100, auto ? this.offset : this.brightness) + this.preview = null + this.interacting = false + if (next !== (auto ? this.offset : this.brightness)) await this.commit(auto ? this.offsetKey : this.param.key, next) + event.target.value = this.sliderValue + }, + onSliderBlur(event) { if (this.interacting) this.onSliderCommit(event) }, + reset() { this.commit(this.mode === "auto" ? this.offsetKey : this.param.key, this.mode === "auto" ? 0 : 100) }, + }, + template: ` +
+
+ + {{ tr(param.description) }} + {{ tr(lockMessage) }} +
+ +
+
+ + {{ sliderReadout }} +
+ +
{{ mode === 'auto' ? '-30%' : '0%' }}{{ mode === 'auto' ? '+30%' : '100%' }}
+ {{ tr("Adjust automatic brightness by up to 30% of its normal level. 0% keeps it unchanged. Auto stays at least 5% while the screen is awake.") }} + {{ tr("At 0%, the screen turns off.") }} + +
+
+ `, +} diff --git a/starpilot/system/the_galaxy/assets/mobile/js/params.js b/starpilot/system/the_galaxy/assets/mobile/js/params.js index 94e3d68bde..6547245f17 100644 --- a/starpilot/system/the_galaxy/assets/mobile/js/params.js +++ b/starpilot/system/the_galaxy/assets/mobile/js/params.js @@ -99,6 +99,7 @@ export function matchesSettingValueCondition(param, values) { // Any structural reason a param must not be shown (regardless of dev mode). function isHiddenByConditions(section, param, values) { if (HIDDEN_SETTING_KEYS.has(param.key) || !isVehicleSettingVisible(section, param, values) || !matchesSettingValueCondition(param, values)) return true + if (param.visible_when_all_true?.some((key) => !values[key])) return true if (param.requires_capability && !values[param.requires_capability]) return true if (RADAR_REQUIRED_KEYS.has(param.key) && !values.HasRadar) return true if (param.key === "AlphaLongitudinalEnabled" && !values.AlphaLongitudinalAvailable) return true diff --git a/starpilot/system/the_galaxy/tests/test_screen_settings_dom.cjs b/starpilot/system/the_galaxy/tests/test_screen_settings_dom.cjs new file mode 100644 index 0000000000..f5d260abe1 --- /dev/null +++ b/starpilot/system/the_galaxy/tests/test_screen_settings_dom.cjs @@ -0,0 +1,201 @@ +// Local-only Vue interaction tests. All API requests use synthetic state. +// GALAXY_REPO / PLAYWRIGHT_MODULE may point at an isolated checkout/runtime. +const assert = require('node:assert/strict') +const fs = require('node:fs') +const path = require('node:path') +const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright') +const repo = process.env.GALAXY_REPO || path.resolve(__dirname, '../../../..') +const assets = path.join(repo, 'starpilot/system/the_galaxy/assets') +const layout = JSON.parse(fs.readFileSync(path.join(repo, 'starpilot/common/assets/device_settings_layout.json'))) +const section = layout.find(s => s.params.some(p => p.key === 'ScreenManagement')) +const wakeDefaults = { + StandbyWakeEngage:true, StandbyWakeDisengage:true, StandbyWakeInfoAlert:true, StandbyWakeWarningAlert:true, + StandbyWakeCriticalAlert:true, StandbyWakeTurnSignal:false, StandbyWakeBrake:false, StandbyWakeAccelerator:false, + StandbyWakeTouch:true, StandbyWakeDriveState:true, StandbyWakeButton:false, +} +const wakes = Object.keys(wakeDefaults) +const fixture = ` +import {createApp, reactive} from 'vue'; +import {SettingTree} from '/assets/mobile/js/components/SettingTree.js'; +import {applyParamChange, isSettingVisible} from '/assets/mobile/js/params.js'; +import {api} from '/assets/mobile/js/api.js'; +const layout = ${JSON.stringify(layout)}; +const values = reactive({ScreenManagement:true, ScreenBrightness:101, ScreenBrightnessManual:67, ScreenBrightnessOffset:0, + ScreenBrightnessOnroad:101, ScreenBrightnessOnroadOffset:0, ScreenTimeout:30, ScreenTimeoutOnroad:15, StandbyMode:false, + ...${JSON.stringify(wakeDefaults)}}); +window.values=values; window.writes=[]; window.holdWrite=false; window.failWrite=false; +window.fetch=async (input,init={}) => { + const url=new URL(input,location.href); + if(url.pathname==='/assets/components/tools/device_settings_layout.json') return new Response(JSON.stringify(layout),{status:200}); + if(url.pathname!=='/api/params' || init.method!=='PUT') throw new Error('Unexpected API: '+url.pathname); + const {key,value}=JSON.parse(init.body); window.writes.push({key,value}); + if(window.holdWrite) await new Promise(resolve=>window.releaseWrite=resolve); + if(window.failWrite) return new Response(JSON.stringify({error:'Injected save failure'}),{status:500}); + const updated={[key]:value}; + if(/^ScreenBrightness(Onroad)?$/.test(key) && value<=100) updated[key+'Manual']=value; + return new Response(JSON.stringify({updated}),{status:200}); +}; +const section=(await api.getLayout()).find(s=>s.params.some(p=>p.key==='ScreenManagement')); +createApp({components:{SettingTree},setup:()=>({values}), + data:()=>({expanded:{ScreenManagement:true}}), + computed:{params(){return section.params.filter(p=>isSettingVisible(section,p,this.values))}}, + methods:{change(patch){Object.assign(values,applyParamChange(values,patch))}}, + template:'
' +}).mount('#app'); +` +;(async () => { + assert.equal(section.params.find(p => p.key === 'ScreenBrightness').ui_type, 'numeric', 'shared layout preserves old Galaxy brightness rendering') + assert.equal(section.params.find(p => p.key === 'ScreenBrightness').galaxy_ui_type, 'brightness', 'New Galaxy needs its separate Auto/Manual control') + const browser = await chromium.launch({headless:true, executablePath:process.env.CHROMIUM_EXECUTABLE}) + try { + const page=await browser.newPage({viewport:{width:1100,height:1000}}) + const errors=[] + page.on('pageerror',e=>errors.push(e.message)) + await page.route('**/*',async route=>{ + const url=new URL(route.request().url()) + assert.equal(url.hostname,'offline.invalid','all network must stay synthetic') + if(url.pathname==='/') return route.fulfill({contentType:'text/html',body:'
'}) + if(url.pathname==='/setup.js') return route.fulfill({contentType:'text/javascript',body:fixture}) + const file=path.join(assets,url.pathname.slice('/assets/'.length)) + if(url.pathname.startsWith('/assets/') && fs.existsSync(file) && fs.statSync(file).isFile()) return route.fulfill({path:file}) + return route.fulfill({status:404,body:'not found'}) + }) + await page.goto('http://offline.invalid/') + const offMode=page.locator('#gx-ScreenBrightness-mode') + const onMode=page.locator('#gx-ScreenBrightnessOnroad-mode') + const offSlider=page.locator('#gx-ScreenBrightness-slider') + const onSlider=page.locator('#gx-ScreenBrightnessOnroad-slider') + await offMode.waitFor() + assert.equal(await offMode.inputValue(),'auto') + assert.equal(await onMode.inputValue(),'auto') + assert.deepEqual(await offMode.locator('option').allTextContents(),['Auto','Manual']) + assert.equal(await offSlider.getAttribute('min'),'-30') + assert.equal(await offSlider.getAttribute('max'),'30') + assert.equal(await offSlider.inputValue(),'0') + assert.equal(await offSlider.getAttribute('aria-valuetext'),'0%') + assert.equal(await page.getByText('-30%',{exact:true}).count(),2) + assert.equal(await page.getByText('+30%',{exact:true}).count(),2) + assert.equal(await page.getByText(/5% while the screen is awake/).count(),2) + assert.equal(await page.getByText('Screen Timeout (Offroad)',{exact:true}).count(),1) + assert.equal(await page.getByText('Screen Timeout (Onroad)',{exact:true}).count(),0) + assert.equal(await page.getByText(/Works independently of Standby Mode/).count(),0) + assert.ok(section.params.filter(p => /^ScreenTimeout/.test(p.key)).every(p => p.unit === ' seconds'),'timeouts display seconds') + assert.equal(await page.getByText('30 seconds',{exact:true}).count(),1,'offroad timeout readout includes its unit') + assert.equal(await page.evaluate(()=>window.writes.length),0,'initial render cannot write settings') + assert.equal(await page.getByText('101',{exact:true}).count(),0,'internal Auto sentinel cannot appear') + + await offMode.selectOption('manual') + await page.waitForFunction(()=>window.values.ScreenBrightness===67) + assert.equal(await offSlider.getAttribute('min'),'0') + assert.equal(await offSlider.getAttribute('max'),'100') + assert.equal(await offSlider.inputValue(),'67','mode change restores saved manual brightness') + await onMode.selectOption('manual') + await page.waitForFunction(()=>window.values.ScreenBrightnessOnroad===100) + assert.equal(await onSlider.inputValue(),'100','missing manual memory defaults to 100') + await onSlider.fill('45'); await onSlider.dispatchEvent('change') + await page.waitForFunction(()=>window.values.ScreenBrightnessOnroadManual===45) + await page.locator('.gx-brightness').filter({has:onSlider}).getByRole('button',{name:'Default',exact:true}).click() + await page.waitForFunction(()=>window.values.ScreenBrightnessOnroadManual===100) + assert.equal(await onSlider.inputValue(),'100','manual Default restores 100%') + await offSlider.fill('0'); await offSlider.dispatchEvent('change') + await page.waitForFunction(()=>window.values.ScreenBrightnessManual===0) + await offMode.selectOption('auto') + await page.waitForFunction(()=>window.values.ScreenBrightness===101) + await offSlider.fill('-30'); await offSlider.dispatchEvent('change') + await page.waitForFunction(()=>window.values.ScreenBrightnessOffset===-30) + assert.equal(await offSlider.getAttribute('aria-valuetext'),'-30%') + await offSlider.fill('30'); await offSlider.dispatchEvent('change') + await page.waitForFunction(()=>window.values.ScreenBrightnessOffset===30) + assert.equal(await offSlider.getAttribute('aria-valuetext'),'+30%') + await offMode.selectOption('manual') + await page.waitForFunction(()=>window.values.ScreenBrightness===0) + assert.equal(await offSlider.inputValue(),'0','manual zero survives Auto roundtrip') + assert.equal(await onSlider.inputValue(),'100','onroad and offroad remain independent') + + // A poll/re-render during a drag must preserve the preview and mode. + await offSlider.evaluate(input=>{input.value='83';input.dispatchEvent(new Event('input',{bubbles:true}))}) + await page.evaluate(()=>{window.values.ScreenBrightness=20;window.values.ScreenBrightnessOffset=-10}) + assert.equal(await offSlider.inputValue(),'83') + await offSlider.dispatchEvent('change') + await page.waitForFunction(()=>window.values.ScreenBrightnessManual===83) + await page.evaluate(()=>{window.holdWrite=true}) + await offMode.selectOption('auto') + await page.waitForFunction(()=>!!window.releaseWrite) + assert.equal(await offMode.isDisabled(),true) + await page.evaluate(()=>{window.values.ScreenBrightness=83}) + assert.equal(await offMode.inputValue(),'auto','stale props cannot reverse pending mode change') + await page.evaluate(()=>{window.holdWrite=false;window.releaseWrite()}) + await page.waitForFunction(()=>!document.querySelector('#gx-ScreenBrightness-mode').disabled) + assert.equal(await offMode.inputValue(),'auto') + + await page.evaluate(()=>{window.failWrite=true}) + await offMode.selectOption('manual') + await page.waitForFunction(()=>!document.querySelector('#gx-ScreenBrightness-mode').disabled) + assert.equal(await offMode.inputValue(),'auto','failed mode saves roll back') + await offSlider.fill('24'); await offSlider.dispatchEvent('change') + await page.waitForFunction(()=>!document.querySelector('#gx-ScreenBrightness-mode').disabled) + assert.equal(await offSlider.inputValue(),'-10','failed offset saves roll back') + await page.evaluate(()=>{window.failWrite=false}) + + const standbyNode=page.locator('.gx-tree-node').filter({has:page.getByText('Standby Mode',{exact:true})}) + const standby=standbyNode.locator('.gx-switch input') + const wakeRow=key=>page.locator('.gx-row').filter({has:page.getByText(section.params.find(p=>p.key===key).label,{exact:true})}) + assert.equal(await wakeRow(wakes[0]).count(),0) + assert.equal(await standbyNode.getByRole('button',{name:'Manage',exact:true}).count(),0) + await standby.check() + await standbyNode.getByRole('button',{name:'Manage',exact:true}).waitFor() + assert.equal(await wakeRow(wakes[0]).count(),0,'Standby children start collapsed') + assert.equal(await page.getByText('Screen Timeout (Onroad)',{exact:true}).count(),0,'onroad timeout waits for Manage') + await standbyNode.getByRole('button',{name:'Manage',exact:true}).click() + await wakeRow(wakes[0]).waitFor() + assert.equal(await page.getByText('Screen Timeout (Onroad)',{exact:true}).count(),1) + assert.equal(await page.getByText('15 seconds',{exact:true}).count(),1,'onroad timeout readout includes its unit') + assert.equal(await page.getByText(/Also sets the duration of a temporary wake at 0% brightness/).count(),0) + assert.equal(section.params.find(p=>p.key==='ScreenTimeoutOnroad').parent_key,'StandbyMode') + assert.equal(await page.locator('.gx-wake-choice, [data-wake-choice]').count(),0,'wake events use ordinary Galaxy switches') + for(const key of wakes) { + const param=section.params.find(p=>p.key===key) + assert.ok(param && param.ui_type==='toggle','all eleven wake events use standard toggles') + assert.equal(param.default,wakeDefaults[key],key+' retains its required default') + assert.match(param.description,/wake.*standby|standby.*wake/i,key+' explains waking Standby') + assert.equal(await wakeRow(key).getByText(param.description,{exact:true}).count(),1,key+' description is visible') + const checkbox=wakeRow(key).locator('.gx-switch input') + assert.equal(await checkbox.isChecked(),wakeDefaults[key]) + await checkbox.setChecked(!wakeDefaults[key]) + await page.waitForFunction(({key,expected})=>window.values[key]===expected,{key,expected:!wakeDefaults[key]}) + } + assert.equal(await page.getByText(/Touch always wakes|ignition changes also wake/).count(),0) + assert.match(section.params.find(p=>p.key==='StandbyMode').description,/only.*selected/i) + assert.match(section.params.find(p=>p.key==='StandbyWakeDriveState').description,/Park.*Reverse.*Neutral.*Drive/) + assert.match(section.params.find(p=>p.key==='StandbyWakeDriveState').description,/ignition.*driving/) + assert.match(section.params.find(p=>p.key==='StandbyWakeButton').description,/Bluetooth.*steering wheel/) + await page.waitForFunction(()=>!Array.from(document.querySelectorAll('.gx-switch input')).some(input=>input.disabled)) + await page.evaluate(()=>{window.values.ScreenBrightnessOffset=80}) + assert.equal(await offSlider.inputValue(),'30','older saved offsets stay within the new display range') + await page.evaluate(defaults=>{ + Object.assign(window.values,defaults) + window.values.ScreenBrightnessOffset=0 + document.querySelector('#snackbar_wrapper').replaceChildren() + },wakeDefaults) + await page.waitForTimeout(350) // Let the standard switch transitions settle for preview images. + if(process.env.GALAXY_DOM_SCREENSHOT) await page.screenshot({path:process.env.GALAXY_DOM_SCREENSHOT+'-desktop.png',fullPage:true}) + await page.setViewportSize({width:390,height:844}) + assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth<=window.innerWidth),true,'mobile must not overflow') + if(process.env.GALAXY_DOM_SCREENSHOT) await page.screenshot({path:process.env.GALAXY_DOM_SCREENSHOT+'-mobile.png',fullPage:true}) + await standbyNode.getByRole('button',{name:'Close',exact:true}).click() + await wakeRow(wakes[0]).waitFor({state:'detached'}) + assert.equal(await page.getByText('Screen Timeout (Onroad)',{exact:true}).count(),0,'Close hides timeout with wake choices') + assert.equal(await page.getByText('Screen Timeout (Offroad)',{exact:true}).count(),1,'offroad timeout stays outside submenu') + if(process.env.GALAXY_DOM_SCREENSHOT) await page.screenshot({path:process.env.GALAXY_DOM_SCREENSHOT+'-mobile-collapsed.png',fullPage:true}) + await standbyNode.getByRole('button',{name:'Manage',exact:true}).click() + await wakeRow(wakes[0]).waitFor() + await standby.uncheck() + await wakeRow(wakes[0]).waitFor({state:'detached'}) + assert.equal(await page.getByText('Screen Timeout (Onroad)',{exact:true}).count(),0) + assert.equal(await standbyNode.getByRole('button',{name:'Manage',exact:true}).count(),0) + await page.evaluate(()=>{window.values.StandbyMode=true;window.values.ScreenManagement=false}) + assert.equal(await wakeRow(wakes[0]).count(),0,'disabled Screen Settings hides wake choices') + assert.deepEqual(errors,[]) + console.log('PASS: real Vue Auto/Manual controls, manual100 default/reset, +/-30% offsets, memory/zero, independent contexts, drag/pending stability, save rollback, eleven described standard wake toggles, Manage/Close submenu, seconds readouts, mobile layout, zero page errors') + } finally {await browser.close()} +})().catch(error=>{console.error(error);process.exitCode=1}) diff --git a/starpilot/system/the_galaxy/tests/test_tesla_can_wake_frontend.cjs b/starpilot/system/the_galaxy/tests/test_tesla_can_wake_frontend.cjs index 06aa7983bc..fe900df1bb 100644 --- a/starpilot/system/the_galaxy/tests/test_tesla_can_wake_frontend.cjs +++ b/starpilot/system/the_galaxy/tests/test_tesla_can_wake_frontend.cjs @@ -8,7 +8,7 @@ const layout = JSON.parse(fs.readFileSync(path.join(root, 'starpilot/common/asse const section = layout.find(s => s.name === 'Vehicle'); const param = section.params.find(p => p.key === 'TeslaWakeOnCAN'); assert.ok(param, 'Wake on CAN must appear in Vehicle settings'); -const ctx = { console, FavoritesEditor: {}, window: { confirm: () => false }, api: {}, showSnackbar: () => {} }; +const ctx = { console, FavoritesEditor: {}, ScreenBrightnessControl: {}, window: { confirm: () => false }, api: {}, showSnackbar: () => {} }; vm.createContext(ctx); function load(file, expose) { const src = fs.readFileSync(path.join(base, file), 'utf8').replace(/^import[\s\S]*?from [^\n]+\n/gm, '').replace(/export /g, ''); diff --git a/starpilot/system/the_galaxy/the_galaxy.py b/starpilot/system/the_galaxy/the_galaxy.py index aff105e91d..2ba675f782 100644 --- a/starpilot/system/the_galaxy/the_galaxy.py +++ b/starpilot/system/the_galaxy/the_galaxy.py @@ -3472,6 +3472,12 @@ def _get_param_type_info(): elif k in types and dt == "bool": types[k] = bool + # Zero-valued offsets must stay numeric; legacy inference treats "0" as bool. + from openpilot.starpilot.common.screen_settings import SCREEN_INT_KEYS + for k in SCREEN_INT_KEYS: + if k in _cached_allowed_keys: + types[k] = int + for k in GALAXY_MANUAL_BOOL_PARAM_KEYS: if k in _cached_allowed_keys: types[k] = bool @@ -5351,6 +5357,7 @@ def setup(app): "/assets/components/home/home.js", "/assets/components/home/home.css", "/assets/mobile/js/params.js", + "/assets/mobile/js/components/ScreenBrightnessControl.js", "/assets/components/tools/device_settings.js", "/assets/components/tools/device_settings.css", "/assets/components/tools/device_settings_layout.json", @@ -6163,6 +6170,18 @@ def setup(app): return jsonify({"error": "Missing 'key' or 'value' in request body."}), 400 key = str(data["key"]).strip() + if key.startswith(("ScreenBrightness", "StandbyWake")): + from openpilot.common.params import UnknownKeyName + from openpilot.starpilot.common.screen_settings import write_screen_setting + try: + updated = write_screen_setting(params, key, data["value"]) + except ValueError as error: + return jsonify({"error": str(error)}), 400 + except (OSError, KeyError, UnknownKeyName): + return jsonify({"error": "Screen setting could not be saved."}), 503 + update_starpilot_toggles() + return jsonify({"updated": updated, "message": "Screen setting saved."}), 200 + if key.lower() == PERSONALITY_PROFILES_PARAM.lower(): return jsonify({"error": "Longitudinal personality profiles must be changed with the Driving Personalities editor."}), 403 if key in PERSONALITY_PARKED_PARAM_KEYS and _personality_editor_write_locked(): diff --git a/starpilot/system/wheel_controls/tesla_standby_buttons.py b/starpilot/system/wheel_controls/tesla_standby_buttons.py new file mode 100644 index 0000000000..a1e7ae4743 --- /dev/null +++ b/starpilot/system/wheel_controls/tesla_standby_buttons.py @@ -0,0 +1,53 @@ +"""Observe Tesla wheel presses for the display without changing vehicle button events.""" +from opendbc.can import CANParser +from opendbc.car import Bus +from opendbc.car.tesla.values import CANBUS, CAR, DBC + + +MAX_SAMPLE_AGE_NS = 2_000_000_000 +UI_WARNING_ADDRESS = 0x311 +UI_WARNING_SIZE = 7 + + +def tesla_button_dbc(cp) -> str | None: + if cp.brand != "tesla" or cp.carFingerprint not in (CAR.TESLA_MODEL_3, CAR.TESLA_MODEL_Y): + return None + dbc = DBC[cp.carFingerprint][Bus.party] + return dbc if dbc == "tesla_model3_party" else None + + +class TeslaStandbyButtonObserver: + def __init__(self, dbc: str): + self._parser = CANParser(dbc, [("UI_warning", 0)], CANBUS.party) + self._pressed: bool | None = None + self._last_sample_ns: int | None = None + + def update(self, messages, now_ns: int) -> int: + """Return the latest fresh press's CAN boot-clock timestamp, or zero.""" + if self._last_sample_ns is not None and not 0 <= now_ns - self._last_sample_ns < MAX_SAMPLE_AGE_NS: + self._pressed = None + + pressed_at = 0 + for message in messages: + timestamp = int(message.logMonoTime) + if not message.valid or not 0 <= now_ns - timestamp < MAX_SAMPLE_AGE_NS: + continue + if self._last_sample_ns is not None and timestamp <= self._last_sample_ns: + continue + frames = [(frame.address, frame.dat, frame.src) for frame in message.can + if frame.address == UI_WARNING_ADDRESS and frame.src == CANBUS.party and len(frame.dat) == UI_WARNING_SIZE] + if not frames: + continue + self._parser.update([(timestamp, frames)]) + values = self._parser.vl_all["UI_warning"]["scrollWheelPressed"] + if not values: + continue + if self._last_sample_ns is not None and timestamp - self._last_sample_ns >= MAX_SAMPLE_AGE_NS: + self._pressed = None + for value in values: + pressed = bool(value) + if self._pressed is False and pressed: + pressed_at = timestamp + self._pressed = pressed + self._last_sample_ns = timestamp + return pressed_at diff --git a/starpilot/system/wheel_controls/tests/test_car_state_button_wake.py b/starpilot/system/wheel_controls/tests/test_car_state_button_wake.py new file mode 100644 index 0000000000..b469a0af48 --- /dev/null +++ b/starpilot/system/wheel_controls/tests/test_car_state_button_wake.py @@ -0,0 +1,123 @@ +"""Preserve short carState button edges between UI refreshes, without vehicle actions.""" +import ast +import sys +from pathlib import Path +from typing import Optional +from types import SimpleNamespace + +import capnp +import pytest +from cereal import log + +from openpilot.starpilot.system.wheel_controls import wheel_controlsd +from test_wheel_controlsd import FakeParams + + +def packet(timestamp, pressed=None, *, valid=True, button_type='accelCruise'): + message = log.Event.new_message(logMonoTime=timestamp, valid=valid) + state = message.init('carState') + if pressed is not None: + buttons = state.init('buttonEvents', 1) + buttons[0].type, buttons[0].pressed = button_type, pressed + return message + + +@pytest.fixture +def car_buttons(monkeypatch): + params = FakeParams({'ScreenManagement': True, 'StandbyMode': True, 'StandbyWakeButton': True, 'IsOnroad': True}) + memory = FakeParams() + daemon = wheel_controlsd.WheelControlsDaemon(params, memory) + queued, subscriptions = [], [] + now_boot, now_mono = [10_000_000_000], [1_000_000_000] + + def subscribe(endpoint, *, conflate=False): + assert endpoint == 'carState' + assert conflate is False, 'Every published button edge must be read' + sock = object() + subscriptions.append(sock) + return sock + + def drain(sock, wait_for_one=False): + assert wait_for_one is False + assert sock is subscriptions[-1] + result, queued[:] = list(queued), [] + return result + + import cereal + messaging = SimpleNamespace(sub_sock=subscribe, drain_sock=drain) + monkeypatch.setitem(sys.modules, 'cereal.messaging', messaging) + monkeypatch.setattr(cereal, 'messaging', messaging, raising=False) + monkeypatch.setattr(wheel_controlsd.time, 'clock_gettime_ns', lambda _clock: now_boot[0]) + monkeypatch.setattr(wheel_controlsd.time, 'monotonic_ns', lambda: now_mono[0]) + yield daemon, params, memory, queued, subscriptions, now_boot, now_mono + daemon.close() + + +def test_short_button_edge_survives_later_empty_frame_and_is_consumed_once(car_buttons): + daemon, _params, memory, queued, subscriptions, _boot, now = car_buttons + daemon._configure_car_buttons() + now[0] += 100_000_000 + queued[:] = [packet(1_020_000_000, True), packet(1_030_000_000)] + daemon._poll_car_buttons() + assert memory.get_int('StandbyButtonPressTime') == 1_020_000_000 + daemon._poll_car_buttons() + assert memory.get_int('StandbyButtonPressTime') == 1_020_000_000 + assert len(subscriptions) == 1 + + +@pytest.mark.parametrize('disabled', ['ScreenManagement', 'StandbyMode', 'StandbyWakeButton', 'IsOnroad']) +def test_subscription_only_runs_when_needed_and_reopens_cleanly(car_buttons, disabled): + daemon, params, memory, queued, subscriptions, _boot, now = car_buttons + params.put_bool(disabled, False) + daemon._configure_car_buttons() + assert subscriptions == [] + params.put_bool(disabled, True) + daemon._configure_car_buttons() + assert len(subscriptions) == 1 + params.put_bool(disabled, False) + daemon._configure_car_buttons() + daemon._poll_car_buttons() + assert memory.get('StandbyButtonPressTime') is None + now[0] += 10_000_000 + params.put_bool(disabled, True) + daemon._configure_car_buttons() + queued[:] = [packet(1_005_000_000, True)] # Retained message predates re-enable. + daemon._poll_car_buttons() + assert memory.get('StandbyButtonPressTime') is None + assert len(subscriptions) == 2 + + +def test_releases_invalid_unknown_future_and_stale_packets_cannot_wake(car_buttons): + daemon, _params, memory, queued, _subscriptions, _boot, now = car_buttons + daemon._configure_car_buttons() + now[0] = 4_000_000_000 + queued[:] = [packet(1_500_000_000, True), packet(3_000_000_000, False), packet(3_100_000_000, True, valid=False), + packet(3_200_000_000, True, button_type='unknown'), packet(4_100_000_000, True)] + daemon._poll_car_buttons() + assert memory.get('StandbyButtonPressTime') is None + queued[:] = [packet(3_900_000_000, True)] + daemon._poll_car_buttons() + assert memory.get_int('StandbyButtonPressTime') == 3_900_000_000 + queued[:] = [packet(3_800_000_000, True), packet(3_900_000_000, True)] + daemon._poll_car_buttons() + assert memory.get_int('StandbyButtonPressTime') == 3_900_000_000 + + +def test_python_car_state_clock_stays_monotonic_after_suspend(car_buttons): + daemon, _params, memory, queued, _subscriptions, now_boot, now_mono = car_buttons + # Execute the actual factory used by card.py, without opening native IPC. + source = Path(__file__).resolve().parents[4] / 'cereal/messaging/__init__.py' + factory = next(node for node in ast.parse(source.read_text()).body if isinstance(node, ast.FunctionDef) and node.name == 'new_message') + env = dict(log=log, Optional=Optional, capnp=capnp, time=SimpleNamespace(monotonic=lambda: now_mono[0] / 1e9)) + exec(compile(ast.Module(body=[factory], type_ignores=[]), str(source), 'exec'), env) + now_boot[0] = 10_000_000_000 + daemon._configure_car_buttons() + now_boot[0] = 20_000_000_000 + now_mono[0] = 4_500_000_000 + message = env['new_message']('carState', valid=True) + button = message.carState.init('buttonEvents', 1)[0] + button.type, button.pressed = 'accelCruise', True + queued[:] = [message] + now_mono[0] = 5_000_000_000 + daemon._poll_car_buttons() + assert memory.get_int('StandbyButtonPressTime') == 4_500_000_000 diff --git a/starpilot/system/wheel_controls/tests/test_standby_button_wake.py b/starpilot/system/wheel_controls/tests/test_standby_button_wake.py new file mode 100644 index 0000000000..4dcd41aff4 --- /dev/null +++ b/starpilot/system/wheel_controls/tests/test_standby_button_wake.py @@ -0,0 +1,177 @@ +"""Exercise physical event reads without a controller, native IPC, or vehicle actions.""" +import ast +import os +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from openpilot.starpilot.system.wheel_controls import wheel_controlsd +from test_wheel_controlsd import FakeParams, source + + +PRESS_PARAM = "StandbyButtonPressTime" + + +@pytest.fixture +def input_pipe(): + params = FakeParams({"ScreenManagement": True, "StandbyMode": True, "StandbyWakeButton": True}) + memory = FakeParams() + daemon = wheel_controlsd.WheelControlsDaemon(params, memory) + read_fd, write_fd = os.pipe() + os.set_blocking(read_fd, False) + daemon.sources[read_fd] = source() + daemon.buffers[read_fd] = bytearray() + + def send(event_type, code, value): + os.write(write_fd, wheel_controlsd.INPUT_EVENT.pack(0, 0, event_type, code, value)) + daemon._read_events(read_fd) + + yield daemon, params, memory, read_fd, send + os.close(write_fd) + daemon.close() + + +def test_unmapped_key_press_publishes_once_until_released(input_pipe, monkeypatch): + _daemon, _params, memory, _fd, send = input_pipe + now = [100] + monkeypatch.setattr(wheel_controlsd.time, "monotonic_ns", lambda: now[0]) + + send(wheel_controlsd.EV_KEY, 304, 1) + assert memory.get_int(PRESS_PARAM) == 100 + now[0] = 200 + for value in (2, 1, 2, 0): + send(wheel_controlsd.EV_KEY, 304, value) + assert memory.get_int(PRESS_PARAM) == 100 + send(wheel_controlsd.EV_KEY, 304, 1) + assert memory.get_int(PRESS_PARAM) == 200 + + +def test_selected_joystick_buttons_wake_without_executing_mappings(input_pipe, monkeypatch): + _daemon, params, memory, _fd, send = input_pipe + wheel_controlsd.upsert_mapping(source(), 304, 0, params) + wheel_controlsd.set_joystick_device(source().device_id, True, params) + actions = [] + monkeypatch.setattr(wheel_controlsd, "execute_mapping_slot", lambda slot, *_args: actions.append(slot)) + monkeypatch.setattr(wheel_controlsd.time, "monotonic_ns", lambda: 123456789) + + send(wheel_controlsd.EV_KEY, 304, 1) + assert memory.get_int(PRESS_PARAM) == 123456789 + assert actions == [] + + +@pytest.mark.parametrize("disabled_key", ["ScreenManagement", "StandbyMode", "StandbyWakeButton"]) +def test_button_wake_is_opt_in_and_enabling_it_while_held_does_not_create_a_press(input_pipe, monkeypatch, disabled_key): + _daemon, params, memory, _fd, send = input_pipe + params.put_bool(disabled_key, False) + monkeypatch.setattr(wheel_controlsd.time, "monotonic_ns", lambda: 100) + send(wheel_controlsd.EV_KEY, 304, 1) + assert memory.get(PRESS_PARAM) is None + params.put_bool(disabled_key, True) + send(wheel_controlsd.EV_KEY, 304, 2) + send(wheel_controlsd.EV_KEY, 304, 1) + assert memory.get(PRESS_PARAM) is None + send(wheel_controlsd.EV_KEY, 304, 0) + send(wheel_controlsd.EV_KEY, 304, 1) + assert memory.get_int(PRESS_PARAM) == 100 + + +def test_dpad_direction_edges_wake_but_neutral_repeats_and_analog_axes_do_not(input_pipe, monkeypatch): + _daemon, _params, memory, _fd, send = input_pipe + now = [100] + monkeypatch.setattr(wheel_controlsd.time, "monotonic_ns", lambda: now[0]) + + send(wheel_controlsd.EV_ABS, 0, 32767) + send(wheel_controlsd.EV_ABS, wheel_controlsd.ABS_HAT0X, 0) + assert memory.get(PRESS_PARAM) is None + send(wheel_controlsd.EV_ABS, wheel_controlsd.ABS_HAT0X, -1) + assert memory.get_int(PRESS_PARAM) == 100 + now[0] = 200 + send(wheel_controlsd.EV_ABS, wheel_controlsd.ABS_HAT0X, -1) + assert memory.get_int(PRESS_PARAM) == 100 + send(wheel_controlsd.EV_ABS, wheel_controlsd.ABS_HAT0X, 1) + assert memory.get_int(PRESS_PARAM) == 200 + now[0] = 300 + send(wheel_controlsd.EV_ABS, wheel_controlsd.ABS_HAT0X, 0) + assert memory.get_int(PRESS_PARAM) == 200 + send(wheel_controlsd.EV_ABS, wheel_controlsd.ABS_HAT0X, 1) + assert memory.get_int(PRESS_PARAM) == 300 + + +def test_wake_only_listener_does_not_reactivate_disabled_mappings(input_pipe, monkeypatch): + _daemon, params, memory, _fd, send = input_pipe + wheel_controlsd.upsert_mapping(source(), 30, 2, params) + params.put_bool("WheelControlsEnabled", False) + actions = [] + monkeypatch.setattr(wheel_controlsd, "execute_mapping_slot", lambda slot, *_args: actions.append(slot)) + monkeypatch.setattr(wheel_controlsd.time, "monotonic_ns", lambda: 999) + + send(wheel_controlsd.EV_KEY, 30, 1) + assert actions == [] + assert memory.get_int(PRESS_PARAM) == 999 + + +def test_enabled_mapping_still_executes_once_on_press_with_wake_timestamp(input_pipe, monkeypatch): + _daemon, params, memory, _fd, send = input_pipe + wheel_controlsd.upsert_mapping(source(), 30, 2, params) + actions = [] + monkeypatch.setattr(wheel_controlsd, "execute_mapping_slot", lambda slot, *_args: actions.append(slot)) + monkeypatch.setattr(wheel_controlsd.time, "monotonic_ns", lambda: 777) + + for value in (1, 2, 0): + send(wheel_controlsd.EV_KEY, 30, value) + assert actions == [2] + assert memory.get_int(PRESS_PARAM) == 777 + + +def test_wake_timestamp_failure_does_not_interrupt_mapped_button_actions(input_pipe, monkeypatch): + _daemon, params, memory, _fd, send = input_pipe + wheel_controlsd.upsert_mapping(source(), 30, 2, params) + actions = [] + monkeypatch.setattr(wheel_controlsd, "execute_mapping_slot", lambda slot, *_args: actions.append(slot)) + + def fail_write(key, value): + raise OSError("memory Params unavailable") + + monkeypatch.setattr(memory, "put_int", fail_write) + send(wheel_controlsd.EV_KEY, 30, 1) + assert actions == [2] + + +def test_disconnected_device_does_not_suppress_next_press_on_reused_descriptor(input_pipe, monkeypatch): + daemon, _params, memory, fd, send = input_pipe + now = [100] + monkeypatch.setattr(wheel_controlsd.time, "monotonic_ns", lambda: now[0]) + send(wheel_controlsd.EV_KEY, 304, 1) + assert memory.get_int(PRESS_PARAM) == 100 + + # Keep this test's pipe open while exercising the real per-device cleanup. + with monkeypatch.context() as patch: + patch.setattr(wheel_controlsd.os, "close", lambda _fd: None) + daemon._remove(fd) + daemon.sources[fd] = source() + daemon.buffers[fd] = bytearray() + now[0] = 200 + send(wheel_controlsd.EV_KEY, 304, 1) + assert memory.get_int(PRESS_PARAM) == 200 + + +@pytest.mark.parametrize("started", [False, True]) +@pytest.mark.parametrize("mapping,management,standby,button,expected", [ + (False, False, False, False, False), + (False, True, True, True, True), + (False, False, True, True, False), + (False, True, False, True, False), + (False, True, True, False, False), + (True, False, False, False, True), +]) +def test_manager_runs_listener_for_enabled_mappings_or_selected_standby_wake(started, mapping, management, standby, button, expected): + # The manager module creates native processes at import, so load its real predicate only. + path = Path(__file__).resolve().parents[4] / "system/manager/process_config.py" + tree = ast.parse(path.read_text()) + node = next(node for node in tree.body if isinstance(node, ast.FunctionDef) and node.name == "wheel_controls_enabled") + namespace = {"Params": object, "car": SimpleNamespace(CarParams=object), "SimpleNamespace": SimpleNamespace} + exec(compile(ast.Module(body=[node], type_ignores=[]), str(path), "exec"), namespace) + params = FakeParams({"WheelControlsEnabled": mapping, "ScreenManagement": management, + "StandbyMode": standby, "StandbyWakeButton": button}) + assert namespace["wheel_controls_enabled"](started, params, None, SimpleNamespace()) is expected diff --git a/starpilot/system/wheel_controls/tests/test_tesla_standby_buttons.py b/starpilot/system/wheel_controls/tests/test_tesla_standby_buttons.py new file mode 100644 index 0000000000..e28c458249 --- /dev/null +++ b/starpilot/system/wheel_controls/tests/test_tesla_standby_buttons.py @@ -0,0 +1,185 @@ +"""Decode real Tesla DBC frames; no CAN sockets or transmitters are opened.""" +import importlib +import sys +from pathlib import Path +from types import SimpleNamespace + +import pytest +from cereal import car, log +from opendbc.can import CANPacker +from opendbc.car.tesla.values import CAR +from openpilot.starpilot.system.wheel_controls import wheel_controlsd +from test_wheel_controlsd import FakeParams + + +def observer_module(): + path = Path(__file__).resolve().parents[1] / "tesla_standby_buttons.py" + assert path.exists(), "The passive Tesla standby button observer is not implemented" + return importlib.import_module("openpilot.starpilot.system.wheel_controls.tesla_standby_buttons") + + +@pytest.fixture +def observer(): + return observer_module().TeslaStandbyButtonObserver("tesla_model3_party") + + +def packet(timestamp, *pressed_values, bus=0, valid=True, address=None): + packer = CANPacker("tesla_model3_party") + event = log.Event.new_message(logMonoTime=timestamp, valid=valid) + event.init("can", len(pressed_values)) + for frame, pressed in zip(event.can, pressed_values, strict=True): + addr, data, src = packer.make_can_msg("UI_warning", bus, {"scrollWheelPressed": pressed}) + frame.address, frame.dat, frame.src = addr if address is None else address, data, src + return event + + +@pytest.mark.parametrize("model", [CAR.TESLA_MODEL_3, CAR.TESLA_MODEL_Y]) +def test_only_supported_tesla_fingerprints_select_existing_party_dbc(model): + cp = car.CarParams.new_message(brand="tesla", carFingerprint=model.value) + assert observer_module().tesla_button_dbc(cp) == "tesla_model3_party" + cp.brand = "toyota" + assert observer_module().tesla_button_dbc(cp) is None + cp.brand, cp.carFingerprint = "tesla", CAR.TESLA_MODEL_S_PREAP.value + assert observer_module().tesla_button_dbc(cp) is None + + +def test_first_held_sample_seeds_then_only_new_presses_emit_original_timestamp(observer): + assert observer.update([packet(100, 1)], 100) == 0 + assert observer.update([packet(200, 1)], 200) == 0 + assert observer.update([packet(300, 0)], 300) == 0 + assert observer.update([packet(400, 1)], 500) == 400 + assert observer.update([packet(600, 1), packet(700, 0)], 800) == 0 + + +def test_press_and_release_in_one_received_packet_are_not_lost(observer): + assert observer.update([packet(100, 0)], 100) == 0 + assert observer.update([packet(200, 1, 0)], 250) == 200 + assert observer.update([packet(300, 1)], 350) == 300 + + +def test_wrong_bus_invalid_unrelated_truncated_and_future_messages_do_not_wake(observer): + assert observer.update([packet(100, 0)], 100) == 0 + assert observer.update([packet(200, 1, bus=2)], 300) == 0 + assert observer.update([packet(300, 1, valid=False)], 400) == 0 + assert observer.update([packet(400, 1, address=0x312)], 500) == 0 + assert observer.update([packet(600, 1)], 500) == 0 + truncated = packet(600, 1) + truncated.can[0].dat = truncated.can[0].dat[:2] + assert observer.update([truncated], 700) == 0 + assert observer.update([packet(800, 1)], 900) == 800 + + +def test_stale_gap_reseeds_held_state_instead_of_waking(observer): + assert observer.update([packet(100, 0)], 100) == 0 + assert observer.update([], 2_000_000_100) == 0 + assert observer.update([packet(2_000_000_200, 1)], 2_000_000_300) == 0 + assert observer.update([packet(2_000_000_400, 0), packet(2_000_000_500, 1)], 2_000_000_600) == 2_000_000_500 + + +def test_old_packets_never_replay_a_button_press(observer): + assert observer.update([packet(100, 0), packet(200, 1)], 300) == 200 + assert observer.update([packet(100, 0), packet(200, 1)], 400) == 0 + assert observer.update([packet(500, 0)], 500) == 0 + assert observer.update([packet(600, 1)], 2_000_000_600) == 0 + assert observer.update([packet(2_000_000_700, 1)], 2_000_000_800) == 0 + + +@pytest.fixture +def daemon_subscription(monkeypatch): + params = FakeParams({"ScreenManagement": True, "StandbyMode": True, "StandbyWakeButton": True, + "IsOnroad": True, "CarParams": car.CarParams.new_message( + brand="tesla", carFingerprint=CAR.TESLA_MODEL_3.value).to_bytes()}) + memory = FakeParams() + daemon = wheel_controlsd.WheelControlsDaemon(params, memory) + subscriptions, queued, drains = [], [], [] + + def subscribe(endpoint, **kwargs): + if endpoint == "carState": + assert kwargs == {"conflate": False} + return "carState-subscription" + assert endpoint == "can" + sock = object() + subscriptions.append(sock) + return sock + + def drain(sock, wait_for_one=False): + assert wait_for_one is False + if sock == "carState-subscription": + return [] + drains.append(sock) + messages, queued[:] = list(queued), [] + return messages + + messaging = SimpleNamespace(sub_sock=subscribe, drain_sock=drain) + import cereal + monkeypatch.setitem(sys.modules, "cereal.messaging", messaging) + monkeypatch.setattr(cereal, "messaging", messaging, raising=False) + monkeypatch.setattr(wheel_controlsd.time, "monotonic_ns", lambda: 1_000_000_000) + monkeypatch.setattr(wheel_controlsd.time, "clock_gettime_ns", lambda _clock: 1_000_000_000) + yield daemon, params, memory, subscriptions, queued, drains + daemon.close() + + +def test_daemon_opens_can_only_for_enabled_onroad_supported_tesla_and_reseeds_changes(daemon_subscription): + daemon, params, memory, subscriptions, queued, drains = daemon_subscription + configure = getattr(daemon, "_configure_tesla_buttons", None) + assert callable(configure), "The passive Tesla subscription is not connected to the daemon" + params.put_bool("StandbyWakeButton", False) + configure() + assert subscriptions == [] + params.put_bool("StandbyWakeButton", True) + params.put_bool("IsOnroad", False) + configure() + assert subscriptions == [] + params.put_bool("IsOnroad", True) + configure() + configure() + assert len(subscriptions) == 1 + queued[:] = [packet(100, 0), packet(200, 1)] + daemon._poll_tesla_buttons() + assert memory.get_int("StandbyButtonPressTime") == 200 + + params.put_bool("StandbyMode", False) + configure() + daemon._poll_tesla_buttons() + assert len(drains) == 1 + params.put_bool("StandbyMode", True) + configure() + queued[:] = [packet(300, 1)] + daemon._poll_tesla_buttons() + assert len(subscriptions) == 2 + assert memory.get_int("StandbyButtonPressTime") == 200 + + params.put("CarParams", car.CarParams.new_message(brand="tesla", carFingerprint=CAR.TESLA_MODEL_Y.value).to_bytes()) + configure() + queued[:] = [packet(400, 1)] + daemon._poll_tesla_buttons() + assert len(subscriptions) == 3 + assert memory.get_int("StandbyButtonPressTime") == 200 + params.put("CarParams", car.CarParams.new_message(brand="toyota", carFingerprint="unsupported").to_bytes()) + configure() + daemon._poll_tesla_buttons() + assert len(subscriptions) == 3 + assert len(drains) == 3 + + +def test_daemon_run_loop_publishes_passive_tesla_press_without_external_inputs(daemon_subscription, monkeypatch): + daemon, _params, memory, subscriptions, queued, _drains = daemon_subscription + queued[:] = [packet(100, 0), packet(200, 1)] + # Only hardware enumeration and the blocking selector are replaced; run/configure/poll stay real. + monkeypatch.setattr(daemon, "_scan_devices", lambda: None) + selections = iter([[]]) + monkeypatch.setattr(daemon.selector, "select", lambda timeout: next(selections)) + with pytest.raises(StopIteration): + daemon.run() + assert memory.get_int("StandbyButtonPressTime") == 200 + assert len(subscriptions) == 1 + + +def test_can_boot_clock_is_converted_to_ui_monotonic_clock_after_suspend(daemon_subscription, monkeypatch): + daemon, _params, memory, _subscriptions, queued, _drains = daemon_subscription + daemon._configure_tesla_buttons() + monkeypatch.setattr(wheel_controlsd.time, "clock_gettime_ns", lambda _clock: 6_000_000_000) + queued[:] = [packet(5_000_000_100, 0), packet(5_000_000_200, 1)] + daemon._poll_tesla_buttons() + assert memory.get_int("StandbyButtonPressTime") == 200 diff --git a/starpilot/system/wheel_controls/wheel_controlsd.py b/starpilot/system/wheel_controls/wheel_controlsd.py index 2e8746574f..9032a1a142 100644 --- a/starpilot/system/wheel_controls/wheel_controlsd.py +++ b/starpilot/system/wheel_controls/wheel_controlsd.py @@ -20,6 +20,7 @@ from openpilot.common.params import Params from openpilot.common.swaglog import cloudlog from openpilot.common.constants import CV from openpilot.starpilot.common.favorite_slots import FAVORITE_SLOT_COUNT +from openpilot.starpilot.common.screen_settings import STANDBY_BUTTON_PRESS_PARAM MAPPINGS_PARAM = "WheelControlMappings" @@ -504,6 +505,7 @@ class WheelControlsDaemon: self.sources: dict[int, InputSource] = {} self.buffers: dict[int, bytearray] = {} self.hat_values: dict[tuple[int, int], int] = {} + self.pressed_keys: set[tuple[int, int]] = set() self.learning_slot: int | None = None self.learning_deadline = 0.0 self.last_learned: dict[str, Any] | None = None @@ -511,8 +513,17 @@ class WheelControlsDaemon: self.last_tested: dict[str, Any] | None = None self.last_scan = 0.0 self.last_status = 0.0 + self._car_state_sock = None + self._car_state_messaging = None + self._last_car_button_frame = 0 + self._tesla_car_params: bytes | None = None + self._tesla_can_sock = None + self._tesla_messaging = None + self._tesla_button_observer = None def close(self) -> None: + self._close_car_buttons() + self._close_tesla_buttons() for fd in list(self.sources): self._remove(fd) self.selector.close() @@ -531,6 +542,7 @@ class WheelControlsDaemon: self.sources.pop(fd, None) self.buffers.pop(fd, None) self.hat_values = {key: value for key, value in self.hat_values.items() if key[0] != fd} + self.pressed_keys = {key for key in self.pressed_keys if key[0] != fd} def _scan_devices(self) -> None: current_paths = {source.path for source in self.sources.values()} @@ -607,6 +619,9 @@ class WheelControlsDaemon: } return + # The daemon may also run only to wake the screen, with mapped actions disabled. + if not self.params.get_bool(ENABLED_PARAM): + return for mapping in mappings: if mapping["device_id"] == source.device_id and mapping["event_code"] == code: try: @@ -636,14 +651,123 @@ class WheelControlsDaemon: raw = bytes(buffer[:INPUT_EVENT.size]) del buffer[:INPUT_EVENT.size] _seconds, _microseconds, event_type, code, value = INPUT_EVENT.unpack(raw) - if event_type == EV_KEY and value == KEY_DOWN: - self._handle_key(source, code) + if event_type == EV_KEY: + key = (fd, code) + if value == 0: + self.pressed_keys.discard(key) + elif value == KEY_DOWN: + if key not in self.pressed_keys: + self.pressed_keys.add(key) + self._publish_button_press(time.monotonic_ns()) + self._handle_key(source, code) elif event_type == EV_ABS and ABS_HAT0X <= code <= ABS_HAT3Y: previous = self.hat_values.get((fd, code), 0) self.hat_values[(fd, code)] = value if value and value != previous: + self._publish_button_press(time.monotonic_ns()) self._handle_key(source, hat_event_code(code, value)) + def _publish_button_press(self, timestamp: int) -> None: + try: + if not all(self.params.get_bool(key) for key in ("ScreenManagement", "StandbyMode", "StandbyWakeButton")): + return + self.params_memory.put_int(STANDBY_BUTTON_PRESS_PARAM, timestamp) + except Exception: + # A display notification must not interrupt existing controller actions. + cloudlog.exception("wheel controls: screen wake notification failed") + + def _close_car_buttons(self) -> None: + self._car_state_sock = None + self._car_state_messaging = None + self._last_car_button_frame = 0 + + def _configure_car_buttons(self) -> None: + if not all(self.params.get_bool(key) for key in ("ScreenManagement", "StandbyMode", "StandbyWakeButton", "IsOnroad")): + self._close_car_buttons() + return + if self._car_state_sock is not None: + return + try: + from cereal import messaging + + # UI SubMaster conflates frames and can discard one-frame button events. + self._car_state_sock = messaging.sub_sock("carState", conflate=False) + self._car_state_messaging = messaging + self._last_car_button_frame = time.monotonic_ns() + except Exception: + self._close_car_buttons() + cloudlog.exception("wheel controls: car button observer unavailable") + + def _poll_car_buttons(self) -> None: + if self._car_state_sock is None: + return + try: + messages = self._car_state_messaging.drain_sock(self._car_state_sock, wait_for_one=False) + # card publishes with Python messaging.new_message: already CLOCK_MONOTONIC. + now_ns = time.monotonic_ns() + pressed_at = 0 + for message in messages: + timestamp = int(message.logMonoTime) + if not message.valid or not 0 <= now_ns - timestamp < 2_000_000_000 or timestamp <= self._last_car_button_frame: + continue + self._last_car_button_frame = timestamp + if any(event.pressed and str(event.type) not in ("unknown", "0") for event in message.carState.buttonEvents): + pressed_at = timestamp + if pressed_at: + self._publish_button_press(pressed_at) + except Exception: + self._close_car_buttons() + cloudlog.exception("wheel controls: car button read failed") + + def _close_tesla_buttons(self) -> None: + # SubSocket releases its native subscription in __dealloc__. + self._tesla_can_sock = None + self._tesla_button_observer = None + self._tesla_messaging = None + self._tesla_car_params = None + + def _configure_tesla_buttons(self) -> None: + if not all(self.params.get_bool(key) for key in ("ScreenManagement", "StandbyMode", "StandbyWakeButton", "IsOnroad")): + self._close_tesla_buttons() + return + cp_bytes = self.params.get("CarParams") + if cp_bytes == self._tesla_car_params: + return + self._close_tesla_buttons() + self._tesla_car_params = cp_bytes + if not cp_bytes: + return + try: + from cereal import car + from openpilot.starpilot.system.wheel_controls.tesla_standby_buttons import TeslaStandbyButtonObserver, tesla_button_dbc + + with car.CarParams.from_bytes(cp_bytes) as cp: + dbc = tesla_button_dbc(cp) + if dbc is not None: + from cereal import messaging + + self._tesla_button_observer = TeslaStandbyButtonObserver(dbc) + self._tesla_can_sock = messaging.sub_sock("can") + self._tesla_messaging = messaging + except Exception: + self._close_tesla_buttons() + cloudlog.exception("wheel controls: passive Tesla button observer unavailable") + + def _poll_tesla_buttons(self) -> None: + if self._tesla_can_sock is None: + return + try: + messages = self._tesla_messaging.drain_sock(self._tesla_can_sock, wait_for_one=False) + now_boot_ns = time.clock_gettime_ns(time.CLOCK_BOOTTIME) + now_ns = time.monotonic_ns() + timestamp = self._tesla_button_observer.update(messages, now_boot_ns) + if timestamp: + # pandad timestamps include suspend time; the UI and external inputs use monotonic(). + self._publish_button_press(now_ns - (now_boot_ns - timestamp)) + except Exception: + self._close_tesla_buttons() + cloudlog.exception("wheel controls: passive Tesla button read failed") + def _publish_status(self, now: float) -> None: remaining = max(0, round(self.learning_deadline - now, 1)) if self.learning_slot is not None else 0 status = { @@ -666,12 +790,16 @@ class WheelControlsDaemon: self._update_testing() if now - self.last_scan >= DEVICE_SCAN_INTERVAL_SECONDS: self._scan_devices() + self._configure_car_buttons() + self._configure_tesla_buttons() self.last_scan = now for key, _mask in self.selector.select(timeout=0.1): try: self._read_events(key.fd) except (KeyError, OSError): self._remove(key.fd) + self._poll_car_buttons() + self._poll_tesla_buttons() now = time.monotonic() if now - self.last_status >= STATUS_INTERVAL_SECONDS: self._publish_status(now) diff --git a/system/manager/process_config.py b/system/manager/process_config.py index 7f8a162131..c2324867bb 100644 --- a/system/manager/process_config.py +++ b/system/manager/process_config.py @@ -134,7 +134,9 @@ def soundd_run(started: bool, params: Params, CP: car.CarParams, starpilot_toggl def wheel_controls_enabled(started: bool, params: Params, CP: car.CarParams, starpilot_toggles: SimpleNamespace) -> bool: - return params.get_bool("WheelControlsEnabled") + return params.get_bool("WheelControlsEnabled") or ( + params.get_bool("ScreenManagement") and params.get_bool("StandbyMode") and params.get_bool("StandbyWakeButton") + ) def run_v_asm(started: bool, params: Params, CP: car.CarParams, starpilot_toggles: SimpleNamespace) -> bool: