Send tab defaults with presets.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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({
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to load tab for profile presets:', e);
|
|
||||||
}
|
}
|
||||||
|
tabsWithPresets += 1;
|
||||||
|
const payload = { preset_ids: presetIds };
|
||||||
|
if (tabData.default_preset) {
|
||||||
|
payload.default = tabData.default_preset;
|
||||||
}
|
}
|
||||||
|
|
||||||
const allPresetIds = Array.from(allPresetIdsSet);
|
|
||||||
if (!allPresetIds.length) {
|
|
||||||
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', {
|
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: allPresetIds }),
|
body: JSON.stringify(payload),
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await response.json().catch(() => ({}));
|
const data = await response.json().catch(() => ({}));
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const msg = (data && data.error) || 'Failed to send presets for profile.';
|
const msg = (data && data.error) || `Failed to send presets for tab ${tabId}.`;
|
||||||
alert(msg);
|
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 send profile presets for tab:', tabId, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tabsWithPresets) {
|
||||||
|
alert('No presets to send for the current profile.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sent = typeof data.presets_sent === 'number' ? data.presets_sent : allPresetIds.length;
|
const messagesLabel = totalMessages ? totalMessages : '?';
|
||||||
const messages = typeof data.messages_sent === 'number' ? data.messages_sent : '?';
|
alert(`Sent ${totalSent} preset(s) across ${tabsWithPresets} tab(s) in ${messagesLabel} ESPNow message(s).`);
|
||||||
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.');
|
||||||
|
|||||||
@@ -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 & Send</button>
|
<button class="btn btn-primary" id="preset-save-btn">Save & 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>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user