Align controller backend and data with new presets

Update palettes, profiles, tabs, preset sending, and ESPNow message format to match the new preset defaults and driver short-field schema.
This commit is contained in:
2026-01-29 00:04:23 +13:00
parent fd37183400
commit cf1d831b5a
11 changed files with 305 additions and 67 deletions

View File

@@ -1,39 +1,12 @@
{
"1": {
"name": "Default Colors",
"colors": [
"#FF0000",
"#00FF00",
"#0000FF",
"#FFFF00",
"#FF00FF",
"#00FFFF",
"#FFFFFF",
"#000000",
"#FFA500",
"#800080"
]
},
"2": {
"name": "Warm Colors",
"colors": [
"#FF6B6B",
"#FF8E53",
"#FFA07A",
"#FFD700",
"#FFA500",
"#FF6347"
]
},
"3": {
"name": "Cool Colors",
"colors": [
"#4ECDC4",
"#44A08D",
"#96CEB4",
"#A8E6CF",
"#5F9EA0",
"#4682B4"
]
}
"1": [
"#FF0000",
"#00FF00",
"#0000FF",
"#FFFF00",
"#FF00FF",
"#00FFFF",
"#FFFFFF",
"#000000"
]
}

View File

@@ -1 +1,149 @@
{"1": {"name": "Warm White", "pattern": "on", "colors": ["#FFE5B4", "#FFDAB9", "#FFE4B5"], "brightness": 200, "delay": 100, "n1": 10, "n2": 10, "n3": 10, "n4": 10, "n5": 0, "n6": 0, "n7": 0, "n8": 0}, "2": {"name": "Rainbow", "pattern": "rainbow", "colors": ["#FF0000", "#FF7F00", "#FFFF00", "#00FF00", "#0000FF", "#4B0082", "#9400D3"], "brightness": 255, "delay": 50, "n1": 20, "n2": 15, "n3": 10, "n4": 5, "n5": 0, "n6": 0, "Step Rate": 20, "n7": 0, "n8": 0}, "3": {"name": "Pulse Red", "pattern": "pulse", "colors": ["#FF0000", "#CC0000", "#990000"], "brightness": 180, "delay": 200, "n1": 30, "n2": 20, "n3": 10, "n4": 5, "n5": 0, "n6": 0}}
{
"1": {
"name": "on",
"pattern": "on",
"colors": [
"#FFFFFF"
],
"brightness": 255,
"delay": 100,
"auto": true,
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0,
"n5": 0,
"n6": 0,
"n7": 0,
"n8": 0
},
"2": {
"name": "off",
"pattern": "off",
"colors": [],
"brightness": 0,
"delay": 100,
"auto": true,
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0,
"n5": 0,
"n6": 0,
"n7": 0,
"n8": 0
},
"3": {
"name": "rainbow",
"pattern": "rainbow",
"colors": [],
"brightness": 255,
"delay": 100,
"auto": true,
"n1": 2,
"n2": 0,
"n3": 0,
"n4": 0,
"n5": 0,
"n6": 0,
"n7": 0,
"n8": 0
},
"4": {
"name": "transition",
"pattern": "transition",
"colors": [
"#FF0000",
"#00FF00",
"#0000FF"
],
"brightness": 255,
"delay": 500,
"auto": true,
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0,
"n5": 0,
"n6": 0,
"n7": 0,
"n8": 0
},
"5": {
"name": "chase",
"pattern": "chase",
"colors": [
"#FF0000",
"#0000FF"
],
"brightness": 255,
"delay": 200,
"auto": true,
"n1": 5,
"n2": 5,
"n3": 1,
"n4": 1,
"n5": 0,
"n6": 0,
"n7": 0,
"n8": 0
},
"6": {
"name": "pulse",
"pattern": "pulse",
"colors": [
"#00FF00"
],
"brightness": 255,
"delay": 500,
"auto": true,
"n1": 1000,
"n2": 500,
"n3": 1000,
"n4": 0,
"n5": 0,
"n6": 0,
"n7": 0,
"n8": 0
},
"7": {
"name": "circle",
"pattern": "circle",
"colors": [
"#FFA500",
"#800080"
],
"brightness": 255,
"delay": 200,
"auto": true,
"n1": 2,
"n2": 10,
"n3": 2,
"n4": 5,
"n5": 0,
"n6": 0,
"n7": 0,
"n8": 0
},
"8": {
"name": "blink",
"pattern": "blink",
"colors": [
"#FF0000",
"#00FF00",
"#0000FF",
"#FFFF00"
],
"brightness": 255,
"delay": 1000,
"auto": true,
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0,
"n5": 0,
"n6": 0,
"n7": 0,
"n8": 0
}
}

