Keep screen settings in Galaxy

Leave the existing UI-state consumer in place so Galaxy values still control the display, but remove the native settings pages and tests. Hide the new brightness and wake-choice controls behind Galaxy Developer Mode while preserving current defaults.

Co-authored-by: AngusBell97 <124716116+AngusBell97@users.noreply.github.com>
This commit is contained in:
firestar5683
2026-09-15 12:53:17 -05:00
parent 68b75fc51e
commit 814af739d0
13 changed files with 85 additions and 1138 deletions
@@ -1,157 +0,0 @@
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)
@@ -57,13 +57,6 @@ 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
@@ -142,7 +135,7 @@ class SystemSettingsManagerView(PanelManagerView):
hours: f"{hours} " + (tr("hour") if hours == 1 else tr("hours"))
for hours in range(1, 31)
}
brightness_labels = {0: tr("Off")}
brightness_labels = {101: tr("Auto"), 0: tr("Off")}
self._slider_specs: dict[str, dict[str, Any]] = {
"ScreenBrightness": {
@@ -151,11 +144,11 @@ class SystemSettingsManagerView(PanelManagerView):
"unit": "%",
"labels": brightness_labels,
"min": 0,
"max": 100,
"max": 101,
"step": 1,
"live": True,
"presets": [0, 25, 50, 75, 100],
"get": lambda: float(brightness_preferences(self._controller._params, "ScreenBrightness")["manual"]),
"presets": [0, 25, 50, 75, 101],
"get": lambda: float(self._controller._params.get_int("ScreenBrightness")),
"set": lambda v: self._controller._set_brightness("ScreenBrightness", v),
},
"ScreenBrightnessOnroad": {
@@ -164,17 +157,17 @@ class SystemSettingsManagerView(PanelManagerView):
"unit": "%",
"labels": brightness_labels,
"min": 0,
"max": 100,
"max": 101,
"step": 1,
"live": True,
"presets": [0, 25, 50, 75, 100],
"get": lambda: float(brightness_preferences(self._controller._params, "ScreenBrightnessOnroad")["manual"]),
"presets": [0, 35, 60, 80, 101],
"get": lambda: float(self._controller._params.get_int("ScreenBrightnessOnroad")),
"set": lambda v: self._controller._set_brightness("ScreenBrightnessOnroad", int(v)),
},
"ScreenTimeout": {
"title": tr("Offroad Timeout"),
"subtitle": tr("Screen sleeps while parked."),
"unit": " seconds",
"title": tr("Offroad Screen Timeout"),
"subtitle": "",
"unit": "s",
"labels": {},
"min": 5,
"max": 60,
@@ -185,9 +178,9 @@ class SystemSettingsManagerView(PanelManagerView):
"set": lambda v: self._set_timeout("ScreenTimeout", v),
},
"ScreenTimeoutOnroad": {
"title": tr("Onroad Timeout"),
"subtitle": tr("Standby sleeps the screen after this many seconds."),
"unit": " seconds",
"title": tr("Onroad Screen Timeout"),
"subtitle": "",
"unit": "s",
"labels": {},
"min": 5,
"max": 60,
@@ -229,10 +222,8 @@ 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(
row_type(
AetherAdjustorRow(
spec["title"],
spec["subtitle"],
spec["min"],
@@ -248,7 +239,6 @@ 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())
@@ -257,7 +247,7 @@ class SystemSettingsManagerView(PanelManagerView):
self._toggle_defs = [
{
"title": tr("Standby Mode"),
"subtitle": tr("Touch and ignition changes always wake the screen. Choose additional wake events below."),
"subtitle": "",
"get_state": lambda: self._controller._params.get_bool("StandbyMode"),
"set_state": lambda v: self._controller._params.put_bool("StandbyMode", v),
},
@@ -309,17 +299,6 @@ 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:
@@ -330,7 +309,8 @@ 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)
self._refresh_toggle_pages()
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._drive_mode_control = self._child(
AetherSegmentedControl(
@@ -342,29 +322,6 @@ 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(
@@ -385,9 +342,6 @@ 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"]()
@@ -535,21 +489,17 @@ class SystemSettingsManagerView(PanelManagerView):
draw_custom_icon("first_aid", icon_x, icon_y, s, icon_color)
def _measure_content_height(self, width: float) -> float:
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
display_h = self._slider_section_height(self._display_slider_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_keys() + self._power_slider_keys:
for key in self._display_slider_keys + self._power_slider_keys:
self._adjustor_rows[key].custom_row_height = None
display_container_h = self._slider_section_height(self._display_keys(), column_w)
display_container_h = self._slider_section_height(self._display_slider_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
@@ -572,7 +522,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_keys() + self._power_slider_keys:
for key in self._display_slider_keys + self._power_slider_keys:
self._adjustor_rows[key].custom_row_height = row_h
self._system_max_container_h = max_container_h
@@ -580,7 +530,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_keys() + self._power_slider_keys:
for key in self._display_slider_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])
@@ -605,8 +555,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_keys()):
current_y = self._draw_slider_row(rl.Rectangle(x, current_y, column_w, 0), key, is_last=index == len(self._display_keys()) - 1)
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)
current_y += SECTION_GAP
@@ -620,7 +570,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_keys())
y = self._draw_slider_section(y, x, width, tr("Display"), self._display_slider_keys)
y += SECTION_GAP
y = self._draw_slider_section(y, x, width, tr("Power"), self._power_slider_keys)
y += SECTION_GAP
@@ -1028,7 +978,13 @@ class StarPilotSystemLayout(_SettingsPage):
self._on_reset_stock()
def _set_brightness(self, key, val):
return try_screen_setting(lambda: write_screen_setting(self._params, key, int(val)), show_screen_save_error)
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))
def _get_konik_state(self):
if Path("/data/not_vetted").exists():
-14
View File
@@ -1,14 +0,0 @@
"""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
@@ -14,7 +14,6 @@ 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
@@ -272,8 +271,6 @@ 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()))
@@ -291,7 +288,6 @@ 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,
@@ -1,344 +0,0 @@
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)
if self._params.get_int(key) != value:
raise OSError("Screen timeout write did not persist")
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)
if self._params.get_bool(self._key) != value:
raise OSError("Screen toggle write did not persist")
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. Touch and ignition changes always 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()
@@ -1,209 +0,0 @@
"""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='StandbyWakeTurnSignal', _default=False, set_checked=values.append)
refresh(control)
params.put_bool('StandbyWakeTurnSignal', 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.', 'StandbyWakeTurnSignal': 'Wake when indicating.'},
'SCREEN_WAKE_OPTIONS': [('StandbyWakeEngage', 'Engagement', True), ('StandbyWakeTurnSignal', 'Turn signals', 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 indicating.']
params.put_bool('StandbyWakeTurnSignal', 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', 'StandbyWakeTurnSignal'):
save(SimpleNamespace(_params=params, _key=key, refresh=lambda: None), True)
assert [params.get_bool(key) for key in ('ScreenManagement', 'StandbyMode', 'StandbyWakeTurnSignal')] == [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': 'Turn signals'}],
)
assert get_defs(view) == [{'title': 'Standby'}, {'title': 'Uploads'}]
params.get_bool = lambda key: True
assert get_defs(view) == [{'title': 'Standby'}, {'title': 'Engagement'}, {'title': 'Turn signals'}, {'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]
@@ -1,302 +0,0 @@
"""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('StandbyWakeTurnSignal', 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),
)
turn_signal = next(control for control in definitions if "Turn signals" in control["title"])
self.assert_recoverable(lambda: turn_signal["set_state"](True))
assert turn_signal["get_state"]() is False
assert self.params.get_bool("StandbyWakeTurnSignal") 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, 'StandbyWakeTurnSignal', 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('StandbyWakeTurnSignal')
@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, "StandbyWakeTurnSignal", 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("StandbyWakeTurnSignal") 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
@pytest.mark.parametrize("key", ["ScreenTimeout", "ScreenTimeoutOnroad"])
def test_c4_silent_timeout_failure_restores_slider_and_allows_retry(self, key):
self.params.put_int(key, 30)
class DroppedWrites:
def put_int(self, key, value):
pass
def __getattr__(_self, name):
return getattr(self.params, name)
button = mici.ScreenValueButton.__new__(mici.ScreenValueButton)
button._params = DroppedWrites()
button.refresh = lambda: None
slider = mici.ScreenSliderMici(
"timeout", 5, 60, 5, 30, " seconds", lambda value: button._save(key, value),
read_value=lambda: button._read_saved_value(key),
)
slider._value, slider._dragging = 60, True
self.assert_recoverable(lambda: slider._handle_mouse_release(None))
assert slider._value == slider._committed_value == self.params.get_int(key) == 30
assert not slider.is_dismissing
button._params = self.params
self.errors.clear()
slider._value, slider._dragging = 60, True
slider._handle_mouse_release(None)
assert slider._value == slider._committed_value == self.params.get_int(key) == 60
assert self.errors == []
@pytest.mark.parametrize("key", ["ScreenManagement", "StandbyMode"])
def test_c4_silent_toggle_failure_restores_state_and_reports_error(self, key):
self.params.put_bool(key, False)
class DroppedWrites:
def put_bool(self, key, value):
pass
def __getattr__(_self, name):
return getattr(self.params, name)
control = mici.ScreenToggleMici.__new__(mici.ScreenToggleMici)
control._params, control._key, control._default = DroppedWrites(), key, False
control._checked = True
assert self.assert_recoverable(lambda: control._save(True)) is False
assert control._checked is False
assert self.params.get_bool(key) is False
control._params = self.params
self.errors.clear()
assert control._save(True) is True
assert control._checked is True and self.params.get_bool(key) is True
assert self.errors == []
@@ -4689,7 +4689,7 @@
"galaxy_ui_type": "brightness",
"step": 1,
"parent_key": "ScreenManagement",
"settings_tier": "simple",
"settings_tier": "advanced",
"default": 101,
"min": 0,
"max": 101
@@ -4703,7 +4703,7 @@
"galaxy_ui_type": "brightness",
"step": 1,
"parent_key": "ScreenManagement",
"settings_tier": "simple",
"settings_tier": "advanced",
"default": 101,
"min": 0,
"max": 101
@@ -4771,7 +4771,7 @@
"ScreenManagement",
"StandbyMode"
],
"settings_tier": "simple"
"settings_tier": "advanced"
},
{
"key": "StandbyWakeDisengage",
@@ -4785,7 +4785,7 @@
"ScreenManagement",
"StandbyMode"
],
"settings_tier": "simple"
"settings_tier": "advanced"
},
{
"key": "StandbyWakeInfoAlert",
@@ -4799,7 +4799,7 @@
"ScreenManagement",
"StandbyMode"
],
"settings_tier": "simple"
"settings_tier": "advanced"
},
{
"key": "StandbyWakeWarningAlert",
@@ -4813,7 +4813,7 @@
"ScreenManagement",
"StandbyMode"
],
"settings_tier": "simple"
"settings_tier": "advanced"
},
{
"key": "StandbyWakeCriticalAlert",
@@ -4827,7 +4827,7 @@
"ScreenManagement",
"StandbyMode"
],
"settings_tier": "simple"
"settings_tier": "advanced"
},
{
"key": "StandbyWakeTurnSignal",
@@ -4841,7 +4841,7 @@
"ScreenManagement",
"StandbyMode"
],
"settings_tier": "simple"
"settings_tier": "advanced"
},
{
"key": "StandbyWakeButton",
@@ -4855,7 +4855,7 @@
"ScreenManagement",
"StandbyMode"
],
"settings_tier": "simple"
"settings_tier": "advanced"
}
]
},
+3 -4
View File
@@ -1,4 +1,4 @@
"""Shared screen preferences and wake policy for native UI and Galaxy.
"""Shared screen preferences and wake policy for Galaxy and services.
The existing brightness value 101 continues to encode Auto for older clients.
Manual values and offsets are independent for the driving and parked screens.
@@ -43,7 +43,7 @@ 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.
# Older registries may not know new settings during an update.
return None
@@ -84,8 +84,7 @@ def enabled_wake_keys(params):
@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.
# Keep the lock outside the key directory, where Params clearing cannot remove it.
directory = Path(params.get_param_path())
lock_path = directory.parent / f'.screen_settings.{directory.name}.lock'
with _WRITE_LOCK:
@@ -1,6 +1,7 @@
import importlib
from pathlib import Path
from tempfile import TemporaryDirectory
import shutil
from tempfile import mkdtemp
from types import SimpleNamespace
import pytest
@@ -14,10 +15,15 @@ class Params:
def __init__(self, values=None):
self.values = dict(values or {})
self.fail_key = None
self.directory = TemporaryDirectory(prefix='screen-settings-test-')
self.directory = Path(mkdtemp(prefix='screen-settings-test-'))
def get_param_path(self):
return str(Path(self.directory.name) / 'd')
return str(self.directory / 'd')
def __del__(self):
directory = getattr(self, 'directory', None)
if directory is not None:
shutil.rmtree(directory, ignore_errors=True)
def get(self, key):
return self.values.get(key)
+8 -15
View File
@@ -1,6 +1,6 @@
# 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.
Screen Management provides independent driving and parked brightness preferences in Galaxy.
## Brightness
@@ -8,9 +8,7 @@ Auto remains the default. It uses the existing automatic brightness calculation
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 0100% 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. Touch and ignition changes temporarily make manual 0% visible at 5%; recognised button presses do so when their wake toggle is enabled. As in Dom, manual onroad 0% suppresses automatic engagement and alert wakes; the same rule applies to the optional turn-signal wake.
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.
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.
## Timeouts and wake choices
@@ -28,19 +26,15 @@ Touch and ignition changes always wake Standby, as in Dom. The **Bluetooth or st
| Turn signals | Off | Signal activation or direction change |
| Bluetooth or steering wheel button | Off | Recognised button press, including unassigned buttons |
Engagement and alert detection use Dom's existing status and alert predicates. Entering override alone does not wake. A selected alert keeps resetting the timer while it remains reported. Primary alerts take precedence over the secondary StarPilot alert state, as in Dom; this code does not duplicate renderer-generated alerts or add a separate freshness policy. Wake preferences only affect the display, not the alert or its sound.
Engagement and alert detection use Dom's existing status and alert predicates. Entering override alone does not wake. A selected alert keeps resetting the timer while it remains reported; wake preferences only affect the display, not the alert or its sound.
Dom's ignition transitions, screen-setting changes and page timeout handling are retained. There are no gear, brake-pedal or accelerator wake triggers. Standby powers the display down after the timeout using Dom's existing display-power path.
Vehicle buttons use the car interface's existing decoded `carState.buttonEvents`. The existing UI subscriber drains every message so short presses survive between UI refreshes, while UI state and frequency tracking receive only the latest frame. No additional vehicle-message reader is opened. Controller buttons use the existing input-device reader. When button wake is enabled, fresh presses wake once; releases, key repeat and held buttons do not keep extending the timer. Mapped actions retain their separate enable setting and continue to work normally. Button coverage depends on what the existing vehicle interface and supported input devices expose; this PR introduces no manufacturer-specific CAN decoding.
Vehicle buttons use the car interface's existing decoded `carState.buttonEvents`. The existing UI subscriber drains every message so short presses survive between UI refreshes, while UI state and frequency tracking receive only the latest frame. Controller buttons use the existing input-device reader. When button wake is enabled, fresh presses wake once; releases, key repeat and held buttons do not keep extending the timer. Mapped actions retain their separate enable setting and continue to work normally. This feature introduces no manufacturer-specific CAN decoding.
## Persistence and compatibility
The existing brightness keys retain 101 as Auto and 0100 as Manual. Four additional persistent integers store manual memory and relative offsets. Seven persistent booleans store wake selections. StandbyButtonPressTime carries fresh external-controller 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.
Galaxy and UI-state 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; caches invalidate inside and after it. A busy or failed save is reported and can be retried. Other direct Params writers should use the shared helper to participate in this transaction contract.
## Focused verification
@@ -49,8 +43,7 @@ From a configured Linux checkout with the project Python dependencies:
```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 \
selfdrive/ui/tests/test_device_screen_settings.py \
starpilot/system/wheel_controls/tests
PYTHONPATH=. python -m pytest -q -c /dev/null --confcutdir=starpilot/system/the_galaxy/tests \
@@ -62,6 +55,6 @@ PYTHONPATH=. python -m pytest -q -c /dev/null --confcutdir=starpilot/system/the_
node starpilot/system/the_galaxy/tests/test_screen_settings_dom.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.
The DOM test requires Playwright and Chromium. `PLAYWRIGHT_MODULE` and `CHROMIUM_EXECUTABLE` can point at 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.
Automated tests cover every wake choice enabled and disabled, generic-button freshness, held inputs, Dom status and alert behavior, 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.
Automated tests cover wake choices, generic-button freshness, held inputs, Dom status and alert behavior, minimum brightness, write failures, cross-process saves, browser interactions and existing controller actions. Physical screen readability and actual car input coverage still require checks on relevant hardware.
@@ -293,6 +293,15 @@ def test_requested_simple_and_advanced_settings_tiers():
if not param["key"].startswith("PIPPreview")
and param["key"] != "DisableWideRoad"
]
if section_name == "Device & Data":
params = [
param for param in params
if param["key"] not in {
"ScreenBrightness", "ScreenBrightnessOnroad", "StandbyWakeEngage",
"StandbyWakeDisengage", "StandbyWakeInfoAlert", "StandbyWakeWarningAlert",
"StandbyWakeCriticalAlert", "StandbyWakeTurnSignal", "StandbyWakeButton",
}
]
assert {param["settings_tier"] for param in params} == {"simple"}
for key in ("AlwaysOnLateral", "LaneChanges", "QOLLateral"):
@@ -338,6 +347,16 @@ def test_requested_simple_and_advanced_settings_tiers():
assert developer["RedneckCruise"]["settings_tier"] == "advanced"
assert sections["Visual (Display & UI)"]["DisableWideRoad"]["settings_tier"] == "advanced"
device = sections["Device & Data"]
assert device["ScreenBrightness"]["settings_tier"] == "advanced"
assert device["ScreenBrightnessOnroad"]["settings_tier"] == "advanced"
for key in (
"StandbyWakeEngage", "StandbyWakeDisengage", "StandbyWakeInfoAlert",
"StandbyWakeWarningAlert", "StandbyWakeCriticalAlert", "StandbyWakeTurnSignal",
"StandbyWakeButton",
):
assert device[key]["settings_tier"] == "advanced"
def test_turn_steering_limit_mute_speed_is_galaxy_developer_only():
sections = _params_by_section(_layout())
@@ -21,7 +21,7 @@ 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)}});
GalaxyDeveloperMode: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);
@@ -64,6 +64,10 @@ createApp({components:{SettingTree},setup:()=>({values}),
const onMode=page.locator('#gx-ScreenBrightnessOnroad-mode')
const offSlider=page.locator('#gx-ScreenBrightness-slider')
const onSlider=page.locator('#gx-ScreenBrightnessOnroad-slider')
assert.equal(section.params.find(p => p.key === 'ScreenBrightness').settings_tier, 'advanced')
assert.equal(section.params.find(p => p.key === 'StandbyWakeButton').settings_tier, 'advanced')
assert.equal(await offMode.count(), 0, 'screen controls stay hidden until Galaxy Developer Mode is enabled')
await page.evaluate(()=>{window.values.GalaxyDeveloperMode=true})
await offMode.waitFor()
assert.equal(await offMode.inputValue(),'auto')
assert.equal(await onMode.inputValue(),'auto')