From ec5739441e577a4b389a26511ee19e2a006b0775 Mon Sep 17 00:00:00 2001 From: firestar5683 <168790843+firestar5683@users.noreply.github.com> Date: Thu, 29 Oct 2026 16:58:07 -0500 Subject: [PATCH] Expose parked shutdown settings in Galaxy --- openpilot/starpilot/galaxy/settings.py | 34 ++++++++++++++++- .../starpilot/galaxy/tests/test_settings.py | 38 +++++++++++++++++++ .../galaxy/tests/test_settings_controls.mjs | 10 ++++- openpilot/starpilot/galaxy/web/js/settings.js | 4 +- prebuilt.json | 2 +- 5 files changed, 81 insertions(+), 7 deletions(-) diff --git a/openpilot/starpilot/galaxy/settings.py b/openpilot/starpilot/galaxy/settings.py index 6e9718a4b0..9181c418b8 100644 --- a/openpilot/starpilot/galaxy/settings.py +++ b/openpilot/starpilot/galaxy/settings.py @@ -22,6 +22,7 @@ from openpilot.starpilot.ui.conditional_feature import confirmation_question as from openpilot.starpilot.ui.appearance_owner import AppearanceOwner, LABELS as APPEARANCE_LABELS from openpilot.starpilot.ui.display_owner import AUTO_PREFIX as DISPLAY_AUTO_PREFIX, DisplayOwner, LABELS as DISPLAY_LABELS from openpilot.starpilot.ui.sounds_owner import SoundsOwner +from openpilot.starpilot.ui.power_owner import PowerOwner, HOURS, VOLTS, POWER_KEYS, confirm_question as power_question from openpilot.starpilot.audio.alert_volume import SPECS as SOUND_SPECS from openpilot.starpilot.ui.presentation import Profile from openpilot.common.hardware import HARDWARE @@ -40,7 +41,7 @@ from openpilot.starpilot.ui.vasm_owner import ANNOTATION as VASM_ANNOTATION, RES PAGES = frozenset(("hub", "localization", "developer", "vehicle", "slc", "lane", "lane_change", "profiles", "personalities", "traffic", "aggressive", "standard", "relaxed", - "curve", "torque", "aol", "wheel", "appearance", "display", "data", "sounds", "pip", "sentry", "vasm", + "curve", "torque", "aol", "wheel", "appearance", "display", "data", "power", "sounds", "pip", "sentry", "vasm", "conditional", "conditional/cem", "conditional/ccm", *(f"{name}/{category}" for name in ("traffic", "aggressive", "standard", "relaxed") for category in ("acceleration", "braking", "following")))) @@ -63,6 +64,14 @@ CAR_CONTROL_SECTIONS = ( ) +def _power_request(request: FeatureSettingsRequest) -> FeatureSettingsRequest: + if request.key == HOURS: + return replace(request, value=f"{float(request.value):.0f} h") + if request.key == VOLTS: + return replace(request, value=f"{float(request.value):.1f} V") + return request + + class SettingsUnavailable(Exception): pass @@ -138,7 +147,7 @@ def _onroad_preference(page: str, key: str) -> bool: # Regular saved choices are consumed by their runtime owners. Only explicit # multi-key reset/repair operations need the offroad transaction boundary; # their owners independently recheck that requirement at commit time. - return bool(page != "developer" and key and key != SETUP_ACTION and key not in ( + return bool(page not in ("developer", "power") and key and key != SETUP_ACTION and key not in ( "IsRhdDetected", "AlphaLongitudinalEnabled", "reset_profiles", "curve_reset", "slc_adopt", "slc_reset", "conditional:reset", "conditional:manual_reset", ) and not is_long_confirm_action(key) and not key.startswith("torque_repair:")) @@ -388,6 +397,8 @@ def _projection(row: FeatureRow) -> dict: def _question(row: FeatureRow, request: FeatureSettingsRequest) -> str: key = request.key + if key in POWER_KEYS: + return power_question(_power_request(request)) if key == "LaneCenteringStrength": raw = float(request.value) level = (1 + (raw - row.minimum) * 4 / (100 - row.minimum) if raw <= 100 else @@ -552,6 +563,15 @@ class SettingsGateway: return fresh.cp_raw == ctx.cp_raw return SoundsOwner(self.params, authorized) + def _power_owner(self, ctx: AuthorityContext, *, live: bool = False, + session_valid: Callable[[], bool] = lambda: True) -> PowerOwner: + def parked() -> bool: + if not session_valid(): + return False + fresh = self.context.sample() if live else ctx + return fresh.parked and fresh.cp_raw == ctx.cp_raw + return PowerOwner(self.params, parked) + def _display_owner(self, ctx: AuthorityContext, *, live: bool = False, session_valid: Callable[[], bool] = lambda: True) -> DisplayOwner: def parked() -> bool: @@ -607,6 +627,14 @@ class SettingsGateway: return self._appearance_owner(ctx).snapshot(Profile.COMPACT, include_large=True) if page == "sounds": return self._sounds_owner(ctx).snapshot() + if page == "power": + state = self._power_owner(ctx).snapshot() + rows = tuple(replace(row, value=row.value.removesuffix(" h").removesuffix(" V"), choices=(), + default_value=row.default_value.removesuffix(" h").removesuffix(" V"), + minimum=1 if row.key == HOURS else 11.8, maximum=30 if row.key == HOURS else 12.5, + step=1 if row.key == HOURS else 0.1, unit="h" if row.key == HOURS else "V") + if row.key in (HOURS, VOLTS) else row for row in state.rows) + return replace(state, rows=rows) if page == "display": return self._display_owner(ctx).snapshot(Profile.COMPACT if HARDWARE.get_device_type() == "mici" else Profile.LARGE) if page == "pip": @@ -820,6 +848,8 @@ class SettingsGateway: return self._appearance_owner(ctx, live=True, session_valid=session_valid).apply(intent.request) if intent.page == "sounds": return self._sounds_owner(ctx, live=True, session_valid=session_valid).apply(intent.request) + if intent.page == "power": + return self._power_owner(ctx, live=True, session_valid=session_valid).apply(_power_request(intent.request)) if intent.page == "display": return self._display_owner(ctx, live=True, session_valid=session_valid).apply(intent.request) if intent.page == "sentry": diff --git a/openpilot/starpilot/galaxy/tests/test_settings.py b/openpilot/starpilot/galaxy/tests/test_settings.py index 42a2c6923f..c96d308d99 100644 --- a/openpilot/starpilot/galaxy/tests/test_settings.py +++ b/openpilot/starpilot/galaxy/tests/test_settings.py @@ -53,6 +53,44 @@ class SettingsGatewayTest(unittest.TestCase): def page(self, name): return self.gateway.page(name, self.session, self.generation) + def test_parked_power_sliders_preserve_defaults_and_save_policy(self): + from openpilot.starpilot.power.offroad_preferences import KEY, read_saved, effective + page = self.page('power') + self.assertIsNone(self.params.get(KEY)) + self.assertEqual([(r['value'], r['minimum'], r['maximum'], r['step'], r['unit']) for r in page['rows'][1:]], + [('30', 1, 30, 1, 'h'), ('11.8', 11.8, 12.5, 0.1, 'V')]) + for index, value in ((1, 6), (2, 12.1), (0, 'On')): + page = self.page('power') + intent = self.gateway.preview(page['view'], index, 0, self.session, self.generation, value=value) + self.assertTrue(self.gateway.confirm(intent['intent'], self.session, self.generation)) + policy = effective(read_saved(self.params)) + self.assertEqual((policy.enabled, policy.delay_hours, policy.cutoff_tenths), (True, 6, 121)) + page = self.page('power') + intent = self.gateway.preview(page['view'], 1, 0, self.session, self.generation, reset_default=True) + self.assertTrue(self.gateway.confirm(intent['intent'], self.session, self.generation)) + self.assertEqual(read_saved(self.params).policy.delay_hours, 30) + page = self.page('power') + intent = self.gateway.preview(page['view'], 0, 0, self.session, self.generation, value='Stock') + self.assertTrue(self.gateway.confirm(intent['intent'], self.session, self.generation)) + self.assertEqual((effective(read_saved(self.params)).delay_hours, effective(read_saved(self.params)).cutoff_tenths), (30, 118)) + + def test_parked_power_refuses_invalid_values_stale_source_and_onroad_save(self): + from openpilot.starpilot.power.offroad_preferences import KEY, to_value, PowerPolicy + for index, value in ((1, 0), (1, 31), (1, 1.5), (2, 11.7), (2, 12.6), (2, 12.15)): + page = self.page('power') + with self.assertRaises(SettingsChanged): + self.gateway.preview(page['view'], index, 0, self.session, self.generation, value=value) + page = self.page('power') + intent = self.gateway.preview(page['view'], 1, 0, self.session, self.generation, value=6) + self.params.put(KEY, to_value(PowerPolicy(True, 10, 120)), block=True) + self.assertFalse(self.gateway.confirm(intent['intent'], self.session, self.generation)) + page = self.page('power') + intent = self.gateway.preview(page['view'], 1, 0, self.session, self.generation, value=6) + self.context.value = replace(self.context.value, parked=False) + with self.assertRaises(SettingsChanged): + self.gateway.confirm(intent['intent'], self.session, self.generation) + self.assertTrue(all(not row['available'] for row in self.page('power')['rows'])) + def test_aol_steering_pause_actual_angle_gateway_save_and_default(self): from opendbc.car.car_helpers import interfaces from opendbc.car.ford.values import CAR as FORD diff --git a/openpilot/starpilot/galaxy/tests/test_settings_controls.mjs b/openpilot/starpilot/galaxy/tests/test_settings_controls.mjs index 3eb6276be1..93da10fb80 100644 --- a/openpilot/starpilot/galaxy/tests/test_settings_controls.mjs +++ b/openpilot/starpilot/galaxy/tests/test_settings_controls.mjs @@ -194,7 +194,7 @@ for (const id of ["wheel", "sounds", "localization"]) { } const deviceData = SETTINGS_SECTIONS.find((item) => item.id === "device") -assert.deepEqual(deviceData.pages, ["display", "data"]) +assert.deepEqual(deviceData.pages, ["power", "display", "data"]) assert.equal(SettingsPage.computed.atSectionRoot.call({ activeSection: deviceData, state: { data: { page: "hub" } } }), true) assert.equal(SettingsPage.computed.atSectionRoot.call({ activeSection: deviceData, @@ -202,7 +202,7 @@ assert.equal(SettingsPage.computed.atSectionRoot.call({ activeSection: deviceDat const dataLinks = SettingsPage.computed.visibleRows.call({ initialPage: "hub", activeSection: deviceData, state: { query: "", data: { page: "hub", rows: [] } } }) -assert.deepEqual(dataLinks.map(({ row }) => row.page), ["display", "data"]) +assert.deepEqual(dataLinks.map(({ row }) => row.page), ["power", "display", "data"]) assert.equal(SettingsPage.computed.atSectionRoot.call({initialPage:'hub',state:{data:null,parents:[]}}),true, 'section tabs stay mounted while loading a new section') @@ -221,3 +221,9 @@ assert.deepEqual(SettingsPage.computed.visibleRows.call({initialPage: "hub", act ["aggressive", "standard", "relaxed", "traffic"]) assert.equal(SettingsPage.computed.activeSection.call({sections: SETTINGS_SECTIONS, state: {data: {page: "personalities"}}}).id, "longitudinal") + +assert.ok(SETTINGS_SECTIONS.find(section => section.id === "device").pages.includes("power")) +for (const row of [{ value: "30", minimum: 1, maximum: 30, step: 1, unit: "h" }, + { value: "11.8", minimum: 11.8, maximum: 12.5, step: .1, unit: "V" }]) { + assert.equal(settingControl(row), "slider") +} diff --git a/openpilot/starpilot/galaxy/web/js/settings.js b/openpilot/starpilot/galaxy/web/js/settings.js index bb711ea7fb..5e29fba875 100644 --- a/openpilot/starpilot/galaxy/web/js/settings.js +++ b/openpilot/starpilot/galaxy/web/js/settings.js @@ -22,7 +22,7 @@ export const SETTINGS_SECTIONS = Object.freeze([ { id: "wheel", label: "Wheel Controls", icon: "bi-controller", pages: ["wheel"] }, { id: "visual", label: "Visual (Display & UI)", icon: "bi-eye", pages: ["appearance", "ui_layout", "favorites", "pip"] }, { id: "sounds", label: "Sounds & Alerts", icon: "bi-volume-up", pages: ["sounds"] }, - { id: "device", label: "Device & Data", icon: "bi-hdd", pages: ["display", "data"] }, + { id: "device", label: "Device & Data", icon: "bi-hdd", pages: ["power", "display", "data"] }, { id: "localization", label: "Localization", icon: "bi-globe", pages: ["localization"] }, { id: "developer", label: "Developer", icon: "bi-code-slash", pages: ["developer"] }, ]) @@ -34,7 +34,7 @@ export const isPipCropRow = (row) => ["Visual camera crop editor", "Camera crop /^Vehicle (left|right) crop/.test(row.label || "") const SECTION_LINKS = { - device: [{ label: "Display", page: "display", value: "Adjust screen brightness and display timing" }, { label: "Data Uploads", page: "data", value: "Choose mobile-data uploads and usage-statistics sharing" }], + device: [{ label: "Parked Power", page: "power", value: "Adjust shutdown hours and battery-voltage cutoff" }, { label: "Display", page: "display", value: "Adjust screen brightness and display timing" }, { label: "Data Uploads", page: "data", value: "Choose mobile-data uploads and usage-statistics sharing" }], visual: [{ label: "Driving Screen Widgets, Colors & Layout", page: "ui_layout" }, { label: "Quick Select", page: "favorites" }, { label: "Blind Spot Camera and Preview", page: "pip" }], } diff --git a/prebuilt.json b/prebuilt.json index c5b5c84f6d..2e0e06521e 100644 --- a/prebuilt.json +++ b/prebuilt.json @@ -1 +1 @@ -{"protected_inventory_sha256": "705cb122e92ebc1be7d7388bc9600fce1eb52bb917ee1926cf626fa7a089b373", "schema_version": 1} +{"protected_inventory_sha256": "bac7d61b5ba9533b8179581ef7b58b9b12726afd6de30981b7bf78b37b1a9e9b", "schema_version": 1}