View File

@@ -1 +1,11 @@
{"1": {"name": "Default", "tabs": ["1", "2"], "scenes": ["1", "2"], "palette": ["#FF0000", "#00FF00", "#0000FF", "#FFFF00", "#FF00FF", "#00FFFF", "#FFFFFF", "#000000"]}, "2": {"name": "test", "type": "tabs", "tabs": ["12", "13"], "scenes": [], "palette": ["#b93c3c", "#3cb961"], "color_palette": ["#b93c3c", "#3cb961"]}}
{
"1": {
"name": "default",
"type": "tabs",
"tabs": [
"1"
],
"scenes": [],
"palette_id": "1"
}
}

View File

@@ -1 +1,30 @@
{"1": {"name": "Main", "names": ["1", "2", "3"], "presets": [["1", "2", "3"]], "presets_flat": ["1", "2", "3"]}, "2": {"name": "Accent", "names": ["4", "5"], "presets": []}, "3": {"name": "", "names": [], "presets": []}, "4": {"name": "", "names": [], "presets": []}, "5": {"name": "", "names": [], "presets": []}, "6": {"name": "", "names": [], "presets": []}, "7": {"name": "", "names": [], "presets": []}, "8": {"name": "", "names": [], "presets": []}, "9": {"name": "", "names": [], "presets": []}, "10": {"name": "", "names": [], "presets": []}, "11": {"name": "", "names": [], "presets": []}, "12": {"name": "test2", "names": ["1"], "presets": [], "colors": ["#b93c3c", "#761e1e", "#ffffff"]}, "13": {"name": "test5", "names": ["1"], "presets": []}}
{
"1": {
"name": "default",
"names": [
"1"
],
"presets": [
[
"1",
"2",
"3",
"4",
"5",
"6",
"7",
"8"
]
],
"presets_flat": [
"1",
"2",
"3",
"4",
"5",
"6",
"7",
"8"
]
}
}

View File

@@ -8,14 +8,18 @@ palettes = Palette()
@controller.get('')
async def list_palettes(request):
"""List all palettes."""
return json.dumps(palettes), 200, {'Content-Type': 'application/json'}
data = {}
for pid in palettes.list():
colors = palettes.read(pid)
data[pid] = colors
return json.dumps(data), 200, {'Content-Type': 'application/json'}
@controller.get('/<id>')
async def get_palette(request, id):
"""Get a specific palette by ID."""
palette = palettes.read(id)
if palette:
return json.dumps(palette), 200, {'Content-Type': 'application/json'}
return json.dumps({"colors": palette, "id": str(id)}), 200, {'Content-Type': 'application/json'}
return json.dumps({"error": "Palette not found"}), 404
@controller.post('')
@@ -23,12 +27,14 @@ async def create_palette(request):
"""Create a new palette."""
try:
data = request.json or {}
name = data.get("name", "")
colors = data.get("colors", None)
palette_id = palettes.create(name, colors)
if data:
palettes.update(palette_id, data)
return json.dumps(palettes.read(palette_id)), 201, {'Content-Type': 'application/json'}
# Palette no longer needs a name; only colors are stored.
palette_id = palettes.create("", colors)
palette = palettes.read(palette_id) or {}
# Include the ID in the response payload so clients can link it.
palette_with_id = {"id": str(palette_id)}
palette_with_id.update(palette)
return json.dumps(palette_with_id), 201, {'Content-Type': 'application/json'}
except Exception as e:
return json.dumps({"error": str(e)}), 400
@@ -36,9 +42,15 @@ async def create_palette(request):
async def update_palette(request, id):
"""Update an existing palette."""
try:
data = request.json
data = request.json or {}
# Ignore any name field; only colors are relevant.
if "name" in data:
data.pop("name", None)
if palettes.update(id, data):
return json.dumps(palettes.read(id)), 200, {'Content-Type': 'application/json'}
palette = palettes.read(id) or {}
palette_with_id = {"id": str(id)}
palette_with_id.update(palette)
return json.dumps(palette_with_id), 200, {'Content-Type': 'application/json'}
return json.dumps({"error": "Palette not found"}), 404
except Exception as e:
return json.dumps({"error": str(e)}), 400

