Send tab defaults with presets.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-02-08 19:40:22 +13:00
parent d8b33923d5
commit 0e96223bf6
6 changed files with 138 additions and 42 deletions

View File

@@ -4,6 +4,7 @@ from models.preset import Preset
from models.profile import Profile from models.profile import Profile
from models.espnow import ESPNow from models.espnow import ESPNow
from util.espnow_message import build_message, build_preset_dict from util.espnow_message import build_message, build_preset_dict
import asyncio
import json import json
controller = Microdot() 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'} 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 = data.get('save', True)
save_flag = bool(save_flag) 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) current_profile_id = get_current_profile_id(session)
presets_by_name = {} presets_by_name = {}
for pid in preset_ids: for pid in preset_ids:
@@ -140,21 +142,27 @@ async def send_presets(request, session):
continue continue
if str(preset_data.get("profile_id")) != str(current_profile_id): if str(preset_data.get("profile_id")) != str(current_profile_id):
continue continue
preset_id_key = str(pid) preset_key = str(pid)
presets_by_name[preset_id_key] = build_preset_dict(preset_data) 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: if not presets_by_name:
return json.dumps({"error": "No matching presets found"}), 404, {'Content-Type': 'application/json'} 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 # Use shared ESPNow singleton
esp = ESPNow() esp = ESPNow()
async def send_chunk(chunk_presets): async def send_chunk(chunk_presets):
# Include save flag so the led-driver can persist when desired. # 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) await esp.send(msg)
MAX_BYTES = 240 MAX_BYTES = 240
SEND_DELAY_MS = 100
entries = list(presets_by_name.items()) entries = list(presets_by_name.items())
total_presets = len(entries) total_presets = len(entries)
messages_sent = 0 messages_sent = 0
@@ -164,7 +172,7 @@ async def send_presets(request, session):
for name, preset_obj in entries: for name, preset_obj in entries:
test_batch = dict(batch) test_batch = dict(batch)
test_batch[name] = preset_obj 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) size = len(test_msg)
if size <= MAX_BYTES or not batch: if size <= MAX_BYTES or not batch:
@@ -172,12 +180,14 @@ async def send_presets(request, session):
last_msg = test_msg last_msg = test_msg
else: else:
await send_chunk(batch) await send_chunk(batch)
await asyncio.sleep_ms(SEND_DELAY_MS)
messages_sent += 1 messages_sent += 1
batch = {name: preset_obj} 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: if batch:
await send_chunk(batch) await send_chunk(batch)
await asyncio.sleep_ms(SEND_DELAY_MS)
messages_sent += 1 messages_sent += 1
return json.dumps({ return json.dumps({

View File

@@ -9,7 +9,8 @@ class Tab(Model):
self[next_id] = { self[next_id] = {
"name": name, "name": name,
"names": names if names else [], "names": names if names else [],
"presets": presets if presets else [] "presets": presets if presets else [],
"default_preset": None
} }
self.save() self.save()
return next_id return next_id

View File

@@ -96,6 +96,7 @@ document.addEventListener('DOMContentLoaded', () => {
const presetAddColorButton = document.getElementById('preset-add-color-btn'); const presetAddColorButton = document.getElementById('preset-add-color-btn');
const presetBrightnessInput = document.getElementById('preset-brightness-input'); const presetBrightnessInput = document.getElementById('preset-brightness-input');
const presetDelayInput = document.getElementById('preset-delay-input'); const presetDelayInput = document.getElementById('preset-delay-input');
const presetDefaultButton = document.getElementById('preset-default-btn');
const presetSaveButton = document.getElementById('preset-save-btn'); const presetSaveButton = document.getElementById('preset-save-btn');
const presetClearButton = document.getElementById('preset-clear-btn'); const presetClearButton = document.getElementById('preset-clear-btn');
const presetAddFromPaletteButton = document.getElementById('preset-add-from-palette-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 = () => { const openEditor = () => {
if (presetEditorModal) { if (presetEditorModal) {
presetEditorModal.classList.add('active'); 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 // Work out the preset ID: for existing presets use currentEditId, otherwise fall back to name
const presetId = currentEditId || payload.name; const presetId = currentEditId || payload.name;
// First send/override the preset definition under its ID // 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 // Then send a separate select-only message for this preset ID to all devices in the tab
sendSelectForCurrentTabDevices(presetId, section); 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 () => { presetSaveButton.addEventListener('click', async () => {
const payload = buildPresetPayload(); const payload = buildPresetPayload();
if (!payload.name) { if (!payload.name) {
@@ -1035,18 +1086,18 @@ document.addEventListener('DOMContentLoaded', () => {
if (saved && typeof saved === 'object') { if (saved && typeof saved === 'object') {
if (currentEditId) { if (currentEditId) {
// PUT returns the preset object directly; use the existing ID // PUT returns the preset object directly; use the existing ID
sendPresetViaEspNow(currentEditId, saved, deviceNames); sendPresetViaEspNow(currentEditId, saved, deviceNames, true, false);
} else { } else {
// POST returns { id: preset } // POST returns { id: preset }
const entries = Object.entries(saved); const entries = Object.entries(saved);
if (entries.length > 0) { if (entries.length > 0) {
const [newId, presetData] = entries[0]; const [newId, presetData] = entries[0];
sendPresetViaEspNow(newId, presetData, deviceNames); sendPresetViaEspNow(newId, presetData, deviceNames, true, false);
} }
} }
} else { } else {
// Fallback: send what we just built // Fallback: send what we just built
sendPresetViaEspNow(payload.name, payload, deviceNames); sendPresetViaEspNow(payload.name, payload, deviceNames, true, false);
} }
await loadPresets(); await loadPresets();
@@ -1112,7 +1163,7 @@ document.addEventListener('DOMContentLoaded', () => {
// Build an ESPNow preset message for a single preset and optionally include a select // Build an ESPNow preset message for a single preset and optionally include a select
// for the given device names, then send it via WebSocket. // for the given device names, then send it via WebSocket.
// saveToDevice defaults to true. // saveToDevice defaults to true.
const sendPresetViaEspNow = (presetId, preset, deviceNames, saveToDevice = true) => { const sendPresetViaEspNow = (presetId, preset, deviceNames, saveToDevice = true, setDefault = false) => {
try { try {
const colors = Array.isArray(preset.colors) && preset.colors.length const colors = Array.isArray(preset.colors) && preset.colors.length
? preset.colors ? preset.colors
@@ -1142,6 +1193,9 @@ const sendPresetViaEspNow = (presetId, preset, deviceNames, saveToDevice = true)
// Instruct led-driver to save this preset when received. // Instruct led-driver to save this preset when received.
message.save = true; message.save = true;
} }
if (setDefault) {
message.default = presetId;
}
// Optionally include a select section for specific devices // Optionally include a select section for specific devices
if (Array.isArray(deviceNames) && deviceNames.length > 0) { 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. // Expose for other scripts (tabs.js) so they can reuse the shared WebSocket.
try { try {
window.sendPresetViaEspNow = sendPresetViaEspNow; window.sendPresetViaEspNow = sendPresetViaEspNow;

View File

@@ -319,7 +319,6 @@ async function loadTabContent(tabId) {
const deviceNames = Array.isArray(tab.names) ? tab.names.join(',') : ''; const deviceNames = Array.isArray(tab.names) ? tab.names.join(',') : '';
container.innerHTML = ` container.innerHTML = `
<div class="presets-section" data-tab-id="${tabId}" data-device-names="${deviceNames}"> <div class="presets-section" data-tab-id="${tabId}" data-device-names="${deviceNames}">
<h3>Presets for ${tabName}</h3>
<div class="profiles-actions presets-toolbar" style="margin-bottom: 1rem;"> <div class="profiles-actions presets-toolbar" style="margin-bottom: 1rem;">
<div class="tab-brightness-group"> <div class="tab-brightness-group">
<label for="tab-brightness-slider">Brightness</label> <label for="tab-brightness-slider">Brightness</label>
@@ -397,13 +396,17 @@ async function sendTabPresets(tabId) {
} }
// Call server-side ESPNow sender with just the IDs; it handles chunking. // 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', { const response = await fetch('/presets/send', {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Accept': 'application/json', 'Accept': 'application/json',
}, },
body: JSON.stringify({ preset_ids: presetIds }), body: JSON.stringify(payload),
}); });
const data = await response.json().catch(() => ({})); const data = await response.json().catch(() => ({}));
@@ -462,9 +465,10 @@ async function sendProfilePresets() {
return; 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) { for (const tabId of tabList) {
try { try {
const tabResp = await fetch(`/tabs/${tabId}`, { const tabResp = await fetch(`/tabs/${tabId}`, {
@@ -484,40 +488,43 @@ async function sendProfilePresets() {
presetIds = tabData.presets.flat(); presetIds = tabData.presets.flat();
} }
} }
(presetIds || []).forEach((id) => { presetIds = (presetIds || []).filter(Boolean);
if (id) allPresetIdsSet.add(id); 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) { } 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 (!tabsWithPresets) {
if (!allPresetIds.length) {
alert('No presets to send for the current profile.'); alert('No presets to send for the current profile.');
return; return;
} }
// Call server-side ESPNow sender with all unique preset IDs; it handles chunking and save flag. const messagesLabel = totalMessages ? totalMessages : '?';
const response = await fetch('/presets/send', { alert(`Sent ${totalSent} preset(s) across ${tabsWithPresets} tab(s) in ${messagesLabel} ESPNow message(s).`);
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).`);
} catch (error) { } catch (error) {
console.error('Failed to send profile presets:', error); console.error('Failed to send profile presets:', error);
alert('Failed to send profile presets.'); alert('Failed to send profile presets.');

View File

@@ -176,6 +176,7 @@
</div> </div>
<div class="modal-actions"> <div class="modal-actions">
<button class="btn btn-secondary" id="preset-send-btn">Try</button> <button class="btn btn-secondary" id="preset-send-btn">Try</button>
<button class="btn btn-secondary" id="preset-default-btn">Default</button>
<button class="btn btn-primary" id="preset-save-btn">Save &amp; Send</button> <button class="btn btn-primary" id="preset-save-btn">Save &amp; Send</button>
<button class="btn btn-danger" id="preset-remove-from-tab-btn">Remove from Tab</button> <button class="btn btn-danger" id="preset-remove-from-tab-btn">Remove from Tab</button>
<button class="btn btn-secondary" id="preset-clear-btn">Clear</button> <button class="btn btn-secondary" id="preset-clear-btn">Clear</button>

View File

@@ -7,7 +7,7 @@ This module provides utilities to build ESPNow messages according to the API spe
import json import json
def build_message(presets=None, select=None, save=False): def build_message(presets=None, select=None, save=False, default=None):
""" """
Build an ESPNow message according to the API specification. Build an ESPNow message according to the API specification.
@@ -48,6 +48,9 @@ def build_message(presets=None, select=None, save=False):
if select: if select:
message["select"] = select message["select"] = select
if default is not None:
message["default"] = default
return json.dumps(message) return json.dumps(message)