diff --git a/src/controllers/preset.py b/src/controllers/preset.py index 1eac6f6..4c2a3dc 100644 --- a/src/controllers/preset.py +++ b/src/controllers/preset.py @@ -4,6 +4,7 @@ from models.preset import Preset from models.profile import Profile from models.espnow import ESPNow from util.espnow_message import build_message, build_preset_dict +import asyncio import json controller = Microdot() @@ -130,8 +131,9 @@ async def send_presets(request, session): return json.dumps({"error": "preset_ids must be a non-empty list"}), 400, {'Content-Type': 'application/json'} save_flag = data.get('save', True) save_flag = bool(save_flag) + default_id = data.get('default') - # Build API-compliant preset map keyed by preset ID (not name) + # Build API-compliant preset map keyed by preset ID, include name current_profile_id = get_current_profile_id(session) presets_by_name = {} for pid in preset_ids: @@ -140,21 +142,27 @@ async def send_presets(request, session): continue if str(preset_data.get("profile_id")) != str(current_profile_id): continue - preset_id_key = str(pid) - presets_by_name[preset_id_key] = build_preset_dict(preset_data) + preset_key = str(pid) + preset_payload = build_preset_dict(preset_data) + preset_payload["name"] = preset_data.get("name", "") + presets_by_name[preset_key] = preset_payload if not presets_by_name: return json.dumps({"error": "No matching presets found"}), 404, {'Content-Type': 'application/json'} + if default_id is not None and str(default_id) not in presets_by_name: + default_id = None + # Use shared ESPNow singleton esp = ESPNow() async def send_chunk(chunk_presets): # Include save flag so the led-driver can persist when desired. - msg = build_message(presets=chunk_presets, save=save_flag) + msg = build_message(presets=chunk_presets, save=save_flag, default=default_id) await esp.send(msg) MAX_BYTES = 240 + SEND_DELAY_MS = 100 entries = list(presets_by_name.items()) total_presets = len(entries) messages_sent = 0 @@ -164,7 +172,7 @@ async def send_presets(request, session): for name, preset_obj in entries: test_batch = dict(batch) test_batch[name] = preset_obj - test_msg = build_message(presets=test_batch, save=save_flag) + test_msg = build_message(presets=test_batch, save=save_flag, default=default_id) size = len(test_msg) if size <= MAX_BYTES or not batch: @@ -172,12 +180,14 @@ async def send_presets(request, session): last_msg = test_msg else: await send_chunk(batch) + await asyncio.sleep_ms(SEND_DELAY_MS) messages_sent += 1 batch = {name: preset_obj} - last_msg = build_message(presets=batch, save=save_flag) + last_msg = build_message(presets=batch, save=save_flag, default=default_id) if batch: await send_chunk(batch) + await asyncio.sleep_ms(SEND_DELAY_MS) messages_sent += 1 return json.dumps({ diff --git a/src/models/tab.py b/src/models/tab.py index c55ca3a..f29fd1a 100644 --- a/src/models/tab.py +++ b/src/models/tab.py @@ -9,7 +9,8 @@ class Tab(Model): self[next_id] = { "name": name, "names": names if names else [], - "presets": presets if presets else [] + "presets": presets if presets else [], + "default_preset": None } self.save() return next_id diff --git a/src/static/presets.js b/src/static/presets.js index 31387c0..50af1b2 100644 --- a/src/static/presets.js +++ b/src/static/presets.js @@ -96,6 +96,7 @@ document.addEventListener('DOMContentLoaded', () => { const presetAddColorButton = document.getElementById('preset-add-color-btn'); const presetBrightnessInput = document.getElementById('preset-brightness-input'); const presetDelayInput = document.getElementById('preset-delay-input'); + const presetDefaultButton = document.getElementById('preset-default-btn'); const presetSaveButton = document.getElementById('preset-save-btn'); const presetClearButton = document.getElementById('preset-clear-btn'); const presetAddFromPaletteButton = document.getElementById('preset-add-from-palette-btn'); @@ -439,6 +440,38 @@ document.addEventListener('DOMContentLoaded', () => { } }; + const getActiveTabId = () => { + if (currentEditTabId) { + return currentEditTabId; + } + const section = document.querySelector('.presets-section[data-tab-id]'); + return section ? section.dataset.tabId : null; + }; + + const updateTabDefaultPreset = async (presetId) => { + const tabId = getActiveTabId(); + if (!tabId) { + return; + } + try { + const tabResponse = await fetch(`/tabs/${tabId}`, { + headers: { Accept: 'application/json' }, + }); + if (!tabResponse.ok) { + return; + } + const tabData = await tabResponse.json(); + tabData.default_preset = presetId; + await fetch(`/tabs/${tabId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(tabData), + }); + } catch (error) { + console.warn('Failed to save tab default preset:', error); + } + }; + const openEditor = () => { if (presetEditorModal) { presetEditorModal.classList.add('active'); @@ -996,12 +1029,30 @@ document.addEventListener('DOMContentLoaded', () => { // Work out the preset ID: for existing presets use currentEditId, otherwise fall back to name const presetId = currentEditId || payload.name; // First send/override the preset definition under its ID - sendPresetViaEspNow(presetId, payload, null); + sendPresetViaEspNow(presetId, payload, null, true, false); // Then send a separate select-only message for this preset ID to all devices in the tab sendSelectForCurrentTabDevices(presetId, section); }); } + if (presetDefaultButton) { + presetDefaultButton.addEventListener('click', async () => { + const payload = buildPresetPayload(); + if (!payload.name) { + alert('Preset name is required.'); + return; + } + const section = document.querySelector('.presets-section[data-tab-id]'); + const namesAttr = section && section.getAttribute('data-device-names'); + const deviceNames = namesAttr + ? namesAttr.split(',').map((n) => n.trim()).filter((n) => n.length > 0) + : []; + const presetId = currentEditId || payload.name; + await updateTabDefaultPreset(presetId); + sendDefaultPreset(presetId, deviceNames); + }); + } + presetSaveButton.addEventListener('click', async () => { const payload = buildPresetPayload(); if (!payload.name) { @@ -1035,18 +1086,18 @@ document.addEventListener('DOMContentLoaded', () => { if (saved && typeof saved === 'object') { if (currentEditId) { // PUT returns the preset object directly; use the existing ID - sendPresetViaEspNow(currentEditId, saved, deviceNames); + sendPresetViaEspNow(currentEditId, saved, deviceNames, true, false); } else { // POST returns { id: preset } const entries = Object.entries(saved); if (entries.length > 0) { const [newId, presetData] = entries[0]; - sendPresetViaEspNow(newId, presetData, deviceNames); + sendPresetViaEspNow(newId, presetData, deviceNames, true, false); } } } else { // Fallback: send what we just built - sendPresetViaEspNow(payload.name, payload, deviceNames); + sendPresetViaEspNow(payload.name, payload, deviceNames, true, false); } await loadPresets(); @@ -1112,7 +1163,7 @@ document.addEventListener('DOMContentLoaded', () => { // Build an ESPNow preset message for a single preset and optionally include a select // for the given device names, then send it via WebSocket. // saveToDevice defaults to true. -const sendPresetViaEspNow = (presetId, preset, deviceNames, saveToDevice = true) => { +const sendPresetViaEspNow = (presetId, preset, deviceNames, saveToDevice = true, setDefault = false) => { try { const colors = Array.isArray(preset.colors) && preset.colors.length ? preset.colors @@ -1142,6 +1193,9 @@ const sendPresetViaEspNow = (presetId, preset, deviceNames, saveToDevice = true) // Instruct led-driver to save this preset when received. message.save = true; } + if (setDefault) { + message.default = presetId; + } // Optionally include a select section for specific devices if (Array.isArray(deviceNames) && deviceNames.length > 0) { @@ -1163,6 +1217,26 @@ const sendPresetViaEspNow = (presetId, preset, deviceNames, saveToDevice = true) } }; +const sendDefaultPreset = (presetId, deviceNames) => { + if (!presetId) { + alert('Select a preset to set as default.'); + return; + } + const message = { v: '1', default: presetId }; + if (Array.isArray(deviceNames) && deviceNames.length > 0) { + const select = {}; + deviceNames.forEach((name) => { + if (name) { + select[name] = [presetId]; + } + }); + if (Object.keys(select).length > 0) { + message.select = select; + } + } + sendEspnowMessage(message); +}; + // Expose for other scripts (tabs.js) so they can reuse the shared WebSocket. try { window.sendPresetViaEspNow = sendPresetViaEspNow; diff --git a/src/static/tabs.js b/src/static/tabs.js index b012391..1e3b227 100644 --- a/src/static/tabs.js +++ b/src/static/tabs.js @@ -319,7 +319,6 @@ async function loadTabContent(tabId) { const deviceNames = Array.isArray(tab.names) ? tab.names.join(',') : ''; container.innerHTML = `
-