View File

@@ -75,14 +75,14 @@ async def send_presets(request):
if not isinstance(preset_ids, list) or not preset_ids:
return json.dumps({"error": "preset_ids must be a non-empty list"}), 400, {'Content-Type': 'application/json'}
# Build API-compliant preset map keyed by preset name
# Build API-compliant preset map keyed by preset ID (not name)
presets_by_name = {}
for pid in preset_ids:
preset_data = presets.read(str(pid))
if not preset_data:
continue
name_key = preset_data.get('name') or str(pid)
presets_by_name[name_key] = build_preset_dict(preset_data)
preset_id_key = str(pid)
presets_by_name[preset_id_key] = build_preset_dict(preset_data)
if not presets_by_name:
return json.dumps({"error": "No matching presets found"}), 404, {'Content-Type': 'application/json'}

View File

@@ -6,22 +6,30 @@ class Palette(Model):
def create(self, name="", colors=None):
next_id = self.get_next_id()
self[next_id] = {
"name": name,
"colors": colors if colors else []
}
# Store palette as a simple list of colors; name is ignored.
self[next_id] = list(colors) if colors else []
self.save()
return next_id
def read(self, id):
id_str = str(id)
return self.get(id_str, None)
value = self.get(id_str, None)
# Backwards compatibility: if stored as {"colors": [...]}, unwrap.
if isinstance(value, dict) and "colors" in value:
return value.get("colors") or []
# Otherwise, expect a list of colors.
return value or []
def update(self, id, data):
id_str = str(id)
if id_str not in self:
return False
self[id_str].update(data)
# Accept either {"colors": [...]} or a raw list.
if isinstance(data, dict):
colors = data.get("colors", [])
else:
colors = data
self[id_str] = list(colors) if colors else []
self.save()
return True

View File

