"""Parked-power document, source-bound UI and stock hardware-policy regressions.""" from unittest.mock import patch import os from dataclasses import replace from pathlib import Path import tempfile import unittest from types import SimpleNamespace as NS from unittest.mock import Mock from openpilot.common.params import Params from openpilot.starpilot.power.offroad_preferences import KEY, PowerPolicy, decode, effective, encode, read_saved from openpilot.starpilot.ui.feature_settings_state import FeatureInput, FeatureSettingsRequest, row_change from openpilot.starpilot.ui.feature_settings_state import FeatureUiAction, value_buttons from openpilot.starpilot.ui.power_owner import ENABLED, HOURS, VOLTS, PowerOwner, confirm_question, power_row_change from openpilot.starpilot.ui.display_owner import DisplayOwner from openpilot.starpilot.ui.power_compact import PowerCompact from openpilot.starpilot.ui.presentation import Profile from openpilot.starpilot.ui.runtime_app import StarShellSession from openpilot.starpilot.ui.settings_state import Destination from openpilot.starpilot.ui.shell import ShellMode from openpilot.system.ui.widgets import DialogResult from openpilot.system.hardware.power_monitoring import PowerMonitoring class PowerSettingsTests(unittest.TestCase): def setUp(self): directory = tempfile.TemporaryDirectory() self.addCleanup(directory.cleanup) self.params = Params(directory.name) self.path = Path(self.params.get_param_path(KEY)) self.parked = True self.owner = PowerOwner(self.params, lambda: self.parked) def row(self, key): return next(row for row in self.owner.snapshot().rows if row.key == key) def request(self, key, direction=1): request = row_change(self.row(key), direction) assert request is not None return FeatureSettingsRequest(request.key, request.expected, request.value, confirmation=True) def test_absent_is_stock_with_no_write_and_legacy_bytes_are_untouched(self): legacy = Path(self.params.get_param_path("DeviceShutdown")) legacy.write_bytes(b"9") # old indexes/minutes cannot safely be inferred as hours self.assertEqual(self.row(ENABLED).value, "Stock") self.assertEqual(self.row(HOURS).value, "30 h") self.assertEqual(self.row(VOLTS).value, "11.8 V") self.assertIsNone(read_saved(self.params).raw) self.assertFalse(self.path.exists()) self.assertEqual(legacy.read_bytes(), b"9") def test_codec_rejects_noncanonical_shapes_and_nonregular_or_oversized_files(self): policy = PowerPolicy(True, 6, 123) self.assertEqual(decode(encode(policy)), policy) for raw in (b"{}", b"[]", b"{", b"{\"version\":1,\"enabled\":1,\"delayHours\":6,\"cutoffTenths\":123}", b"{\"version\":1,\"enabled\":true,\"delayHours\":0,\"cutoffTenths\":123}", b"{\"version\":1,\"enabled\":true,\"delayHours\":6,\"cutoffTenths\":126}", b"{\"version\":1,\"version\":1,\"enabled\":true,\"delayHours\":6,\"cutoffTenths\":123}", b"[" * 250 + b"0" + b"]" * 250, b"\xff"): self.assertIsNone(decode(raw)) self.path.write_bytes(b"x" * 513) self.assertFalse(read_saved(self.params).readable) self.assertFalse(self.row(ENABLED).available) self.path.unlink() self.path.symlink_to(legacy := self.path.parent / "other") legacy.write_bytes(encode(policy)) self.assertFalse(read_saved(self.params).readable) self.path.unlink() os.mkfifo(self.path) self.assertFalse(read_saved(self.params).readable) def test_invalid_document_uses_stock_and_requires_explicit_reset(self): self.path.write_bytes(b"bad") self.assertFalse(read_saved(self.params).valid) self.assertEqual(effective(read_saved(self.params)), PowerPolicy()) self.assertEqual(self.row(ENABLED).repair_value, "Stock") self.assertFalse(self.row(HOURS).available) request = self.request(ENABLED) self.assertTrue(self.owner.apply(request)) self.assertEqual(read_saved(self.params).policy, PowerPolicy()) def test_atomic_edits_and_final_source_and_parked_guards(self): self.assertIsNone(power_row_change(self.row(HOURS), 1)) # 30 h must not wrap to 1 h self.assertIsNone(power_row_change(self.row(VOLTS), -1)) # 11.8 V must not wrap to 12.5 V request = self.request(ENABLED) self.parked = False self.assertFalse(self.owner.apply(request)) self.assertFalse(self.path.exists()) self.parked = True self.assertIn("30-hour maximum and 11.8-V cutoff", confirm_question(request)) self.assertTrue(self.owner.apply(request)) self.assertTrue(read_saved(self.params).policy.enabled) request = self.request(HOURS, -1) self.path.write_bytes(encode(PowerPolicy(True, 5, 118))) self.assertFalse(self.owner.apply(request)) self.assertEqual(read_saved(self.params).policy.delay_hours, 5) request = self.request(VOLTS) self.assertTrue(self.owner.apply(request)) self.assertEqual(read_saved(self.params).policy.cutoff_tenths, 119) self.assertFalse(self.owner.apply(request)) request = self.request(ENABLED) self.assertTrue(self.owner.apply(request)) self.assertFalse(read_saved(self.params).policy.enabled) def test_hardware_stock_and_custom_policy_keep_other_shutdown_gates(self): now = [3601.0] with patch("openpilot.system.hardware.power_monitoring.Params", return_value=self.params), \ patch("openpilot.system.hardware.power_monitoring.time.monotonic", side_effect=lambda: now[0]): monitor = PowerMonitoring() monitor.car_battery_capacity_uWh = 20e6 monitor.car_voltage_mV = 12000 self.assertFalse(monitor.should_shutdown(False, True, 0.0, True)) # stock 30 h self.path.write_bytes(encode(PowerPolicy(True, 1, 125))) now[0] += 1.0 self.assertTrue(monitor.should_shutdown(False, True, 0.0, True)) self.assertFalse(monitor.should_shutdown(True, True, 0.0, True)) self.assertFalse(monitor.should_shutdown(False, False, 0.0, True)) self.params.put_bool("DisablePowerDown", True, block=True) self.assertFalse(monitor.should_shutdown(False, True, 0.0, True)) self.params.put_bool("DisablePowerDown", False, block=True) self.path.write_bytes(b"bad") now[0] += 1.0 self.assertFalse(monitor.should_shutdown(False, True, 0.0, True)) # invalid returns stock def test_hardware_policy_is_cached_for_one_second_then_refreshes(self): with patch("openpilot.system.hardware.power_monitoring.Params", return_value=self.params), \ patch("openpilot.system.hardware.power_monitoring.time.monotonic") as clock, \ patch("openpilot.system.hardware.power_monitoring.read_saved", wraps=read_saved) as reader: monitor = PowerMonitoring() monitor.car_battery_capacity_uWh = 20e6 monitor.car_voltage_mV = 13000 clock.return_value = 600.0 monitor.should_shutdown(False, True, 0.0, True) clock.return_value = 600.5 monitor.should_shutdown(False, True, 0.0, True) reader.assert_called_once() self.path.write_bytes(encode(PowerPolicy(True, 1, 118))) clock.return_value = 601.0 monitor.should_shutdown(False, True, 0.0, True) self.assertEqual(reader.call_count, 2) self.assertEqual(monitor._saved_power_policy.delay_hours, 1) def test_stock_shutdown_boundaries_and_custom_voltage_are_isolated(self): now = [108000.0] # exactly the stock 30-hour limit with patch("openpilot.system.hardware.power_monitoring.Params", return_value=self.params), \ patch("openpilot.system.hardware.power_monitoring.time.monotonic", side_effect=lambda: now[0]): monitor = PowerMonitoring() monitor.car_battery_capacity_uWh = 20e6 monitor.car_voltage_mV = 11800 self.assertFalse(monitor.should_shutdown(False, True, 0.0, True)) # strict > time, strict < voltage now[0] += 1.0 self.assertTrue(monitor.should_shutdown(False, True, 0.0, True)) self.assertFalse(monitor.should_shutdown(True, True, 0.0, True)) self.assertFalse(monitor.should_shutdown(False, False, 0.0, True)) self.params.put_bool("DisablePowerDown", True, block=True) self.assertFalse(monitor.should_shutdown(False, True, 0.0, True)) self.params.put_bool("DisablePowerDown", False, block=True) now[0] = 301.0 monitor.car_battery_capacity_uWh = 0 self.assertTrue(monitor.should_shutdown(False, True, 0.0, True)) now[0] = 300.0 self.assertFalse(monitor.should_shutdown(False, True, 0.0, True)) monitor.car_battery_capacity_uWh = 20e6 monitor.car_voltage_mV = 11799 now[0] = 301.0 self.assertTrue(monitor.should_shutdown(False, True, 0.0, True)) monitor.car_voltage_mV = 12000 self.assertFalse(monitor.should_shutdown(False, True, 0.0, True)) self.path.write_bytes(encode(PowerPolicy(True, 30, 125))) now[0] += 1.0 self.assertTrue(monitor.should_shutdown(False, True, 0.0, True)) self.params.put_bool("ForcePowerDown", True, block=True) self.assertTrue(monitor.should_shutdown(True, False, 0.0, True)) def test_final_document_read_failure_and_parked_loss_write_nothing(self): request = self.request(ENABLED) original = self.params.get_param_path reads = 0 def unavailable_on_final(key): nonlocal reads if key == KEY: reads += 1 if reads == 2: raise OSError("read authority lost") return original(key) with patch.object(self.params, "get_param_path", side_effect=unavailable_on_final): self.assertFalse(self.owner.apply(request)) self.assertFalse(self.path.exists()) checks = 0 def lost_parked(): nonlocal checks checks += 1 return checks < 3 self.assertFalse(PowerOwner(self.params, lost_parked).apply(request)) self.assertFalse(self.path.exists()) def test_large_system_confirm_uses_displayed_source_and_recovers_from_stale_dialog(self): session = StarShellSession.__new__(StarShellSession) self.enterContext(patch.object(session, 'drive_state', NS(snapshot=lambda: {"mode": "auto", "revision": None, "available": False, "effective": None, "overrideAllowed": False}), create=True)) session.profile = Profile.LARGE session._mode = ShellMode.SETTINGS session.selected = Destination.SYSTEM session.display_owner = DisplayOwner(self.params, lambda: self.parked) session.power_owner = self.owner session.display_scroll = 0 session._snapshot_cache = None object.__setattr__(session, "input", NS(cancel=Mock())) session.favorites = Mock() session.view = Mock() system = session.system_snapshot() power_index = next(index for index, row in enumerate(system.rows) if row.key == ENABLED) displayed = replace(system, scroll=power_index) actions = [] native_input = FeatureInput(actions.append) native_input.press(1980, 245, displayed) native_input.release(1980, 245, displayed) self.assertEqual(actions[0].row.key, ENABLED) self.assertEqual(actions[0].kind, 'open') native_input.press(1980, 245, displayed) native_input.cancel() native_input.release(1980, 165, displayed) self.assertEqual(len(actions), 1) def request_enable(): state = session.system_snapshot() if not state.editor: row = next(row for row in state.rows if row.key == ENABLED) session._display_ui(FeatureUiAction('open', row)) state = session.system_snapshot() self.assertTrue(state.editor) self.assertEqual(state.save_hint, 'Changes require confirmation') row = state.rows[0] index = next(i for i, button in enumerate(value_buttons(state, row)) if button[0] == 'On') session._display_ui(FeatureUiAction('action', row, index)) def fake_dialog(_question, _button, callback): return NS(_callback=callback) with patch("openpilot.starpilot.ui.runtime_app.gui_app.push_widget") as pushed, \ patch("openpilot.system.ui.widgets.confirm_dialog.ConfirmDialog", side_effect=fake_dialog): request_enable() dialog = pushed.call_args.args[0] self.path.write_bytes(encode(PowerPolicy(False, 4, 121))) dialog._callback(DialogResult.CONFIRM) self.assertEqual(read_saved(self.params).policy, PowerPolicy(False, 4, 121)) with patch("openpilot.starpilot.ui.runtime_app.gui_app.push_widget") as pushed, \ patch("openpilot.system.ui.widgets.confirm_dialog.ConfirmDialog", side_effect=fake_dialog): request_enable() self.parked = False pushed.call_args.args[0]._callback(DialogResult.CONFIRM) self.assertFalse(read_saved(self.params).policy.enabled) self.parked = True with patch("openpilot.starpilot.ui.runtime_app.gui_app.push_widget") as pushed, \ patch("openpilot.system.ui.widgets.confirm_dialog.ConfirmDialog", side_effect=fake_dialog): request_enable() abandoned = pushed.call_args.args[0] session.cancel() # Leaving settings revokes the old confirmation, even after returning. abandoned._callback(DialogResult.CONFIRM) self.assertFalse(read_saved(self.params).policy.enabled) with patch("openpilot.starpilot.ui.runtime_app.gui_app.push_widget") as pushed, \ patch("openpilot.system.ui.widgets.confirm_dialog.ConfirmDialog", side_effect=fake_dialog): request_enable() pushed.call_args.args[0]._callback(DialogResult.CONFIRM) self.assertTrue(read_saved(self.params).policy.enabled) def test_compact_device_child_uses_same_owner_and_refreshes_after_confirm(self): class Button: def __init__(self, label, description): self.label, self.description = label, description self.click = None def set_click_callback(self, click): self.click = click class Scroller: def __init__(self): self._scroller = NS(items=[], add_widgets=lambda widgets: self._scroller.items.extend(widgets)) owner = self.owner class Session: def power_snapshot(self): return owner.snapshot() def power_request(self, request): return owner.apply(request) compact = PowerCompact(Session()) def fake_dialog(_question, _button, callback): return NS(_callback=callback) def fake_picker(_title, choices, current, callback): return NS(options=choices, selection=current, _callback=callback) with patch("openpilot.starpilot.ui.power_compact.BigButton", Button), \ patch("openpilot.starpilot.ui.power_compact.GreyBigButton", Button), \ patch("openpilot.starpilot.ui.power_compact.NavScroller", Scroller), \ patch("openpilot.starpilot.ui.power_compact.ConfirmDialog", side_effect=fake_dialog), \ patch("openpilot.starpilot.ui.power_compact.MultiOptionDialog", side_effect=fake_picker), \ patch("openpilot.starpilot.ui.power_compact.gui_app.get_active_widget") as active, \ patch("openpilot.starpilot.ui.power_compact.gui_app.push_widget") as pushed: compact.open() page = pushed.call_args.args[0] active.return_value = page self.assertEqual(len(page._scroller.items), 4) page._scroller.items[1].click() dialog = pushed.call_args.args[0] dialog._callback(DialogResult.CONFIRM) self.assertTrue(read_saved(self.params).policy.enabled) self.assertEqual(page._scroller.items[1].description, "on") page._scroller.items[2].click() picker = pushed.call_args.args[0] self.assertIn("6 h", picker.options) picker.selection = "6 h" picker._callback(DialogResult.CONFIRM) pushed.call_args.args[0]._callback(DialogResult.CONFIRM) self.assertEqual(read_saved(self.params).policy.delay_hours, 6) self.assertEqual(page._scroller.items[2].description.split(".")[0], "6 h") page._scroller.items[2].click() picker = pushed.call_args.args[0] picker.selection = "7 h" picker._callback(DialogResult.CONFIRM) abandoned = pushed.call_args.args[0] compact.open() # A reopened page has a different identity. active.return_value = pushed.call_args.args[0] abandoned._callback(DialogResult.CONFIRM) self.assertEqual(read_saved(self.params).policy.delay_hours, 6)