Compare commits
21 Commits
ac9fca8d4b
...
764d918d5b
| Author | SHA1 | Date | |
|---|---|---|---|
| 764d918d5b | |||
| edadb40cb6 | |||
| 9323719a85 | |||
| 91de705647 | |||
| 3ee7b74152 | |||
| 98bbdcbb3d | |||
| a2abd3e833 | |||
| 550217c443 | |||
| 2d2032e8b9 | |||
| 81bf4dded5 | |||
| a75e27e3d2 | |||
| 13538c39a6 | |||
| 7b724e9ce1 | |||
| aaca5435e9 | |||
| b64dacc1c3 | |||
| 8689bdb6ef | |||
| c178e87966 | |||
| dfe7ae50d2 | |||
| 8e87559af6 | |||
| aa3546e9ac | |||
| b56af23cbf |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -23,7 +23,7 @@ ENV/
|
||||
Thumbs.db
|
||||
|
||||
# Project specific
|
||||
settings.json
|
||||
*.log
|
||||
*.db
|
||||
*.sqlite
|
||||
|
||||
|
||||
6
.gitmodules
vendored
Normal file
6
.gitmodules
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
[submodule "led-driver"]
|
||||
path = led-driver
|
||||
url = git@git.technical.kiwi:technicalkiwi/led-driver.git
|
||||
[submodule "led-tool"]
|
||||
path = led-tool
|
||||
url = git@git.technical.kiwi:technicalkiwi/led-tool.git
|
||||
1
Pipfile
1
Pipfile
@@ -24,3 +24,4 @@ web = "python /home/pi/led-controller/tests/web.py"
|
||||
watch = "python -m watchfiles 'python tests/web.py' src tests"
|
||||
install = "pipenv install"
|
||||
run = "sh -c 'cd src && python main.py'"
|
||||
dev = "watchfiles \"sh -c 'cd src && python main.py'\" src"
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
#!/usr/bin/env sh
|
||||
rm -f /home/pi/led-controller/.cursor/debug.log
|
||||
1
db/device.json
Normal file
1
db/device.json
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -1,17 +1 @@
|
||||
{
|
||||
"1": {
|
||||
"name": "Main Group",
|
||||
"devices": [
|
||||
"1",
|
||||
"2",
|
||||
"3"
|
||||
]
|
||||
},
|
||||
"2": {
|
||||
"name": "Accent Group",
|
||||
"devices": [
|
||||
"4",
|
||||
"5"
|
||||
]
|
||||
}
|
||||
}
|
||||
{"1": {"name": "Main Group", "devices": ["1", "2", "3"]}, "2": {"name": "Accent Group", "devices": ["4", "5"]}}
|
||||
@@ -1,12 +1 @@
|
||||
{
|
||||
"1": [
|
||||
"#FF0000",
|
||||
"#00FF00",
|
||||
"#0000FF",
|
||||
"#FFFF00",
|
||||
"#FF00FF",
|
||||
"#00FFFF",
|
||||
"#FFFFFF",
|
||||
"#000000"
|
||||
]
|
||||
}
|
||||
{"1": ["#FF0000", "#00FF00", "#0000FF", "#FFFF00", "#FF00FF", "#00FFFF", "#FFFFFF", "#000000"], "2": [], "3": [], "4": [], "5": [], "6": [], "7": ["#FF0000", "#00FF00", "#0000FF", "#FFFF00", "#FF00FF", "#00FFFF", "#FFFFFF", "#000000"], "8": [], "9": [], "10": [], "11": [], "12": ["#890b0b", "#0b8935"]}
|
||||
277
db/preset.json
277
db/preset.json
@@ -1,276 +1 @@
|
||||
{
|
||||
"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,
|
||||
"profile_id": "1"
|
||||
},
|
||||
"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,
|
||||
"profile_id": "1"
|
||||
},
|
||||
"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,
|
||||
"profile_id": "1"
|
||||
},
|
||||
"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,
|
||||
"profile_id": "1"
|
||||
},
|
||||
"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,
|
||||
"profile_id": "1"
|
||||
},
|
||||
"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,
|
||||
"profile_id": "1"
|
||||
},
|
||||
"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,
|
||||
"profile_id": "1"
|
||||
},
|
||||
"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,
|
||||
"profile_id": "1"
|
||||
},
|
||||
"9": {
|
||||
"name": "warm white",
|
||||
"pattern": "on",
|
||||
"colors": ["#FFF5E6"],
|
||||
"brightness": 200,
|
||||
"delay": 100,
|
||||
"auto": true,
|
||||
"n1": 0,
|
||||
"n2": 0,
|
||||
"n3": 0,
|
||||
"n4": 0,
|
||||
"n5": 0,
|
||||
"n6": 0,
|
||||
"n7": 0,
|
||||
"n8": 0,
|
||||
"profile_id": "1"
|
||||
},
|
||||
"10": {
|
||||
"name": "cool white",
|
||||
"pattern": "on",
|
||||
"colors": ["#E6F2FF"],
|
||||
"brightness": 200,
|
||||
"delay": 100,
|
||||
"auto": true,
|
||||
"n1": 0,
|
||||
"n2": 0,
|
||||
"n3": 0,
|
||||
"n4": 0,
|
||||
"n5": 0,
|
||||
"n6": 0,
|
||||
"n7": 0,
|
||||
"n8": 0,
|
||||
"profile_id": "1"
|
||||
},
|
||||
"11": {
|
||||
"name": "red",
|
||||
"pattern": "on",
|
||||
"colors": ["#FF0000"],
|
||||
"brightness": 255,
|
||||
"delay": 100,
|
||||
"auto": true,
|
||||
"n1": 0,
|
||||
"n2": 0,
|
||||
"n3": 0,
|
||||
"n4": 0,
|
||||
"n5": 0,
|
||||
"n6": 0,
|
||||
"n7": 0,
|
||||
"n8": 0,
|
||||
"profile_id": "1"
|
||||
},
|
||||
"12": {
|
||||
"name": "blue",
|
||||
"pattern": "on",
|
||||
"colors": ["#0000FF"],
|
||||
"brightness": 255,
|
||||
"delay": 100,
|
||||
"auto": true,
|
||||
"n1": 0,
|
||||
"n2": 0,
|
||||
"n3": 0,
|
||||
"n4": 0,
|
||||
"n5": 0,
|
||||
"n6": 0,
|
||||
"n7": 0,
|
||||
"n8": 0,
|
||||
"profile_id": "1"
|
||||
},
|
||||
"13": {
|
||||
"name": "rainbow slow",
|
||||
"pattern": "rainbow",
|
||||
"colors": [],
|
||||
"brightness": 255,
|
||||
"delay": 150,
|
||||
"auto": true,
|
||||
"n1": 1,
|
||||
"n2": 0,
|
||||
"n3": 0,
|
||||
"n4": 0,
|
||||
"n5": 0,
|
||||
"n6": 0,
|
||||
"n7": 0,
|
||||
"n8": 0,
|
||||
"profile_id": "1"
|
||||
},
|
||||
"14": {
|
||||
"name": "pulse slow",
|
||||
"pattern": "pulse",
|
||||
"colors": ["#FF6600"],
|
||||
"brightness": 255,
|
||||
"delay": 800,
|
||||
"auto": true,
|
||||
"n1": 2000,
|
||||
"n2": 1000,
|
||||
"n3": 2000,
|
||||
"n4": 0,
|
||||
"n5": 0,
|
||||
"n6": 0,
|
||||
"n7": 0,
|
||||
"n8": 0,
|
||||
"profile_id": "1"
|
||||
},
|
||||
"15": {
|
||||
"name": "blink red green",
|
||||
"pattern": "blink",
|
||||
"colors": ["#FF0000", "#00FF00"],
|
||||
"brightness": 255,
|
||||
"delay": 500,
|
||||
"auto": true,
|
||||
"n1": 0,
|
||||
"n2": 0,
|
||||
"n3": 0,
|
||||
"n4": 0,
|
||||
"n5": 0,
|
||||
"n6": 0,
|
||||
"n7": 0,
|
||||
"n8": 0,
|
||||
"profile_id": "1"
|
||||
}
|
||||
}
|
||||
{"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, "profile_id": "1"}, "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, "profile_id": "1"}, "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, "profile_id": "1"}, "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, "profile_id": "1"}, "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, "profile_id": "1"}, "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, "profile_id": "1"}, "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, "profile_id": "1"}, "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, "profile_id": "1"}, "9": {"name": "warm white", "pattern": "on", "colors": ["#FFF5E6"], "brightness": 200, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "10": {"name": "cool white", "pattern": "on", "colors": ["#E6F2FF"], "brightness": 200, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "11": {"name": "red", "pattern": "on", "colors": ["#FF0000"], "brightness": 255, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "12": {"name": "blue", "pattern": "on", "colors": ["#0000FF"], "brightness": 255, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "13": {"name": "rainbow slow", "pattern": "rainbow", "colors": [], "brightness": 255, "delay": 150, "auto": true, "n1": 1, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "14": {"name": "pulse slow", "pattern": "pulse", "colors": ["#FF6600"], "brightness": 255, "delay": 800, "auto": true, "n1": 2000, "n2": 1000, "n3": 2000, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "15": {"name": "blink red green", "pattern": "blink", "colors": ["#FF0000", "#00FF00"], "brightness": 255, "delay": 500, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "30": {"name": "rainbow slow", "pattern": "rainbow", "colors": [], "brightness": 255, "delay": 150, "auto": true, "n1": 1, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "2"}, "31": {"name": "DJ Rainbow", "pattern": "rainbow", "colors": [], "brightness": 220, "delay": 60, "n1": 12, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "2"}, "32": {"name": "DJ Single Color", "pattern": "on", "colors": ["#ff00ff"], "brightness": 220, "delay": 100, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "2"}, "33": {"name": "DJ Transition", "pattern": "transition", "colors": ["#ff0000", "#00ff00", "#0000ff"], "brightness": 220, "delay": 250, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "2"}, "34": {"name": "DJ Rainbow", "pattern": "rainbow", "colors": [], "brightness": 220, "delay": 60, "n1": 12, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "2"}, "35": {"name": "DJ Single Color", "pattern": "on", "colors": ["#ff00ff"], "brightness": 220, "delay": 100, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "2"}, "36": {"name": "DJ Transition", "pattern": "transition", "colors": ["#ff0000", "#00ff00", "#0000ff"], "brightness": 220, "delay": 250, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "2"}}
|
||||
@@ -1,11 +1 @@
|
||||
{
|
||||
"1": {
|
||||
"name": "default",
|
||||
"type": "tabs",
|
||||
"tabs": [
|
||||
"1"
|
||||
],
|
||||
"scenes": [],
|
||||
"palette_id": "1"
|
||||
}
|
||||
}
|
||||
{"1": {"name": "default", "type": "tabs", "tabs": ["1"], "scenes": [], "palette_id": "1"}, "2": {"name": "test", "type": "tabs", "tabs": ["6", "7"], "scenes": [], "palette_id": "12"}}
|
||||
@@ -1,30 +1 @@
|
||||
{
|
||||
"1": {
|
||||
"group_name": "Main Group",
|
||||
"presets": [
|
||||
"1",
|
||||
"2"
|
||||
],
|
||||
"sequence_duration": 3000,
|
||||
"sequence_transition": 500,
|
||||
"sequence_loop": true,
|
||||
"sequence_repeat_count": 0,
|
||||
"sequence_active": false,
|
||||
"sequence_index": 0,
|
||||
"sequence_start_time": 0
|
||||
},
|
||||
"2": {
|
||||
"group_name": "Accent Group",
|
||||
"presets": [
|
||||
"2",
|
||||
"3"
|
||||
],
|
||||
"sequence_duration": 2000,
|
||||
"sequence_transition": 300,
|
||||
"sequence_loop": true,
|
||||
"sequence_repeat_count": 0,
|
||||
"sequence_active": false,
|
||||
"sequence_index": 0,
|
||||
"sequence_start_time": 0
|
||||
}
|
||||
}
|
||||
{"1": {"group_name": "Main Group", "presets": ["1", "2"], "sequence_duration": 3000, "sequence_transition": 500, "sequence_loop": true, "sequence_repeat_count": 0, "sequence_active": false, "sequence_index": 0, "sequence_start_time": 0}, "2": {"group_name": "Accent Group", "presets": ["2", "3"], "sequence_duration": 2000, "sequence_transition": 300, "sequence_loop": true, "sequence_repeat_count": 0, "sequence_active": false, "sequence_index": 0, "sequence_start_time": 0}}
|
||||
28
db/tab.json
28
db/tab.json
@@ -1,27 +1 @@
|
||||
{
|
||||
"1": {
|
||||
"name": "default",
|
||||
"names": [
|
||||
"1","2","3","4","5","6","7","8"
|
||||
],
|
||||
"presets": [
|
||||
[
|
||||
"1",
|
||||
"2",
|
||||
"3",
|
||||
"4",
|
||||
"5",
|
||||
"6",
|
||||
"7",
|
||||
"8",
|
||||
"9",
|
||||
"10",
|
||||
"11",
|
||||
"12",
|
||||
"13",
|
||||
"14",
|
||||
"15"
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
{"1": {"name": "default", "names": ["1", "2", "3", "4", "5", "6", "7", "8", "0", "a"], "presets": [["4", "2", "7"], ["15", "3", "14"], ["5", "6", "8"], ["10", "11", "9"], ["12", "1", "13"]], "presets_flat": ["4", "2", "7", "15", "3", "14", "5", "6", "8", "10", "11", "9", "12", "1", "13"]}, "2": {"name": "default", "names": ["1", "2", "3", "4", "5", "6", "7", "8", "0", "a"], "presets": [["16", "17", "18"], ["19", "20", "21"], ["22", "23", "24"], ["25", "26", "27"], ["28", "29", "30"]], "presets_flat": ["16", "17", "18", "19", "20", "21", "22", "23", "24", "25", "26", "27", "28", "29", "30"]}, "3": {"name": "default", "names": ["1"], "presets": [], "default_preset": null}, "4": {"name": "default", "names": ["1"], "presets": [], "default_preset": null}, "5": {"name": "dj", "names": ["dj"], "presets": [["31", "32", "33"]], "default_preset": "31", "presets_flat": ["31", "32", "33"]}, "6": {"name": "default", "names": ["1"], "presets": [], "default_preset": null}, "7": {"name": "dj", "names": ["dj"], "presets": [["34", "35", "36"]], "default_preset": "34", "presets_flat": ["34", "35", "36"]}}
|
||||
446
docs/API.md
446
docs/API.md
@@ -1,263 +1,297 @@
|
||||
# LED Driver ESPNow API Documentation
|
||||
# LED Controller API
|
||||
|
||||
This document describes the ESPNow message format for controlling LED driver devices.
|
||||
This document covers:
|
||||
|
||||
## Message Format
|
||||
1. **HTTP and WebSocket** exposed by the Raspberry Pi app (`src/main.py`) — profiles, presets, transport send, and related resources.
|
||||
2. **LED driver JSON** — the compact message format sent over the serial→ESP-NOW bridge to devices (same logical API as ESP-NOW payloads).
|
||||
|
||||
All messages are JSON objects sent via ESPNow with the following structure:
|
||||
Default listen address: `0.0.0.0`. Port defaults to **80**; override with the `PORT` environment variable (see `pipenv run run`).
|
||||
|
||||
All JSON APIs use `Content-Type: application/json` for bodies and responses unless noted.
|
||||
|
||||
---
|
||||
|
||||
## Session and scoping
|
||||
|
||||
Several routes use **`@with_session`**: the server stores a **current profile** in the session (cookie). Endpoints that scope data to “the current profile” (notably **`/presets`**) only return or mutate presets whose `profile_id` matches that session value.
|
||||
|
||||
Profiles are selected with **`POST /profiles/<id>/apply`**, which sets `current_profile` in the session.
|
||||
|
||||
---
|
||||
|
||||
## Static pages and assets
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/` | Main UI (`templates/index.html`) |
|
||||
| GET | `/settings` | Settings page (`templates/settings.html`) |
|
||||
| GET | `/favicon.ico` | Empty response (204) |
|
||||
| GET | `/static/<path>` | Static files under `src/static/` |
|
||||
|
||||
---
|
||||
|
||||
## WebSocket: `/ws`
|
||||
|
||||
Connect to **`ws://<host>:<port>/ws`**.
|
||||
|
||||
- Send **JSON**: the object is forwarded to the transport (serial bridge → ESP-NOW) as JSON. Optional key **`to`**: 12-character hex MAC address; if present it is removed from the object and the payload is sent to that peer; otherwise the default destination is used.
|
||||
- Send **non-JSON text**: forwarded as raw bytes with the default address.
|
||||
- On send failure, the server may reply with `{"error": "Send failed"}`.
|
||||
|
||||
---
|
||||
|
||||
## HTTP API by resource
|
||||
|
||||
Below, `<id>` values are string identifiers used by the JSON stores (numeric strings in practice).
|
||||
|
||||
### Settings — `/settings`
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/settings` | Full settings object (from `settings.json` / `Settings` model). |
|
||||
| PUT | `/settings/settings` | Merge keys into settings and save. Returns `{"message": "Settings updated successfully"}`. |
|
||||
| GET | `/settings/wifi/ap` | Saved Wi‑Fi AP fields: `saved_ssid`, `saved_password`, `saved_channel`, `active` (Pi: `active` is always false). |
|
||||
| POST | `/settings/wifi/ap` | Body: `ssid` (required), `password`, `channel` (1–11). Persists AP-related settings. |
|
||||
| GET | `/settings/page` | Serves `templates/settings.html` (same page as `GET /settings` from the root app, for convenience). |
|
||||
|
||||
### Profiles — `/profiles`
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/profiles` | `{"profiles": {...}, "current_profile_id": "<id>"}`. Ensures a default current profile when possible. |
|
||||
| GET | `/profiles/current` | `{"id": "...", "profile": {...}}` |
|
||||
| GET | `/profiles/<id>` | Single profile. If `<id>` is `current`, same as `/profiles/current`. |
|
||||
| POST | `/profiles` | Create profile. Body may include `name` and other fields. Returns `{ "<id>": { ... } }` with status 201. |
|
||||
| POST | `/profiles/<id>/apply` | Sets session current profile to `<id>`. |
|
||||
| POST | `/profiles/<id>/clone` | Clone profile (tabs, palettes, presets). Body may include `name`. |
|
||||
| PUT | `/profiles/current` | Update the current profile (from session). |
|
||||
| PUT | `/profiles/<id>` | Update profile by id. |
|
||||
| DELETE | `/profiles/<id>` | Delete profile. |
|
||||
|
||||
### Presets — `/presets`
|
||||
|
||||
Scoped to **current profile** in session (see above).
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/presets` | Map of preset id → preset object for the current profile only. |
|
||||
| GET | `/presets/<id>` | One preset, 404 if missing or wrong profile. |
|
||||
| POST | `/presets` | Create preset; server assigns id and sets `profile_id`. Body fields stored on the preset. Returns `{ "<id>": { ... } }`, 201. |
|
||||
| PUT | `/presets/<id>` | Update preset (must belong to current profile). |
|
||||
| DELETE | `/presets/<id>` | Delete preset. |
|
||||
| POST | `/presets/send` | Push presets to the LED driver over the configured transport (see below). |
|
||||
|
||||
**`POST /presets/send` body:**
|
||||
|
||||
```json
|
||||
{
|
||||
"preset_ids": ["1", "2"],
|
||||
"save": true,
|
||||
"default": "1",
|
||||
"destination_mac": "aabbccddeeff"
|
||||
}
|
||||
```
|
||||
|
||||
- **`preset_ids`** (or **`ids`**): non-empty list of preset ids to include.
|
||||
- **`save`**: if true, the outgoing message includes `"save": true` so the driver may persist presets (default true).
|
||||
- **`default`**: optional preset id string; forwarded as top-level `"default"` in the driver message (startup selection on device).
|
||||
- **`destination_mac`** (or **`to`**): optional 12-character hex MAC for unicast; omitted uses the transport default (e.g. broadcast).
|
||||
|
||||
Response on success includes `presets_sent`, `messages_sent` (chunking splits payloads so each JSON string stays ≤ 240 bytes).
|
||||
|
||||
### Tabs — `/tabs`
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/tabs` | `tabs`, `tab_order`, `current_tab_id`, `profile_id` for the session-backed profile. |
|
||||
| GET | `/tabs/current` | Current tab from cookie/session. |
|
||||
| POST | `/tabs` | Create tab; optional JSON `name`, `names`, `presets`; can append to current profile’s tab list. |
|
||||
| GET | `/tabs/<id>` | Tab JSON. |
|
||||
| PUT | `/tabs/<id>` | Update tab. |
|
||||
| DELETE | `/tabs/<id>` | Delete tab; can delete `current` to remove the active tab; updates profile tab list. |
|
||||
| POST | `/tabs/<id>/set-current` | Sets `current_tab` cookie. |
|
||||
| POST | `/tabs/<id>/clone` | Clone tab into current profile. |
|
||||
|
||||
### Palettes — `/palettes`
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/palettes` | Map of id → color list. |
|
||||
| GET | `/palettes/<id>` | `{"colors": [...], "id": "<id>"}` |
|
||||
| POST | `/palettes` | Body may include `colors`. Returns palette object with `id`, 201. |
|
||||
| PUT | `/palettes/<id>` | Update colors (`name` ignored). |
|
||||
| DELETE | `/palettes/<id>` | Delete palette. |
|
||||
|
||||
### Groups — `/groups`
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/groups` | All groups. |
|
||||
| GET | `/groups/<id>` | One group. |
|
||||
| POST | `/groups` | Create; optional `name` and fields. |
|
||||
| PUT | `/groups/<id>` | Update. |
|
||||
| DELETE | `/groups/<id>` | Delete. |
|
||||
|
||||
### Scenes — `/scenes`
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/scenes` | All scenes. |
|
||||
| GET | `/scenes/<id>` | One scene. |
|
||||
| POST | `/scenes` | Create (body JSON stored on scene). |
|
||||
| PUT | `/scenes/<id>` | Update. |
|
||||
| DELETE | `/scenes/<id>` | Delete. |
|
||||
|
||||
### Sequences — `/sequences`
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/sequences` | All sequences. |
|
||||
| GET | `/sequences/<id>` | One sequence. |
|
||||
| POST | `/sequences` | Create; may use `group_name`, `presets` in body. |
|
||||
| PUT | `/sequences/<id>` | Update. |
|
||||
| DELETE | `/sequences/<id>` | Delete. |
|
||||
|
||||
### Patterns — `/patterns`
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/patterns/definitions` | Contents of `pattern.json` (pattern metadata for the UI). |
|
||||
| GET | `/patterns` | All pattern records. |
|
||||
| GET | `/patterns/<id>` | One pattern. |
|
||||
| POST | `/patterns` | Create (`name`, optional `data`). |
|
||||
| PUT | `/patterns/<id>` | Update. |
|
||||
| DELETE | `/patterns/<id>` | Delete. |
|
||||
|
||||
---
|
||||
|
||||
## LED driver message format (transport / ESP-NOW)
|
||||
|
||||
Messages are JSON objects. The Pi **`build_message()`** helper (`src/util/espnow_message.py`) produces the same shape sent over serial and forwarded by the ESP32 bridge.
|
||||
|
||||
### Top-level fields
|
||||
|
||||
```json
|
||||
{
|
||||
"v": "1",
|
||||
"presets": { ... },
|
||||
"select": { ... }
|
||||
"presets": { },
|
||||
"select": { },
|
||||
"save": true,
|
||||
"default": "preset_id",
|
||||
"b": 255
|
||||
}
|
||||
```
|
||||
|
||||
### Version Field
|
||||
- **`v`** (required): Must be `"1"` or the driver ignores the message.
|
||||
- **`presets`**: Map of **preset id** (string) → preset object (see below). Optional **`name`** field on each value is accepted for display; the driver keys presets by map key.
|
||||
- **`select`**: Map of **device name** (as in device settings) → `[ "preset_id" ]` or `[ "preset_id", step ]`.
|
||||
- **`save`**: If present (e.g. true), the driver may persist presets to flash after applying.
|
||||
- **`default`**: Preset id string to use as startup default on the device.
|
||||
- **`b`**: Optional **global** brightness 0–255 (driver applies this in addition to per-preset brightness).
|
||||
|
||||
- **`v`** (required): Message version, must be `"1"`. Messages with other versions are ignored.
|
||||
### Preset object (wire / driver keys)
|
||||
|
||||
## Presets
|
||||
On the wire, presets use **short keys** (saves space in the ≤240-byte chunks):
|
||||
|
||||
Presets define LED patterns with their configuration. Each preset has a name and contains pattern-specific settings.
|
||||
| Key | Meaning | Notes |
|
||||
|-----|---------|--------|
|
||||
| `p` | Pattern id | `off`, `on`, `blink`, `rainbow`, `pulse`, `transition`, `chase`, `circle` |
|
||||
| `c` | Colors | Array of `"#RRGGBB"` hex strings; converted to RGB on device |
|
||||
| `d` | Delay ms | Default 100 |
|
||||
| `b` | Preset brightness | 0–255; combined with global `b` on the device |
|
||||
| `a` | Auto | `true`: run continuously; `false`: one step/cycle per “beat” |
|
||||
| `n1`–`n6` | Pattern parameters | See below |
|
||||
|
||||
### Preset Structure
|
||||
The HTTP app’s **`POST /presets/send`** path builds this from stored presets via **`build_preset_dict()`** (long names like `pattern` / `colors` in the DB are translated to `p` / `c` / …).
|
||||
|
||||
```json
|
||||
{
|
||||
"presets": {
|
||||
"preset_name": {
|
||||
"pattern": "pattern_type",
|
||||
"colors": ["#RRGGBB", ...],
|
||||
"delay": 100,
|
||||
"brightness": 127,
|
||||
"auto": true,
|
||||
"n1": 0,
|
||||
"n2": 0,
|
||||
"n3": 0,
|
||||
"n4": 0,
|
||||
"n5": 0,
|
||||
"n6": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Preset Fields
|
||||
|
||||
- **`pattern`** (required): Pattern type. Options:
|
||||
- `"off"` - Turn off all LEDs
|
||||
- `"on"` - Solid color
|
||||
- `"blink"` - Blinking pattern
|
||||
- `"rainbow"` - Rainbow color cycle
|
||||
- `"pulse"` - Pulse/fade pattern
|
||||
- `"transition"` - Color transition
|
||||
- `"chase"` - Chasing pattern
|
||||
- `"circle"` - Circle loading pattern
|
||||
|
||||
- **`colors`** (optional): Array of hex color strings (e.g., `"#FF0000"` for red). Default: `["#FFFFFF"]`
|
||||
- Colors are automatically converted from hex to RGB and reordered based on device color order setting
|
||||
- Supports multiple colors for patterns that use them
|
||||
|
||||
- **`delay`** (optional): Delay in milliseconds between pattern updates. Default: `100`
|
||||
|
||||
- **`brightness`** (optional): Brightness level (0-255). Default: `127`
|
||||
|
||||
- **`auto`** (optional): Auto mode flag. Default: `true`
|
||||
- `true`: Pattern runs continuously
|
||||
- `false`: Pattern advances one step per beat (manual mode)
|
||||
|
||||
- **`n1` through `n6`** (optional): Pattern-specific numeric parameters. Default: `0`
|
||||
- See pattern-specific documentation below
|
||||
|
||||
### Pattern-Specific Parameters
|
||||
### Pattern-specific parameters (`n1`–`n6`)
|
||||
|
||||
#### Rainbow
|
||||
- **`n1`**: Step increment (how many color wheel positions to advance per update). Default: `1`
|
||||
- **`n1`**: Step increment on the color wheel per update (default 1).
|
||||
|
||||
#### Pulse
|
||||
- **`n1`**: Attack time in milliseconds (fade in)
|
||||
- **`n2`**: Hold time in milliseconds (full brightness)
|
||||
- **`n3`**: Decay time in milliseconds (fade out)
|
||||
- **`delay`**: Delay time in milliseconds (off between pulses)
|
||||
- **`n1`**: Attack (fade in) ms
|
||||
- **`n2`**: Hold ms
|
||||
- **`n3`**: Decay (fade out) ms
|
||||
- **`d`**: Off time between pulses ms
|
||||
|
||||
#### Transition
|
||||
- **`delay`**: Transition duration in milliseconds
|
||||
- **`d`**: Transition duration ms
|
||||
|
||||
#### Chase
|
||||
- **`n1`**: Number of LEDs with first color
|
||||
- **`n2`**: Number of LEDs with second color
|
||||
- **`n3`**: Movement amount on even steps (can be negative)
|
||||
- **`n4`**: Movement amount on odd steps (can be negative)
|
||||
- **`n1`**: LEDs with first color
|
||||
- **`n2`**: LEDs with second color
|
||||
- **`n3`**: Movement on even steps (may be negative)
|
||||
- **`n4`**: Movement on odd steps (may be negative)
|
||||
|
||||
#### Circle
|
||||
- **`n1`**: Head movement rate (LEDs per second)
|
||||
- **`n2`**: Maximum length
|
||||
- **`n3`**: Tail movement rate (LEDs per second)
|
||||
- **`n4`**: Minimum length
|
||||
- **`n1`**: Head speed (LEDs/s)
|
||||
- **`n2`**: Max length
|
||||
- **`n3`**: Tail speed (LEDs/s)
|
||||
- **`n4`**: Min length
|
||||
|
||||
## Select Messages
|
||||
|
||||
Select messages control which preset is active on which device. The format uses a list to support step synchronization.
|
||||
|
||||
### Select Format
|
||||
### Select messages
|
||||
|
||||
```json
|
||||
{
|
||||
"select": {
|
||||
"device_name": ["preset_name"],
|
||||
"device_name2": ["preset_name2", step_value]
|
||||
"device_name": ["preset_id"],
|
||||
"other_device": ["preset_id", 10]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Select Fields
|
||||
- One element: select preset; step behavior follows driver rules (reset on `off`, etc.).
|
||||
- Two elements: explicit **step** for sync.
|
||||
|
||||
- **`select`**: Object mapping device names to selection lists
|
||||
- **Key**: Device name (as configured in device settings)
|
||||
- **Value**: List with one or two elements:
|
||||
- `["preset_name"]` - Select preset (uses default step behavior)
|
||||
- `["preset_name", step]` - Select preset with explicit step value (for synchronization)
|
||||
### Beat and sync behavior
|
||||
|
||||
### Step Synchronization
|
||||
- Sending **`select`** again with the **same** preset name acts as a **beat** (advances manual patterns / restarts generators per driver logic).
|
||||
- Choosing **`off`** resets step as a sync point; then selecting a pattern aligns step 0 across devices unless a step is passed explicitly.
|
||||
|
||||
The step value allows precise synchronization across multiple devices:
|
||||
|
||||
- **Without step**: `["preset_name"]`
|
||||
- If switching to different preset: step resets to 0
|
||||
- If selecting "off" pattern: step resets to 0
|
||||
- If selecting same preset (beat): step is preserved, pattern restarts
|
||||
|
||||
- **With step**: `["preset_name", 10]`
|
||||
- Explicitly sets step to the specified value
|
||||
- Useful for synchronizing multiple devices to the same step
|
||||
|
||||
### Beat Functionality
|
||||
|
||||
Calling `select()` again with the same preset name acts as a "beat" - it restarts the pattern generator:
|
||||
|
||||
- **Single-tick patterns** (rainbow, chase in manual mode): Advance one step per beat
|
||||
- **Multi-tick patterns** (pulse in manual mode): Run through full cycle per beat
|
||||
|
||||
Example beat sequence:
|
||||
```json
|
||||
// Beat 1
|
||||
{"select": {"device1": ["rainbow_preset"]}}
|
||||
|
||||
// Beat 2 (same preset = beat)
|
||||
{"select": {"device1": ["rainbow_preset"]}}
|
||||
|
||||
// Beat 3
|
||||
{"select": {"device1": ["rainbow_preset"]}}
|
||||
```
|
||||
|
||||
## Synchronization
|
||||
|
||||
### Using "off" Pattern
|
||||
|
||||
Selecting the "off" pattern resets the step counter to 0, providing a synchronization point:
|
||||
|
||||
```json
|
||||
{
|
||||
"select": {
|
||||
"device1": ["off"],
|
||||
"device2": ["off"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
After all devices are "off", switching to a pattern ensures they all start from step 0:
|
||||
|
||||
```json
|
||||
{
|
||||
"select": {
|
||||
"device1": ["rainbow_preset"],
|
||||
"device2": ["rainbow_preset"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Using Step Parameter
|
||||
|
||||
For precise synchronization, use the step parameter:
|
||||
|
||||
```json
|
||||
{
|
||||
"select": {
|
||||
"device1": ["rainbow_preset", 10],
|
||||
"device2": ["rainbow_preset", 10],
|
||||
"device3": ["rainbow_preset", 10]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
All devices will start at step 10 and advance together on subsequent beats.
|
||||
|
||||
## Complete Example
|
||||
### Example (compact preset map)
|
||||
|
||||
```json
|
||||
{
|
||||
"v": "1",
|
||||
"save": true,
|
||||
"presets": {
|
||||
"red_blink": {
|
||||
"pattern": "blink",
|
||||
"colors": ["#FF0000"],
|
||||
"delay": 200,
|
||||
"brightness": 255,
|
||||
"auto": true
|
||||
},
|
||||
"rainbow_manual": {
|
||||
"pattern": "rainbow",
|
||||
"delay": 100,
|
||||
"n1": 2,
|
||||
"auto": false
|
||||
},
|
||||
"pulse_slow": {
|
||||
"pattern": "pulse",
|
||||
"colors": ["#00FF00"],
|
||||
"delay": 500,
|
||||
"n1": 1000,
|
||||
"n2": 500,
|
||||
"n3": 1000,
|
||||
"auto": false
|
||||
"1": {
|
||||
"name": "Red blink",
|
||||
"p": "blink",
|
||||
"c": ["#FF0000"],
|
||||
"d": 200,
|
||||
"b": 255,
|
||||
"a": true,
|
||||
"n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0
|
||||
}
|
||||
},
|
||||
"select": {
|
||||
"device1": ["red_blink"],
|
||||
"device2": ["rainbow_manual", 0],
|
||||
"device3": ["pulse_slow"]
|
||||
"living-room": ["1"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Message Processing
|
||||
---
|
||||
|
||||
1. **Version Check**: Messages with `v != "1"` are rejected
|
||||
2. **Preset Processing**: Presets are created or updated (upsert behavior)
|
||||
3. **Color Conversion**: Hex colors are converted to RGB tuples and reordered based on device color order
|
||||
4. **Selection**: Devices select their assigned preset, optionally with step value
|
||||
## Processing summary (driver)
|
||||
|
||||
## Best Practices
|
||||
1. Reject if `v != "1"`.
|
||||
2. Apply optional top-level **`b`** (global brightness).
|
||||
3. For each entry in **`presets`**, normalize colors and upsert preset by id.
|
||||
4. If this device’s **`name`** appears in **`select`**, run selection (optional step).
|
||||
5. If **`default`** is set, store startup preset id.
|
||||
6. If **`save`** is set, persist presets.
|
||||
|
||||
1. **Always include version**: Set `"v": "1"` in all messages
|
||||
2. **Use "off" for sync**: Select "off" pattern to synchronize devices before starting patterns
|
||||
3. **Beats for manual mode**: Send select messages repeatedly with same preset name to advance manual patterns
|
||||
4. **Step for precision**: Use step parameter when exact synchronization is required
|
||||
5. **Color format**: Always use hex strings (`"#RRGGBB"`), conversion is automatic
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
## Error handling (HTTP)
|
||||
|
||||
- Invalid version: Message is ignored
|
||||
- Missing preset: Selection fails, device keeps current preset
|
||||
- Invalid pattern: Selection fails, device keeps current preset
|
||||
- Missing colors: Pattern uses default white color
|
||||
- Invalid step: Step value is used as-is (may cause unexpected behavior)
|
||||
Controllers typically return JSON with an **`error`** string and 4xx/5xx status codes. Invalid JSON bodies often yield `{"error": "Invalid JSON"}`.
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- Colors are automatically converted from hex strings to RGB tuples
|
||||
- Color order reordering happens automatically based on device settings
|
||||
- Step counter wraps around (0-255 for rainbow, unbounded for others)
|
||||
- Manual mode patterns stop after one step/cycle, waiting for next beat
|
||||
- Auto mode patterns run continuously until changed
|
||||
- **Human-readable preset fields** (`pattern`, `colors`, `delay`, …) are fine in the **web app / database**; the **send path** converts them to **`p` / `c` / `d`** for the driver.
|
||||
- For a copy of the older long-key reference, see **`led-driver/docs/API.md`** in this repo (conceptually the same behavior; wire format prefers short keys).
|
||||
|
||||
@@ -1,30 +1,23 @@
|
||||
{
|
||||
"grps": [
|
||||
{
|
||||
"n": "group1",
|
||||
"g":{
|
||||
"df": {
|
||||
"pt": "on",
|
||||
"cl": [
|
||||
"000000",
|
||||
"000000"
|
||||
],
|
||||
"br": 100,
|
||||
"dl": 100,
|
||||
"n1": 0,
|
||||
"n2": 0,
|
||||
"n3": 0,
|
||||
"n4": 0,
|
||||
"n5": 0,
|
||||
"n6": 0
|
||||
},
|
||||
{
|
||||
"n": "group2",
|
||||
"pt": "on",
|
||||
"cl": [
|
||||
"000000",
|
||||
"000000"
|
||||
],
|
||||
"br": 100,
|
||||
"cl": ["#ff0000"],
|
||||
"br": 200,
|
||||
"n1": 10,
|
||||
"n2": 10,
|
||||
"n3": 10,
|
||||
"n4": 10,
|
||||
"n5": 10,
|
||||
"n6": 10,
|
||||
"dl": 100
|
||||
},
|
||||
"dj": {
|
||||
"pt": "blink",
|
||||
"cl": ["#00ff00"],
|
||||
"dl": 500
|
||||
}
|
||||
]
|
||||
},
|
||||
"sv": true,
|
||||
"st": 0
|
||||
}
|
||||
@@ -51,11 +51,14 @@ def ensure_peer(addr):
|
||||
raise
|
||||
|
||||
|
||||
print("Starting ESP32 main.py")
|
||||
|
||||
while True:
|
||||
if uart.any():
|
||||
data = uart.read()
|
||||
if not data or len(data) < 6:
|
||||
continue
|
||||
print(f"Received data: {data}")
|
||||
addr = data[:6]
|
||||
payload = data[6:]
|
||||
ensure_peer(addr)
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Install script - runs pipenv install
|
||||
|
||||
pipenv install "$@"
|
||||
1
led-driver
Submodule
1
led-driver
Submodule
Submodule led-driver added at 4c7646b2fe
1
led-tool
Submodule
1
led-tool
Submodule
Submodule led-tool added at 3844aa9d6a
23
msg.json
23
msg.json
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"g":{
|
||||
"df": {
|
||||
"pt": "on",
|
||||
"cl": ["#ff0000"],
|
||||
"br": 200,
|
||||
"n1": 10,
|
||||
"n2": 10,
|
||||
"n3": 10,
|
||||
"n4": 10,
|
||||
"n5": 10,
|
||||
"n6": 10,
|
||||
"dl": 100
|
||||
},
|
||||
"dj": {
|
||||
"pt": "blink",
|
||||
"cl": ["#00ff00"],
|
||||
"dl": 500
|
||||
}
|
||||
},
|
||||
"sv": true,
|
||||
"st": 0
|
||||
}
|
||||
173
run_web.py
173
run_web.py
@@ -1,173 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Local development web server - imports and runs main.py with port 5000
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import asyncio
|
||||
|
||||
# Add src and lib to path
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src'))
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'lib'))
|
||||
|
||||
# Import the main module
|
||||
from src import main as main_module
|
||||
|
||||
# Override the port in the main function
|
||||
async def run_local():
|
||||
"""Run main with port 5000 for local development."""
|
||||
from settings import Settings
|
||||
import gc
|
||||
|
||||
# Mock MicroPython modules for local development
|
||||
class MockMachine:
|
||||
class WDT:
|
||||
def __init__(self, timeout):
|
||||
pass
|
||||
def feed(self):
|
||||
pass
|
||||
import sys as sys_module
|
||||
sys_module.modules['machine'] = MockMachine()
|
||||
|
||||
class MockESPNow:
|
||||
def __init__(self):
|
||||
self.active_value = False
|
||||
self.peers = []
|
||||
def active(self, value):
|
||||
self.active_value = value
|
||||
print(f"[MOCK] ESPNow active: {value}")
|
||||
def add_peer(self, peer):
|
||||
self.peers.append(peer)
|
||||
print(f"[MOCK] Added peer: {peer.hex() if hasattr(peer, 'hex') else peer}")
|
||||
async def asend(self, peer, data):
|
||||
print(f"[MOCK] Would send to {peer.hex() if hasattr(peer, 'hex') else peer}: {data}")
|
||||
|
||||
class MockAIOESPNow:
|
||||
def __init__(self):
|
||||
pass
|
||||
def active(self, value):
|
||||
return MockESPNow()
|
||||
def add_peer(self, peer):
|
||||
pass
|
||||
|
||||
class MockNetwork:
|
||||
class WLAN:
|
||||
def __init__(self, interface):
|
||||
self.interface = interface
|
||||
def active(self, value):
|
||||
print(f"[MOCK] WLAN({self.interface}) active: {value}")
|
||||
STA_IF = 0
|
||||
|
||||
# Replace MicroPython modules with mocks
|
||||
sys_module.modules['aioespnow'] = type('module', (), {'AIOESPNow': MockESPNow})()
|
||||
sys_module.modules['network'] = MockNetwork()
|
||||
|
||||
# Mock gc if needed
|
||||
if not hasattr(gc, 'collect'):
|
||||
class MockGC:
|
||||
def collect(self):
|
||||
pass
|
||||
gc = MockGC()
|
||||
|
||||
settings = Settings()
|
||||
print("Starting LED Controller Web Server (Local Development)")
|
||||
print("=" * 60)
|
||||
|
||||
# Mock network
|
||||
import network
|
||||
network.WLAN(network.STA_IF).active(True)
|
||||
|
||||
# Mock ESPNow
|
||||
import aioespnow
|
||||
e = aioespnow.AIOESPNow()
|
||||
e.active(True)
|
||||
e.add_peer(b"\xbb\xbb\xbb\xbb\xbb\xbb")
|
||||
|
||||
from microdot import Microdot, send_file
|
||||
from microdot.websocket import with_websocket
|
||||
|
||||
from microdot.session import Session
|
||||
|
||||
import controllers.preset as preset
|
||||
import controllers.profile as profile
|
||||
import controllers.group as group
|
||||
import controllers.sequence as sequence
|
||||
import controllers.tab as tab
|
||||
import controllers.palette as palette
|
||||
import controllers.scene as scene
|
||||
import controllers.pattern as pattern
|
||||
import controllers.settings as settings_controller
|
||||
|
||||
app = Microdot()
|
||||
|
||||
# Initialize sessions with a secret key from settings
|
||||
secret_key = settings.get('session_secret_key', 'led-controller-secret-key-change-in-production')
|
||||
Session(app, secret_key=secret_key)
|
||||
|
||||
# Mount model controllers as subroutes
|
||||
app.mount(preset.controller, '/presets')
|
||||
app.mount(profile.controller, '/profiles')
|
||||
app.mount(group.controller, '/groups')
|
||||
app.mount(sequence.controller, '/sequences')
|
||||
app.mount(tab.controller, '/tabs')
|
||||
app.mount(palette.controller, '/palettes')
|
||||
app.mount(scene.controller, '/scenes')
|
||||
app.mount(pattern.controller, '/patterns')
|
||||
app.mount(settings_controller.controller, '/settings')
|
||||
|
||||
# Serve index.html at root
|
||||
@app.route('/')
|
||||
def index(request):
|
||||
"""Serve the main web UI."""
|
||||
return send_file('src/templates/index.html')
|
||||
|
||||
# Serve settings page
|
||||
@app.route('/settings')
|
||||
def settings_page(request):
|
||||
"""Serve the settings page."""
|
||||
return send_file('src/templates/settings.html')
|
||||
|
||||
# Favicon: avoid 404 in browser console (no file needed)
|
||||
@app.route('/favicon.ico')
|
||||
def favicon(request):
|
||||
return '', 204
|
||||
|
||||
# Static file route
|
||||
@app.route("/static/<path:path>")
|
||||
def static_handler(request, path):
|
||||
"""Serve static files."""
|
||||
if '..' in path:
|
||||
return 'Not found', 404
|
||||
return send_file('src/static/' + path)
|
||||
|
||||
@app.route('/ws')
|
||||
@with_websocket
|
||||
async def ws(request, ws):
|
||||
while True:
|
||||
data = await ws.receive()
|
||||
if data:
|
||||
await e.asend(b"\xbb\xbb\xbb\xbb\xbb\xbb", data)
|
||||
print(data)
|
||||
else:
|
||||
break
|
||||
|
||||
# Use port 5000 for local development
|
||||
port = 5000
|
||||
print(f"Starting server on http://0.0.0.0:{port}")
|
||||
print(f"Open http://localhost:{port} in your browser")
|
||||
print("=" * 60)
|
||||
|
||||
try:
|
||||
await app.start_server(host="0.0.0.0", port=port, debug=True)
|
||||
except KeyboardInterrupt:
|
||||
print("\nShutting down server...")
|
||||
|
||||
if __name__ == '__main__':
|
||||
# Change to project root
|
||||
os.chdir(os.path.dirname(os.path.abspath(__file__)))
|
||||
# Override settings path for local development
|
||||
import settings as settings_module
|
||||
settings_module.Settings.SETTINGS_FILE = os.path.join(os.getcwd(), 'settings.json')
|
||||
|
||||
asyncio.run(run_local())
|
||||
4
scripts/cp-esp32-main.sh
Normal file
4
scripts/cp-esp32-main.sh
Normal file
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env bash
|
||||
# Copy esp32/main.py to the connected ESP32 as /main.py (single line, no wrap).
|
||||
cd "$(dirname "$0")/.."
|
||||
pipenv run mpremote fs cp esp32/main.py :/main.py
|
||||
20
scripts/install-boot-service.sh
Executable file
20
scripts/install-boot-service.sh
Executable file
@@ -0,0 +1,20 @@
|
||||
#!/usr/bin/env bash
|
||||
# Install systemd service so LED controller starts at boot.
|
||||
# Run once: sudo scripts/install-boot-service.sh
|
||||
set -e
|
||||
cd "$(dirname "$0")/.."
|
||||
REPO="$(pwd)"
|
||||
SERVICE_NAME="led-controller.service"
|
||||
UNIT_PATH="/etc/systemd/system/$SERVICE_NAME"
|
||||
if [ ! -f "scripts/led-controller.service" ]; then
|
||||
echo "Run this script from the repo root."
|
||||
exit 1
|
||||
fi
|
||||
chmod +x scripts/start.sh
|
||||
sudo cp "scripts/led-controller.service" "$UNIT_PATH"
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable "$SERVICE_NAME"
|
||||
echo "Installed and enabled $SERVICE_NAME"
|
||||
echo "Start now: sudo systemctl start $SERVICE_NAME"
|
||||
echo "Status: sudo systemctl status $SERVICE_NAME"
|
||||
echo "Logs: journalctl -u $SERVICE_NAME -f"
|
||||
17
scripts/led-controller.service
Normal file
17
scripts/led-controller.service
Normal file
@@ -0,0 +1,17 @@
|
||||
[Unit]
|
||||
Description=LED Controller web server
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=pi
|
||||
WorkingDirectory=/home/pi/led-controller
|
||||
Environment=PORT=80
|
||||
Environment=PATH=/home/pi/.local/bin:/usr/local/bin:/usr/bin:/bin
|
||||
ExecStart=/bin/bash /home/pi/led-controller/scripts/start.sh
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
5
scripts/start.sh
Executable file
5
scripts/start.sh
Executable file
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
# Start the LED controller web server (port 80 by default).
|
||||
cd "$(dirname "$0")/.."
|
||||
export PORT="${PORT:-80}"
|
||||
pipenv run run
|
||||
@@ -1,44 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
import socket
|
||||
import struct
|
||||
import base64
|
||||
import hashlib
|
||||
|
||||
# Connect to the WebSocket
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
s.connect(('192.168.4.1', 80))
|
||||
|
||||
# Send HTTP WebSocket upgrade request
|
||||
key = base64.b64encode(b'test-nonce').decode('utf-8')
|
||||
request = f'''GET /ws HTTP/1.1\r
|
||||
Host: 192.168.4.1\r
|
||||
Upgrade: websocket\r
|
||||
Connection: Upgrade\r
|
||||
Sec-WebSocket-Key: {key}\r
|
||||
Sec-WebSocket-Version: 13\r
|
||||
\r
|
||||
'''
|
||||
s.send(request.encode())
|
||||
|
||||
# Read upgrade response
|
||||
response = s.recv(4096)
|
||||
print(response.decode())
|
||||
|
||||
# Send WebSocket TEXT frame with empty JSON '{}'
|
||||
payload = b'{}'
|
||||
mask = b'\x12\x34\x56\x78'
|
||||
payload_masked = bytes(p ^ mask[i % 4] for i, p in enumerate(payload))
|
||||
|
||||
frame = struct.pack('BB', 0x81, 0x80 | len(payload))
|
||||
frame += mask
|
||||
frame += payload_masked
|
||||
|
||||
s.send(frame)
|
||||
print("Sent empty JSON to WebSocket")
|
||||
s.close()
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
{"session_secret_key": "06a2a6ac631cc8db0658373b37f7fe922a8a3ed3a27229e1adb5448cb368f2b7"}
|
||||
68
src/controllers/device.py
Normal file
68
src/controllers/device.py
Normal file
@@ -0,0 +1,68 @@
|
||||
from microdot import Microdot
|
||||
from models.device import Device
|
||||
import json
|
||||
|
||||
controller = Microdot()
|
||||
devices = Device()
|
||||
|
||||
|
||||
@controller.get("")
|
||||
async def list_devices(request):
|
||||
"""List all devices."""
|
||||
devices_data = {}
|
||||
for dev_id in devices.list():
|
||||
d = devices.read(dev_id)
|
||||
if d:
|
||||
devices_data[dev_id] = d
|
||||
return json.dumps(devices_data), 200, {"Content-Type": "application/json"}
|
||||
|
||||
|
||||
@controller.get("/<id>")
|
||||
async def get_device(request, id):
|
||||
"""Get a device by ID."""
|
||||
dev = devices.read(id)
|
||||
if dev:
|
||||
return json.dumps(dev), 200, {"Content-Type": "application/json"}
|
||||
return json.dumps({"error": "Device not found"}), 404
|
||||
|
||||
|
||||
@controller.post("")
|
||||
async def create_device(request):
|
||||
"""Create a new device."""
|
||||
try:
|
||||
data = request.json or {}
|
||||
name = data.get("name", "").strip()
|
||||
address = data.get("address")
|
||||
default_pattern = data.get("default_pattern")
|
||||
tabs = data.get("tabs")
|
||||
if isinstance(tabs, list):
|
||||
tabs = [str(t) for t in tabs]
|
||||
else:
|
||||
tabs = []
|
||||
dev_id = devices.create(name=name, address=address, default_pattern=default_pattern, tabs=tabs)
|
||||
dev = devices.read(dev_id)
|
||||
return json.dumps({dev_id: dev}), 201, {"Content-Type": "application/json"}
|
||||
except Exception as e:
|
||||
return json.dumps({"error": str(e)}), 400
|
||||
|
||||
|
||||
@controller.put("/<id>")
|
||||
async def update_device(request, id):
|
||||
"""Update a device."""
|
||||
try:
|
||||
data = request.json or {}
|
||||
if "tabs" in data and isinstance(data["tabs"], list):
|
||||
data["tabs"] = [str(t) for t in data["tabs"]]
|
||||
if devices.update(id, data):
|
||||
return json.dumps(devices.read(id)), 200, {"Content-Type": "application/json"}
|
||||
return json.dumps({"error": "Device not found"}), 404
|
||||
except Exception as e:
|
||||
return json.dumps({"error": str(e)}), 400
|
||||
|
||||
|
||||
@controller.delete("/<id>")
|
||||
async def delete_device(request, id):
|
||||
"""Delete a device."""
|
||||
if devices.delete(id):
|
||||
return json.dumps({"message": "Device deleted successfully"}), 200
|
||||
return json.dumps({"error": "Device not found"}), 404
|
||||
@@ -17,9 +17,9 @@ async def list_palettes(request):
|
||||
@controller.get('/<id>')
|
||||
async def get_palette(request, id):
|
||||
"""Get a specific palette by ID."""
|
||||
palette = palettes.read(id)
|
||||
if palette:
|
||||
return json.dumps({"colors": palette, "id": str(id)}), 200, {'Content-Type': 'application/json'}
|
||||
if str(id) in palettes:
|
||||
palette = palettes.read(id)
|
||||
return json.dumps({"colors": palette or [], "id": str(id)}), 200, {'Content-Type': 'application/json'}
|
||||
return json.dumps({"error": "Palette not found"}), 404
|
||||
|
||||
@controller.post('')
|
||||
@@ -30,11 +30,8 @@ async def create_palette(request):
|
||||
colors = data.get("colors", None)
|
||||
# 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'}
|
||||
created_colors = palettes.read(palette_id) or []
|
||||
return json.dumps({"id": str(palette_id), "colors": created_colors}), 201, {'Content-Type': 'application/json'}
|
||||
except Exception as e:
|
||||
return json.dumps({"error": str(e)}), 400
|
||||
|
||||
@@ -47,10 +44,8 @@ async def update_palette(request, id):
|
||||
if "name" in data:
|
||||
data.pop("name", None)
|
||||
if palettes.update(id, data):
|
||||
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'}
|
||||
colors = palettes.read(id) or []
|
||||
return json.dumps({"id": str(id), "colors": colors}), 200, {'Content-Type': 'application/json'}
|
||||
return json.dumps({"error": "Palette not found"}), 404
|
||||
except Exception as e:
|
||||
return json.dumps({"error": str(e)}), 400
|
||||
|
||||
@@ -36,11 +36,11 @@ async def list_presets(request, session):
|
||||
}
|
||||
return json.dumps(scoped), 200, {'Content-Type': 'application/json'}
|
||||
|
||||
@controller.get('/<id>')
|
||||
@controller.get('/<preset_id>')
|
||||
@with_session
|
||||
async def get_preset(request, id, session):
|
||||
async def get_preset(request, session, preset_id):
|
||||
"""Get a specific preset by ID (current profile only)."""
|
||||
preset = presets.read(id)
|
||||
preset = presets.read(preset_id)
|
||||
current_profile_id = get_current_profile_id(session)
|
||||
if preset and str(preset.get("profile_id")) == str(current_profile_id):
|
||||
return json.dumps(preset), 200, {'Content-Type': 'application/json'}
|
||||
@@ -70,12 +70,12 @@ async def create_preset(request, session):
|
||||
except Exception as e:
|
||||
return json.dumps({"error": str(e)}), 400
|
||||
|
||||
@controller.put('/<id>')
|
||||
@controller.put('/<preset_id>')
|
||||
@with_session
|
||||
async def update_preset(request, id, session):
|
||||
async def update_preset(request, session, preset_id):
|
||||
"""Update an existing preset (current profile only)."""
|
||||
try:
|
||||
preset = presets.read(id)
|
||||
preset = presets.read(preset_id)
|
||||
current_profile_id = get_current_profile_id(session)
|
||||
if not preset or str(preset.get("profile_id")) != str(current_profile_id):
|
||||
return json.dumps({"error": "Preset not found"}), 404
|
||||
@@ -87,21 +87,36 @@ async def update_preset(request, id, session):
|
||||
data = {}
|
||||
data = dict(data)
|
||||
data["profile_id"] = str(current_profile_id)
|
||||
if presets.update(id, data):
|
||||
return json.dumps(presets.read(id)), 200, {'Content-Type': 'application/json'}
|
||||
if presets.update(preset_id, data):
|
||||
return json.dumps(presets.read(preset_id)), 200, {'Content-Type': 'application/json'}
|
||||
return json.dumps({"error": "Preset not found"}), 404
|
||||
except Exception as e:
|
||||
return json.dumps({"error": str(e)}), 400
|
||||
|
||||
@controller.delete('/<id>')
|
||||
@controller.delete('/<preset_id>')
|
||||
@with_session
|
||||
async def delete_preset(request, id, session):
|
||||
async def delete_preset(request, *args, **kwargs):
|
||||
"""Delete a preset (current profile only)."""
|
||||
preset = presets.read(id)
|
||||
# Be tolerant of wrapper/arg-order variations.
|
||||
session = None
|
||||
preset_id = None
|
||||
if len(args) > 0:
|
||||
session = args[0]
|
||||
if len(args) > 1:
|
||||
preset_id = args[1]
|
||||
if 'session' in kwargs and kwargs.get('session') is not None:
|
||||
session = kwargs.get('session')
|
||||
if 'preset_id' in kwargs and kwargs.get('preset_id') is not None:
|
||||
preset_id = kwargs.get('preset_id')
|
||||
if 'id' in kwargs and kwargs.get('id') is not None and preset_id is None:
|
||||
preset_id = kwargs.get('id')
|
||||
if preset_id is None:
|
||||
return json.dumps({"error": "Preset ID is required"}), 400
|
||||
preset = presets.read(preset_id)
|
||||
current_profile_id = get_current_profile_id(session)
|
||||
if not preset or str(preset.get("profile_id")) != str(current_profile_id):
|
||||
return json.dumps({"error": "Preset not found"}), 404
|
||||
if presets.delete(id):
|
||||
if presets.delete(preset_id):
|
||||
return json.dumps({"message": "Preset deleted successfully"}), 200
|
||||
return json.dumps({"error": "Preset not found"}), 404
|
||||
|
||||
|
||||
@@ -81,11 +81,117 @@ async def apply_profile(request, session, id):
|
||||
async def create_profile(request):
|
||||
"""Create a new profile."""
|
||||
try:
|
||||
data = request.json or {}
|
||||
data = dict(request.json or {})
|
||||
name = data.get("name", "")
|
||||
seed_raw = data.get("seed_dj_tab", False)
|
||||
if isinstance(seed_raw, str):
|
||||
seed_dj_tab = seed_raw.strip().lower() in ("1", "true", "yes", "on")
|
||||
else:
|
||||
seed_dj_tab = bool(seed_raw)
|
||||
# Request-only flag: do not persist on profile records.
|
||||
data.pop("seed_dj_tab", None)
|
||||
profile_id = profiles.create(name)
|
||||
# Avoid persisting request-only fields.
|
||||
data.pop("name", None)
|
||||
if data:
|
||||
profiles.update(profile_id, data)
|
||||
|
||||
# New profiles always start with a default tab pre-populated with starter presets.
|
||||
default_preset_ids = []
|
||||
default_preset_defs = [
|
||||
{
|
||||
"name": "on",
|
||||
"pattern": "on",
|
||||
"colors": ["#FFFFFF"],
|
||||
"brightness": 255,
|
||||
"delay": 100,
|
||||
"auto": True,
|
||||
},
|
||||
{
|
||||
"name": "off",
|
||||
"pattern": "off",
|
||||
"colors": [],
|
||||
"brightness": 0,
|
||||
"delay": 100,
|
||||
"auto": True,
|
||||
},
|
||||
{
|
||||
"name": "rainbow",
|
||||
"pattern": "rainbow",
|
||||
"colors": [],
|
||||
"brightness": 255,
|
||||
"delay": 100,
|
||||
"auto": True,
|
||||
"n1": 2,
|
||||
},
|
||||
{
|
||||
"name": "transition",
|
||||
"pattern": "transition",
|
||||
"colors": ["#FF0000", "#00FF00", "#0000FF"],
|
||||
"brightness": 255,
|
||||
"delay": 500,
|
||||
"auto": True,
|
||||
},
|
||||
]
|
||||
|
||||
for preset_data in default_preset_defs:
|
||||
pid = presets.create(profile_id)
|
||||
presets.update(pid, preset_data)
|
||||
default_preset_ids.append(str(pid))
|
||||
|
||||
default_tab_id = tabs.create(name="default", names=["1"], presets=[default_preset_ids])
|
||||
tabs.update(default_tab_id, {
|
||||
"presets_flat": default_preset_ids,
|
||||
"default_preset": default_preset_ids[0] if default_preset_ids else None,
|
||||
})
|
||||
|
||||
profile = profiles.read(profile_id) or {}
|
||||
profile_tabs = profile.get("tabs", []) if isinstance(profile.get("tabs", []), list) else []
|
||||
profile_tabs.append(str(default_tab_id))
|
||||
|
||||
if seed_dj_tab:
|
||||
# Seed a DJ-focused tab with three starter presets.
|
||||
seeded_preset_ids = []
|
||||
preset_defs = [
|
||||
{
|
||||
"name": "DJ Rainbow",
|
||||
"pattern": "rainbow",
|
||||
"colors": [],
|
||||
"brightness": 220,
|
||||
"delay": 60,
|
||||
"n1": 12,
|
||||
},
|
||||
{
|
||||
"name": "DJ Single Color",
|
||||
"pattern": "on",
|
||||
"colors": ["#ff00ff"],
|
||||
"brightness": 220,
|
||||
"delay": 100,
|
||||
},
|
||||
{
|
||||
"name": "DJ Transition",
|
||||
"pattern": "transition",
|
||||
"colors": ["#ff0000", "#00ff00", "#0000ff"],
|
||||
"brightness": 220,
|
||||
"delay": 250,
|
||||
},
|
||||
]
|
||||
|
||||
for preset_data in preset_defs:
|
||||
pid = presets.create(profile_id)
|
||||
presets.update(pid, preset_data)
|
||||
seeded_preset_ids.append(str(pid))
|
||||
|
||||
dj_tab_id = tabs.create(name="dj", names=["dj"], presets=[seeded_preset_ids])
|
||||
tabs.update(dj_tab_id, {
|
||||
"presets_flat": seeded_preset_ids,
|
||||
"default_preset": seeded_preset_ids[0] if seeded_preset_ids else None,
|
||||
})
|
||||
|
||||
profile_tabs.append(str(dj_tab_id))
|
||||
|
||||
profiles.update(profile_id, {"tabs": profile_tabs})
|
||||
|
||||
profile_data = profiles.read(profile_id)
|
||||
return json.dumps({profile_id: profile_data}), 201, {'Content-Type': 'application/json'}
|
||||
except Exception as e:
|
||||
|
||||
54
src/models/device.py
Normal file
54
src/models/device.py
Normal file
@@ -0,0 +1,54 @@
|
||||
from models.model import Model
|
||||
|
||||
|
||||
def _normalize_address(addr):
|
||||
"""Normalize 6-byte ESP32 address to 12-char lowercase hex (no colons)."""
|
||||
if addr is None:
|
||||
return None
|
||||
s = str(addr).strip().lower().replace(":", "").replace("-", "")
|
||||
if len(s) == 12 and all(c in "0123456789abcdef" for c in s):
|
||||
return s
|
||||
return None
|
||||
|
||||
|
||||
class Device(Model):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
def create(self, name="", address=None, default_pattern=None, tabs=None):
|
||||
next_id = self.get_next_id()
|
||||
addr = _normalize_address(address)
|
||||
self[next_id] = {
|
||||
"name": name,
|
||||
"address": addr,
|
||||
"default_pattern": default_pattern if default_pattern else None,
|
||||
"tabs": list(tabs) if tabs else [],
|
||||
}
|
||||
self.save()
|
||||
return next_id
|
||||
|
||||
def read(self, id):
|
||||
id_str = str(id)
|
||||
return self.get(id_str, None)
|
||||
|
||||
def update(self, id, data):
|
||||
id_str = str(id)
|
||||
if id_str not in self:
|
||||
return False
|
||||
if "address" in data and data["address"] is not None:
|
||||
data = dict(data)
|
||||
data["address"] = _normalize_address(data["address"])
|
||||
self[id_str].update(data)
|
||||
self.save()
|
||||
return True
|
||||
|
||||
def delete(self, id):
|
||||
id_str = str(id)
|
||||
if id_str not in self:
|
||||
return False
|
||||
self.pop(id_str)
|
||||
self.save()
|
||||
return True
|
||||
|
||||
def list(self):
|
||||
return list(self.keys())
|
||||
@@ -4,7 +4,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const closeButton = document.getElementById('color-palette-close-btn');
|
||||
const paletteContainer = document.getElementById('palette-container');
|
||||
const paletteNewColor = document.getElementById('palette-new-color');
|
||||
const paletteAddButton = document.getElementById('palette-add-color-btn');
|
||||
const profileNameDisplay = document.getElementById('palette-current-profile-name');
|
||||
|
||||
if (!paletteButton || !paletteModal || !paletteContainer) {
|
||||
@@ -177,8 +176,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
if (closeButton) {
|
||||
closeButton.addEventListener('click', closeModal);
|
||||
}
|
||||
if (paletteAddButton && paletteNewColor) {
|
||||
paletteAddButton.addEventListener('click', async () => {
|
||||
if (paletteNewColor) {
|
||||
const addSelectedColor = async () => {
|
||||
const color = paletteNewColor.value;
|
||||
if (!color) {
|
||||
return;
|
||||
@@ -188,7 +187,9 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
return;
|
||||
}
|
||||
await savePalette([...currentPalette, color]);
|
||||
});
|
||||
};
|
||||
// Add when the picker closes (user confirms selection).
|
||||
paletteNewColor.addEventListener('change', addSelectedColor);
|
||||
}
|
||||
paletteModal.addEventListener('click', (event) => {
|
||||
if (event.target === paletteModal) {
|
||||
|
||||
251
src/static/devices.js
Normal file
251
src/static/devices.js
Normal file
@@ -0,0 +1,251 @@
|
||||
// Device management: list, create, edit, delete (name and 6-byte address)
|
||||
|
||||
const HEX_BOX_COUNT = 12;
|
||||
|
||||
function makeHexAddressBoxes(container) {
|
||||
if (!container || container.querySelector('.hex-addr-box')) return;
|
||||
container.innerHTML = '';
|
||||
for (let i = 0; i < HEX_BOX_COUNT; i++) {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.className = 'hex-addr-box';
|
||||
input.maxLength = 1;
|
||||
input.autocomplete = 'off';
|
||||
input.setAttribute('data-index', i);
|
||||
input.setAttribute('inputmode', 'numeric');
|
||||
input.setAttribute('aria-label', `Hex digit ${i + 1}`);
|
||||
input.addEventListener('input', (e) => {
|
||||
const v = e.target.value.replace(/[^0-9a-fA-F]/g, '');
|
||||
e.target.value = v;
|
||||
if (v && e.target.nextElementSibling && e.target.nextElementSibling.classList.contains('hex-addr-box')) {
|
||||
e.target.nextElementSibling.focus();
|
||||
}
|
||||
});
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Backspace' && !e.target.value && e.target.previousElementSibling) {
|
||||
e.target.previousElementSibling.focus();
|
||||
}
|
||||
});
|
||||
input.addEventListener('paste', (e) => {
|
||||
e.preventDefault();
|
||||
const pasted = (e.clipboardData.getData('text') || '').replace(/[^0-9a-fA-F]/g, '').slice(0, HEX_BOX_COUNT);
|
||||
const boxes = container.querySelectorAll('.hex-addr-box');
|
||||
for (let j = 0; j < pasted.length && j < boxes.length; j++) {
|
||||
boxes[j].value = pasted[j];
|
||||
}
|
||||
if (pasted.length > 0) {
|
||||
const nextIdx = Math.min(pasted.length, boxes.length - 1);
|
||||
boxes[nextIdx].focus();
|
||||
}
|
||||
});
|
||||
container.appendChild(input);
|
||||
}
|
||||
}
|
||||
|
||||
function getAddressFromBoxes(container) {
|
||||
if (!container) return '';
|
||||
const boxes = container.querySelectorAll('.hex-addr-box');
|
||||
return Array.from(boxes).map((b) => b.value).join('').toLowerCase();
|
||||
}
|
||||
|
||||
function setAddressToBoxes(container, addrStr) {
|
||||
if (!container) return;
|
||||
const s = (addrStr || '').replace(/[^0-9a-fA-F]/g, '').slice(0, HEX_BOX_COUNT);
|
||||
const boxes = container.querySelectorAll('.hex-addr-box');
|
||||
boxes.forEach((b, i) => {
|
||||
b.value = s[i] || '';
|
||||
});
|
||||
}
|
||||
|
||||
async function loadDevicesModal() {
|
||||
const container = document.getElementById('devices-list-modal');
|
||||
if (!container) return;
|
||||
container.innerHTML = '<span class="muted-text">Loading...</span>';
|
||||
try {
|
||||
const response = await fetch('/devices', { headers: { Accept: 'application/json' } });
|
||||
if (!response.ok) throw new Error('Failed to load devices');
|
||||
const devices = await response.json();
|
||||
renderDevicesList(devices || {});
|
||||
} catch (e) {
|
||||
console.error('loadDevicesModal:', e);
|
||||
container.innerHTML = '<span class="muted-text">Failed to load devices.</span>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderDevicesList(devices) {
|
||||
const container = document.getElementById('devices-list-modal');
|
||||
if (!container) return;
|
||||
container.innerHTML = '';
|
||||
const ids = Object.keys(devices).filter((k) => devices[k] && typeof devices[k] === 'object');
|
||||
if (ids.length === 0) {
|
||||
const p = document.createElement('p');
|
||||
p.className = 'muted-text';
|
||||
p.textContent = 'No devices. Create one above.';
|
||||
container.appendChild(p);
|
||||
return;
|
||||
}
|
||||
ids.forEach((devId) => {
|
||||
const dev = devices[devId];
|
||||
const row = document.createElement('div');
|
||||
row.className = 'profiles-row';
|
||||
row.style.display = 'flex';
|
||||
row.style.alignItems = 'center';
|
||||
row.style.gap = '0.5rem';
|
||||
row.style.flexWrap = 'wrap';
|
||||
|
||||
const label = document.createElement('span');
|
||||
label.textContent = (dev && dev.name) || devId;
|
||||
label.style.flex = '1';
|
||||
label.style.minWidth = '100px';
|
||||
|
||||
const meta = document.createElement('span');
|
||||
meta.className = 'muted-text';
|
||||
meta.style.fontSize = '0.85em';
|
||||
const addr = (dev && dev.address) ? dev.address : '—';
|
||||
meta.textContent = `Address: ${addr}`;
|
||||
|
||||
const editBtn = document.createElement('button');
|
||||
editBtn.className = 'btn btn-secondary btn-small';
|
||||
editBtn.textContent = 'Edit';
|
||||
editBtn.addEventListener('click', () => openEditDeviceModal(devId, dev));
|
||||
|
||||
const deleteBtn = document.createElement('button');
|
||||
deleteBtn.className = 'btn btn-secondary btn-small';
|
||||
deleteBtn.textContent = 'Delete';
|
||||
deleteBtn.addEventListener('click', async () => {
|
||||
if (!confirm(`Delete device "${(dev && dev.name) || devId}"?`)) return;
|
||||
try {
|
||||
const res = await fetch(`/devices/${devId}`, { method: 'DELETE' });
|
||||
if (res.ok) await loadDevicesModal();
|
||||
else {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
alert(data.error || 'Delete failed');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert('Delete failed');
|
||||
}
|
||||
});
|
||||
|
||||
row.appendChild(label);
|
||||
row.appendChild(meta);
|
||||
row.appendChild(editBtn);
|
||||
row.appendChild(deleteBtn);
|
||||
container.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
function openEditDeviceModal(devId, dev) {
|
||||
const modal = document.getElementById('edit-device-modal');
|
||||
const idInput = document.getElementById('edit-device-id');
|
||||
const nameInput = document.getElementById('edit-device-name');
|
||||
const addressBoxes = document.getElementById('edit-device-address-boxes');
|
||||
if (!modal || !idInput) return;
|
||||
idInput.value = devId;
|
||||
if (nameInput) nameInput.value = (dev && dev.name) || '';
|
||||
setAddressToBoxes(addressBoxes, (dev && dev.address) || '');
|
||||
modal.classList.add('active');
|
||||
}
|
||||
|
||||
async function createDevice(name, address) {
|
||||
try {
|
||||
const res = await fetch('/devices', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, address: address || null }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (res.ok) {
|
||||
await loadDevicesModal();
|
||||
return true;
|
||||
}
|
||||
alert(data.error || 'Failed to create device');
|
||||
return false;
|
||||
} catch (e) {
|
||||
console.error('createDevice:', e);
|
||||
alert('Failed to create device');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateDevice(devId, name, address) {
|
||||
try {
|
||||
const res = await fetch(`/devices/${devId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, address: address || null }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (res.ok) {
|
||||
await loadDevicesModal();
|
||||
return true;
|
||||
}
|
||||
alert(data.error || 'Failed to update device');
|
||||
return false;
|
||||
} catch (e) {
|
||||
console.error('updateDevice:', e);
|
||||
alert('Failed to update device');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
makeHexAddressBoxes(document.getElementById('new-device-address-boxes'));
|
||||
makeHexAddressBoxes(document.getElementById('edit-device-address-boxes'));
|
||||
|
||||
const devicesBtn = document.getElementById('devices-btn');
|
||||
const devicesModal = document.getElementById('devices-modal');
|
||||
const devicesCloseBtn = document.getElementById('devices-close-btn');
|
||||
const newName = document.getElementById('new-device-name');
|
||||
const createBtn = document.getElementById('create-device-btn');
|
||||
const editForm = document.getElementById('edit-device-form');
|
||||
const editCloseBtn = document.getElementById('edit-device-close-btn');
|
||||
const editDeviceModal = document.getElementById('edit-device-modal');
|
||||
|
||||
if (devicesBtn && devicesModal) {
|
||||
devicesBtn.addEventListener('click', () => {
|
||||
devicesModal.classList.add('active');
|
||||
loadDevicesModal();
|
||||
});
|
||||
}
|
||||
if (devicesCloseBtn) {
|
||||
devicesCloseBtn.addEventListener('click', () => devicesModal && devicesModal.classList.remove('active'));
|
||||
}
|
||||
const newAddressBoxes = document.getElementById('new-device-address-boxes');
|
||||
const doCreate = async () => {
|
||||
const name = (newName && newName.value.trim()) || '';
|
||||
if (!name) {
|
||||
alert('Device name is required.');
|
||||
return;
|
||||
}
|
||||
const address = newAddressBoxes ? getAddressFromBoxes(newAddressBoxes) : '';
|
||||
const ok = await createDevice(name, address);
|
||||
if (ok && newName) {
|
||||
newName.value = '';
|
||||
setAddressToBoxes(newAddressBoxes, '');
|
||||
}
|
||||
};
|
||||
if (createBtn) createBtn.addEventListener('click', doCreate);
|
||||
if (newName) newName.addEventListener('keypress', (e) => { if (e.key === 'Enter') doCreate(); });
|
||||
|
||||
if (editForm) {
|
||||
editForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const idInput = document.getElementById('edit-device-id');
|
||||
const nameInput = document.getElementById('edit-device-name');
|
||||
const addressBoxes = document.getElementById('edit-device-address-boxes');
|
||||
const devId = idInput && idInput.value;
|
||||
if (!devId) return;
|
||||
const address = addressBoxes ? getAddressFromBoxes(addressBoxes) : '';
|
||||
const ok = await updateDevice(
|
||||
devId,
|
||||
nameInput ? nameInput.value.trim() : '',
|
||||
address
|
||||
);
|
||||
if (ok) editDeviceModal.classList.remove('active');
|
||||
});
|
||||
}
|
||||
if (editCloseBtn) {
|
||||
editCloseBtn.addEventListener('click', () => editDeviceModal && editDeviceModal.classList.remove('active'));
|
||||
}
|
||||
});
|
||||
@@ -2,6 +2,72 @@
|
||||
let espnowSocket = null;
|
||||
let espnowSocketReady = false;
|
||||
let espnowPendingMessages = [];
|
||||
let currentProfileIdCache = null;
|
||||
|
||||
const getCurrentProfileId = async () => {
|
||||
try {
|
||||
const res = await fetch('/profiles/current', { headers: { Accept: 'application/json' } });
|
||||
if (!res.ok) return currentProfileIdCache ? String(currentProfileIdCache) : null;
|
||||
const data = await res.json();
|
||||
const id = data && (data.id || (data.profile && data.profile.id));
|
||||
currentProfileIdCache = id ? String(id) : null;
|
||||
return currentProfileIdCache;
|
||||
} catch (_) {
|
||||
return currentProfileIdCache ? String(currentProfileIdCache) : null;
|
||||
}
|
||||
};
|
||||
|
||||
const filterPresetsForCurrentProfile = async (presetsObj) => {
|
||||
const scoped = presetsObj && typeof presetsObj === 'object' ? presetsObj : {};
|
||||
const currentProfileId = await getCurrentProfileId();
|
||||
if (!currentProfileId) return scoped;
|
||||
return Object.fromEntries(
|
||||
Object.entries(scoped).filter(([, preset]) => {
|
||||
if (!preset || typeof preset !== 'object') return false;
|
||||
if (!('profile_id' in preset)) return true; // Legacy records
|
||||
return String(preset.profile_id) === String(currentProfileId);
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const getCurrentProfileData = async () => {
|
||||
try {
|
||||
const res = await fetch('/profiles/current', { headers: { Accept: 'application/json' } });
|
||||
if (!res.ok) return null;
|
||||
return await res.json();
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getCurrentProfilePaletteColors = async () => {
|
||||
const profileData = await getCurrentProfileData();
|
||||
const profile = profileData && profileData.profile;
|
||||
const paletteId = profile && (profile.palette_id || profile.paletteId);
|
||||
if (!paletteId) return [];
|
||||
try {
|
||||
const res = await fetch(`/palettes/${paletteId}`, { headers: { Accept: 'application/json' } });
|
||||
if (!res.ok) return [];
|
||||
const pal = await res.json();
|
||||
return Array.isArray(pal.colors) ? pal.colors : [];
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const resolveColorsWithPaletteRefs = (colors, paletteRefs, paletteColors) => {
|
||||
const baseColors = Array.isArray(colors) ? colors : [];
|
||||
const refs = Array.isArray(paletteRefs) ? paletteRefs : [];
|
||||
const pal = Array.isArray(paletteColors) ? paletteColors : [];
|
||||
return baseColors.map((color, idx) => {
|
||||
const refRaw = refs[idx];
|
||||
const ref = Number.isInteger(refRaw) ? refRaw : parseInt(refRaw, 10);
|
||||
if (Number.isInteger(ref) && ref >= 0 && ref < pal.length && pal[ref]) {
|
||||
return pal[ref];
|
||||
}
|
||||
return color;
|
||||
});
|
||||
};
|
||||
|
||||
const getEspnowSocket = () => {
|
||||
if (espnowSocket && (espnowSocket.readyState === WebSocket.OPEN || espnowSocket.readyState === WebSocket.CONNECTING)) {
|
||||
@@ -105,7 +171,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const presetPatternInput = document.getElementById('preset-pattern-input');
|
||||
const presetColorsContainer = document.getElementById('preset-colors-container');
|
||||
const presetNewColorInput = document.getElementById('preset-new-color');
|
||||
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');
|
||||
@@ -123,6 +188,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
let cachedPresets = {};
|
||||
let cachedPatterns = {};
|
||||
let currentPresetColors = []; // Track colors for the current preset
|
||||
let currentPresetPaletteRefs = []; // Palette index refs per color (null for direct colors)
|
||||
|
||||
// Function to get max colors for current pattern
|
||||
const getMaxColors = () => {
|
||||
@@ -158,7 +224,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
// Hide/show the actions (color picker and buttons)
|
||||
const colorActions = presetColorsContainer.nextElementSibling;
|
||||
if (colorActions && (colorActions.querySelector('#preset-add-color-btn') || colorActions.querySelector('#preset-new-color'))) {
|
||||
if (colorActions && colorActions.querySelector('#preset-new-color')) {
|
||||
colorActions.style.display = shouldShow ? '' : 'none';
|
||||
}
|
||||
}
|
||||
@@ -172,11 +238,24 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
return parseInt(input.value, 10) || 0;
|
||||
};
|
||||
|
||||
const renderPresetColors = (colors) => {
|
||||
const renderPresetColors = (colors, paletteRefs) => {
|
||||
if (!presetColorsContainer) return;
|
||||
|
||||
presetColorsContainer.innerHTML = '';
|
||||
currentPresetColors = colors || [];
|
||||
currentPresetColors = Array.isArray(colors) ? colors.slice() : [];
|
||||
if (Array.isArray(paletteRefs)) {
|
||||
currentPresetPaletteRefs = currentPresetColors.map((_, i) => {
|
||||
const refRaw = paletteRefs[i];
|
||||
const ref = Number.isInteger(refRaw) ? refRaw : parseInt(refRaw, 10);
|
||||
return Number.isInteger(ref) ? ref : null;
|
||||
});
|
||||
} else {
|
||||
currentPresetPaletteRefs = currentPresetColors.map((_, i) => {
|
||||
const refRaw = currentPresetPaletteRefs[i];
|
||||
const ref = Number.isInteger(refRaw) ? refRaw : parseInt(refRaw, 10);
|
||||
return Number.isInteger(ref) ? ref : null;
|
||||
});
|
||||
}
|
||||
|
||||
// Get max colors for current pattern
|
||||
const maxColors = getMaxColors();
|
||||
@@ -185,7 +264,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
if (currentPresetColors.length === 0) {
|
||||
const empty = document.createElement('p');
|
||||
empty.className = 'muted-text';
|
||||
empty.textContent = `No colors added. Click "Add Color" to add colors.${maxColorsText}`;
|
||||
empty.textContent = `No colors added. Use the color picker to add colors.${maxColorsText}`;
|
||||
presetColorsContainer.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
@@ -208,6 +287,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
swatchWrapper.style.cssText = 'position: relative; display: inline-block;';
|
||||
swatchWrapper.draggable = true;
|
||||
swatchWrapper.dataset.colorIndex = index;
|
||||
const refAtIndex = currentPresetPaletteRefs[index];
|
||||
swatchWrapper.dataset.paletteRef = Number.isInteger(refAtIndex) ? String(refAtIndex) : '';
|
||||
swatchWrapper.classList.add('draggable-color-swatch');
|
||||
|
||||
const swatch = document.createElement('div');
|
||||
@@ -222,6 +303,31 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
transition: opacity 0.2s, transform 0.2s;
|
||||
`;
|
||||
swatch.title = `${color} - Drag to reorder`;
|
||||
|
||||
if (Number.isInteger(refAtIndex)) {
|
||||
const linkedBadge = document.createElement('span');
|
||||
linkedBadge.textContent = 'P';
|
||||
linkedBadge.title = `Linked to palette color #${refAtIndex + 1}`;
|
||||
linkedBadge.style.cssText = `
|
||||
position: absolute;
|
||||
left: -6px;
|
||||
top: -6px;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 9px;
|
||||
background: #3f51b5;
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 11;
|
||||
border: 1px solid rgba(255,255,255,0.35);
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.35);
|
||||
`;
|
||||
swatchWrapper.appendChild(linkedBadge);
|
||||
}
|
||||
|
||||
// Color picker overlay
|
||||
const colorPicker = document.createElement('input');
|
||||
@@ -239,7 +345,9 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
`;
|
||||
colorPicker.addEventListener('change', (e) => {
|
||||
currentPresetColors[index] = e.target.value;
|
||||
renderPresetColors(currentPresetColors);
|
||||
// Manual picker edit breaks palette linkage for this slot.
|
||||
currentPresetPaletteRefs[index] = null;
|
||||
renderPresetColors(currentPresetColors, currentPresetPaletteRefs);
|
||||
});
|
||||
// Prevent color picker from interfering with drag
|
||||
colorPicker.addEventListener('mousedown', (e) => {
|
||||
@@ -271,7 +379,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
removeBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
currentPresetColors.splice(index, 1);
|
||||
renderPresetColors(currentPresetColors);
|
||||
currentPresetPaletteRefs.splice(index, 1);
|
||||
renderPresetColors(currentPresetColors, currentPresetPaletteRefs);
|
||||
});
|
||||
// Prevent remove button from interfering with drag
|
||||
removeBtn.addEventListener('mousedown', (e) => {
|
||||
@@ -326,12 +435,18 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const colorPicker = el.querySelector('input[type="color"]');
|
||||
return colorPicker ? colorPicker.value : null;
|
||||
}).filter(color => color !== null);
|
||||
const newRefOrder = colorElements.map((el) => {
|
||||
const refRaw = el.dataset.paletteRef;
|
||||
const ref = Number.isInteger(refRaw) ? refRaw : parseInt(refRaw, 10);
|
||||
return Number.isInteger(ref) ? ref : null;
|
||||
});
|
||||
|
||||
// Update current colors array
|
||||
currentPresetColors = newColorOrder;
|
||||
currentPresetPaletteRefs = newRefOrder;
|
||||
|
||||
// Re-render to update indices
|
||||
renderPresetColors(currentPresetColors);
|
||||
renderPresetColors(currentPresetColors, currentPresetPaletteRefs);
|
||||
});
|
||||
|
||||
presetColorsContainer.appendChild(swatchContainer);
|
||||
@@ -361,7 +476,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const patternName = preset.pattern || '';
|
||||
presetPatternInput.value = patternName;
|
||||
const colors = Array.isArray(preset.colors) ? preset.colors : [];
|
||||
renderPresetColors(colors);
|
||||
const paletteRefs = Array.isArray(preset.palette_refs) ? preset.palette_refs : [];
|
||||
renderPresetColors(colors, paletteRefs);
|
||||
presetBrightnessInput.value = preset.brightness || 0;
|
||||
presetDelayInput.value = preset.delay || 0;
|
||||
|
||||
@@ -424,6 +540,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
currentEditId = null;
|
||||
currentEditTabId = null;
|
||||
currentPresetColors = [];
|
||||
currentPresetPaletteRefs = [];
|
||||
setFormValues({
|
||||
name: '',
|
||||
pattern: '',
|
||||
@@ -505,6 +622,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
name: presetNameInput ? presetNameInput.value.trim() : '',
|
||||
pattern: presetPatternInput ? presetPatternInput.value.trim() : '',
|
||||
colors: currentPresetColors || [],
|
||||
palette_refs: currentPresetPaletteRefs || [],
|
||||
// Use canonical field names expected by the device / API
|
||||
brightness: presetBrightnessInput ? parseInt(presetBrightnessInput.value, 10) || 0 : 0,
|
||||
delay: presetDelayInput ? parseInt(presetDelayInput.value, 10) || 0 : 0,
|
||||
@@ -633,9 +751,18 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const editButton = document.createElement('button');
|
||||
editButton.className = 'btn btn-secondary btn-small';
|
||||
editButton.textContent = 'Edit';
|
||||
editButton.addEventListener('click', () => {
|
||||
editButton.addEventListener('click', async () => {
|
||||
currentEditId = presetId;
|
||||
setFormValues(preset || {});
|
||||
const paletteColors = await getCurrentProfilePaletteColors();
|
||||
const presetForEditor = {
|
||||
...(preset || {}),
|
||||
colors: resolveColorsWithPaletteRefs(
|
||||
(preset && preset.colors) || [],
|
||||
(preset && preset.palette_refs) || [],
|
||||
paletteColors,
|
||||
),
|
||||
};
|
||||
setFormValues(presetForEditor);
|
||||
openEditor();
|
||||
});
|
||||
|
||||
@@ -698,7 +825,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
throw new Error('Failed to load presets');
|
||||
}
|
||||
const presets = await response.json();
|
||||
renderPresets(presets);
|
||||
const filtered = await filterPresetsForCurrentProfile(presets);
|
||||
renderPresets(filtered);
|
||||
} catch (error) {
|
||||
console.error('Load presets failed:', error);
|
||||
presetsList.innerHTML = '';
|
||||
@@ -757,7 +885,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load presets');
|
||||
}
|
||||
const allPresets = await response.json();
|
||||
const allPresetsRaw = await response.json();
|
||||
const allPresets = await filterPresetsForCurrentProfile(allPresetsRaw);
|
||||
|
||||
// Load only the current tab's presets so we can avoid duplicates within this tab.
|
||||
let currentTabPresets = [];
|
||||
@@ -946,12 +1075,12 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
// Update color section visibility
|
||||
updateColorSectionVisibility();
|
||||
// Re-render colors to show updated max colors limit
|
||||
renderPresetColors(currentPresetColors);
|
||||
renderPresetColors(currentPresetColors, currentPresetPaletteRefs);
|
||||
});
|
||||
}
|
||||
// Add Color button handler
|
||||
if (presetAddColorButton && presetNewColorInput) {
|
||||
presetAddColorButton.addEventListener('click', () => {
|
||||
// Color picker auto-add handler
|
||||
if (presetNewColorInput) {
|
||||
const tryAddSelectedColor = () => {
|
||||
const color = presetNewColorInput.value;
|
||||
if (!color) return;
|
||||
|
||||
@@ -967,60 +1096,86 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
|
||||
currentPresetColors.push(color);
|
||||
renderPresetColors(currentPresetColors);
|
||||
});
|
||||
currentPresetPaletteRefs.push(null);
|
||||
renderPresetColors(currentPresetColors, currentPresetPaletteRefs);
|
||||
};
|
||||
// Add when the picker closes (user confirms selection).
|
||||
presetNewColorInput.addEventListener('change', tryAddSelectedColor);
|
||||
}
|
||||
|
||||
// Add from Palette button handler
|
||||
if (presetAddFromPaletteButton) {
|
||||
presetAddFromPaletteButton.addEventListener('click', () => {
|
||||
const openButton = document.getElementById('color-palette-btn');
|
||||
if (openButton) {
|
||||
openButton.click();
|
||||
}
|
||||
const modal = document.getElementById('color-palette-modal');
|
||||
const modalList = document.getElementById('palette-container');
|
||||
if (modal) {
|
||||
modal.classList.add('active');
|
||||
}
|
||||
if (!modalList) {
|
||||
return;
|
||||
}
|
||||
presetAddFromPaletteButton.addEventListener('click', async () => {
|
||||
try {
|
||||
const paletteColors = await getCurrentProfilePaletteColors();
|
||||
if (!Array.isArray(paletteColors) || paletteColors.length === 0) {
|
||||
alert('No profile palette colors available.');
|
||||
return;
|
||||
}
|
||||
|
||||
const handlePick = (event) => {
|
||||
const row = event.target.closest('[data-color]');
|
||||
if (!row) {
|
||||
return;
|
||||
}
|
||||
const picked = row.dataset.color;
|
||||
if (!picked) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentPresetColors.includes(picked)) {
|
||||
alert('This color is already in the list.');
|
||||
return;
|
||||
}
|
||||
|
||||
const maxColors = getMaxColors();
|
||||
if (currentPresetColors.length >= maxColors) {
|
||||
alert(`This pattern allows a maximum of ${maxColors} color${maxColors !== 1 ? 's' : ''}.`);
|
||||
if (modal) {
|
||||
modal.classList.remove('active');
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'modal active';
|
||||
modal.innerHTML = `
|
||||
<div class="modal-content">
|
||||
<h2>Pick Palette Color</h2>
|
||||
<div id="pick-palette-list" class="profiles-list" style="max-height: 300px; overflow-y: auto;"></div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-secondary" id="pick-palette-close-btn">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(modal);
|
||||
|
||||
const list = modal.querySelector('#pick-palette-list');
|
||||
paletteColors.forEach((color, idx) => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'profiles-row';
|
||||
row.style.display = 'flex';
|
||||
row.style.alignItems = 'center';
|
||||
row.style.gap = '0.75rem';
|
||||
row.dataset.paletteIndex = String(idx);
|
||||
row.dataset.paletteColor = color;
|
||||
row.innerHTML = `
|
||||
<div style="width:28px;height:28px;border-radius:4px;border:1px solid #555;background:${color};"></div>
|
||||
<span style="flex:1">${color}</span>
|
||||
<button class="btn btn-primary btn-small" type="button">Use</button>
|
||||
`;
|
||||
list.appendChild(row);
|
||||
});
|
||||
|
||||
const close = () => modal.remove();
|
||||
modal.querySelector('#pick-palette-close-btn').addEventListener('click', close);
|
||||
modal.addEventListener('click', (e) => {
|
||||
if (e.target === modal) close();
|
||||
});
|
||||
|
||||
list.addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('button');
|
||||
if (!btn) return;
|
||||
const row = e.target.closest('[data-palette-index]');
|
||||
if (!row) return;
|
||||
const color = row.dataset.paletteColor;
|
||||
const ref = parseInt(row.dataset.paletteIndex, 10);
|
||||
if (!color || !Number.isInteger(ref)) return;
|
||||
|
||||
if (currentPresetColors.includes(color) && currentPresetPaletteRefs.includes(ref)) {
|
||||
alert('That palette color is already linked.');
|
||||
return;
|
||||
}
|
||||
const maxColors = getMaxColors();
|
||||
if (currentPresetColors.length >= maxColors) {
|
||||
alert(`This pattern allows a maximum of ${maxColors} color${maxColors !== 1 ? 's' : ''}.`);
|
||||
return;
|
||||
}
|
||||
modalList.removeEventListener('click', handlePick);
|
||||
return;
|
||||
}
|
||||
|
||||
currentPresetColors.push(picked);
|
||||
renderPresetColors(currentPresetColors);
|
||||
if (modal) {
|
||||
modal.classList.remove('active');
|
||||
}
|
||||
modalList.removeEventListener('click', handlePick);
|
||||
};
|
||||
|
||||
modalList.addEventListener('click', handlePick);
|
||||
currentPresetColors.push(color);
|
||||
currentPresetPaletteRefs.push(ref);
|
||||
renderPresetColors(currentPresetColors, currentPresetPaletteRefs);
|
||||
close();
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Failed to add from palette:', err);
|
||||
alert('Failed to load palette colors.');
|
||||
}
|
||||
});
|
||||
}
|
||||
const presetSendButton = document.getElementById('preset-send-btn');
|
||||
@@ -1136,7 +1291,15 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
currentEditId = presetId;
|
||||
currentEditTabId = tabId || null;
|
||||
await loadPatterns();
|
||||
setFormValues(preset);
|
||||
const paletteColors = await getCurrentProfilePaletteColors();
|
||||
setFormValues({
|
||||
...(preset || {}),
|
||||
colors: resolveColorsWithPaletteRefs(
|
||||
(preset && preset.colors) || [],
|
||||
(preset && preset.palette_refs) || [],
|
||||
paletteColors,
|
||||
),
|
||||
});
|
||||
openEditor();
|
||||
});
|
||||
|
||||
@@ -1175,11 +1338,13 @@ 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, setDefault = false) => {
|
||||
const sendPresetViaEspNow = async (presetId, preset, deviceNames, saveToDevice = true, setDefault = false) => {
|
||||
try {
|
||||
const colors = Array.isArray(preset.colors) && preset.colors.length
|
||||
const baseColors = Array.isArray(preset.colors) && preset.colors.length
|
||||
? preset.colors
|
||||
: ['#FFFFFF'];
|
||||
const paletteColors = await getCurrentProfilePaletteColors();
|
||||
const colors = resolveColorsWithPaletteRefs(baseColors, preset.palette_refs, paletteColors);
|
||||
|
||||
const message = {
|
||||
v: '1',
|
||||
@@ -1261,97 +1426,29 @@ try {
|
||||
|
||||
// Store selected preset per tab
|
||||
const selectedPresets = {};
|
||||
// Run vs Edit for tab preset strip (in-memory only — each full page load starts in run mode)
|
||||
let presetUiMode = 'run';
|
||||
|
||||
const getPresetUiMode = () => (presetUiMode === 'edit' ? 'edit' : 'run');
|
||||
|
||||
const setPresetUiMode = (mode) => {
|
||||
presetUiMode = mode === 'edit' ? 'edit' : 'run';
|
||||
};
|
||||
|
||||
const updateUiModeToggleButtons = () => {
|
||||
const mode = getPresetUiMode();
|
||||
// Label is the mode you switch *to* (opposite of current)
|
||||
const label = mode === 'edit' ? 'Run mode' : 'Edit mode';
|
||||
document.querySelectorAll('.ui-mode-toggle').forEach((btn) => {
|
||||
btn.textContent = label;
|
||||
btn.setAttribute('aria-pressed', mode === 'edit' ? 'true' : 'false');
|
||||
btn.classList.toggle('ui-mode-toggle--edit', mode === 'edit');
|
||||
});
|
||||
document.body.classList.toggle('preset-ui-edit', mode === 'edit');
|
||||
document.body.classList.toggle('preset-ui-run', mode === 'run');
|
||||
};
|
||||
// Track if we're currently dragging a preset
|
||||
let isDraggingPreset = false;
|
||||
// Context menu for tab presets
|
||||
let presetContextMenu = null;
|
||||
let presetContextTarget = null;
|
||||
|
||||
const ensurePresetContextMenu = () => {
|
||||
if (presetContextMenu) {
|
||||
return presetContextMenu;
|
||||
}
|
||||
const menu = document.createElement('div');
|
||||
menu.id = 'preset-context-menu';
|
||||
menu.style.cssText = `
|
||||
position: fixed;
|
||||
z-index: 2000;
|
||||
background: #2e2e2e;
|
||||
border: 1px solid #4a4a4a;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,0.6);
|
||||
padding: 0.25rem 0;
|
||||
min-width: 160px;
|
||||
display: none;
|
||||
`;
|
||||
|
||||
const addItem = (label, action) => {
|
||||
const item = document.createElement('button');
|
||||
item.type = 'button';
|
||||
item.textContent = label;
|
||||
item.dataset.action = action;
|
||||
item.style.cssText = `
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0.4rem 0.75rem;
|
||||
background: transparent;
|
||||
color: #eee;
|
||||
border: none;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
`;
|
||||
item.addEventListener('mouseover', () => {
|
||||
item.style.backgroundColor = '#3a3a3a';
|
||||
});
|
||||
item.addEventListener('mouseout', () => {
|
||||
item.style.backgroundColor = 'transparent';
|
||||
});
|
||||
menu.appendChild(item);
|
||||
};
|
||||
|
||||
addItem('Edit preset…', 'edit');
|
||||
|
||||
menu.addEventListener('click', async (e) => {
|
||||
const btn = e.target.closest('button[data-action]');
|
||||
if (!btn || !presetContextTarget) {
|
||||
return;
|
||||
}
|
||||
const { presetId } = presetContextTarget;
|
||||
const action = btn.dataset.action;
|
||||
hidePresetContextMenu();
|
||||
if (action === 'edit') {
|
||||
await editPresetFromTab(presetId);
|
||||
}
|
||||
});
|
||||
|
||||
document.body.appendChild(menu);
|
||||
presetContextMenu = menu;
|
||||
|
||||
// Hide on outside click
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!presetContextMenu) return;
|
||||
if (e.target.closest('#preset-context-menu')) return;
|
||||
hidePresetContextMenu();
|
||||
});
|
||||
|
||||
return menu;
|
||||
};
|
||||
|
||||
const showPresetContextMenu = (x, y, tabId, presetId, preset) => {
|
||||
const menu = ensurePresetContextMenu();
|
||||
presetContextTarget = { tabId, presetId, preset };
|
||||
menu.style.left = `${x}px`;
|
||||
menu.style.top = `${y}px`;
|
||||
menu.style.display = 'block';
|
||||
};
|
||||
|
||||
const hidePresetContextMenu = () => {
|
||||
if (presetContextMenu) {
|
||||
presetContextMenu.style.display = 'none';
|
||||
}
|
||||
presetContextTarget = null;
|
||||
};
|
||||
|
||||
// Function to convert 2D grid to flat array (for backward compatibility)
|
||||
const gridToArray = (presetsGrid) => {
|
||||
@@ -1449,6 +1546,25 @@ const getDropTarget = (container, x, y) => {
|
||||
return closest.element;
|
||||
};
|
||||
|
||||
/**
|
||||
* Move dragged tile onto the drop target's slot.
|
||||
* When moving down the list (fromIdx < toIdx), insertBefore(dragging, dropTarget) lands one index
|
||||
* too early; use the next element sibling so the item occupies the target slot.
|
||||
*/
|
||||
const insertDraggingOntoTarget = (presetsList, dragging, dropTarget) => {
|
||||
const siblings = [...presetsList.querySelectorAll('.draggable-preset')];
|
||||
const fromIdx = siblings.indexOf(dragging);
|
||||
const toIdx = siblings.indexOf(dropTarget);
|
||||
if (fromIdx === -1 || toIdx === -1) return;
|
||||
|
||||
if (fromIdx < toIdx) {
|
||||
const next = dropTarget.nextElementSibling;
|
||||
presetsList.insertBefore(dragging, next);
|
||||
} else {
|
||||
presetsList.insertBefore(dragging, dropTarget);
|
||||
}
|
||||
};
|
||||
|
||||
// Function to render presets for a specific tab in 2D grid
|
||||
const renderTabPresets = async (tabId) => {
|
||||
const presetsList = document.getElementById('presets-list-tab');
|
||||
@@ -1482,47 +1598,74 @@ const renderTabPresets = async (tabId) => {
|
||||
if (!presetsResponse.ok) {
|
||||
throw new Error('Failed to load presets');
|
||||
}
|
||||
const allPresets = await presetsResponse.json();
|
||||
const allPresetsRaw = await presetsResponse.json();
|
||||
const allPresets = await filterPresetsForCurrentProfile(allPresetsRaw);
|
||||
const paletteColors = await getCurrentProfilePaletteColors();
|
||||
|
||||
presetsList.innerHTML = '';
|
||||
|
||||
// Add drag and drop handlers to the container
|
||||
presetsList.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
const dragging = presetsList.querySelector('.dragging');
|
||||
if (!dragging) return;
|
||||
|
||||
const dropTarget = getDropTarget(presetsList, e.clientX, e.clientY);
|
||||
if (dropTarget && dropTarget !== dragging) {
|
||||
// Insert before drop target so the dragged item takes that cell's position
|
||||
presetsList.insertBefore(dragging, dropTarget);
|
||||
}
|
||||
});
|
||||
|
||||
presetsList.addEventListener('drop', async (e) => {
|
||||
e.preventDefault();
|
||||
const dragging = presetsList.querySelector('.dragging');
|
||||
if (!dragging) return;
|
||||
|
||||
// Get new grid layout from DOM
|
||||
const presetElements = [...presetsList.querySelectorAll('.draggable-preset')];
|
||||
const presetIds = presetElements.map(el => el.dataset.presetId);
|
||||
|
||||
// Convert to 2D grid (3 columns)
|
||||
const newGrid = arrayToGrid(presetIds, 3);
|
||||
|
||||
// Save new grid
|
||||
try {
|
||||
await savePresetGrid(tabId, newGrid);
|
||||
// Re-render to ensure consistency
|
||||
await renderTabPresets(tabId);
|
||||
} catch (error) {
|
||||
console.error('Failed to save preset grid:', error);
|
||||
alert('Failed to save preset order. Please try again.');
|
||||
// Re-render to restore original order
|
||||
await renderTabPresets(tabId);
|
||||
}
|
||||
});
|
||||
presetsList.dataset.reorderTabId = tabId;
|
||||
|
||||
// Drag-and-drop on the list (wire once — re-render would duplicate listeners otherwise)
|
||||
if (!presetsList.dataset.dragWired) {
|
||||
presetsList.dataset.dragWired = '1';
|
||||
// dragenter + dropEffect tell the browser this zone accepts a move (avoids ⊘ cursor)
|
||||
presetsList.addEventListener('dragenter', (e) => {
|
||||
if (getPresetUiMode() !== 'edit') return;
|
||||
e.preventDefault();
|
||||
});
|
||||
presetsList.addEventListener('dragover', (e) => {
|
||||
if (getPresetUiMode() !== 'edit') return;
|
||||
e.preventDefault();
|
||||
try {
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
} catch (_) {}
|
||||
const dragging = presetsList.querySelector('.dragging');
|
||||
if (!dragging) return;
|
||||
|
||||
const dropTarget = getDropTarget(presetsList, e.clientX, e.clientY);
|
||||
// Keep dragover side-effect free; commit placement only on drop.
|
||||
if (!dropTarget || dropTarget === dragging) {
|
||||
delete presetsList.dataset.dropTargetId;
|
||||
return;
|
||||
}
|
||||
presetsList.dataset.dropTargetId = dropTarget.dataset.presetId || '';
|
||||
});
|
||||
|
||||
presetsList.addEventListener('drop', async (e) => {
|
||||
if (getPresetUiMode() !== 'edit') return;
|
||||
e.preventDefault();
|
||||
const dragging = presetsList.querySelector('.dragging');
|
||||
if (!dragging) return;
|
||||
const targetId = presetsList.dataset.dropTargetId;
|
||||
if (targetId) {
|
||||
const dropTarget = presetsList.querySelector(`.draggable-preset[data-preset-id="${targetId}"]:not(.dragging)`);
|
||||
if (dropTarget) {
|
||||
insertDraggingOntoTarget(presetsList, dragging, dropTarget);
|
||||
}
|
||||
}
|
||||
delete presetsList.dataset.dropTargetId;
|
||||
|
||||
const saveId = presetsList.dataset.reorderTabId;
|
||||
const presetElements = [...presetsList.querySelectorAll('.draggable-preset')];
|
||||
const presetIds = presetElements.map((el) => el.dataset.presetId);
|
||||
|
||||
const newGrid = arrayToGrid(presetIds, 3);
|
||||
|
||||
try {
|
||||
if (!saveId) {
|
||||
console.warn('No tab id for preset reorder save');
|
||||
return;
|
||||
}
|
||||
await savePresetGrid(saveId, newGrid);
|
||||
await renderTabPresets(saveId);
|
||||
} catch (error) {
|
||||
console.error('Failed to save preset grid:', error);
|
||||
alert('Failed to save preset order. Please try again.');
|
||||
const fallbackId = presetsList.dataset.reorderTabId;
|
||||
if (fallbackId) await renderTabPresets(fallbackId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Get the currently selected preset for this tab
|
||||
const selectedPresetId = selectedPresets[tabId];
|
||||
@@ -1543,7 +1686,11 @@ const renderTabPresets = async (tabId) => {
|
||||
const preset = allPresets[presetId];
|
||||
if (preset) {
|
||||
const isSelected = presetId === selectedPresetId;
|
||||
const wrapper = createPresetButton(presetId, preset, tabId, isSelected);
|
||||
const displayPreset = {
|
||||
...preset,
|
||||
colors: resolveColorsWithPaletteRefs(preset.colors, preset.palette_refs, paletteColors),
|
||||
};
|
||||
const wrapper = createPresetButton(presetId, displayPreset, tabId, isSelected);
|
||||
presetsList.appendChild(wrapper);
|
||||
}
|
||||
});
|
||||
@@ -1555,15 +1702,22 @@ const renderTabPresets = async (tabId) => {
|
||||
};
|
||||
|
||||
const createPresetButton = (presetId, preset, tabId, isSelected = false) => {
|
||||
const uiMode = getPresetUiMode();
|
||||
|
||||
const row = document.createElement('div');
|
||||
const canDrag = uiMode === 'edit';
|
||||
row.className = `preset-tile-row preset-tile-row--${uiMode}${canDrag ? ' draggable-preset' : ''}`;
|
||||
row.draggable = canDrag;
|
||||
row.dataset.presetId = presetId;
|
||||
|
||||
const button = document.createElement('button');
|
||||
button.className = 'pattern-button draggable-preset';
|
||||
button.draggable = true;
|
||||
button.dataset.presetId = presetId;
|
||||
button.type = 'button';
|
||||
button.className = 'pattern-button preset-tile-main';
|
||||
if (isSelected) {
|
||||
button.classList.add('active');
|
||||
}
|
||||
|
||||
const colors = Array.isArray(preset.colors) ? preset.colors.filter(c => c) : [];
|
||||
const colors = Array.isArray(preset.colors) ? preset.colors.filter((c) => c) : [];
|
||||
const isRainbow = (preset.pattern || '').toLowerCase() === 'rainbow';
|
||||
const barColors = isRainbow
|
||||
? ['#FF0000', '#FF7F00', '#FFFF00', '#00FF00', '#0000FF', '#4B0082', '#8F00FF']
|
||||
@@ -1584,38 +1738,76 @@ const createPresetButton = (presetId, preset, tabId, isSelected = false) => {
|
||||
presetNameLabel.className = 'pattern-button-label';
|
||||
button.appendChild(presetNameLabel);
|
||||
|
||||
button.addEventListener('click', (e) => {
|
||||
button.addEventListener('click', () => {
|
||||
if (isDraggingPreset) return;
|
||||
const presetsList = document.getElementById('presets-list-tab');
|
||||
if (presetsList) {
|
||||
presetsList.querySelectorAll('.pattern-button').forEach(btn => btn.classList.remove('active'));
|
||||
const presetsListEl = document.getElementById('presets-list-tab');
|
||||
if (presetsListEl) {
|
||||
presetsListEl.querySelectorAll('.pattern-button').forEach((btn) => btn.classList.remove('active'));
|
||||
}
|
||||
button.classList.add('active');
|
||||
selectedPresets[tabId] = presetId;
|
||||
const section = button.closest('.presets-section');
|
||||
const section = row.closest('.presets-section');
|
||||
sendSelectForCurrentTabDevices(presetId, section);
|
||||
});
|
||||
|
||||
button.addEventListener('contextmenu', async (e) => {
|
||||
e.preventDefault();
|
||||
if (isDraggingPreset) return;
|
||||
await editPresetFromTab(presetId, tabId, preset);
|
||||
});
|
||||
if (canDrag) {
|
||||
row.addEventListener('dragstart', (e) => {
|
||||
isDraggingPreset = true;
|
||||
row.classList.add('dragging');
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('text/plain', presetId);
|
||||
});
|
||||
|
||||
button.addEventListener('dragstart', (e) => {
|
||||
isDraggingPreset = true;
|
||||
button.classList.add('dragging');
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('text/plain', presetId);
|
||||
});
|
||||
row.addEventListener('dragend', () => {
|
||||
row.classList.remove('dragging');
|
||||
const presetsListEl = document.getElementById('presets-list-tab');
|
||||
if (presetsListEl) {
|
||||
delete presetsListEl.dataset.dropTargetId;
|
||||
}
|
||||
document.querySelectorAll('.draggable-preset').forEach((el) => el.classList.remove('drag-over'));
|
||||
setTimeout(() => {
|
||||
isDraggingPreset = false;
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
|
||||
button.addEventListener('dragend', (e) => {
|
||||
button.classList.remove('dragging');
|
||||
document.querySelectorAll('.draggable-preset').forEach(el => el.classList.remove('drag-over'));
|
||||
setTimeout(() => { isDraggingPreset = false; }, 100);
|
||||
});
|
||||
row.appendChild(button);
|
||||
|
||||
return button;
|
||||
if (uiMode === 'edit') {
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'preset-tile-actions';
|
||||
|
||||
const editBtn = document.createElement('button');
|
||||
editBtn.type = 'button';
|
||||
editBtn.className = 'btn btn-secondary btn-small';
|
||||
editBtn.textContent = 'Edit';
|
||||
editBtn.title = 'Edit preset';
|
||||
editBtn.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (isDraggingPreset) return;
|
||||
editPresetFromTab(presetId, tabId, preset);
|
||||
});
|
||||
|
||||
const removeBtn = document.createElement('button');
|
||||
removeBtn.type = 'button';
|
||||
removeBtn.className = 'btn btn-danger btn-small';
|
||||
removeBtn.textContent = 'Remove';
|
||||
removeBtn.title = 'Remove from this tab';
|
||||
removeBtn.addEventListener('click', async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (isDraggingPreset) return;
|
||||
if (!window.confirm('Remove this preset from this tab?')) return;
|
||||
await removePresetFromTab(tabId, presetId);
|
||||
});
|
||||
|
||||
actions.appendChild(editBtn);
|
||||
actions.appendChild(removeBtn);
|
||||
row.appendChild(actions);
|
||||
}
|
||||
|
||||
return row;
|
||||
};
|
||||
|
||||
const editPresetFromTab = async (presetId, tabId, existingPreset) => {
|
||||
@@ -1731,3 +1923,20 @@ document.body.addEventListener('htmx:afterSwap', (event) => {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
updateUiModeToggleButtons();
|
||||
document.querySelectorAll('.ui-mode-toggle').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
const next = getPresetUiMode() === 'edit' ? 'run' : 'edit';
|
||||
setPresetUiMode(next);
|
||||
updateUiModeToggleButtons();
|
||||
const mainMenu = document.getElementById('main-menu-dropdown');
|
||||
if (mainMenu) mainMenu.classList.remove('open');
|
||||
const leftPanel = document.querySelector('.presets-section[data-tab-id]');
|
||||
if (leftPanel) {
|
||||
renderTabPresets(leftPanel.dataset.tabId);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
const profilesCloseButton = document.getElementById("profiles-close-btn");
|
||||
const profilesList = document.getElementById("profiles-list");
|
||||
const newProfileInput = document.getElementById("new-profile-name");
|
||||
const newProfileSeedDjInput = document.getElementById("new-profile-seed-dj");
|
||||
const createProfileButton = document.getElementById("create-profile-btn");
|
||||
|
||||
if (!profilesButton || !profilesModal || !profilesList) {
|
||||
@@ -19,6 +20,18 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
profilesModal.classList.remove("active");
|
||||
};
|
||||
|
||||
const refreshTabsForActiveProfile = async () => {
|
||||
// Clear stale current tab so tab controller falls back to first tab of applied profile.
|
||||
document.cookie = "current_tab=; path=/; max-age=0";
|
||||
|
||||
if (window.tabsManager && typeof window.tabsManager.loadTabs === "function") {
|
||||
await window.tabsManager.loadTabs();
|
||||
}
|
||||
if (window.tabsManager && typeof window.tabsManager.loadTabsModal === "function") {
|
||||
await window.tabsManager.loadTabsModal();
|
||||
}
|
||||
};
|
||||
|
||||
const renderProfiles = (profiles, currentProfileId) => {
|
||||
profilesList.innerHTML = "";
|
||||
let entries = [];
|
||||
@@ -66,7 +79,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
throw new Error("Failed to apply profile");
|
||||
}
|
||||
await loadProfiles();
|
||||
document.body.dispatchEvent(new Event("tabs-updated"));
|
||||
await refreshTabsForActiveProfile();
|
||||
} catch (error) {
|
||||
console.error("Apply profile failed:", error);
|
||||
alert("Failed to apply profile.");
|
||||
@@ -115,22 +128,8 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
}
|
||||
document.cookie = "current_tab=; path=/; max-age=0";
|
||||
await loadProfiles();
|
||||
if (typeof window.loadTabs === "function") {
|
||||
await window.loadTabs();
|
||||
}
|
||||
if (typeof window.loadTabsModal === "function") {
|
||||
await window.loadTabsModal();
|
||||
}
|
||||
const tabContent = document.getElementById("tab-content");
|
||||
if (tabContent) {
|
||||
tabContent.innerHTML = `
|
||||
<div class="tab-content-placeholder">
|
||||
Select a tab to get started
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
await refreshTabsForActiveProfile();
|
||||
} catch (error) {
|
||||
console.error("Clone profile failed:", error);
|
||||
alert("Failed to clone profile.");
|
||||
@@ -210,7 +209,10 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
const response = await fetch("/profiles", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name }),
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
seed_dj_tab: !!(newProfileSeedDjInput && newProfileSeedDjInput.checked),
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to create profile");
|
||||
@@ -236,23 +238,11 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
}
|
||||
|
||||
newProfileInput.value = "";
|
||||
// Clear current tab and refresh the UI so the new profile starts empty.
|
||||
document.cookie = "current_tab=; path=/; max-age=0";
|
||||
if (newProfileSeedDjInput) {
|
||||
newProfileSeedDjInput.checked = false;
|
||||
}
|
||||
await loadProfiles();
|
||||
if (typeof window.loadTabs === "function") {
|
||||
await window.loadTabs();
|
||||
}
|
||||
if (typeof window.loadTabsModal === "function") {
|
||||
await window.loadTabsModal();
|
||||
}
|
||||
const tabContent = document.getElementById("tab-content");
|
||||
if (tabContent) {
|
||||
tabContent.innerHTML = `
|
||||
<div class="tab-content-placeholder">
|
||||
Select a tab to get started
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
await refreshTabsForActiveProfile();
|
||||
} catch (error) {
|
||||
console.error("Create profile failed:", error);
|
||||
alert("Failed to create profile.");
|
||||
|
||||
@@ -77,6 +77,11 @@ header h1 {
|
||||
background-color: #333;
|
||||
}
|
||||
|
||||
/* Header/menu actions that should only appear in Edit mode */
|
||||
body.preset-ui-run .edit-mode-only {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.45rem 0.9rem;
|
||||
border: none;
|
||||
@@ -596,6 +601,52 @@ header h1 {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Preset tile: main button + optional edit/remove (Edit mode) */
|
||||
.preset-tile-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: stretch;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.preset-tile-row--run .preset-tile-actions {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.preset-tile-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 5rem;
|
||||
}
|
||||
|
||||
.preset-tile-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
padding: 0.15rem 0 0.15rem 0.25rem;
|
||||
}
|
||||
|
||||
.preset-tile-actions .btn {
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
padding: 0.15rem 0.35rem;
|
||||
font-size: 0.68rem;
|
||||
line-height: 1.15;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ui-mode-toggle--edit {
|
||||
background-color: #4a3f8f;
|
||||
border: 1px solid #7b6fd6;
|
||||
}
|
||||
|
||||
.ui-mode-toggle--edit:hover {
|
||||
background-color: #5a4f9f;
|
||||
}
|
||||
|
||||
/* Preset select buttons inside the tab grid */
|
||||
#presets-list-tab .pattern-button {
|
||||
display: flex;
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
// Tab management JavaScript
|
||||
let currentTabId = null;
|
||||
|
||||
const isEditModeActive = () => {
|
||||
const toggle = document.querySelector('.ui-mode-toggle');
|
||||
return !!(toggle && toggle.getAttribute('aria-pressed') === 'true');
|
||||
};
|
||||
|
||||
// Get current tab from cookie
|
||||
function getCurrentTabFromCookie() {
|
||||
const cookies = document.cookie.split(';');
|
||||
@@ -38,10 +43,12 @@ async function loadTabs() {
|
||||
|
||||
// Load current tab content if available
|
||||
if (currentTabId) {
|
||||
loadTabContent(currentTabId);
|
||||
await loadTabContent(currentTabId);
|
||||
} else if (data.tab_order && data.tab_order.length > 0) {
|
||||
// Set first tab as current if none is set
|
||||
await setCurrentTab(data.tab_order[0]);
|
||||
const firstTabId = data.tab_order[0];
|
||||
await setCurrentTab(firstTabId);
|
||||
await loadTabContent(firstTabId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load tabs:', error);
|
||||
@@ -62,6 +69,7 @@ function renderTabsList(tabs, tabOrder, currentTabId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const editMode = isEditModeActive();
|
||||
let html = '<div class="tabs-list">';
|
||||
for (const tabId of tabOrder) {
|
||||
const tab = tabs[tabId];
|
||||
@@ -71,7 +79,7 @@ function renderTabsList(tabs, tabOrder, currentTabId) {
|
||||
html += `
|
||||
<button class="tab-button ${activeClass}"
|
||||
data-tab-id="${tabId}"
|
||||
title="Click to select, right-click to edit"
|
||||
title="${editMode ? 'Click to select, right-click to edit' : 'Click to select'}"
|
||||
onclick="selectTab('${tabId}')">
|
||||
${tabName}
|
||||
</button>
|
||||
@@ -106,6 +114,7 @@ function renderTabsListModal(tabs, tabOrder, currentTabId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const editMode = isEditModeActive();
|
||||
entries.forEach(([tabId, tab]) => {
|
||||
const row = document.createElement("div");
|
||||
row.className = "profiles-row";
|
||||
@@ -224,10 +233,12 @@ function renderTabsListModal(tabs, tabOrder, currentTabId) {
|
||||
|
||||
row.appendChild(label);
|
||||
row.appendChild(applyButton);
|
||||
row.appendChild(editButton);
|
||||
row.appendChild(sendPresetsButton);
|
||||
row.appendChild(cloneButton);
|
||||
row.appendChild(deleteButton);
|
||||
if (editMode) {
|
||||
row.appendChild(editButton);
|
||||
row.appendChild(cloneButton);
|
||||
row.appendChild(deleteButton);
|
||||
}
|
||||
container.appendChild(row);
|
||||
});
|
||||
}
|
||||
@@ -714,6 +725,9 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
// Right-click on a tab button in the main header bar to edit that tab
|
||||
document.addEventListener('contextmenu', async (event) => {
|
||||
if (!isEditModeActive()) {
|
||||
return;
|
||||
}
|
||||
const btn = event.target.closest('.tab-button');
|
||||
if (!btn || !btn.dataset.tabId) {
|
||||
return;
|
||||
@@ -796,11 +810,22 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
await sendProfilePresets();
|
||||
});
|
||||
}
|
||||
|
||||
// When run/edit mode toggles, refresh tabs UI so edit actions show/hide immediately.
|
||||
document.querySelectorAll('.ui-mode-toggle').forEach((btn) => {
|
||||
btn.addEventListener('click', async () => {
|
||||
await loadTabs();
|
||||
if (tabsModal && tabsModal.classList.contains('active')) {
|
||||
await loadTabsModal();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Export for use in other scripts
|
||||
window.tabsManager = {
|
||||
loadTabs,
|
||||
loadTabsModal,
|
||||
selectTab,
|
||||
createTab,
|
||||
updateTab,
|
||||
|
||||
@@ -15,24 +15,26 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button class="btn btn-secondary" id="tabs-btn">Tabs</button>
|
||||
<button class="btn btn-secondary" id="color-palette-btn">Color Palette</button>
|
||||
<button class="btn btn-secondary" id="presets-btn">Presets</button>
|
||||
<button class="btn btn-secondary edit-mode-only" id="tabs-btn">Tabs</button>
|
||||
<button class="btn btn-secondary edit-mode-only" id="color-palette-btn">Color Palette</button>
|
||||
<button class="btn btn-secondary edit-mode-only" id="presets-btn">Presets</button>
|
||||
<button class="btn btn-secondary" id="send-profile-presets-btn">Send Presets</button>
|
||||
<button class="btn btn-secondary" id="patterns-btn">Patterns</button>
|
||||
<button class="btn btn-secondary" id="profiles-btn">Profiles</button>
|
||||
<button class="btn btn-secondary edit-mode-only" id="patterns-btn">Patterns</button>
|
||||
<button class="btn btn-secondary edit-mode-only" id="profiles-btn">Profiles</button>
|
||||
<button class="btn btn-secondary" id="settings-btn">Settings</button>
|
||||
<button class="btn btn-secondary" id="help-btn">Help</button>
|
||||
<button type="button" class="btn btn-secondary ui-mode-toggle" id="ui-mode-toggle" aria-pressed="false" title="Switch preset strip mode — label is the mode you will switch to">Edit mode</button>
|
||||
</div>
|
||||
<div class="header-menu-mobile">
|
||||
<button class="btn btn-secondary" id="main-menu-btn">Menu</button>
|
||||
<div id="main-menu-dropdown" class="main-menu-dropdown">
|
||||
<button type="button" data-target="tabs-btn">Tabs</button>
|
||||
<button type="button" data-target="color-palette-btn">Color Palette</button>
|
||||
<button type="button" data-target="presets-btn">Presets</button>
|
||||
<button type="button" class="ui-mode-toggle" id="ui-mode-toggle-mobile" aria-pressed="false">Edit mode</button>
|
||||
<button type="button" class="edit-mode-only" data-target="tabs-btn">Tabs</button>
|
||||
<button type="button" class="edit-mode-only" data-target="color-palette-btn">Color Palette</button>
|
||||
<button type="button" class="edit-mode-only" data-target="presets-btn">Presets</button>
|
||||
<button type="button" data-target="send-profile-presets-btn">Send Presets</button>
|
||||
<button type="button" data-target="patterns-btn">Patterns</button>
|
||||
<button type="button" data-target="profiles-btn">Profiles</button>
|
||||
<button type="button" class="edit-mode-only" data-target="patterns-btn">Patterns</button>
|
||||
<button type="button" class="edit-mode-only" data-target="profiles-btn">Profiles</button>
|
||||
<button type="button" data-target="settings-btn">Settings</button>
|
||||
<button type="button" data-target="help-btn">Help</button>
|
||||
</div>
|
||||
@@ -92,6 +94,12 @@
|
||||
<input type="text" id="new-profile-name" placeholder="Profile name">
|
||||
<button class="btn btn-primary" id="create-profile-btn">Create</button>
|
||||
</div>
|
||||
<div class="form-group" style="margin-top: 0.5rem; margin-bottom: 0;">
|
||||
<label style="display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0;">
|
||||
<input type="checkbox" id="new-profile-seed-dj">
|
||||
DJ tab
|
||||
</label>
|
||||
</div>
|
||||
<div id="profiles-list" class="profiles-list"></div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-secondary" id="profiles-close-btn">Close</button>
|
||||
@@ -126,9 +134,8 @@
|
||||
<label>Colors</label>
|
||||
<div id="preset-colors-container" class="preset-colors-container"></div>
|
||||
<div class="profiles-actions">
|
||||
<input type="color" id="preset-new-color" value="#ffffff">
|
||||
<button class="btn btn-secondary btn-small" id="preset-add-color-btn">Add Color</button>
|
||||
<button class="btn btn-secondary btn-small" id="preset-add-from-palette-btn">Add from Palette</button>
|
||||
<input type="color" id="preset-new-color" value="#ffffff" title="Choose color (auto-adds)">
|
||||
<button class="btn btn-secondary btn-small" id="preset-add-from-palette-btn">From Palette</button>
|
||||
</div>
|
||||
<div class="profiles-actions">
|
||||
<div class="preset-editor-field">
|
||||
@@ -204,7 +211,6 @@
|
||||
<div id="palette-container" class="profiles-list"></div>
|
||||
<div class="profiles-actions">
|
||||
<input type="color" id="palette-new-color" value="#ffffff">
|
||||
<button class="btn btn-primary" id="palette-add-color-btn">Add Color</button>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-secondary" id="color-palette-close-btn">Close</button>
|
||||
@@ -228,9 +234,10 @@
|
||||
<h3>Presets in a tab</h3>
|
||||
<ul>
|
||||
<li><strong>Select preset</strong>: left-click a preset tile to select it and send a <code>select</code> message to all devices in the tab.</li>
|
||||
<li><strong>Edit preset</strong>: right-click a preset tile and choose <strong>Edit preset…</strong>.</li>
|
||||
<li><strong>Remove from tab</strong>: right-click a preset tile and choose <strong>Remove from this tab</strong> (the preset itself is not deleted, only its link from this tab).</li>
|
||||
<li><strong>Reorder presets</strong>: drag preset tiles to change their order; the new layout is saved automatically.</li>
|
||||
<li><strong>Run vs Edit mode</strong>: use the mode button in the menu (it shows the mode you will <em>switch to</em>). In <strong>Edit mode</strong>, each preset tile shows <strong>Edit</strong> and <strong>Remove</strong> on the right.</li>
|
||||
<li><strong>Edit preset</strong>: switch to <strong>Edit mode</strong> (menu button) and use <strong>Edit</strong> on each tile.</li>
|
||||
<li><strong>Remove from tab</strong>: in <strong>Edit mode</strong>, use <strong>Remove</strong> on the tile (the preset itself is not deleted, only its link from this tab).</li>
|
||||
<li><strong>Reorder presets</strong>: in <strong>Edit mode</strong> only, drag tiles to change order; the layout saves automatically.</li>
|
||||
</ul>
|
||||
|
||||
<h3>Presets, profiles & colors</h3>
|
||||
@@ -287,7 +294,7 @@
|
||||
|
||||
<div class="form-group">
|
||||
<label for="ap-password">AP Password</label>
|
||||
<input type="password" id="ap-password" name="password" placeholder="Enter AP password (min 8 chars)">
|
||||
<input type="password" id="ap-password" name="password" placeholder="Enter AP password (min 8 chars)" data-bwignore>
|
||||
<small>Leave empty for open network (min 8 characters if set)</small>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -193,7 +193,7 @@
|
||||
|
||||
<div class="form-group">
|
||||
<label for="ap-password">AP Password</label>
|
||||
<input type="password" id="ap-password" name="password" placeholder="Enter AP password (min 8 chars)">
|
||||
<input type="password" id="ap-password" name="password" placeholder="Enter AP password (min 8 chars)" data-bwignore>
|
||||
<small>Leave empty for open network (min 8 characters if set)</small>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ from test_group import test_group
|
||||
from test_sequence import test_sequence
|
||||
from test_tab import test_tab
|
||||
from test_palette import test_palette
|
||||
from test_device import test_device
|
||||
|
||||
def run_all_tests():
|
||||
"""Run all model tests."""
|
||||
@@ -27,6 +28,7 @@ def run_all_tests():
|
||||
("Sequence", test_sequence),
|
||||
("Tab", test_tab),
|
||||
("Palette", test_palette),
|
||||
("Device", test_device),
|
||||
]
|
||||
|
||||
passed = 0
|
||||
|
||||
64
tests/models/test_device.py
Normal file
64
tests/models/test_device.py
Normal file
@@ -0,0 +1,64 @@
|
||||
from models.device import Device
|
||||
import os
|
||||
|
||||
def test_device():
|
||||
"""Test Device model CRUD operations."""
|
||||
db_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "db")
|
||||
device_file = os.path.join(db_dir, "device.json")
|
||||
if os.path.exists(device_file):
|
||||
os.remove(device_file)
|
||||
|
||||
devices = Device()
|
||||
|
||||
print("Testing create device")
|
||||
device_id = devices.create("Test Device", address="aa:bb:cc:dd:ee:ff", default_pattern="on", tabs=["1", "2"])
|
||||
print(f"Created device with ID: {device_id}")
|
||||
assert device_id is not None
|
||||
assert device_id in devices
|
||||
|
||||
print("\nTesting read device")
|
||||
device = devices.read(device_id)
|
||||
print(f"Read: {device}")
|
||||
assert device is not None
|
||||
assert device["name"] == "Test Device"
|
||||
assert device["address"] == "aabbccddeeff"
|
||||
assert device["default_pattern"] == "on"
|
||||
assert device["tabs"] == ["1", "2"]
|
||||
|
||||
print("\nTesting address normalization")
|
||||
devices.update(device_id, {"address": "11:22:33:44:55:66"})
|
||||
updated = devices.read(device_id)
|
||||
assert updated["address"] == "112233445566"
|
||||
|
||||
print("\nTesting update device")
|
||||
update_data = {
|
||||
"name": "Updated Device",
|
||||
"default_pattern": "rainbow",
|
||||
"tabs": ["1", "2", "3"],
|
||||
}
|
||||
result = devices.update(device_id, update_data)
|
||||
assert result is True
|
||||
updated = devices.read(device_id)
|
||||
assert updated["name"] == "Updated Device"
|
||||
assert updated["default_pattern"] == "rainbow"
|
||||
assert len(updated["tabs"]) == 3
|
||||
|
||||
print("\nTesting list devices")
|
||||
device_list = devices.list()
|
||||
print(f"Device list: {device_list}")
|
||||
assert device_id in device_list
|
||||
|
||||
print("\nTesting delete device")
|
||||
deleted = devices.delete(device_id)
|
||||
assert deleted is True
|
||||
assert device_id not in devices
|
||||
|
||||
print("\nTesting read after delete")
|
||||
device = devices.read(device_id)
|
||||
assert device is None
|
||||
|
||||
print("\nAll device tests passed!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_device()
|
||||
@@ -6,11 +6,13 @@ def test_model():
|
||||
# Create a test model class
|
||||
class TestModel(Model):
|
||||
pass
|
||||
|
||||
# Clean up any existing test file
|
||||
if os.path.exists("TestModel.json"):
|
||||
os.remove("TestModel.json")
|
||||
|
||||
|
||||
# Clean up any existing test file (model uses db/<classname>.json)
|
||||
db_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "db")
|
||||
testmodel_file = os.path.join(db_dir, "testmodel.json")
|
||||
if os.path.exists(testmodel_file):
|
||||
os.remove(testmodel_file)
|
||||
|
||||
model = TestModel()
|
||||
|
||||
print("Testing get_next_id with empty model")
|
||||
@@ -43,9 +45,9 @@ def test_model():
|
||||
assert hasattr(model2, 'set_defaults')
|
||||
|
||||
# Clean up
|
||||
if os.path.exists("TestModel.json"):
|
||||
os.remove("TestModel.json")
|
||||
|
||||
if os.path.exists(testmodel_file):
|
||||
os.remove(testmodel_file)
|
||||
|
||||
print("\nAll model base class tests passed!")
|
||||
|
||||
|
||||
|
||||
@@ -2,10 +2,14 @@ from models.pallet import Palette
|
||||
import os
|
||||
|
||||
def test_palette():
|
||||
"""Test Palette model CRUD operations."""
|
||||
# Clean up any existing test file
|
||||
if os.path.exists("Palette.json"):
|
||||
os.remove("Palette.json")
|
||||
"""Test Palette model CRUD operations.
|
||||
Palette stores a list of colors per ID; read() returns that list (or unwraps from dict).
|
||||
"""
|
||||
# Clean up any existing test file (model uses db/palette.json from project root)
|
||||
db_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "db")
|
||||
palette_file = os.path.join(db_dir, "palette.json")
|
||||
if os.path.exists(palette_file):
|
||||
os.remove(palette_file)
|
||||
|
||||
palettes = Palette()
|
||||
|
||||
@@ -19,10 +23,12 @@ def test_palette():
|
||||
print("\nTesting read palette")
|
||||
palette = palettes.read(palette_id)
|
||||
print(f"Read: {palette}")
|
||||
# read() returns list of colors (name is not stored)
|
||||
assert palette is not None
|
||||
assert palette["name"] == "test_palette"
|
||||
assert len(palette["colors"]) == 4
|
||||
assert "#FF0000" in palette["colors"]
|
||||
assert isinstance(palette, list) or (isinstance(palette, dict) and "colors" in palette)
|
||||
colors_read = palette if isinstance(palette, list) else palette.get("colors", [])
|
||||
assert len(colors_read) == 4
|
||||
assert "#FF0000" in colors_read
|
||||
|
||||
print("\nTesting update palette")
|
||||
update_data = {
|
||||
@@ -32,9 +38,9 @@ def test_palette():
|
||||
result = palettes.update(palette_id, update_data)
|
||||
assert result is True
|
||||
updated = palettes.read(palette_id)
|
||||
assert updated["name"] == "updated_palette"
|
||||
assert len(updated["colors"]) == 3
|
||||
assert "#FF00FF" in updated["colors"]
|
||||
updated_colors = updated if isinstance(updated, list) else (updated.get("colors") or [])
|
||||
assert len(updated_colors) == 3
|
||||
assert "#FF00FF" in updated_colors
|
||||
|
||||
print("\nTesting list palettes")
|
||||
palette_list = palettes.list()
|
||||
@@ -48,7 +54,8 @@ def test_palette():
|
||||
|
||||
print("\nTesting read after delete")
|
||||
palette = palettes.read(palette_id)
|
||||
assert palette is None
|
||||
# read() returns [] when id is missing (value or [])
|
||||
assert palette == [] or palette is None
|
||||
|
||||
print("\nAll palette tests passed!")
|
||||
|
||||
|
||||
@@ -2,10 +2,14 @@ from models.profile import Profile
|
||||
import os
|
||||
|
||||
def test_profile():
|
||||
"""Test Profile model CRUD operations."""
|
||||
# Clean up any existing test file
|
||||
if os.path.exists("Profile.json"):
|
||||
os.remove("Profile.json")
|
||||
"""Test Profile model CRUD operations.
|
||||
Profile create() sets name, type, tabs (list of tab IDs), scenes, palette_id.
|
||||
"""
|
||||
# Clean up any existing test file (model uses db/profile.json from project root)
|
||||
db_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "db")
|
||||
profile_file = os.path.join(db_dir, "profile.json")
|
||||
if os.path.exists(profile_file):
|
||||
os.remove(profile_file)
|
||||
|
||||
profiles = Profile()
|
||||
|
||||
@@ -21,15 +25,13 @@ def test_profile():
|
||||
assert profile is not None
|
||||
assert profile["name"] == "test_profile"
|
||||
assert "tabs" in profile
|
||||
assert "palette" in profile
|
||||
assert "tab_order" in profile
|
||||
assert "palette_id" in profile
|
||||
assert "type" in profile
|
||||
|
||||
print("\nTesting update profile")
|
||||
update_data = {
|
||||
"name": "updated_profile",
|
||||
"tabs": {"tab1": {"names": ["1"], "presets": []}},
|
||||
"palette": ["#FF0000", "#00FF00"],
|
||||
"tab_order": ["tab1"]
|
||||
"tabs": ["tab1"],
|
||||
}
|
||||
result = profiles.update(profile_id, update_data)
|
||||
assert result is True
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
"""
|
||||
Browser automation tests using Selenium.
|
||||
Tests run against the device at 192.168.4.1 in an actual browser.
|
||||
|
||||
On Pi OS Lite (no desktop) these tests are skipped unless headless Chromium
|
||||
and chromedriver are installed (e.g. chromium-browser chromium-chromedriver).
|
||||
"""
|
||||
|
||||
import sys
|
||||
@@ -13,8 +16,8 @@ from selenium import webdriver
|
||||
from selenium.webdriver.common.by import By
|
||||
from selenium.webdriver.support.ui import WebDriverWait
|
||||
from selenium.webdriver.support import expected_conditions as EC
|
||||
from selenium.webdriver.chrome.options import Options
|
||||
from selenium.webdriver.chrome.service import Service
|
||||
from selenium.webdriver.chrome.options import Options as ChromeOptions
|
||||
from selenium.webdriver.firefox.options import Options as FirefoxOptions
|
||||
from selenium.webdriver.common.action_chains import ActionChains
|
||||
from selenium.common.exceptions import TimeoutException, NoSuchElementException
|
||||
|
||||
@@ -33,24 +36,41 @@ class BrowserTest:
|
||||
self.created_presets: List[str] = []
|
||||
|
||||
def setup(self):
|
||||
"""Set up the browser driver."""
|
||||
"""Set up the browser driver. Tries Chrome first, then Firefox."""
|
||||
err_chrome, err_firefox = None, None
|
||||
# Try Chrome first
|
||||
try:
|
||||
chrome_options = Options()
|
||||
opts = ChromeOptions()
|
||||
if self.headless:
|
||||
chrome_options.add_argument('--headless')
|
||||
chrome_options.add_argument('--no-sandbox')
|
||||
chrome_options.add_argument('--disable-dev-shm-usage')
|
||||
chrome_options.add_argument('--disable-gpu')
|
||||
chrome_options.add_argument('--window-size=1920,1080')
|
||||
|
||||
self.driver = webdriver.Chrome(options=chrome_options)
|
||||
opts.add_argument('--headless')
|
||||
opts.add_argument('--no-sandbox')
|
||||
opts.add_argument('--disable-dev-shm-usage')
|
||||
opts.add_argument('--disable-gpu')
|
||||
opts.add_argument('--window-size=1920,1080')
|
||||
self.driver = webdriver.Chrome(options=opts)
|
||||
self.driver.implicitly_wait(5)
|
||||
print("✓ Browser started")
|
||||
print("✓ Browser started (Chrome)")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"✗ Failed to start browser: {e}")
|
||||
print(" Make sure Chrome and ChromeDriver are installed")
|
||||
return False
|
||||
err_chrome = e
|
||||
# Fallback to Firefox
|
||||
try:
|
||||
opts = FirefoxOptions()
|
||||
if self.headless:
|
||||
opts.add_argument('--headless')
|
||||
self.driver = webdriver.Firefox(options=opts)
|
||||
self.driver.implicitly_wait(5)
|
||||
print("✓ Browser started (Firefox)")
|
||||
return True
|
||||
except Exception as e:
|
||||
err_firefox = e
|
||||
print("✗ Failed to start browser.")
|
||||
if err_chrome:
|
||||
print(f" Chrome: {err_chrome}")
|
||||
if err_firefox:
|
||||
print(f" Firefox: {err_firefox}")
|
||||
print(" On Raspberry Pi (aarch64), install: chromium-browser and chromium-chromedriver")
|
||||
return False
|
||||
|
||||
def teardown(self):
|
||||
"""Close the browser."""
|
||||
@@ -209,46 +229,6 @@ class BrowserTest:
|
||||
except Exception as e:
|
||||
print(f" ⚠ Cleanup error: {e}")
|
||||
|
||||
def cleanup_test_data(self):
|
||||
"""Clean up test data created during tests."""
|
||||
try:
|
||||
# Use requests to make API calls for cleanup
|
||||
session = requests.Session()
|
||||
|
||||
# Delete created presets
|
||||
for preset_id in self.created_presets:
|
||||
try:
|
||||
response = session.delete(f"{self.base_url}/presets/{preset_id}")
|
||||
if response.status_code == 200:
|
||||
print(f" ✓ Cleaned up preset: {preset_id}")
|
||||
except Exception as e:
|
||||
print(f" ⚠ Failed to cleanup preset {preset_id}: {e}")
|
||||
|
||||
# Delete created tabs
|
||||
for tab_id in self.created_tabs:
|
||||
try:
|
||||
response = session.delete(f"{self.base_url}/tabs/{tab_id}")
|
||||
if response.status_code == 200:
|
||||
print(f" ✓ Cleaned up tab: {tab_id}")
|
||||
except Exception as e:
|
||||
print(f" ⚠ Failed to cleanup tab {tab_id}: {e}")
|
||||
|
||||
# Delete created profiles
|
||||
for profile_id in self.created_profiles:
|
||||
try:
|
||||
response = session.delete(f"{self.base_url}/profiles/{profile_id}")
|
||||
if response.status_code == 200:
|
||||
print(f" ✓ Cleaned up profile: {profile_id}")
|
||||
except Exception as e:
|
||||
print(f" ⚠ Failed to cleanup profile {profile_id}: {e}")
|
||||
|
||||
# Clear the lists
|
||||
self.created_tabs.clear()
|
||||
self.created_profiles.clear()
|
||||
self.created_presets.clear()
|
||||
except Exception as e:
|
||||
print(f" ⚠ Cleanup error: {e}")
|
||||
|
||||
def fill_input(self, by, value, text, timeout=10):
|
||||
"""Fill an input field."""
|
||||
try:
|
||||
@@ -553,7 +533,7 @@ def test_mobile_tab_presets_two_columns():
|
||||
container = bt.wait_for_element(By.ID, 'presets-list-tab', timeout=10)
|
||||
assert container is not None, "presets-list-tab not found"
|
||||
|
||||
tiles = bt.driver.find_elements(By.CSS_SELECTOR, '#presets-list-tab .draggable-preset')
|
||||
tiles = bt.driver.find_elements(By.CSS_SELECTOR, '#presets-list-tab .preset-tile-row')
|
||||
# Need at least 2 presets to make this meaningful
|
||||
assert len(tiles) >= 2, "Fewer than 2 presets found for tab"
|
||||
|
||||
@@ -902,14 +882,20 @@ def test_preset_drag_and_drop(browser: BrowserTest) -> bool:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
# Test 5: Find presets in tab and test drag and drop
|
||||
# Test 5: Find presets in tab and test drag and drop (Edit mode only)
|
||||
total += 1
|
||||
try:
|
||||
# Wait for presets to load in the tab
|
||||
presets_list_tab = browser.wait_for_element(By.ID, 'presets-list-tab', timeout=5)
|
||||
if presets_list_tab:
|
||||
time.sleep(1) # Wait for presets to render
|
||||
|
||||
|
||||
# Reordering is only available in Edit mode (tiles get .draggable-preset)
|
||||
mode_toggle = browser.wait_for_element(By.CSS_SELECTOR, '.ui-mode-toggle', timeout=5)
|
||||
if mode_toggle and mode_toggle.get_attribute('aria-pressed') == 'false':
|
||||
mode_toggle.click()
|
||||
time.sleep(0.5)
|
||||
|
||||
# Find draggable preset elements - wait a bit more for rendering
|
||||
time.sleep(1)
|
||||
draggable_presets = browser.driver.find_elements(By.CSS_SELECTOR, '#presets-list-tab .draggable-preset')
|
||||
@@ -1005,11 +991,19 @@ def main():
|
||||
print("LED Controller Browser Tests")
|
||||
print(f"Testing against: {BASE_URL}")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
# On Pi OS Lite there is no browser by default; skip with exit 0 instead of failing
|
||||
browser = BrowserTest(headless=True)
|
||||
if not browser.setup():
|
||||
print("\nSkipped (Pi OS Lite / no browser). Install chromium-browser and")
|
||||
print("chromium-chromedriver to run browser tests, or run on Pi OS with desktop.")
|
||||
sys.exit(0)
|
||||
browser.teardown()
|
||||
|
||||
browser = BrowserTest(headless=False) # Set to True for headless mode
|
||||
|
||||
|
||||
results = []
|
||||
|
||||
|
||||
# Run browser tests
|
||||
results.append(("Browser Connection", test_browser_connection(browser)))
|
||||
results.append(("Tabs UI", test_tabs_ui(browser)))
|
||||
|
||||
@@ -499,6 +499,7 @@ def test_static_files(client: TestClient) -> bool:
|
||||
'/static/tabs.js',
|
||||
'/static/presets.js',
|
||||
'/static/profiles.js',
|
||||
'/static/devices.js',
|
||||
]
|
||||
|
||||
for file_path in static_files:
|
||||
|
||||
Reference in New Issue
Block a user