@@ -1,21 +1,45 @@
from models.model import Model
from models.pallet import Palette
class Profile(Model):
def __init__(self):
"""Profile model.
Each profile owns a single, unique palette stored in the Palette model.
The profile stores a `palette_id` that points to its palette; any legacy
inline `palette` arrays are migrated to a dedicated Palette entry.
"""
super().__init__()
self._palette_model = Palette()
# Migrate legacy inline palettes to separate Palette entries.
changed = False
for pid, pdata in list(self.items()):
if isinstance(pdata, dict):
if "palette" in pdata and "palette_id" not in pdata:
colors = pdata.get("palette") or []
palette_id = self._palette_model.create(colors=colors)
pdata.pop("palette", None)
pdata["palette_id"] = str(palette_id)
changed = True
if changed:
self.save()
def create(self, name="", profile_type="tabs"):
"""
Create a new profile.
"""Create a new profile and its own empty palette.
profile_type: "tabs" or "scenes" (ignoring scenes for now)
"""
next_id = self.get_next_id()
# Create a unique palette for this profile.
palette_id = self._palette_model.create(colors=[])
self[next_id] = {
"name": name,
"type": profile_type, # "tabs" or "scenes"
"tabs": [], # Array of tab IDs
"scenes": [], # Array of scene IDs (for future use)
"palette": []
"palette_id": str(palette_id),
}
self.save()
return next_id

View File

@@ -1176,6 +1176,9 @@ const sendPresetViaEspNow = (presetId, preset, deviceNames) => {
// Expose for other scripts (tabs.js) so they can reuse the shared WebSocket.
try {
window.sendPresetViaEspNow = sendPresetViaEspNow;
// Expose a generic ESPNow sender so other scripts (tabs.js) can send
// non-preset messages such as global brightness.
window.sendEspnowRaw = sendEspnowMessage;
} catch (e) {
// window may not exist in some environments; ignore.
}

View File

@@ -262,6 +262,11 @@ async function loadTabContent(tabId) {
<div class="profiles-actions" style="margin-bottom: 1rem;">
<button class="btn btn-primary" id="preset-add-btn-tab">Add Preset</button>
<button class="btn btn-secondary" id="send-tab-presets-btn">Send Presets</button>
<div style="display: flex; align-items: center; gap: 0.5rem; margin-left: auto;">
<label for="tab-brightness-slider" style="white-space: nowrap;">Brightness</label>
<input type="range" id="tab-brightness-slider" min="0" max="255" value="255">
<span id="tab-brightness-value">255</span>
</div>
</div>
<div id="presets-list-tab" class="presets-list">
<!-- Presets will be loaded here by presets.js -->
@@ -276,6 +281,31 @@ async function loadTabContent(tabId) {
sendTabPresets(tabId);
});
}
// Wire up per-tab brightness slider to send global brightness via ESPNow.
const brightnessSlider = container.querySelector('#tab-brightness-slider');
const brightnessValue = container.querySelector('#tab-brightness-value');
// Simple debounce so we don't spam ESPNow while dragging
let brightnessSendTimeout = null;
if (brightnessSlider && brightnessValue) {
brightnessSlider.addEventListener('input', (e) => {
const val = parseInt(e.target.value, 10) || 0;
brightnessValue.textContent = String(val);
if (brightnessSendTimeout) {
clearTimeout(brightnessSendTimeout);
}
brightnessSendTimeout = setTimeout(() => {
if (typeof window.sendEspnowRaw === 'function') {
try {
// Include version so led-driver accepts the message.
window.sendEspnowRaw({ v: '1', b: val });
} catch (err) {
console.error('Failed to send brightness via ESPNow:', err);
}
}
}, 150);
});
}
// Trigger presets loading if the function exists
if (typeof renderTabPresets === 'function') {

View File

@@ -97,7 +97,7 @@ def build_preset_dict(preset_data):
})
"""
# Ensure colors are in hex format
colors = preset_data.get("colors", ["#FFFFFF"])
colors = preset_data.get("colors", preset_data.get("c", ["#FFFFFF"]))
if colors:
# Convert RGB tuples to hex strings if needed
if isinstance(colors[0], list) and len(colors[0]) == 3:
@@ -111,12 +111,13 @@ def build_preset_dict(preset_data):
else:
colors = ["#FFFFFF"]
# Build payload using the short keys expected by led-driver
preset = {
"pattern": preset_data.get("pattern", "off"),
"colors": colors,
"delay": preset_data.get("delay", 100),
"brightness": preset_data.get("brightness", preset_data.get("br", 127)),
"auto": preset_data.get("auto", True),
"p": preset_data.get("pattern", preset_data.get("p", "off")),
"c": colors,
"d": preset_data.get("delay", preset_data.get("d", 100)),
"b": preset_data.get("brightness", preset_data.get("b", preset_data.get("br", 127))),
"a": preset_data.get("auto", preset_data.get("a", True)),
"n1": preset_data.get("n1", 0),
"n2": preset_data.get("n2", 0),
"n3": preset_data.get("n3", 0),