Presets for ${tabName}

@@ -397,13 +396,17 @@ async function sendTabPresets(tabId) { } // Call server-side ESPNow sender with just the IDs; it handles chunking. + const payload = { preset_ids: presetIds }; + if (tabData.default_preset) { + payload.default = tabData.default_preset; + } const response = await fetch('/presets/send', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', }, - body: JSON.stringify({ preset_ids: presetIds }), + body: JSON.stringify(payload), }); const data = await response.json().catch(() => ({})); @@ -462,9 +465,10 @@ async function sendProfilePresets() { return; } - const allPresetIdsSet = new Set(); + let totalSent = 0; + let totalMessages = 0; + let tabsWithPresets = 0; - // Collect all preset IDs used in all tabs of this profile for (const tabId of tabList) { try { const tabResp = await fetch(`/tabs/${tabId}`, { @@ -484,40 +488,43 @@ async function sendProfilePresets() { presetIds = tabData.presets.flat(); } } - (presetIds || []).forEach((id) => { - if (id) allPresetIdsSet.add(id); + presetIds = (presetIds || []).filter(Boolean); + if (!presetIds.length) { + continue; + } + tabsWithPresets += 1; + const payload = { preset_ids: presetIds }; + if (tabData.default_preset) { + payload.default = tabData.default_preset; + } + const response = await fetch('/presets/send', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + body: JSON.stringify(payload), }); + const data = await response.json().catch(() => ({})); + if (!response.ok) { + const msg = (data && data.error) || `Failed to send presets for tab ${tabId}.`; + console.warn(msg); + continue; + } + totalSent += typeof data.presets_sent === 'number' ? data.presets_sent : presetIds.length; + totalMessages += typeof data.messages_sent === 'number' ? data.messages_sent : 0; } catch (e) { - console.error('Failed to load tab for profile presets:', e); + console.error('Failed to send profile presets for tab:', tabId, e); } } - const allPresetIds = Array.from(allPresetIdsSet); - if (!allPresetIds.length) { + if (!tabsWithPresets) { alert('No presets to send for the current profile.'); return; } - // Call server-side ESPNow sender with all unique preset IDs; it handles chunking and save flag. - const response = await fetch('/presets/send', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/json', - }, - body: JSON.stringify({ preset_ids: allPresetIds }), - }); - - const data = await response.json().catch(() => ({})); - if (!response.ok) { - const msg = (data && data.error) || 'Failed to send presets for profile.'; - alert(msg); - return; - } - - const sent = typeof data.presets_sent === 'number' ? data.presets_sent : allPresetIds.length; - const messages = typeof data.messages_sent === 'number' ? data.messages_sent : '?'; - alert(`Sent ${sent} preset(s) for the current profile in ${messages} ESPNow message(s).`); + const messagesLabel = totalMessages ? totalMessages : '?'; + alert(`Sent ${totalSent} preset(s) across ${tabsWithPresets} tab(s) in ${messagesLabel} ESPNow message(s).`); } catch (error) { console.error('Failed to send profile presets:', error); alert('Failed to send profile presets.'); diff --git a/src/templates/index.html b/src/templates/index.html index 5c93816..faf0248 100644 --- a/src/templates/index.html +++ b/src/templates/index.html @@ -176,6 +176,7 @@