Add configurable screen brightness and standby wakes

This commit is contained in:
AngusBell97
2026-09-15 12:26:49 -05:00
committed by firestar5683
parent 6e37c0917c
commit cdaf33529a
32 changed files with 3540 additions and 93 deletions
+16
View File
@@ -625,6 +625,10 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> 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<std::string, ParamKeyAttributes> 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}},
@@ -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)
@@ -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():
+14
View File
@@ -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
@@ -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,
@@ -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()
@@ -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()
@@ -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]
@@ -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
+91 -40
View File
@@ -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:
@@ -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"
}
]
+260
View File
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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}
@@ -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}
+70
View File
@@ -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 5100% 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 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. 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 560 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 0100 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.
@@ -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); }
@@ -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)
},
@@ -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: `
<div>
<ScreenBrightnessControl v-if="param.ui_type === 'brightness'" :param="param" :value="value" :values="values"
:locked="locked" :lock-message="lockMessage" @change="$emit('change', $event)" />
<div v-else>
<div class="gx-row" :class="{ disabled: locked, 'gx-row--favorites': isFavorites, 'gx-row--stack': isSlider || isSelect }">
<div class="gx-row__info">
<span class="gx-row__label">{{ tr(displayParam.label, displayParam.label) }}
@@ -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: `
<div class="gx-row gx-row--stack gx-brightness" :class="{ disabled: locked }">
<div class="gx-row__info">
<label class="gx-row__label" :for="controlId + '-mode'">{{ tr(param.label) }}</label>
<span class="gx-row__desc">{{ tr(param.description) }}</span>
<span v-if="locked" class="gx-row__desc">{{ tr(lockMessage) }}</span>
</div>
<select class="gx-field" :id="controlId + '-mode'" :value="mode" :disabled="locked || updating" @change="onModeChange">
<option value="auto">{{ tr("Auto") }}</option>
<option value="manual">{{ tr("Manual") }}</option>
</select>
<div class="gx-slider-row">
<div class="gx-brightness__readout">
<label :for="controlId + '-slider'">{{ mode === 'auto' ? tr("Auto brightness offset") : tr("Brightness") }}</label>
<output class="gx-row__value" :for="controlId + '-slider'">{{ sliderReadout }}</output>
</div>
<input :id="controlId + '-slider'" type="range" class="gx-slider" :min="mode === 'auto' ? -30 : 0" :max="mode === 'auto' ? 30 : 100" step="1"
:value="sliderValue" :aria-valuetext="sliderReadout" :disabled="locked || updating"
@input="onSliderInput" @change="onSliderCommit" @blur="onSliderBlur"
@touchstart="beginInteract" @mousedown="beginInteract" @keydown="beginInteract" />
<div class="gx-slider-meta"><span>{{ mode === 'auto' ? '-30%' : '0%' }}</span><span>{{ mode === 'auto' ? '+30%' : '100%' }}</span></div>
<span v-if="mode === 'auto'" class="gx-row__desc">{{ 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.") }}</span>
<span v-else-if="brightness === 0" class="gx-row__desc">{{ tr("At 0%, the screen turns off.") }}</span>
<button type="button" class="gx-slider-reset" :disabled="locked || updating" @click="reset">{{ mode === 'auto' ? tr("Reset offset") : tr("Default") }}</button>
</div>
</div>
`,
}
@@ -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
@@ -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:'<div class="gx-card"><SettingTree :params="params" :values="values" :expanded="expanded" @change="change" @manage="expanded[$event]=!expanded[$event]" /></div>'
}).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:'<html data-theme="dark"><head><meta name="viewport" content="width=device-width, initial-scale=1"><link rel="stylesheet" href="/assets/mobile/css/material.css"><script type="importmap">{"imports":{"vue":"/assets/vendor/vue/vue.esm-browser.js"}}</script></head><body><main id="app"></main><div id="snackbar_wrapper"></div><script type="module" src="/setup.js"></script></body></html>'})
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})
@@ -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, '');
+19
View File
@@ -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():
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
+3 -1
View File
@@ -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: