diff --git a/selfdrive/ui/soundd.py b/selfdrive/ui/soundd.py index 151a69fb0..1f118e7e8 100644 --- a/selfdrive/ui/soundd.py +++ b/selfdrive/ui/soundd.py @@ -101,12 +101,32 @@ class Soundd: self.auto_volume = 0 self.previous_sound_pack = None + self.previous_sound_source_signature = None self.error_log = ERROR_LOGS_PATH / "error.txt" self.random_events_directory = RANDOM_EVENTS_PATH / "sounds" self.update_starpilot_sounds() + def get_sound_source_signature(self): + try: + resolved_path = self.sound_directory.resolve() + except OSError: + resolved_path = self.sound_directory + + sound_files = () + try: + if self.sound_directory.exists(): + sound_files = tuple(sorted( + (entry.name, entry.stat().st_mtime_ns) + for entry in self.sound_directory.iterdir() + if entry.is_file() + )) + except OSError: + sound_files = () + + return str(resolved_path), sound_files + def load_sounds(self): self.loaded_sounds: dict[int, np.ndarray] = {} @@ -268,6 +288,8 @@ class Soundd: self.starpilot_toggles = starpilot_toggles stream = self.update_starpilot_sounds(sd, stream) + elif rk.frame % 5 == 0: + stream = self.update_starpilot_sounds(sd, stream) def update_starpilot_sounds(self, sd=None, stream=None): self.volume_map = { @@ -295,10 +317,15 @@ class Soundd: else: self.sound_directory = Path(BASEDIR) / "selfdrive" / "assets" / "sounds" - if self.starpilot_toggles.sound_pack != self.previous_sound_pack: + sound_source_signature = self.get_sound_source_signature() + should_reload = self.starpilot_toggles.sound_pack != self.previous_sound_pack + should_reload |= sound_source_signature != self.previous_sound_source_signature + + if should_reload: self.load_sounds() self.previous_sound_pack = self.starpilot_toggles.sound_pack + self.previous_sound_source_signature = sound_source_signature if stream is not None: stream.close() diff --git a/starpilot/system/the_pond/assets/components/tools/theme_maker.js b/starpilot/system/the_pond/assets/components/tools/theme_maker.js index 91eabca65..8d15f3e3c 100644 --- a/starpilot/system/the_pond/assets/components/tools/theme_maker.js +++ b/starpilot/system/the_pond/assets/components/tools/theme_maker.js @@ -42,6 +42,15 @@ const BUILTIN_THEME_OPTIONS = { turn_signals: { name: "None (Stock)", path: "__stock_none__", type: "stock_none", builtin: true }, }; +const PARAM_KEY_BY_ASSET_TYPE = { + colors: "ColorScheme", + distance_icons: "DistanceIconPack", + icons: "IconPack", + sounds: "SoundPack", + turn_signals: "SignalAnimation", + steering_wheel: "WheelIcon", +}; + const fileStore = { images: { distanceIcons: {} }, sounds: {}, @@ -107,6 +116,14 @@ const state = reactive({ themeToDelete: null, themes: [], activeTab: "colors", + selectedThemeSources: { + colors: null, + distance_icons: null, + icons: null, + sounds: null, + turn_signals: null, + steering_wheel: null, + }, fetched: false, }); @@ -190,6 +207,7 @@ function handleDrop(e) { }; state.sequentialImages = move(state.sequentialImages, draggedIndex, dropIndex); fileStore.sequentialFiles = move(fileStore.sequentialFiles, draggedIndex, dropIndex); + state.selectedThemeSources.turn_signals = null; } draggedIndex = -1; @@ -212,6 +230,20 @@ function handleDragEnd(e) { const clearAsset = (type, key, subkey = null) => { state.themeSubmitted = false; + const assetType = key === "turnSignal" || key === "turnSignalBlindspot" + ? "turn_signals" + : key === "distanceIcons" + ? "distance_icons" + : key === "homeButton" || key === "settingsButton" + ? "icons" + : key === "steeringWheel" + ? "steering_wheel" + : type === "audio" + ? "sounds" + : null; + if (assetType) { + state.selectedThemeSources[assetType] = null; + } if (key === "turnSignal") { if (state.turnSignalType === "Sequential") { fileStore.sequentialFiles = []; @@ -243,6 +275,7 @@ const onClearClick = (e, type, key, subkey = null) => { }; const clearAssetType = (assetType) => { + state.selectedThemeSources[assetType] = null; if (assetType === "colors") { state.colors = { ...defaultColors }; } @@ -526,6 +559,20 @@ export function ThemeMaker() { } state.themeSubmitted = false; + const assetType = key === "turnSignal" || key === "turnSignalBlindspot" + ? "turn_signals" + : key === "distanceIcons" + ? "distance_icons" + : key === "homeButton" || key === "settingsButton" + ? "icons" + : key === "steeringWheel" + ? "steering_wheel" + : type === "audio" + ? "sounds" + : null; + if (assetType) { + state.selectedThemeSources[assetType] = null; + } if (key === "turnSignal" && state.turnSignalType === "Sequential") { fileStore.sequentialFiles.push(...files); @@ -555,6 +602,7 @@ export function ThemeMaker() { alpha: 255, }; state.themeSubmitted = false; + state.selectedThemeSources.colors = null; }; const validateTurnSignalLength = (e) => { @@ -562,21 +610,30 @@ export function ThemeMaker() { const clampedValue = isNaN(value) ? 25 : Math.max(25, Math.min(1000, value)); state.turnSignalLength = clampedValue; e.target.value = clampedValue; + state.selectedThemeSources.turn_signals = null; }; const toggleTurnSignalType = (type) => { state.turnSignalType = type; state.themeSubmitted = false; + state.selectedThemeSources.turn_signals = null; state.sequentialImages = []; fileStore.sequentialFiles = []; fileStore.images.turnSignal = undefined; state.imageFileNames.turnSignal = ""; }; + const setTurnSignalStyle = (style) => { + state.turnSignalStyle = style; + state.themeSubmitted = false; + state.selectedThemeSources.turn_signals = null; + }; + const getFormData = () => { const formData = new FormData(); formData.append("themeName", state.themeName); formData.append("saveChecklist", JSON.stringify(state.saveChecklist)); + formData.append("selectedThemeSources", JSON.stringify(state.selectedThemeSources)); if (state.discordUsername && state.discordUsername.trim()) { formData.append("discordUsername", state.discordUsername.trim()); @@ -646,6 +703,48 @@ export function ThemeMaker() { } }; + const persistThemeSelection = async (theme, assetType) => { + const paramKey = PARAM_KEY_BY_ASSET_TYPE[assetType]; + if (!paramKey) { + return true; + } + + let value = theme?.path || ""; + if (theme?.type === "stock") { + value = "stock"; + } else if (theme?.type === "stock_none") { + value = "none"; + } else if (assetType === "steering_wheel" && value) { + value = value.replace(/\.[^.]+$/, ""); + } + + const customThemesResult = await performApiAction( + "/api/params", + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ key: "CustomThemes", value: "true" }), + }, + "", + "Failed to enable custom themes." + ); + if (!customThemesResult.ok) { + return false; + } + + const result = await performApiAction( + "/api/params", + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ key: paramKey, value }), + }, + "", + `Failed to update ${assetType.replace("_", " ")}.` + ); + return result.ok; + }; + const applyTheme = async () => { if (!Object.values(state.saveChecklist).some(v => v)) { return showSnackbar("Please select at least one component to apply!", "error"); @@ -996,6 +1095,11 @@ export function ThemeMaker() { } } + state.selectedThemeSources[assetType] = { + path: theme.path, + type: theme.type, + }; + showSnackbar(`Loaded ${assetType.replace("_", " ")} from "${theme.name}"!`); } catch (err) { console.error("Failed to load theme asset:", err); @@ -1013,10 +1117,17 @@ export function ThemeMaker() { state.turnSignalStyle = "Traditional"; state.imageFileNames.turnSignal = "None"; state.imageFileNames.turnSignalBlindspot = ""; + state.selectedThemeSources.turn_signals = { + path: "__stock_none__", + type: "stock_none", + }; showSnackbar('Loaded stock turn signals.'); }; const loadSelectableThemeAsset = async (theme, assetType) => { + const persisted = await persistThemeSelection(theme, assetType); + if (!persisted) return; + if (theme?.type === "stock_none" && assetType === "turn_signals") { loadBuiltinTurnSignals(); return; @@ -1225,9 +1336,9 @@ export function ThemeMaker() {
+ @click="${() => setTurnSignalStyle("Static")}">Static + @click="${() => setTurnSignalStyle("Traditional")}">Traditional
${() => state.showTurnSignalHelp && html`

Static - The turn signal animation appears next to the current speed.

diff --git a/starpilot/system/the_pond/the_pond.py b/starpilot/system/the_pond/the_pond.py index 740fa9fe9..1b76dc282 100644 --- a/starpilot/system/the_pond/the_pond.py +++ b/starpilot/system/the_pond/the_pond.py @@ -4778,6 +4778,44 @@ def setup(app): return jsonify({"error": error}), 400 save_checklist = json.loads(form_data.get("saveChecklist", "{}")) + selected_theme_sources = json.loads(form_data.get("selectedThemeSources", "{}")) + + theme_param_map = { + "colors": "ColorScheme", + "distance_icons": "DistanceIconPack", + "icons": "IconPack", + "sounds": "SoundPack", + "turn_signals": "SignalAnimation", + "steering_wheel": "WheelIcon", + } + + def get_selected_theme_value(asset_type): + source = selected_theme_sources.get(asset_type) + if not isinstance(source, dict): + return None + + source_type = str(source.get("type") or "").strip().lower() + source_path = str(source.get("path") or "").strip() + if not source_path: + return None + + if source_type == "stock": + return "stock" + if source_type == "stock_none": + return "none" + if source_path == "__stock__": + return "stock" + if source_path == "__stock_none__": + return "none" + if asset_type == "steering_wheel": + return Path(source_path).stem + return source_path + + for asset_type, param_key in theme_param_map.items(): + if save_checklist.get(asset_type): + selected_value = get_selected_theme_value(asset_type) + if selected_value is not None: + params.put(param_key, selected_value) if save_checklist.get("colors") and temp_path is not None: asset_location = temp_path / "colors" @@ -4850,6 +4888,13 @@ def setup(app): return stock_asset_path stock_fallbacks = { + "icons/button_home.png": STOCK_THEME_PATH.parents[2] / "selfdrive" / "assets" / "images" / "button_home.png", + "icons/button_settings.png": STOCK_THEME_PATH.parents[2] / "selfdrive" / "assets" / "images" / "button_settings.png", + "sounds/disengage.wav": STOCK_THEME_PATH.parents[2] / "selfdrive" / "assets" / "sounds" / "disengage.wav", + "sounds/engage.wav": STOCK_THEME_PATH.parents[2] / "selfdrive" / "assets" / "sounds" / "engage.wav", + "sounds/prompt.wav": STOCK_THEME_PATH.parents[2] / "selfdrive" / "assets" / "sounds" / "prompt.wav", + # Stock openpilot has no dedicated startup clip; runtime falls back to engage. + "sounds/startup.wav": STOCK_THEME_PATH.parents[2] / "selfdrive" / "assets" / "sounds" / "engage.wav", "steering_wheel/wheel.png": STOCK_THEME_PATH.parents[2] / "selfdrive" / "assets" / "icons" / "chffr_wheel.png", } @@ -5116,6 +5161,13 @@ def setup(app): "path": f"icons/{filename}" } break + elif theme_type == "stock" or theme_path == "__stock__": + for filename, response_key in [("button_home.png", "homeButton"), ("button_settings.png", "settingsButton")]: + if _resolve_stock_theme_asset_path(f"icons/{filename}").exists(): + response_data["images"][response_key] = { + "filename": filename, + "path": f"icons/{filename}", + } distance_dir = theme_dir / "distance_icons" if distance_dir.exists(): @@ -5179,6 +5231,13 @@ def setup(app): "filename": f"{name}.wav", "path": f"sounds/{name}.wav" } + elif theme_type == "stock" or theme_path == "__stock__": + for name in ["engage", "disengage", "startup", "prompt"]: + if _resolve_stock_theme_asset_path(f"sounds/{name}.wav").exists(): + response_data["sounds"][name] = { + "filename": f"{name}.wav", + "path": f"sounds/{name}.wav" + } steering_wheel_path = None if theme_type == "stock" or theme_path == "__stock__":