This commit is contained in:
firestar5683
2026-07-17 11:59:47 -05:00
parent 9ccd61e8cf
commit d699f26ded
9 changed files with 581 additions and 63 deletions
@@ -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(
+1
View File
@@ -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")
@@ -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)
@@ -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"
@@ -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
@@ -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() {
</section>
<section class="toggle-control-widget" style="margin-left: 1.5rem">
<div class="toggle-control-title">Reset Toggles to Default StarPilot</div>
<div class="toggle-control-title">Reset Toggles to Default</div>
<p class="toggle-control-text">
Reset all toggles to default StarPilot settings.
</p>
<button class="toggle-control-button" @click="${confirmResetDefault}">
Reset Toggles to Default
</button>
<button class="toggle-control-button" @click="${confirmResetStock}">
Reset Toggles to Stock
</button>
<div class="toggle-control-danger-zone">
<div class="toggle-control-danger-title">WARNING: Factory Reset</div>
<p class="toggle-control-danger-text">
@@ -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",
@@ -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
@@ -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")
+185 -22
View File
@@ -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/<path:filename>", methods=["GET"])
def serve_mapbox_help(filename):