From d699f26dedd10273c0c489e2dc145bf4aa4dbf4c Mon Sep 17 00:00:00 2001 From: firestar5683 <168790843+firestar5683@users.noreply.github.com> Date: Fri, 17 Jul 2026 11:59:47 -0500 Subject: [PATCH] leedle --- .../test_conditional_experimental_mode.py | 78 +++++++ starpilot/common/starpilot_variables.py | 1 + .../lib/conditional_experimental_mode.py | 22 +- .../the_galaxy/assets/components/router.js | 2 +- .../components/tools/device_settings.js | 44 +++- .../assets/components/tools/toggles.js | 77 ++++--- .../the_galaxy/tests/test_dashboard_stats.py | 181 ++++++++++++++- .../tests/test_navigation_params.py | 32 +++ starpilot/system/the_galaxy/the_galaxy.py | 207 ++++++++++++++++-- 9 files changed, 581 insertions(+), 63 deletions(-) diff --git a/selfdrive/controls/tests/test_conditional_experimental_mode.py b/selfdrive/controls/tests/test_conditional_experimental_mode.py index 2b0d20d9d..cc967f346 100644 --- a/selfdrive/controls/tests/test_conditional_experimental_mode.py +++ b/selfdrive/controls/tests/test_conditional_experimental_mode.py @@ -462,6 +462,47 @@ def test_post_stop_speed_trigger_is_suppressed_after_red_light_release(monkeypat assert cem.status_value == conditional_experimental_mode_module.CEStatus["SPEED"] +def test_post_stop_suppression_survives_hold_release_before_motion(monkeypatch): + cem = make_cem(model_length=80.0, model_stopped=False) + toggles = make_update_toggles() + toggles.conditional_limit = 5.0 * CV.MPH_TO_MS + standstill_sm = make_update_sm(standstill=True) + moving_sm = make_update_sm(standstill=False) + + now = [100.0] + monkeypatch.setattr(conditional_experimental_mode_module.time, "monotonic", lambda: now[0]) + + def hold_red_light(*args, **kwargs): + cem.stop_light_detected = True + + monkeypatch.setattr(cem, "stop_sign_and_light", hold_red_light) + cem.update(0.0, standstill_sm, toggles) + assert cem.experimental_mode + + def clear_red_light(*args, **kwargs): + cem.stop_light_detected = False + cem.stop_light_model_detected = False + + monkeypatch.setattr(cem, "stop_sign_and_light", clear_red_light) + + now[0] = 100.1 + cem.update(0.0, standstill_sm, toggles) + assert not cem.experimental_mode + assert cem.standstill_stop_release_pending + + low_speed_trigger_v_ego = 4.0 * CV.MPH_TO_MS + + now[0] = 100.2 + cem.update(low_speed_trigger_v_ego, moving_sm, toggles) + assert not cem.experimental_mode + assert not cem.standstill_stop_release_pending + + now[0] = 102.5 + cem.update(low_speed_trigger_v_ego, moving_sm, toggles) + assert cem.experimental_mode + assert cem.status_value == conditional_experimental_mode_module.CEStatus["SPEED"] + + def test_post_stop_slow_lead_trigger_is_suppressed_after_red_light_release(monkeypatch): cem = make_cem(model_length=80.0, model_stopped=False) toggles = make_update_toggles() @@ -825,6 +866,43 @@ def test_tracked_following_slower_lead_still_triggers_slow_lead(): assert cem.slow_lead_detected +def test_tracked_vision_slow_lead_continues_existing_experimental_mode(): + v_ego = 8.0 + cem = make_cem( + model_length=80.0, + tracking_lead=True, + lead_status=True, + lead_d_rel=22.0, + lead_v_lead=6.4, + lead_model_prob=0.99, + ) + toggles = SimpleNamespace(conditional_slower_lead=True, conditional_stopped_lead=False) + + cem.prev_experimental_mode = True + for _ in range(24): + cem.slow_lead(toggles, v_ego) + + assert cem.slow_lead_detected + + +def test_tracked_vision_slow_lead_does_not_start_experimental_mode(): + v_ego = 8.0 + cem = make_cem( + model_length=80.0, + tracking_lead=True, + lead_status=True, + lead_d_rel=22.0, + lead_v_lead=6.4, + lead_model_prob=0.99, + ) + toggles = SimpleNamespace(conditional_slower_lead=True, conditional_stopped_lead=False) + + for _ in range(24): + cem.slow_lead(toggles, v_ego) + + assert not cem.slow_lead_detected + + def test_slow_lead_does_not_linger_at_crawl_when_stopped_lead_disabled(): v_ego = 1.5 cem = make_cem( diff --git a/starpilot/common/starpilot_variables.py b/starpilot/common/starpilot_variables.py index bad96b30f..5b57aa726 100644 --- a/starpilot/common/starpilot_variables.py +++ b/starpilot/common/starpilot_variables.py @@ -1065,6 +1065,7 @@ class StarPilotVariables: toggle.force_torque_controller = self.get_value("ForceTorqueController", condition=lateral_tuning and not is_torque_car and not is_angle_car) toggle.nnff = self.get_value("NNFF", condition=lateral_tuning and has_nnff and not is_angle_car) toggle.nnff_lite = self.get_value("NNFFLite", condition=not toggle.nnff and lateral_tuning and not is_angle_car) + toggle.nav_desires_allowed = self.get_value("NavDesiresAllowed") toggle.use_turn_desires = self.get_value("TurnDesires", condition=lateral_tuning) lkas_button_control = self.get_button_function("LKASButtonControl", condition=toggle.car_make != "subaru") diff --git a/starpilot/controls/lib/conditional_experimental_mode.py b/starpilot/controls/lib/conditional_experimental_mode.py index dedb7b94f..4727f7260 100644 --- a/starpilot/controls/lib/conditional_experimental_mode.py +++ b/starpilot/controls/lib/conditional_experimental_mode.py @@ -109,6 +109,7 @@ class ConditionalExperimentalMode: self._prev_ce_status = None self.prev_standstill = False self.prev_standstill_stop_hold = False + self.standstill_stop_release_pending = False self.post_stop_launch_trigger_suppress_until = 0.0 def update(self, v_ego, sm, starpilot_toggles): @@ -116,13 +117,15 @@ class ConditionalExperimentalMode: standstill = bool(sm["carState"].standstill) current_standstill_stop_hold = False released_standstill_stop_hold = self.prev_standstill and self.prev_standstill_stop_hold and not standstill + completed_pending_stop_release = self.standstill_stop_release_pending and not standstill - if released_standstill_stop_hold: + if released_standstill_stop_hold or completed_pending_stop_release: self.post_stop_launch_trigger_suppress_until = now + self.POST_STOP_LAUNCH_TRIGGER_SUPPRESS_TIME self.mode_hold_until = 0.0 self.mode_false_since = 0.0 self.slow_lead_mode_hold_until = 0.0 self.prev_experimental_mode = False + self.standstill_stop_release_pending = False if not standstill: self.standstill_stop_reason = None @@ -175,6 +178,14 @@ class ConditionalExperimentalMode: standstill_stop_hold = self.get_standstill_stop_hold(sm) current_standstill_stop_hold = standstill_stop_hold + if current_standstill_stop_hold: + self.standstill_stop_release_pending = False + elif self.prev_standstill_stop_hold: + self.standstill_stop_release_pending = True + + if self.standstill_stop_release_pending: + self.post_stop_launch_trigger_suppress_until = now + self.POST_STOP_LAUNCH_TRIGGER_SUPPRESS_TIME + self.experimental_mode = standstill_stop_hold self.prev_experimental_mode = self.experimental_mode self.status_value = CEStatus["STOP_LIGHT"] if self.experimental_mode else CEStatus["OFF"] @@ -187,6 +198,7 @@ class ConditionalExperimentalMode: self.mode_false_since = 0.0 self.slow_lead_mode_hold_until = 0.0 self._prev_ce_status = None + self.standstill_stop_release_pending = False self.experimental_mode = self.status_value == CEStatus["USER_OVERRIDDEN"] self.prev_experimental_mode = self.experimental_mode self.stop_light_detected &= not is_manual_ce_status(self.status_value) @@ -323,8 +335,14 @@ class ConditionalExperimentalMode: now < self.slow_lead_continuity_until and vision_slow_lead_candidate ) + tracked_vision_mode_continuation = bool( + starpilot_toggles.conditional_slower_lead and + tracking_lead and + self.prev_experimental_mode and + vision_slow_lead_candidate + ) - slow_lead_active = bool(slower_lead or raw_vision_slow_lead or stopped_lead) + slow_lead_active = bool(slower_lead or raw_vision_slow_lead or stopped_lead or tracked_vision_mode_continuation) if slow_lead_active: self.slow_lead_clear_since = 0.0 self.slow_lead_filter.update(True) diff --git a/starpilot/system/the_galaxy/assets/components/router.js b/starpilot/system/the_galaxy/assets/components/router.js index 90de167de..a699d65a0 100644 --- a/starpilot/system/the_galaxy/assets/components/router.js +++ b/starpilot/system/the_galaxy/assets/components/router.js @@ -1,7 +1,7 @@ import { html, reactive } from "/assets/vendor/arrow-core.js" import { createBrowserHistory, createRouter } from "/assets/vendor/remix-router-1.3.1.js" import { hideSidebar } from "/assets/js/utils.js" -import { DeviceSettings } from "/assets/components/tools/device_settings.js?v=flm-overrides-1" +import { DeviceSettings } from "/assets/components/tools/device_settings.js?v=favorite-live-values-1" import { ErrorLogs } from "/assets/components/tools/error_logs.js" import { VehicleFeatures } from "/assets/components/tools/vehicle_features.js" import { GalaxyPairing } from "/assets/components/tools/galaxy.js" diff --git a/starpilot/system/the_galaxy/assets/components/tools/device_settings.js b/starpilot/system/the_galaxy/assets/components/tools/device_settings.js index 1f4f95b2e..fde49549b 100644 --- a/starpilot/system/the_galaxy/assets/components/tools/device_settings.js +++ b/starpilot/system/the_galaxy/assets/components/tools/device_settings.js @@ -14,6 +14,8 @@ let syncScheduled = false let lastParams = null let flmWorkspaceInflight = null let lastFlmWorkspaceFetch = 0 +let favoritePollInflight = null +let favoritePollTimer = null const DYNAMIC_DEFAULT_DEP_KEYS = new Set(["AccelerationProfile", "EVTuning", "TruckTuning"]) const PANDA_FIRMWARE_TOGGLE_KEYS = new Set(["IgnoreIgnitionLine", "RemoteStartBootsComma", "HKGRemoteStartBootsComma"]) const FLM_ADVANCED_LATERAL_KEYS = new Set([ @@ -531,7 +533,7 @@ function populateFavoriteSelect(index, selectEl = null) { async function fetchFavoriteSlots() { state.favoriteLoading = true try { - const res = await fetch("/api/favorites/slots") + const res = await fetch("/api/favorites/slots", { cache: "no-store" }) const data = await res.json() if (res.ok) { state.favoriteOptions = normalizeFavoriteOptions(data.options) @@ -545,6 +547,45 @@ async function fetchFavoriteSlots() { state.favoriteLoading = false } +async function refreshFavoriteValues() { + if (favoritePollInflight || state.favoriteSaving || state.favoriteLoading) return favoritePollInflight + + favoritePollInflight = fetch("/api/favorites/values", { cache: "no-store" }) + .then(async res => { + if (!res.ok) return + + const data = await res.json() + const values = (data.values && typeof data.values === "object") ? data.values : {} + const changed = Object.entries(values).some(([key, value]) => state.values[key] !== value) + if (!changed) return + + state.favoriteValues = { ...state.favoriteValues, ...values } + state.values = { ...state.values, ...values } + scheduleSyncInputs() + }) + .catch(() => {}) + .finally(() => { + favoritePollInflight = null + }) + + return favoritePollInflight +} + +function ensureFavoriteValuePolling() { + if (favoritePollTimer !== null) return + + favoritePollTimer = setInterval(() => { + if (!window.location.pathname.startsWith("/device_settings")) { + clearInterval(favoritePollTimer) + favoritePollTimer = null + return + } + if (document.visibilityState === "visible") { + refreshFavoriteValues() + } + }, 1000) +} + async function saveFavoriteSlots(slots) { if (state.favoriteSaving) return @@ -1487,6 +1528,7 @@ export function DeviceSettings({ params }) { lastParams = params fetchFlmWorkspace() + ensureFavoriteValuePolling() if (!state.fetched) { state.fetched = true diff --git a/starpilot/system/the_galaxy/assets/components/tools/toggles.js b/starpilot/system/the_galaxy/assets/components/tools/toggles.js index 79d075fe5..971e747e1 100644 --- a/starpilot/system/the_galaxy/assets/components/tools/toggles.js +++ b/starpilot/system/the_galaxy/assets/components/tools/toggles.js @@ -6,7 +6,6 @@ const FACTORY_RESET_STATUS_POLL_INTERVAL_MS = 1000 const state = reactive({ showResetDefaultModal: false, - showResetStockModal: false, showSaveMeModal: false, factoryResetBusy: false, factoryResetStatus: null, @@ -99,9 +98,18 @@ async function fetchFactoryResetStatus() { async function restoreToggles(event) { const uploadedFile = event.target.files[0] - if (uploadedFile) { + if (!uploadedFile) return + + try { + if (uploadedFile.size > 5_000_000) { + throw new Error("That toggle backup file is too large.") + } + const fileContents = await uploadedFile.text() const toggleData = JSON.parse(fileContents) + if (!toggleData || typeof toggleData !== "object" || Array.isArray(toggleData)) { + throw new Error("That file is not a valid toggle backup.") + } const response = await fetch("/api/toggles/restore", { method: "POST", @@ -109,9 +117,17 @@ async function restoreToggles(event) { body: JSON.stringify(toggleData), }) - const result = await response.json() + const result = await response.json().catch(() => ({})) + if (!response.ok || result.success === false) { + throw new Error(result.message || "Failed to restore toggles.") + } showSnackbar(result.message || "Toggles restored!") - + } catch (error) { + const message = error instanceof SyntaxError + ? "That file is not a valid toggle backup." + : (error?.message || "Failed to restore toggles.") + showSnackbar(message, "error") + } finally { event.target.value = "" } } @@ -139,15 +155,27 @@ export function ToggleControl() { fetchFactoryResetStatus() async function backupToggles() { - const response = await fetch("/api/toggles/backup", { method: "POST" }) - const blob = await response.blob() + try { + const response = await fetch("/api/toggles/backup", { method: "POST" }) + if (!response.ok) { + const result = await response.json().catch(() => ({})) + throw new Error(result.message || "Failed to create toggle backup.") + } - const downloadUrl = URL.createObjectURL(blob) - const downloadLink = document.createElement("a") - downloadLink.href = downloadUrl - downloadLink.download = "toggle-backup.json" - downloadLink.click() - URL.revokeObjectURL(downloadUrl) + const blob = await response.blob() + const downloadUrl = URL.createObjectURL(blob) + const downloadLink = document.createElement("a") + downloadLink.href = downloadUrl + downloadLink.download = "toggle-backup.json" + downloadLink.style.display = "none" + document.body.appendChild(downloadLink) + downloadLink.click() + downloadLink.remove() + setTimeout(() => URL.revokeObjectURL(downloadUrl), 1000) + showSnackbar("Toggle backup downloaded.") + } catch (error) { + showSnackbar(error?.message || "Failed to create toggle backup.", "error") + } } function confirmResetDefault() { @@ -163,19 +191,6 @@ export function ToggleControl() { await fetch("/api/toggles/reset_default", { method: "POST" }); } - function confirmResetStock() { - state.showResetStockModal = true; - } - - async function resetTogglesToStock() { - state.showResetStockModal = false; - showSnackbar("Resetting toggles to stock openpilot values..."); - await new Promise(resolve => setTimeout(resolve, 3000)); - showSnackbar("Rebooting..."); - await new Promise(resolve => setTimeout(resolve, 3000)); - await fetch("/api/toggles/reset_stock", { method: "POST" }); - } - function triggerRestorePrompt() { fileInput.click() } @@ -229,16 +244,13 @@ export function ToggleControl() {
-
Reset Toggles to Default StarPilot
+
Reset Toggles to Default

Reset all toggles to default StarPilot settings.

-
WARNING: Factory Reset

@@ -277,13 +289,6 @@ export function ToggleControl() { onConfirm: resetTogglesToDefault, onCancel: () => { state.showResetDefaultModal = false; }, confirmText: "Reset to Default" - }) : ""} - ${() => state.showResetStockModal ? Modal({ - title: "Reset Toggles", - message: "Are you sure you want to reset all toggles to stock openpilot values?", - onConfirm: resetTogglesToStock, - onCancel: () => { state.showResetStockModal = false; }, - confirmText: "Reset to Stock" }) : ""} ${() => state.showSaveMeModal ? Modal({ title: "SAVE ME", diff --git a/starpilot/system/the_galaxy/tests/test_dashboard_stats.py b/starpilot/system/the_galaxy/tests/test_dashboard_stats.py index b8f82f98a..3fa7cf517 100644 --- a/starpilot/system/the_galaxy/tests/test_dashboard_stats.py +++ b/starpilot/system/the_galaxy/tests/test_dashboard_stats.py @@ -96,7 +96,10 @@ def _install_server_import_stubs(): ) sys.modules["openpilot.system.hardware.hw"] = _simple_module( "openpilot.system.hardware.hw", - Paths=SimpleNamespace(log_root=lambda **kwargs: "/tmp/dashboard-test-routes/"), + Paths=SimpleNamespace( + comma_home=lambda: "/tmp/dashboard-test-comma-home", + log_root=lambda **kwargs: "/tmp/dashboard-test-routes/", + ), ) sys.modules["openpilot.system.version"] = _simple_module("openpilot.system.version", get_build_metadata=lambda: SimpleNamespace()) sys.modules["openpilot.tools.longitudinal_maneuvers.capabilities"] = _simple_module( @@ -183,6 +186,9 @@ def _install_server_import_stubs(): "openpilot.starpilot.system.the_galaxy.factory_reset", remove_path=lambda *args, **kwargs: None, ) + sys.modules["openpilot.starpilot.system.the_galaxy.flm_workspace"] = _simple_module( + "openpilot.starpilot.system.the_galaxy.flm_workspace", + ) sys.modules["openpilot.starpilot.system.the_galaxy.utilities"] = utilities @@ -1259,3 +1265,176 @@ def test_stats_endpoint_keeps_existing_keys_and_adds_dashboard(monkeypatch): assert include_response.status_code == 200 assert ignored_calls == [route_name] assert included_calls == [route_name] + + +def test_toggle_backup_restore_round_trip_filters_non_settings(monkeypatch): + server = _load_server_module() + assert server._import_galaxy_web_symbols() + + persistent = server.ParamKeyFlag.PERSISTENT + dont_log = server.ParamKeyFlag.DONT_LOG + definitions = { + "EnabledSetting": (True, server.ParamKeyType.BOOL, persistent), + "AdbEnabled": (None, server.ParamKeyType.BOOL, persistent), + "NumericSetting": (1.5, server.ParamKeyType.FLOAT, persistent), + "JsonSetting": ({"mode": "default"}, server.ParamKeyType.JSON, persistent), + "SensitiveSetting": ("", server.ParamKeyType.STRING, persistent | dont_log), + "TransientSetting": (False, server.ParamKeyType.BOOL, server.ParamKeyFlag.CLEAR_ON_MANAGER_START), + "NoDefaultSetting": (None, server.ParamKeyType.STRING, persistent), + "StatsSetting": ({}, server.ParamKeyType.JSON, persistent), + } + + class ToggleParams: + def __init__(self): + self.values = { + "EnabledSetting": False, + "AdbEnabled": True, + "NumericSetting": 2.75, + "JsonSetting": {"mode": "custom"}, + "SensitiveSetting": "secret", + "TransientSetting": True, + "NoDefaultSetting": "runtime", + "StatsSetting": {"drives": 10}, + } + + def all_keys(self): + return list(definitions) + + def get(self, key, block=False): + del block + default = definitions[key][0] + return self.values.get(key, default) + + def get_default_value(self, key): + return definitions[key][0] + + def get_key_flag(self, key): + return definitions[key][2] + + def get_type(self, key): + return definitions[key][1] + + def get_tuning_level(self, key): + del key + return 0 + + def put(self, key, value): + self.values[key] = value + + raw_params = ToggleParams() + server.starpilot_default_params = [ + (key, default, value_type, 0) + for key, (default, value_type, _) in definitions.items() + ] + server._cached_static_default_values = None + monkeypatch.setattr(server, "_params_raw", raw_params) + monkeypatch.setattr(server, "params", server.ParamsCompat(raw_params)) + monkeypatch.setattr(server, "EXCLUDED_KEYS", {"StatsSetting"}) + update_calls = [] + monkeypatch.setattr(server, "update_starpilot_toggles", lambda: update_calls.append(True)) + + app = server.Flask( + "toggle_backup_test", + template_folder=str(MODULE_DIR / "templates"), + static_folder=str(MODULE_DIR / "assets"), + ) + server.setup(app) + client = app.test_client() + + backup_response = client.post("/api/toggles/backup") + backup_payload = json.loads(backup_response.data) + backed_up_values = utilities.decode_parameters(backup_payload["data"]) + + assert backup_response.status_code == 200 + assert backup_payload["format"] == server.TOGGLE_BACKUP_FORMAT + assert backup_payload["version"] == server.TOGGLE_BACKUP_VERSION + assert backup_payload["settingsCount"] == 4 + assert backed_up_values == { + "AdbEnabled": True, + "EnabledSetting": False, + "JsonSetting": {"mode": "custom"}, + "NumericSetting": 2.75, + } + + raw_params.values.update({ + "EnabledSetting": True, + "AdbEnabled": False, + "NumericSetting": 9.0, + "JsonSetting": {"mode": "changed"}, + "SensitiveSetting": "new-secret", + "TransientSetting": False, + "NoDefaultSetting": "new-runtime", + "StatsSetting": {"drives": 99}, + }) + restore_response = client.post("/api/toggles/restore", json={"data": backup_payload["data"]}) + restore_payload = restore_response.get_json() + + assert restore_response.status_code == 200 + assert restore_payload["restoredCount"] == 4 + assert restore_payload["skippedCount"] == 0 + assert raw_params.values["AdbEnabled"] is True + assert raw_params.values["EnabledSetting"] is False + assert raw_params.values["NumericSetting"] == 2.75 + assert raw_params.values["JsonSetting"] == {"mode": "custom"} + assert raw_params.values["SensitiveSetting"] == "new-secret" + assert raw_params.values["TransientSetting"] is False + assert raw_params.values["NoDefaultSetting"] == "new-runtime" + assert raw_params.values["StatsSetting"] == {"drives": 99} + assert update_calls == [True] + + +def test_toggle_restore_reports_invalid_and_unavailable_settings(monkeypatch): + server = _load_server_module() + assert server._import_galaxy_web_symbols() + + class ToggleParams: + def __init__(self): + self.values = {"EnabledSetting": False} + + def get_key_flag(self, key): + del key + return server.ParamKeyFlag.PERSISTENT + + def get_type(self, key): + del key + return server.ParamKeyType.BOOL + + def put(self, key, value): + self.values[key] = value + + raw_params = ToggleParams() + server.starpilot_default_params = [("EnabledSetting", True, server.ParamKeyType.BOOL, 0)] + monkeypatch.setattr(server, "_params_raw", raw_params) + monkeypatch.setattr(server, "EXCLUDED_KEYS", set()) + monkeypatch.setattr(server, "update_starpilot_toggles", lambda: None) + + app = server.Flask( + "toggle_restore_validation_test", + template_folder=str(MODULE_DIR / "templates"), + static_folder=str(MODULE_DIR / "assets"), + ) + server.setup(app) + client = app.test_client() + + mixed_data = utilities.encode_parameters({ + "EnabledSetting": "true", + "RemovedSetting": "1", + }) + mixed_response = client.post("/api/toggles/restore", json={"data": mixed_data}) + mixed_payload = mixed_response.get_json() + + assert mixed_response.status_code == 200 + assert mixed_payload["restoredCount"] == 1 + assert mixed_payload["skippedCount"] == 1 + assert raw_params.values["EnabledSetting"] is True + + damaged_response = client.post("/api/toggles/restore", json={"data": "not-a-backup"}) + wrong_format_response = client.post("/api/toggles/restore", json={ + "format": "different-format", + "data": mixed_data, + }) + + assert damaged_response.status_code == 400 + assert damaged_response.get_json()["success"] is False + assert wrong_format_response.status_code == 400 + assert wrong_format_response.get_json()["success"] is False diff --git a/starpilot/system/the_galaxy/tests/test_navigation_params.py b/starpilot/system/the_galaxy/tests/test_navigation_params.py index 50a8ada34..bbfd12b6d 100644 --- a/starpilot/system/the_galaxy/tests/test_navigation_params.py +++ b/starpilot/system/the_galaxy/tests/test_navigation_params.py @@ -193,6 +193,38 @@ def test_galaxy_session_value_matches_cookie_format(): ) == f"testGalaxySlug01%3A{'a' * 64}" +def test_configured_favorite_slot_values_only_reads_selected_keys(monkeypatch): + fake_params = WritableFakeParams({ + "NavDesiresAllowed": False, + "RedneckCruise": True, + "UnusedToggle": True, + }) + monkeypatch.setattr(the_galaxy, "params", fake_params) + + values = the_galaxy._configured_favorite_slot_values([ + {"enabled": True, "key": "NavDesiresAllowed"}, + {"enabled": False, "key": "RedneckCruise"}, + {"enabled": False, "key": None}, + ]) + + assert values == {"NavDesiresAllowed": False, "RedneckCruise": True} + + +def test_favorite_values_endpoint_returns_current_selected_value(monkeypatch): + client, _ = _params_client(monkeypatch, {"UseOldUI": False}, "tici") + monkeypatch.setattr(the_galaxy, "_get_favorite_slot_options", lambda: [{"key": "UseOldUI"}]) + monkeypatch.setattr( + the_galaxy, + "normalize_favorite_slots", + lambda *args, **kwargs: [{"enabled": True, "key": "UseOldUI"}], + ) + + response = client.get("/api/favorites/values") + + assert response.status_code == 200 + assert response.get_json() == {"values": {"UseOldUI": False}} + + def test_use_old_ui_is_noop_on_c4_mici(monkeypatch): client, fake_params = _params_client(monkeypatch, {"UseOldUI": False, "IsOnroad": False}, "mici") diff --git a/starpilot/system/the_galaxy/the_galaxy.py b/starpilot/system/the_galaxy/the_galaxy.py index aeeabb926..2ba659318 100644 --- a/starpilot/system/the_galaxy/the_galaxy.py +++ b/starpilot/system/the_galaxy/the_galaxy.py @@ -34,7 +34,7 @@ from opendbc.car.gm.values import GMFlags from opendbc.car.toyota.carcontroller import LOCK_CMD, UNLOCK_CMD from opendbc.car.toyota.values import ToyotaStarPilotFlags from openpilot.common.constants import CV -from openpilot.common.params import ParamKeyType, Params +from openpilot.common.params import ParamKeyFlag, ParamKeyType, Params from openpilot.common.realtime import DT_HW from openpilot.common.time_helpers import system_time_valid from openpilot.system.hardware import HARDWARE, PC @@ -488,6 +488,100 @@ def _build_default_params(): starpilot_default_params = _build_default_params() +TOGGLE_BACKUP_FORMAT = "starpilot-toggle-backup" +TOGGLE_BACKUP_VERSION = 1 +TOGGLE_BACKUP_MAX_ENCODED_BYTES = 2_000_000 +TOGGLE_BACKUP_NO_DEFAULT_KEYS = { + "AdbEnabled", + "AlphaLongitudinalEnabled", + "AlwaysOnDM", + "ExperimentalMode", + "ExperimentalModeConfirmed", + "IsLdwEnabled", + "IsMetric", + "IsRHD", + "IsRHDOverride", + "RecordAudio", + "RecordFront", + "SshEnabled", +} + + +def _get_toggle_backup_keys(): + keys = set() + for key, default_value, _, _ in starpilot_default_params: + if key in EXCLUDED_KEYS: + continue + if default_value is None and key not in TOGGLE_BACKUP_NO_DEFAULT_KEYS: + continue + + try: + flags = _params_raw.get_key_flag(key) + except Exception: + continue + + if not flags & ParamKeyFlag.PERSISTENT or flags & ParamKeyFlag.DONT_LOG: + continue + + keys.add(key) + + return keys + + +def _coerce_toggle_restore_value(key, value): + value_type = _get_param_key_type(_params_raw, key) + + if value_type == ParamKeyType.BOOL: + if isinstance(value, bool): + return value + if isinstance(value, numbers.Real) and value in (0, 1): + return bool(value) + if isinstance(value, str): + normalized = value.strip().lower() + if normalized in {"1", "true", "yes", "on"}: + return True + if normalized in {"0", "false", "no", "off", ""}: + return False + raise ValueError(f"Invalid boolean value for {key}") + + if value_type == ParamKeyType.INT: + if isinstance(value, bool): + raise ValueError(f"Invalid integer value for {key}") + return int(float(value)) + + if value_type == ParamKeyType.FLOAT: + if isinstance(value, bool): + raise ValueError(f"Invalid numeric value for {key}") + result = float(value) + if not math.isfinite(result): + raise ValueError(f"Invalid numeric value for {key}") + return result + + if value_type == ParamKeyType.JSON: + if isinstance(value, str): + value = json.loads(value) + if not isinstance(value, (dict, list)): + raise ValueError(f"Invalid JSON value for {key}") + return value + + if value_type == ParamKeyType.BYTES: + if isinstance(value, bytes): + return value + if isinstance(value, str): + return value.encode("utf-8") + raise ValueError(f"Invalid byte value for {key}") + + if value_type == ParamKeyType.TIME: + if isinstance(value, datetime): + return value + if isinstance(value, str): + return datetime.fromisoformat(value) + raise ValueError(f"Invalid time value for {key}") + + if value is None or isinstance(value, (dict, list)): + raise ValueError(f"Invalid string value for {key}") + return str(value) + params = ParamsCompat(_params_raw) params_memory = ParamsCompat(_params_memory_raw) STATS_RESPONSE_CACHE_SECONDS = 2.0 @@ -2324,6 +2418,13 @@ def _favorite_slot_values(options): if option.get("key") } +def _configured_favorite_slot_values(slots): + return { + slot["key"]: _safe_params_get_bool(slot["key"]) + for slot in slots + if slot.get("key") + } + _cached_allowed_keys = None _cached_param_types = None _cached_default_values = None @@ -3789,6 +3890,7 @@ def setup(app): "/assets/components/tools/device_settings.js", "/assets/components/tools/device_settings.css", "/assets/components/tools/device_settings_layout.json", + "/assets/components/tools/toggles.js", }: response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0" response.headers["Pragma"] = "no-cache" @@ -4140,6 +4242,13 @@ def setup(app): "values": _favorite_slot_values(options), }), 200 + @app.route("/api/favorites/values", methods=["GET"]) + def favorite_values(): + options = _get_favorite_slot_options() + eligible_keys = {option["key"] for option in options} + slots = normalize_favorite_slots(params.get(FAVORITE_SLOTS_PARAM), params=params, eligible_keys=eligible_keys) + return jsonify({"values": _configured_favorite_slot_values(slots)}), 200 + @app.route("/api/params", methods=["GET", "PUT"]) def get_param(): if request.method == "PUT": @@ -7115,21 +7224,27 @@ def setup(app): @app.route("/api/toggles/backup", methods=["POST"]) def backup_toggle_values(): toggle_values = {} - for key, _, _, _ in starpilot_default_params: - if key in EXCLUDED_KEYS: + default_values = _get_static_default_param_values() + for key in sorted(_get_toggle_backup_keys()): + raw_value = _params_raw.get(key) + if raw_value is None: + raw_value = default_values.get(key) + if raw_value is None: continue - - raw_value = params.get(key) value = _sanitize_json_value(raw_value) - if value is None: - value = "0" - elif not isinstance(value, (str, int, float, bool, dict, list)): + if not isinstance(value, (str, int, float, bool, dict, list)): value = str(value) toggle_values[key] = value encoded = utilities.encode_parameters(toggle_values) - wrapped = json.dumps({"data": encoded}, indent=2) + wrapped = json.dumps({ + "format": TOGGLE_BACKUP_FORMAT, + "version": TOGGLE_BACKUP_VERSION, + "createdAt": datetime.now(timezone.utc).isoformat(), + "settingsCount": len(toggle_values), + "data": encoded, + }, indent=2) buffer = BytesIO(wrapped.encode("utf-8")) buffer.seek(0) @@ -7138,30 +7253,78 @@ def setup(app): @app.route("/api/toggles/restore", methods=["POST"]) def restore_toggle_values(): - request_data = request.get_json() - if not request_data or "data" not in request_data: - return jsonify({"success": False, "message": "Missing 'data' in request."}), 400 + request_data = request.get_json(silent=True) + if not isinstance(request_data, dict): + return jsonify({"success": False, "message": "Invalid toggle backup file."}), 400 - allowed_keys = {key for key, _, _, _ in starpilot_default_params if key not in EXCLUDED_KEYS} + backup_format = request_data.get("format") + if backup_format not in (None, TOGGLE_BACKUP_FORMAT): + return jsonify({"success": False, "message": "This file is not a Galaxy toggle backup."}), 400 - toggle_values = utilities.decode_parameters(request_data["data"]) + backup_version = request_data.get("version", 1) + if not isinstance(backup_version, int) or backup_version > TOGGLE_BACKUP_VERSION: + return jsonify({"success": False, "message": "This toggle backup requires a newer Galaxy version."}), 400 + + encoded_data = request_data.get("data") + if not isinstance(encoded_data, str) or not encoded_data.strip(): + return jsonify({"success": False, "message": "Toggle backup data is missing."}), 400 + if len(encoded_data.encode("utf-8")) > TOGGLE_BACKUP_MAX_ENCODED_BYTES: + return jsonify({"success": False, "message": "Toggle backup file is too large."}), 413 + + try: + toggle_values = utilities.decode_parameters(encoded_data) + except Exception: + return jsonify({"success": False, "message": "Toggle backup data is damaged or invalid."}), 400 + if not isinstance(toggle_values, dict): + return jsonify({"success": False, "message": "Toggle backup does not contain settings."}), 400 + + allowed_keys = _get_toggle_backup_keys() + restored_count = 0 + skipped_count = 0 for key, value in toggle_values.items(): + if not isinstance(key, str): + skipped_count += 1 + continue + mapped_key = LEGACY_STARPILOT_PARAM_RENAMES.get(key, key) - if mapped_key in allowed_keys: - params.put(mapped_key, value) + if mapped_key not in allowed_keys: + skipped_count += 1 + continue + + try: + _params_raw.put(mapped_key, _coerce_toggle_restore_value(mapped_key, value)) + restored_count += 1 + except (TypeError, ValueError, json.JSONDecodeError): + skipped_count += 1 + + if restored_count == 0: + return jsonify({"success": False, "message": "No compatible toggle settings were found in this backup."}), 400 update_starpilot_toggles() - return jsonify({"success": True, "message": "Toggles restored!"}) + message = f"Restored {restored_count} toggle settings." + if skipped_count: + message += f" Skipped {skipped_count} incompatible or unavailable settings." + return jsonify({ + "success": True, + "message": message, + "restoredCount": restored_count, + "skippedCount": skipped_count, + }) @app.route("/api/toggles/reset_default", methods=["POST"]) def reset_toggle_values(): - params.put_bool("DoToggleReset", True) - HARDWARE.reboot() + for raw_key in _params_raw.all_keys(): + key = raw_key.decode() if isinstance(raw_key, bytes) else str(raw_key) + if key in EXCLUDED_KEYS: + continue - @app.route("/api/toggles/reset_stock", methods=["POST"]) - def reset_toggle_values_to_stock(): - params.put_bool("DoToggleResetStock", True) + default_value = _params_raw.get_default_value(raw_key) + if default_value is not None: + _params_raw.put(raw_key, default_value) + + update_starpilot_toggles() HARDWARE.reboot() + return jsonify({"success": True, "message": "Toggles reset to default StarPilot values. Rebooting..."}) @app.route("/mapbox-help/", methods=["GET"]) def serve_mapbox_help(filename):