Compare commits
46 Commits
8b6bbdeb56
...
v1.0
| Author | SHA1 | Date | |
|---|---|---|---|
| b2077c0199 | |||
| 0fdc11c0b0 | |||
| 91bd78ab31 | |||
| 2be0640622 | |||
| 0e96223bf6 | |||
| d8b33923d5 | |||
| 4ce515be1c | |||
| f88bf03939 | |||
| 7cd4a91350 | |||
| d907ca37ad | |||
| 6c6ed22dbe | |||
| 00514f0525 | |||
| cf1d831b5a | |||
| fd37183400 | |||
| 5fdeb57b74 | |||
| 1576383d09 | |||
| 8503315bef | |||
| 928263fbd8 | |||
| 7e33f7db6a | |||
| e74ef6d64f | |||
| 3ed435824c | |||
| d7fabf58a4 | |||
| a7e921805a | |||
| c56739c5fa | |||
| fd52e40d17 | |||
| f48c8789c7 | |||
| 80ff216e54 | |||
| 1fb3dee942 | |||
| a4502055fb | |||
| 6e61ec8de6 | |||
| 48d02f0e70 | |||
| cacaa3505e | |||
| 97ffc69b12 | |||
| 9f37dbbff0 | |||
| df37f15f73 | |||
| 9c43a0a22b | |||
| d41faddfca | |||
| 9e2409430c | |||
| 5f6e45af09 | |||
| cccda24448 | |||
| 5cca60d830 | |||
| ac750a36e7 | |||
| 01f373f0bd | |||
| d00d21e2b6 | |||
| deca1b6c37 | |||
| 5c35e68ab2 |
116
.cursor/debug.log
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768434706543}
|
||||||
|
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768434706552}
|
||||||
|
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768434707852}
|
||||||
|
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768434707860}
|
||||||
|
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768434708466}
|
||||||
|
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768434708474}
|
||||||
|
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/create-form-fragment","targetId":""},"timestamp":1768434709765}
|
||||||
|
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/create-form-fragment","targetId":""},"timestamp":1768434709787}
|
||||||
|
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs","targetId":"tabs-list"},"timestamp":1768434717888}
|
||||||
|
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":201,"responseURL":"http://localhost:5000/tabs","targetId":"tabs-list"},"timestamp":1768434717903}
|
||||||
|
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768434717904}
|
||||||
|
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768434717913}
|
||||||
|
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768434738084}
|
||||||
|
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768434738093}
|
||||||
|
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768434739031}
|
||||||
|
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768434739040}
|
||||||
|
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768434746453}
|
||||||
|
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768434746496}
|
||||||
|
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/create-form-fragment","targetId":""},"timestamp":1768434748859}
|
||||||
|
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/create-form-fragment","targetId":""},"timestamp":1768434748866}
|
||||||
|
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs","targetId":"tabs-list"},"timestamp":1768434773921}
|
||||||
|
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":201,"responseURL":"http://localhost:5000/tabs","targetId":"tabs-list"},"timestamp":1768434773931}
|
||||||
|
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768434773931}
|
||||||
|
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768434773940}
|
||||||
|
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/create-form-fragment","targetId":""},"timestamp":1768434810105}
|
||||||
|
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/create-form-fragment","targetId":""},"timestamp":1768434810119}
|
||||||
|
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs","targetId":"tabs-list"},"timestamp":1768434816383}
|
||||||
|
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":201,"responseURL":"http://localhost:5000/tabs","targetId":"tabs-list"},"timestamp":1768434816399}
|
||||||
|
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768434816400}
|
||||||
|
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768434816414}
|
||||||
|
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768434944656}
|
||||||
|
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768434944756}
|
||||||
|
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768434945369}
|
||||||
|
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768434945427}
|
||||||
|
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768434946108}
|
||||||
|
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768434946162}
|
||||||
|
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768434946680}
|
||||||
|
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768434946736}
|
||||||
|
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/create-form-fragment","targetId":""},"timestamp":1768434947640}
|
||||||
|
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/create-form-fragment","targetId":""},"timestamp":1768434947656}
|
||||||
|
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs","targetId":"tabs-list"},"timestamp":1768434953064}
|
||||||
|
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":201,"responseURL":"http://localhost:5000/tabs","targetId":"tabs-list"},"timestamp":1768434953079}
|
||||||
|
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768434953080}
|
||||||
|
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768434953093}
|
||||||
|
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768435103720}
|
||||||
|
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768435103776}
|
||||||
|
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768435104593}
|
||||||
|
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768435104647}
|
||||||
|
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768435105158}
|
||||||
|
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768435105253}
|
||||||
|
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768435275247}
|
||||||
|
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768435275315}
|
||||||
|
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768435276178}
|
||||||
|
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768435276278}
|
||||||
|
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768435276945}
|
||||||
|
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768435276998}
|
||||||
|
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/create-form-fragment","targetId":""},"timestamp":1768435278150}
|
||||||
|
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/create-form-fragment","targetId":""},"timestamp":1768435278162}
|
||||||
|
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs","targetId":"tabs-list"},"timestamp":1768435281966}
|
||||||
|
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":400,"responseURL":"http://localhost:5000/tabs","targetId":"tabs-list"},"timestamp":1768435281988}
|
||||||
|
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768435387623}
|
||||||
|
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768435387680}
|
||||||
|
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768435388399}
|
||||||
|
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768435388454}
|
||||||
|
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/create-form-fragment","targetId":""},"timestamp":1768435389910}
|
||||||
|
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/create-form-fragment","targetId":""},"timestamp":1768435389922}
|
||||||
|
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs","targetId":"tabs-list"},"timestamp":1768435393213}
|
||||||
|
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs","targetId":"tabs-list"},"timestamp":1768435393231}
|
||||||
|
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768435393233}
|
||||||
|
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768435393245}
|
||||||
|
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/12/content-fragment","targetId":"tab-content"},"timestamp":1768435395729}
|
||||||
|
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/12/content-fragment","targetId":"tab-content"},"timestamp":1768435395748}
|
||||||
|
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/13/content-fragment","targetId":"tab-content"},"timestamp":1768435396771}
|
||||||
|
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/13/content-fragment","targetId":"tab-content"},"timestamp":1768435396788}
|
||||||
|
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/12/content-fragment","targetId":"tab-content"},"timestamp":1768435398656}
|
||||||
|
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/12/content-fragment","targetId":"tab-content"},"timestamp":1768435398674}
|
||||||
|
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/13/content-fragment","targetId":"tab-content"},"timestamp":1768435399748}
|
||||||
|
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/13/content-fragment","targetId":"tab-content"},"timestamp":1768435399774}
|
||||||
|
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768435668310}
|
||||||
|
{"sessionId":"debug-session","runId":"palette-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:DOMContentLoaded","message":"palette elements presence","data":{"hasPaletteButton":true,"hasPaletteModal":false,"hasPaletteContainer":false,"hasLightingController":false},"timestamp":1768435668311}
|
||||||
|
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768435668355}
|
||||||
|
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768435669841}
|
||||||
|
{"sessionId":"debug-session","runId":"palette-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:DOMContentLoaded","message":"palette elements presence","data":{"hasPaletteButton":true,"hasPaletteModal":false,"hasPaletteContainer":false,"hasLightingController":false},"timestamp":1768435669842}
|
||||||
|
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768435669852}
|
||||||
|
{"sessionId":"debug-session","runId":"palette-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:color-palette-btn","message":"color palette button clicked","data":{"hasPaletteModal":false,"modalActive":null},"timestamp":1768435672686}
|
||||||
|
{"sessionId":"debug-session","runId":"palette-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:color-palette-btn","message":"color palette button clicked","data":{"hasPaletteModal":false,"modalActive":null},"timestamp":1768435673713}
|
||||||
|
{"sessionId":"debug-session","runId":"palette-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:color-palette-btn","message":"color palette button clicked","data":{"hasPaletteModal":false,"modalActive":null},"timestamp":1768435674316}
|
||||||
|
{"sessionId":"debug-session","runId":"palette-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:color-palette-btn","message":"color palette button clicked","data":{"hasPaletteModal":false,"modalActive":null},"timestamp":1768435674560}
|
||||||
|
{"sessionId":"debug-session","runId":"palette-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:color-palette-btn","message":"color palette button clicked","data":{"hasPaletteModal":false,"modalActive":null},"timestamp":1768435680419}
|
||||||
|
{"sessionId":"debug-session","runId":"palette-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:color-palette-btn","message":"color palette button clicked","data":{"hasPaletteModal":false,"modalActive":null},"timestamp":1768435680897}
|
||||||
|
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768435814285}
|
||||||
|
{"sessionId":"debug-session","runId":"palette-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:DOMContentLoaded","message":"palette elements presence","data":{"hasPaletteButton":true,"hasPaletteModal":true,"hasPaletteContainer":false,"hasLightingController":false},"timestamp":1768435814287}
|
||||||
|
{"sessionId":"debug-session","runId":"palette-pre-fix","hypothesisId":"H3","location":"src/static/color_palette.js:DOMContentLoaded","message":"palette script loaded","data":{"hasButton":true,"hasModal":true,"hasClose":true},"timestamp":1768435814287}
|
||||||
|
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768435814350}
|
||||||
|
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768435815080}
|
||||||
|
{"sessionId":"debug-session","runId":"palette-pre-fix","hypothesisId":"H3","location":"src/static/color_palette.js:DOMContentLoaded","message":"palette script loaded","data":{"hasButton":true,"hasModal":true,"hasClose":true},"timestamp":1768435815081}
|
||||||
|
{"sessionId":"debug-session","runId":"palette-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:DOMContentLoaded","message":"palette elements presence","data":{"hasPaletteButton":true,"hasPaletteModal":true,"hasPaletteContainer":false,"hasLightingController":false},"timestamp":1768435815082}
|
||||||
|
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768435815135}
|
||||||
|
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768435815724}
|
||||||
|
{"sessionId":"debug-session","runId":"palette-pre-fix","hypothesisId":"H3","location":"src/static/color_palette.js:DOMContentLoaded","message":"palette script loaded","data":{"hasButton":true,"hasModal":true,"hasClose":true},"timestamp":1768435815725}
|
||||||
|
{"sessionId":"debug-session","runId":"palette-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:DOMContentLoaded","message":"palette elements presence","data":{"hasPaletteButton":true,"hasPaletteModal":true,"hasPaletteContainer":false,"hasLightingController":false},"timestamp":1768435815725}
|
||||||
|
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768435815778}
|
||||||
|
{"sessionId":"debug-session","runId":"palette-pre-fix","hypothesisId":"H4","location":"src/static/color_palette.js:openModal","message":"palette modal opened","data":{"active":true},"timestamp":1768435817104}
|
||||||
|
{"sessionId":"debug-session","runId":"palette-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:color-palette-btn","message":"color palette button clicked","data":{"hasPaletteModal":true,"modalActive":true},"timestamp":1768435817105}
|
||||||
|
{"sessionId":"debug-session","runId":"palette-pre-fix","hypothesisId":"H4","location":"src/static/color_palette.js:closeModal","message":"palette modal closed","data":{"active":false},"timestamp":1768435820180}
|
||||||
|
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768435931118}
|
||||||
|
{"sessionId":"debug-session","runId":"palette-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:DOMContentLoaded","message":"palette elements presence","data":{"hasPaletteButton":true,"hasPaletteModal":true,"hasPaletteContainer":true,"hasLightingController":false},"timestamp":1768435931120}
|
||||||
|
{"sessionId":"debug-session","runId":"palette-pre-fix","hypothesisId":"H3","location":"src/static/color_palette.js:DOMContentLoaded","message":"palette script loaded","data":{"hasButton":true,"hasModal":true,"hasClose":true,"hasContainer":true,"hasAddButton":true},"timestamp":1768435931119}
|
||||||
|
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768435931173}
|
||||||
|
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768435931791}
|
||||||
|
{"sessionId":"debug-session","runId":"palette-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:DOMContentLoaded","message":"palette elements presence","data":{"hasPaletteButton":true,"hasPaletteModal":true,"hasPaletteContainer":true,"hasLightingController":false},"timestamp":1768435931793}
|
||||||
|
{"sessionId":"debug-session","runId":"palette-pre-fix","hypothesisId":"H3","location":"src/static/color_palette.js:DOMContentLoaded","message":"palette script loaded","data":{"hasButton":true,"hasModal":true,"hasClose":true,"hasContainer":true,"hasAddButton":true},"timestamp":1768435931793}
|
||||||
|
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768435931895}
|
||||||
|
{"sessionId":"debug-session","runId":"palette-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:color-palette-btn","message":"color palette button clicked","data":{"hasPaletteModal":true,"modalActive":true},"timestamp":1768435933111}
|
||||||
|
{"sessionId":"debug-session","runId":"palette-pre-fix","hypothesisId":"H4","location":"src/static/color_palette.js:openModal","message":"palette modal opened","data":{"active":true},"timestamp":1768435933110}
|
||||||
|
{"sessionId":"debug-session","runId":"palette-pre-fix","hypothesisId":"H4","location":"src/static/color_palette.js:closeModal","message":"palette modal closed","data":{"active":false},"timestamp":1768435943332}
|
||||||
29
.gitignore
vendored
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
ENV/
|
||||||
|
.venv
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Project specific
|
||||||
|
*.log
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
|
||||||
10
Pipfile
@@ -7,8 +7,18 @@ name = "pypi"
|
|||||||
mpremote = "*"
|
mpremote = "*"
|
||||||
pyserial = "*"
|
pyserial = "*"
|
||||||
esptool = "*"
|
esptool = "*"
|
||||||
|
pyjwt = "*"
|
||||||
|
watchfiles = "*"
|
||||||
|
requests = "*"
|
||||||
|
selenium = "*"
|
||||||
|
adafruit-ampy = "*"
|
||||||
|
|
||||||
[dev-packages]
|
[dev-packages]
|
||||||
|
|
||||||
[requires]
|
[requires]
|
||||||
python_version = "3.12"
|
python_version = "3.12"
|
||||||
|
|
||||||
|
[scripts]
|
||||||
|
web = "python /home/pi/led-controller/tests/web.py"
|
||||||
|
watch = "python -m watchfiles 'python tests/web.py' src tests"
|
||||||
|
install = "pipenv install"
|
||||||
1162
Pipfile.lock
generated
BIN
build_static/app.js.gz
Normal file
BIN
build_static/styles.css.gz
Normal file
2
clear-debug-log.sh
Executable file
@@ -0,0 +1,2 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
rm -f /home/pi/led-controller/.cursor/debug.log
|
||||||
17
db/group.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"1": {
|
||||||
|
"name": "Main Group",
|
||||||
|
"devices": [
|
||||||
|
"1",
|
||||||
|
"2",
|
||||||
|
"3"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"2": {
|
||||||
|
"name": "Accent Group",
|
||||||
|
"devices": [
|
||||||
|
"4",
|
||||||
|
"5"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
12
db/palette.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"1": [
|
||||||
|
"#FF0000",
|
||||||
|
"#00FF00",
|
||||||
|
"#0000FF",
|
||||||
|
"#FFFF00",
|
||||||
|
"#FF00FF",
|
||||||
|
"#00FFFF",
|
||||||
|
"#FFFFFF",
|
||||||
|
"#000000"
|
||||||
|
]
|
||||||
|
}
|
||||||
54
db/pattern.json
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
{
|
||||||
|
"on": {
|
||||||
|
"min_delay": 10,
|
||||||
|
"max_delay": 10000,
|
||||||
|
"max_colors": 1
|
||||||
|
},
|
||||||
|
"off": {
|
||||||
|
"min_delay": 10,
|
||||||
|
"max_delay": 10000,
|
||||||
|
"max_colors": 0
|
||||||
|
},
|
||||||
|
"rainbow": {
|
||||||
|
"n1": "Step Rate",
|
||||||
|
"min_delay": 10,
|
||||||
|
"max_delay": 10000,
|
||||||
|
"max_colors": 0
|
||||||
|
},
|
||||||
|
"transition": {
|
||||||
|
"min_delay": 10,
|
||||||
|
"max_delay": 10000,
|
||||||
|
"max_colors": 10
|
||||||
|
},
|
||||||
|
"chase": {
|
||||||
|
"n1": "Colour 1 Length",
|
||||||
|
"n2": "Colour 2 Length",
|
||||||
|
"n3": "Step 1",
|
||||||
|
"n4": "Step 2",
|
||||||
|
"min_delay": 10,
|
||||||
|
"max_delay": 10000,
|
||||||
|
"max_colors": 2
|
||||||
|
},
|
||||||
|
"pulse": {
|
||||||
|
"n1": "Attack",
|
||||||
|
"n2": "Hold",
|
||||||
|
"n3": "Decay",
|
||||||
|
"min_delay": 10,
|
||||||
|
"max_delay": 10000,
|
||||||
|
"max_colors": 10
|
||||||
|
},
|
||||||
|
"circle": {
|
||||||
|
"n1": "Head Rate",
|
||||||
|
"n2": "Max Length",
|
||||||
|
"n3": "Tail Rate",
|
||||||
|
"n4": "Min Length",
|
||||||
|
"min_delay": 10,
|
||||||
|
"max_delay": 10000,
|
||||||
|
"max_colors": 2
|
||||||
|
},
|
||||||
|
"blink": {
|
||||||
|
"min_delay": 10,
|
||||||
|
"max_delay": 10000,
|
||||||
|
"max_colors": 10
|
||||||
|
}
|
||||||
|
}
|
||||||
276
db/preset.json
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
11
db/profile.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"1": {
|
||||||
|
"name": "default",
|
||||||
|
"type": "tabs",
|
||||||
|
"tabs": [
|
||||||
|
"1"
|
||||||
|
],
|
||||||
|
"scenes": [],
|
||||||
|
"palette_id": "1"
|
||||||
|
}
|
||||||
|
}
|
||||||
22
db/scene.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"1": {
|
||||||
|
"name": "Default Scene",
|
||||||
|
"sequences": [
|
||||||
|
"1"
|
||||||
|
],
|
||||||
|
"groups": [
|
||||||
|
"1"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"2": {
|
||||||
|
"name": "Party Mode",
|
||||||
|
"sequences": [
|
||||||
|
"1",
|
||||||
|
"2"
|
||||||
|
],
|
||||||
|
"groups": [
|
||||||
|
"1",
|
||||||
|
"2"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
30
db/sequence.json
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
}
|
||||||
27
db/tab.json
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"1": {
|
||||||
|
"name": "default",
|
||||||
|
"names": [
|
||||||
|
"a","b","c","d","e","f","g","h", "i", "j", "k", "l", "m", "n", "o", "p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z", "1", "2", "3", "4", "5", "6", "7", "8", "9", "0"
|
||||||
|
],
|
||||||
|
"presets": [
|
||||||
|
[
|
||||||
|
"1",
|
||||||
|
"2",
|
||||||
|
"3",
|
||||||
|
"4",
|
||||||
|
"5",
|
||||||
|
"6",
|
||||||
|
"7",
|
||||||
|
"8",
|
||||||
|
"9",
|
||||||
|
"10",
|
||||||
|
"11",
|
||||||
|
"12",
|
||||||
|
"13",
|
||||||
|
"14",
|
||||||
|
"15"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
50
dev.py
@@ -6,28 +6,48 @@ import sys
|
|||||||
|
|
||||||
print(sys.argv)
|
print(sys.argv)
|
||||||
|
|
||||||
port = sys.argv[1]
|
# Extract port (first arg if it's not a command)
|
||||||
|
commands = ["src", "lib", "ls", "reset", "follow", "db"]
|
||||||
|
port = None
|
||||||
|
if len(sys.argv) > 1 and sys.argv[1] not in commands:
|
||||||
|
port = sys.argv[1]
|
||||||
|
|
||||||
cmd = sys.argv[1]
|
|
||||||
|
|
||||||
for cmd in sys.argv[1:]:
|
for cmd in sys.argv[1:]:
|
||||||
print(cmd)
|
print(cmd)
|
||||||
match cmd:
|
match cmd:
|
||||||
case "src":
|
case "src":
|
||||||
subprocess.call(["mpremote", "connect", port, "fs", "cp", "-r", ".", ":" ], cwd="src")
|
if port:
|
||||||
|
subprocess.call(["mpremote", "connect", port, "fs", "cp", "-r", ".", ":" ], cwd="src")
|
||||||
|
else:
|
||||||
|
print("Error: Port required for 'src' command")
|
||||||
case "lib":
|
case "lib":
|
||||||
subprocess.call(["mpremote", "connect", port, "fs", "cp", "-r", "lib", ":" ])
|
if port:
|
||||||
|
subprocess.call(["mpremote", "connect", port, "fs", "cp", "-r", "lib", ":" ])
|
||||||
|
else:
|
||||||
|
print("Error: Port required for 'lib' command")
|
||||||
case "ls":
|
case "ls":
|
||||||
subprocess.call(["mpremote", "connect", port, "fs", "ls", ":" ])
|
if port:
|
||||||
|
subprocess.call(["mpremote", "connect", port, "fs", "ls", ":" ])
|
||||||
|
else:
|
||||||
|
print("Error: Port required for 'ls' command")
|
||||||
case "reset":
|
case "reset":
|
||||||
with serial.Serial(port, baudrate=115200) as ser:
|
if port:
|
||||||
ser.write(b'\x03\x03\x04')
|
with serial.Serial(port, baudrate=115200) as ser:
|
||||||
|
ser.write(b'\x03\x03\x04')
|
||||||
|
else:
|
||||||
|
print("Error: Port required for 'reset' command")
|
||||||
case "follow":
|
case "follow":
|
||||||
with serial.Serial(port, baudrate=115200) as ser:
|
if port:
|
||||||
while True:
|
with serial.Serial(port, baudrate=115200) as ser:
|
||||||
if ser.in_waiting > 0: # Check if there is data in the buffer
|
while True:
|
||||||
data = ser.readline().decode('utf-8').strip() # Read and decode the data
|
if ser.in_waiting > 0: # Check if there is data in the buffer
|
||||||
print(data)
|
data = ser.readline().decode('utf-8').strip() # Read and decode the data
|
||||||
|
print(data)
|
||||||
|
else:
|
||||||
|
print("Error: Port required for 'follow' command")
|
||||||
|
case "db":
|
||||||
|
if port:
|
||||||
|
subprocess.call(["mpremote", "connect", port, "fs", "cp", "-r", "db", ":" ])
|
||||||
|
else:
|
||||||
|
print("Error: Port required for 'db' command")
|
||||||
|
|||||||
263
docs/API.md
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
# LED Driver ESPNow API Documentation
|
||||||
|
|
||||||
|
This document describes the ESPNow message format for controlling LED driver devices.
|
||||||
|
|
||||||
|
## Message Format
|
||||||
|
|
||||||
|
All messages are JSON objects sent via ESPNow with the following structure:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"v": "1",
|
||||||
|
"presets": { ... },
|
||||||
|
"select": { ... }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Version Field
|
||||||
|
|
||||||
|
- **`v`** (required): Message version, must be `"1"`. Messages with other versions are ignored.
|
||||||
|
|
||||||
|
## Presets
|
||||||
|
|
||||||
|
Presets define LED patterns with their configuration. Each preset has a name and contains pattern-specific settings.
|
||||||
|
|
||||||
|
### Preset Structure
|
||||||
|
|
||||||
|
```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
|
||||||
|
|
||||||
|
#### Rainbow
|
||||||
|
- **`n1`**: Step increment (how many color wheel positions to advance 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)
|
||||||
|
|
||||||
|
#### Transition
|
||||||
|
- **`delay`**: Transition duration in milliseconds
|
||||||
|
|
||||||
|
#### 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)
|
||||||
|
|
||||||
|
#### Circle
|
||||||
|
- **`n1`**: Head movement rate (LEDs per second)
|
||||||
|
- **`n2`**: Maximum length
|
||||||
|
- **`n3`**: Tail movement rate (LEDs per second)
|
||||||
|
- **`n4`**: Minimum length
|
||||||
|
|
||||||
|
## Select Messages
|
||||||
|
|
||||||
|
Select messages control which preset is active on which device. The format uses a list to support step synchronization.
|
||||||
|
|
||||||
|
### Select Format
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"select": {
|
||||||
|
"device_name": ["preset_name"],
|
||||||
|
"device_name2": ["preset_name2", step_value]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Select Fields
|
||||||
|
|
||||||
|
- **`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)
|
||||||
|
|
||||||
|
### Step Synchronization
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"v": "1",
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"select": {
|
||||||
|
"device1": ["red_blink"],
|
||||||
|
"device2": ["rainbow_manual", 0],
|
||||||
|
"device3": ["pulse_slow"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
- 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)
|
||||||
|
|
||||||
|
## 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
|
||||||
1846
docs/SPECIFICATION.md
Normal file
239
docs/mockups/COLOR_PICKER_README.md
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
# Custom Color Picker Component
|
||||||
|
|
||||||
|
A cross-platform, cross-browser color picker component that provides a consistent user experience across all operating systems and browsers.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
✅ **Consistent UI** - Same appearance and behavior on Windows, macOS, Linux, iOS, and Android
|
||||||
|
✅ **Browser Support** - Works in Chrome, Firefox, Safari, Edge, Opera, and mobile browsers
|
||||||
|
✅ **Touch Support** - Full touch/gesture support for mobile devices
|
||||||
|
✅ **HSB Color Model** - Uses Hue, Saturation, Brightness for intuitive color selection
|
||||||
|
✅ **Multiple Input Methods** - Hex input, RGB inputs, and visual picker
|
||||||
|
✅ **Accessible** - Keyboard accessible and screen reader friendly
|
||||||
|
✅ **Customizable** - Easy to style and integrate
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- `color-picker.js` - Main JavaScript component (14KB)
|
||||||
|
- `color-picker.css` - Stylesheet (4KB)
|
||||||
|
- `color-picker-demo.html` - Demo page showing usage examples
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### 1. Include the files
|
||||||
|
|
||||||
|
```html
|
||||||
|
<link rel="stylesheet" href="color-picker.css">
|
||||||
|
<script src="color-picker.js"></script>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Create a container element
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div id="my-color-picker"></div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Initialize the color picker
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const picker = new ColorPicker('#my-color-picker', {
|
||||||
|
initialColor: '#FF0000',
|
||||||
|
onColorChange: (color) => {
|
||||||
|
console.log('Color changed to:', color);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
### Constructor
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
new ColorPicker(container, options)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- `container` (string|HTMLElement) - CSS selector or DOM element
|
||||||
|
- `options` (object) - Configuration options
|
||||||
|
|
||||||
|
**Options:**
|
||||||
|
- `initialColor` (string) - Initial color in hex format (default: '#FF0000')
|
||||||
|
- `onColorChange` (function) - Callback when color changes (receives hex color string)
|
||||||
|
- `showHexInput` (boolean) - Show hex input field (default: true)
|
||||||
|
|
||||||
|
### Methods
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Get current color
|
||||||
|
const color = picker.getColor(); // Returns hex string like '#FF0000'
|
||||||
|
|
||||||
|
// Set color programmatically
|
||||||
|
picker.setColor('#00FF00');
|
||||||
|
|
||||||
|
// Open the picker panel
|
||||||
|
picker.open();
|
||||||
|
|
||||||
|
// Close the picker panel
|
||||||
|
picker.close();
|
||||||
|
|
||||||
|
// Toggle the picker panel
|
||||||
|
picker.toggle();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage Examples
|
||||||
|
|
||||||
|
### Basic Usage
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const picker = new ColorPicker('#picker1', {
|
||||||
|
initialColor: '#FF0000'
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### With Callback
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const picker = new ColorPicker('#picker1', {
|
||||||
|
initialColor: '#FF0000',
|
||||||
|
onColorChange: (color) => {
|
||||||
|
document.body.style.backgroundColor = color;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Multiple Color Pickers
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const colors = ['#FF0000', '#00FF00', '#0000FF'];
|
||||||
|
const pickers = colors.map((color, index) => {
|
||||||
|
return new ColorPicker(`#picker-${index}`, {
|
||||||
|
initialColor: color,
|
||||||
|
onColorChange: (newColor) => {
|
||||||
|
colors[index] = newColor;
|
||||||
|
updateLEDColors(colors);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dynamic Color Picker Creation
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
function addColorPicker(containerId, initialColor = '#000000') {
|
||||||
|
const container = document.createElement('div');
|
||||||
|
container.id = containerId;
|
||||||
|
document.getElementById('color-list').appendChild(container);
|
||||||
|
|
||||||
|
return new ColorPicker(container, {
|
||||||
|
initialColor: initialColor,
|
||||||
|
onColorChange: (color) => {
|
||||||
|
console.log(`Color ${containerId} changed to ${color}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add multiple pickers
|
||||||
|
addColorPicker('color-1', '#FF0000');
|
||||||
|
addColorPicker('color-2', '#00FF00');
|
||||||
|
```
|
||||||
|
|
||||||
|
## Styling
|
||||||
|
|
||||||
|
The color picker uses CSS classes that can be customized:
|
||||||
|
|
||||||
|
- `.color-picker-container` - Main container
|
||||||
|
- `.color-picker-preview` - Color preview button
|
||||||
|
- `.color-picker-panel` - Dropdown panel
|
||||||
|
- `.color-picker-main` - Main color area
|
||||||
|
- `.color-picker-hue` - Hue slider
|
||||||
|
- `.color-picker-controls` - Controls section
|
||||||
|
|
||||||
|
### Custom Styling Example
|
||||||
|
|
||||||
|
```css
|
||||||
|
.color-picker-preview {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-panel {
|
||||||
|
background: #2d3748;
|
||||||
|
border-color: #4a5568;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Browser Compatibility
|
||||||
|
|
||||||
|
| Browser | Version | Status |
|
||||||
|
|---------|---------|--------|
|
||||||
|
| Chrome | 60+ | ✅ Full support |
|
||||||
|
| Firefox | 55+ | ✅ Full support |
|
||||||
|
| Safari | 12+ | ✅ Full support |
|
||||||
|
| Edge | 79+ | ✅ Full support |
|
||||||
|
| Opera | 47+ | ✅ Full support |
|
||||||
|
| Mobile Safari | iOS 12+ | ✅ Full support |
|
||||||
|
| Chrome Mobile | Android 7+ | ✅ Full support |
|
||||||
|
|
||||||
|
## Operating System Compatibility
|
||||||
|
|
||||||
|
- ✅ Windows 10/11
|
||||||
|
- ✅ macOS 10.14+
|
||||||
|
- ✅ Linux (all major distributions)
|
||||||
|
- ✅ iOS 12+
|
||||||
|
- ✅ Android 7+
|
||||||
|
|
||||||
|
## Color Format
|
||||||
|
|
||||||
|
The color picker uses **hex color format** (`#RRGGBB`):
|
||||||
|
- Always returns uppercase hex strings (e.g., `#FF0000`)
|
||||||
|
- Accepts both uppercase and lowercase input
|
||||||
|
- Automatically validates hex format
|
||||||
|
|
||||||
|
## Integration with LED Driver Mockups
|
||||||
|
|
||||||
|
The color picker is integrated into:
|
||||||
|
- `dashboard.html` - Color selection for patterns
|
||||||
|
- `presets.html` - Color selection when creating/editing presets
|
||||||
|
|
||||||
|
### Example: Getting Colors from Multiple Pickers
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const colorPickers = [];
|
||||||
|
|
||||||
|
function getSelectedColors() {
|
||||||
|
return colorPickers.map(picker => picker.getColor());
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendColorsToDevice() {
|
||||||
|
const colors = getSelectedColors();
|
||||||
|
// Send to LED device via API
|
||||||
|
fetch('/api/colors', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ colors: colors })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
- Lightweight: ~14KB JavaScript, ~4KB CSS
|
||||||
|
- Fast rendering: Uses Canvas API for color gradients
|
||||||
|
- Smooth interactions: Optimized event handling
|
||||||
|
- Memory efficient: No external dependencies
|
||||||
|
|
||||||
|
## Accessibility
|
||||||
|
|
||||||
|
- Keyboard navigation support
|
||||||
|
- ARIA labels on interactive elements
|
||||||
|
- High contrast cursor indicators
|
||||||
|
- Screen reader compatible
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Part of the LED Driver project. Use freely in your projects.
|
||||||
|
|
||||||
|
## Demo
|
||||||
|
|
||||||
|
See `color-picker-demo.html` for a live demonstration of the color picker component.
|
||||||
|
|
||||||
56
docs/mockups/README.md
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
# UI Mockups
|
||||||
|
|
||||||
|
This directory contains HTML mockups and generated images for the LED Driver user interface.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
### HTML Mockups
|
||||||
|
- **index.html** - Navigation page linking to all mockups
|
||||||
|
- **dashboard.html** - Main control panel for managing LED patterns and devices
|
||||||
|
- **pattern-selector.html** - Visual pattern selection interface
|
||||||
|
- **device-management.html** - Device and group management interface
|
||||||
|
- **settings.html** - Comprehensive settings configuration panel
|
||||||
|
|
||||||
|
### Generated Images
|
||||||
|
Images are automatically generated in the `images/` directory:
|
||||||
|
- `dashboard.png`
|
||||||
|
- `pattern-selector.png`
|
||||||
|
- `device-management.png`
|
||||||
|
- `settings.png`
|
||||||
|
- `index.png`
|
||||||
|
|
||||||
|
## Generating Images
|
||||||
|
|
||||||
|
To generate images from the HTML files, use the provided script:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install dependencies (if not already installed)
|
||||||
|
pipenv install playwright
|
||||||
|
pipenv run playwright install chromium
|
||||||
|
|
||||||
|
# Generate images
|
||||||
|
pipenv run python generate_images.py
|
||||||
|
```
|
||||||
|
|
||||||
|
The script will:
|
||||||
|
1. Check for available screenshot libraries (Playwright, Selenium, or html2image)
|
||||||
|
2. Generate PNG images from all HTML files
|
||||||
|
3. Save images to the `images/` directory
|
||||||
|
|
||||||
|
### Requirements
|
||||||
|
|
||||||
|
The script supports multiple screenshot libraries (in order of preference):
|
||||||
|
1. **Playwright** (recommended) - `pip install playwright && playwright install chromium`
|
||||||
|
2. **Selenium** - `pip install selenium` (requires ChromeDriver)
|
||||||
|
3. **html2image** - `pip install html2image`
|
||||||
|
|
||||||
|
## Viewing Mockups
|
||||||
|
|
||||||
|
Simply open any HTML file in a web browser to view the mockup. Start with `index.html` for navigation to all mockups.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- All mockups are responsive and work on desktop and mobile devices
|
||||||
|
- The mockups use modern CSS with gradients and smooth animations
|
||||||
|
- Interactive elements (buttons, sliders, etc.) are functional in the HTML but are mockups (no backend connection)
|
||||||
|
|
||||||
210
docs/mockups/color-picker-chromium-demo.html
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Chromium Color Picker Demo</title>
|
||||||
|
<link rel="stylesheet" href="color-picker-chromium.css">
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500&family=Roboto+Mono&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 40px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 40px;
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
color: #202124;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
color: #5f6368;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-section {
|
||||||
|
margin-bottom: 40px;
|
||||||
|
padding: 24px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #e8eaed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-section h2 {
|
||||||
|
color: #202124;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-pickers {
|
||||||
|
display: flex;
|
||||||
|
gap: 24px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-display {
|
||||||
|
margin-top: 16px;
|
||||||
|
padding: 12px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-family: 'Roboto Mono', 'Courier New', monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
border: 1px solid #e8eaed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-display strong {
|
||||||
|
color: #4285f4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comparison {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 24px;
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comparison-item {
|
||||||
|
padding: 16px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid #e8eaed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comparison-item h3 {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #202124;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>Chromium-style Color Picker</h1>
|
||||||
|
<p>Color picker that matches the native Chromium browser color picker design</p>
|
||||||
|
|
||||||
|
<div class="demo-section">
|
||||||
|
<h2>Single Color Picker</h2>
|
||||||
|
<div class="color-pickers">
|
||||||
|
<div id="picker1"></div>
|
||||||
|
</div>
|
||||||
|
<div class="color-display">
|
||||||
|
Selected color: <strong id="color1-display">#FF0000</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="demo-section">
|
||||||
|
<h2>Multiple Color Pickers</h2>
|
||||||
|
<p style="margin-bottom: 16px; color: #5f6368; font-size: 14px;">Example: Multiple colors for LED patterns</p>
|
||||||
|
<div class="color-pickers">
|
||||||
|
<div id="picker2"></div>
|
||||||
|
<div id="picker3"></div>
|
||||||
|
<div id="picker4"></div>
|
||||||
|
</div>
|
||||||
|
<div class="color-display">
|
||||||
|
Colors: <strong id="colors-display">#FF0000, #00FF00, #0000FF</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="demo-section">
|
||||||
|
<h2>Features</h2>
|
||||||
|
<ul style="color: #5f6368; line-height: 1.8; font-size: 14px;">
|
||||||
|
<li>✅ Matches native Chromium browser color picker design</li>
|
||||||
|
<li>✅ Clean, minimal interface with native system fonts</li>
|
||||||
|
<li>✅ RGB number inputs (no sliders) - Chromium style</li>
|
||||||
|
<li>✅ Hex input with uppercase formatting</li>
|
||||||
|
<li>✅ HSB (Hue, Saturation, Brightness) color model</li>
|
||||||
|
<li>✅ Touch support for mobile devices</li>
|
||||||
|
<li>✅ Keyboard accessible</li>
|
||||||
|
<li>✅ Dark mode support</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="demo-section">
|
||||||
|
<h2>Design Notes</h2>
|
||||||
|
<div class="comparison">
|
||||||
|
<div class="comparison-item">
|
||||||
|
<h3>Chromium Style</h3>
|
||||||
|
<ul style="color: #5f6368; line-height: 1.8; font-size: 13px; list-style: none; padding-left: 0;">
|
||||||
|
<li>• RGB number inputs only</li>
|
||||||
|
<li>• Compact preview button</li>
|
||||||
|
<li>• Native system fonts</li>
|
||||||
|
<li>• Minimal borders and shadows</li>
|
||||||
|
<li>• Chromium color scheme</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="comparison-item">
|
||||||
|
<h3>Standard Style</h3>
|
||||||
|
<ul style="color: #5f6368; line-height: 1.8; font-size: 13px; list-style: none; padding-left: 0;">
|
||||||
|
<li>• RGB sliders + inputs</li>
|
||||||
|
<li>• Larger preview button</li>
|
||||||
|
<li>• Custom styling</li>
|
||||||
|
<li>• Enhanced shadows</li>
|
||||||
|
<li>• Custom color scheme</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="color-picker-chromium.js"></script>
|
||||||
|
<script>
|
||||||
|
// Initialize Chromium-style color pickers
|
||||||
|
const picker1 = new ColorPickerChromium('#picker1', {
|
||||||
|
initialColor: '#FF0000',
|
||||||
|
onColorChange: (color) => {
|
||||||
|
document.getElementById('color1-display').textContent = color;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const picker2 = new ColorPickerChromium('#picker2', {
|
||||||
|
initialColor: '#FF0000',
|
||||||
|
onColorChange: updateColors
|
||||||
|
});
|
||||||
|
|
||||||
|
const picker3 = new ColorPickerChromium('#picker3', {
|
||||||
|
initialColor: '#00FF00',
|
||||||
|
onColorChange: updateColors
|
||||||
|
});
|
||||||
|
|
||||||
|
const picker4 = new ColorPickerChromium('#picker4', {
|
||||||
|
initialColor: '#0000FF',
|
||||||
|
onColorChange: updateColors
|
||||||
|
});
|
||||||
|
|
||||||
|
function updateColors() {
|
||||||
|
const colors = [
|
||||||
|
picker2.getColor(),
|
||||||
|
picker3.getColor(),
|
||||||
|
picker4.getColor()
|
||||||
|
];
|
||||||
|
document.getElementById('colors-display').textContent = colors.join(', ');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
253
docs/mockups/color-picker-chromium.css
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
/* Chromium-style Color Picker - Matches native browser color picker dialog */
|
||||||
|
|
||||||
|
.color-picker-container {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Preview button - opens the picker */
|
||||||
|
.color-picker-preview {
|
||||||
|
width: 40px;
|
||||||
|
height: 32px;
|
||||||
|
border: 1px solid #d0d0d0;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
background: none;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-preview:hover {
|
||||||
|
border-color: #8ab4f8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-preview:active {
|
||||||
|
border-color: #4285f4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main picker panel - always visible when open, styled like Chromium dialog */
|
||||||
|
.color-picker-panel {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 4px);
|
||||||
|
left: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #dadce0;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15), 0 4px 16px rgba(0, 0, 0, 0.1);
|
||||||
|
padding: 16px;
|
||||||
|
min-width: 260px;
|
||||||
|
font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Color area - main saturation/brightness square + hue slider */
|
||||||
|
.color-picker-area {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main color square - saturation (left-right) and brightness (top-bottom) */
|
||||||
|
.color-picker-main {
|
||||||
|
position: relative;
|
||||||
|
width: 200px;
|
||||||
|
height: 200px;
|
||||||
|
border: 1px solid #dadce0;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: crosshair;
|
||||||
|
touch-action: none;
|
||||||
|
background: #ffffff;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-canvas {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-moz-user-select: none;
|
||||||
|
-ms-user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cursor for main color area */
|
||||||
|
.color-picker-cursor {
|
||||||
|
position: absolute;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border: 2px solid #ffffff;
|
||||||
|
border-radius: 50%;
|
||||||
|
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.2), 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
pointer-events: none;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hue slider - vertical strip on the right */
|
||||||
|
.color-picker-hue {
|
||||||
|
position: relative;
|
||||||
|
width: 24px;
|
||||||
|
height: 200px;
|
||||||
|
border: 1px solid #dadce0;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
touch-action: none;
|
||||||
|
background: #ffffff;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hue slider cursor/indicator */
|
||||||
|
.color-picker-hue-cursor {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 6px;
|
||||||
|
border: 2px solid #ffffff;
|
||||||
|
border-radius: 2px;
|
||||||
|
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.2), 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||||
|
pointer-events: none;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Controls section - hex and RGB inputs */
|
||||||
|
.color-picker-controls {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hex input field */
|
||||||
|
.color-picker-hex {
|
||||||
|
width: 100%;
|
||||||
|
padding: 7px 10px;
|
||||||
|
border: 1px solid #dadce0;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: 'Roboto Mono', 'Courier New', monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
transition: border-color 0.15s, box-shadow 0.15s;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-hex:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #4285f4;
|
||||||
|
box-shadow: 0 0 0 2px rgba(66, 133, 244, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* RGB inputs container */
|
||||||
|
.color-picker-rgb {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-rgb-item {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-rgb-item label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #5f6368;
|
||||||
|
text-align: left;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* RGB number input fields */
|
||||||
|
.color-picker-rgb-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 7px 10px;
|
||||||
|
border: 1px solid #dadce0;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: left;
|
||||||
|
transition: border-color 0.15s, box-shadow 0.15s;
|
||||||
|
background: #ffffff;
|
||||||
|
font-family: 'Roboto', sans-serif;
|
||||||
|
-moz-appearance: textfield;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-rgb-input::-webkit-outer-spin-button,
|
||||||
|
.color-picker-rgb-input::-webkit-inner-spin-button {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-rgb-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #4285f4;
|
||||||
|
box-shadow: 0 0 0 2px rgba(66, 133, 244, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide RGB sliders - Chromium uses only number inputs */
|
||||||
|
.color-picker-rgb-slider {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive adjustments */
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.color-picker-panel {
|
||||||
|
left: auto;
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-main {
|
||||||
|
width: 180px;
|
||||||
|
height: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-hue {
|
||||||
|
height: 180px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode support */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.color-picker-panel {
|
||||||
|
background: #202124;
|
||||||
|
border-color: #5f6368;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-preview {
|
||||||
|
border-color: #5f6368;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-main,
|
||||||
|
.color-picker-hue {
|
||||||
|
border-color: #5f6368;
|
||||||
|
background: #202124;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-hex,
|
||||||
|
.color-picker-rgb-input {
|
||||||
|
background: #303134;
|
||||||
|
border-color: #5f6368;
|
||||||
|
color: #e8eaed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-rgb-item label {
|
||||||
|
color: #9aa0a6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-hex:focus,
|
||||||
|
.color-picker-rgb-input:focus {
|
||||||
|
border-color: #8ab4f8;
|
||||||
|
box-shadow: 0 0 0 2px rgba(138, 180, 248, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-preview:hover {
|
||||||
|
border-color: #8ab4f8;
|
||||||
|
}
|
||||||
|
}
|
||||||
452
docs/mockups/color-picker-chromium.js
Normal file
@@ -0,0 +1,452 @@
|
|||||||
|
/**
|
||||||
|
* Chromium-style Color Picker Component
|
||||||
|
* Matches native Chromium browser color picker design
|
||||||
|
*/
|
||||||
|
|
||||||
|
class ColorPickerChromium {
|
||||||
|
constructor(container, options = {}) {
|
||||||
|
this.container = typeof container === 'string' ? document.querySelector(container) : container;
|
||||||
|
this.options = {
|
||||||
|
initialColor: options.initialColor || '#FF0000',
|
||||||
|
onColorChange: options.onColorChange || null,
|
||||||
|
showHexInput: options.showHexInput !== false,
|
||||||
|
...options
|
||||||
|
};
|
||||||
|
this.currentColor = this.options.initialColor;
|
||||||
|
this.isOpen = false;
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this.createPicker();
|
||||||
|
this.setupEventListeners();
|
||||||
|
this.updateColor(this.options.initialColor);
|
||||||
|
}
|
||||||
|
|
||||||
|
createPicker() {
|
||||||
|
this.container.innerHTML = '';
|
||||||
|
this.container.className = 'color-picker-container';
|
||||||
|
|
||||||
|
// Color preview button
|
||||||
|
this.previewBtn = document.createElement('button');
|
||||||
|
this.previewBtn.className = 'color-picker-preview';
|
||||||
|
this.previewBtn.type = 'button';
|
||||||
|
this.previewBtn.style.backgroundColor = this.currentColor;
|
||||||
|
this.previewBtn.setAttribute('aria-label', 'Open color picker');
|
||||||
|
|
||||||
|
// Dropdown panel
|
||||||
|
this.panel = document.createElement('div');
|
||||||
|
this.panel.className = 'color-picker-panel';
|
||||||
|
this.panel.style.display = 'none';
|
||||||
|
|
||||||
|
// Main color area (hue/saturation)
|
||||||
|
this.mainArea = document.createElement('div');
|
||||||
|
this.mainArea.className = 'color-picker-main';
|
||||||
|
this.mainCanvas = document.createElement('canvas');
|
||||||
|
this.mainCanvas.width = 200;
|
||||||
|
this.mainCanvas.height = 200;
|
||||||
|
this.mainCanvas.className = 'color-picker-canvas';
|
||||||
|
this.mainArea.appendChild(this.mainCanvas);
|
||||||
|
|
||||||
|
// Main area cursor
|
||||||
|
this.mainCursor = document.createElement('div');
|
||||||
|
this.mainCursor.className = 'color-picker-cursor';
|
||||||
|
this.mainArea.appendChild(this.mainCursor);
|
||||||
|
|
||||||
|
// Hue slider
|
||||||
|
this.hueArea = document.createElement('div');
|
||||||
|
this.hueArea.className = 'color-picker-hue';
|
||||||
|
this.hueCanvas = document.createElement('canvas');
|
||||||
|
this.hueCanvas.width = 24;
|
||||||
|
this.hueCanvas.height = 200;
|
||||||
|
this.hueCanvas.className = 'color-picker-canvas';
|
||||||
|
this.hueArea.appendChild(this.hueCanvas);
|
||||||
|
|
||||||
|
// Hue slider cursor
|
||||||
|
this.hueCursor = document.createElement('div');
|
||||||
|
this.hueCursor.className = 'color-picker-hue-cursor';
|
||||||
|
this.hueArea.appendChild(this.hueCursor);
|
||||||
|
|
||||||
|
// Controls section
|
||||||
|
this.controls = document.createElement('div');
|
||||||
|
this.controls.className = 'color-picker-controls';
|
||||||
|
|
||||||
|
// Hex input
|
||||||
|
if (this.options.showHexInput) {
|
||||||
|
this.hexInput = document.createElement('input');
|
||||||
|
this.hexInput.type = 'text';
|
||||||
|
this.hexInput.className = 'color-picker-hex';
|
||||||
|
this.hexInput.placeholder = '#000000';
|
||||||
|
this.hexInput.maxLength = 7;
|
||||||
|
this.controls.appendChild(this.hexInput);
|
||||||
|
}
|
||||||
|
|
||||||
|
// RGB inputs (Chromium style - no sliders, just number inputs)
|
||||||
|
this.rgbContainer = document.createElement('div');
|
||||||
|
this.rgbContainer.className = 'color-picker-rgb';
|
||||||
|
|
||||||
|
['R', 'G', 'B'].forEach((label) => {
|
||||||
|
const wrapper = document.createElement('div');
|
||||||
|
wrapper.className = 'color-picker-rgb-item';
|
||||||
|
wrapper.dataset.channel = label.toLowerCase();
|
||||||
|
|
||||||
|
const labelEl = document.createElement('label');
|
||||||
|
labelEl.textContent = label;
|
||||||
|
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'number';
|
||||||
|
input.className = 'color-picker-rgb-input';
|
||||||
|
input.min = 0;
|
||||||
|
input.max = 255;
|
||||||
|
input.value = 0;
|
||||||
|
input.dataset.channel = label.toLowerCase();
|
||||||
|
|
||||||
|
wrapper.appendChild(labelEl);
|
||||||
|
wrapper.appendChild(input);
|
||||||
|
this.rgbContainer.appendChild(wrapper);
|
||||||
|
|
||||||
|
this[`rgb${label}`] = input;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.controls.appendChild(this.rgbContainer);
|
||||||
|
|
||||||
|
// Assemble panel
|
||||||
|
const pickerArea = document.createElement('div');
|
||||||
|
pickerArea.className = 'color-picker-area';
|
||||||
|
pickerArea.appendChild(this.mainArea);
|
||||||
|
pickerArea.appendChild(this.hueArea);
|
||||||
|
|
||||||
|
this.panel.appendChild(pickerArea);
|
||||||
|
this.panel.appendChild(this.controls);
|
||||||
|
|
||||||
|
// Assemble container
|
||||||
|
this.container.appendChild(this.previewBtn);
|
||||||
|
this.container.appendChild(this.panel);
|
||||||
|
|
||||||
|
// Draw canvases
|
||||||
|
this.drawHueCanvas();
|
||||||
|
this.drawMainCanvas(1.0); // Start with full saturation
|
||||||
|
}
|
||||||
|
|
||||||
|
setupEventListeners() {
|
||||||
|
// Toggle panel
|
||||||
|
this.previewBtn.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
this.toggle();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close on outside click
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
if (!this.container.contains(e.target) && this.isOpen) {
|
||||||
|
this.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Main area interaction
|
||||||
|
let isMainDragging = false;
|
||||||
|
this.mainCanvas.addEventListener('mousedown', (e) => {
|
||||||
|
isMainDragging = true;
|
||||||
|
this.handleMainAreaClick(e);
|
||||||
|
});
|
||||||
|
this.mainCanvas.addEventListener('mousemove', (e) => {
|
||||||
|
if (isMainDragging) {
|
||||||
|
this.handleMainAreaClick(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.addEventListener('mouseup', () => {
|
||||||
|
isMainDragging = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Touch support for main area
|
||||||
|
this.mainCanvas.addEventListener('touchstart', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
isMainDragging = true;
|
||||||
|
this.handleMainAreaClick(e.touches[0]);
|
||||||
|
});
|
||||||
|
this.mainCanvas.addEventListener('touchmove', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (isMainDragging) {
|
||||||
|
this.handleMainAreaClick(e.touches[0]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.mainCanvas.addEventListener('touchend', () => {
|
||||||
|
isMainDragging = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Hue slider interaction
|
||||||
|
let isHueDragging = false;
|
||||||
|
this.hueCanvas.addEventListener('mousedown', (e) => {
|
||||||
|
isHueDragging = true;
|
||||||
|
this.handleHueClick(e);
|
||||||
|
});
|
||||||
|
this.hueCanvas.addEventListener('mousemove', (e) => {
|
||||||
|
if (isHueDragging) {
|
||||||
|
this.handleHueClick(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.addEventListener('mouseup', () => {
|
||||||
|
isHueDragging = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Touch support for hue slider
|
||||||
|
this.hueCanvas.addEventListener('touchstart', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
isHueDragging = true;
|
||||||
|
this.handleHueClick(e.touches[0]);
|
||||||
|
});
|
||||||
|
this.hueCanvas.addEventListener('touchmove', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (isHueDragging) {
|
||||||
|
this.handleHueClick(e.touches[0]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.hueCanvas.addEventListener('touchend', () => {
|
||||||
|
isHueDragging = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Hex input
|
||||||
|
if (this.hexInput) {
|
||||||
|
this.hexInput.addEventListener('input', (e) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
if (/^#[0-9A-Fa-f]{6}$/.test(value)) {
|
||||||
|
this.updateColor(value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.hexInput.addEventListener('blur', (e) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
if (!/^#[0-9A-Fa-f]{6}$/.test(value) && value.length > 0) {
|
||||||
|
e.target.value = this.currentColor;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// RGB inputs (Chromium style - only number inputs)
|
||||||
|
['R', 'G', 'B'].forEach(label => {
|
||||||
|
this[`rgb${label}`].addEventListener('input', (e) => {
|
||||||
|
let value = parseInt(e.target.value) || 0;
|
||||||
|
value = Math.max(0, Math.min(255, value)); // Clamp to 0-255
|
||||||
|
e.target.value = value;
|
||||||
|
const r = parseInt(this.rgbR.value) || 0;
|
||||||
|
const g = parseInt(this.rgbG.value) || 0;
|
||||||
|
const b = parseInt(this.rgbB.value) || 0;
|
||||||
|
const hex = this.rgbToHex(r, g, b);
|
||||||
|
this.updateColor(hex, false); // Don't update RGB inputs to avoid loop
|
||||||
|
});
|
||||||
|
|
||||||
|
this[`rgb${label}`].addEventListener('blur', (e) => {
|
||||||
|
let value = parseInt(e.target.value) || 0;
|
||||||
|
value = Math.max(0, Math.min(255, value));
|
||||||
|
e.target.value = value;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
drawHueCanvas() {
|
||||||
|
const ctx = this.hueCanvas.getContext('2d');
|
||||||
|
const gradient = ctx.createLinearGradient(0, 0, 0, 200);
|
||||||
|
|
||||||
|
for (let i = 0; i <= 6; i++) {
|
||||||
|
const hue = i * 60;
|
||||||
|
gradient.addColorStop(i / 6, `hsl(${hue}, 100%, 50%)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.fillStyle = gradient;
|
||||||
|
ctx.fillRect(0, 0, 24, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
drawMainCanvas(hue) {
|
||||||
|
const ctx = this.mainCanvas.getContext('2d');
|
||||||
|
|
||||||
|
// Saturation gradient (left to right)
|
||||||
|
const satGradient = ctx.createLinearGradient(0, 0, 200, 0);
|
||||||
|
satGradient.addColorStop(0, `hsl(${hue}, 0%, 50%)`);
|
||||||
|
satGradient.addColorStop(1, `hsl(${hue}, 100%, 50%)`);
|
||||||
|
ctx.fillStyle = satGradient;
|
||||||
|
ctx.fillRect(0, 0, 200, 200);
|
||||||
|
|
||||||
|
// Brightness gradient (top to bottom)
|
||||||
|
const brightGradient = ctx.createLinearGradient(0, 0, 0, 200);
|
||||||
|
brightGradient.addColorStop(0, 'rgba(255, 255, 255, 0)');
|
||||||
|
brightGradient.addColorStop(1, 'rgba(0, 0, 0, 1)');
|
||||||
|
ctx.fillStyle = brightGradient;
|
||||||
|
ctx.fillRect(0, 0, 200, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleMainAreaClick(e) {
|
||||||
|
const rect = this.mainCanvas.getBoundingClientRect();
|
||||||
|
const x = Math.max(0, Math.min(200, e.clientX - rect.left));
|
||||||
|
const y = Math.max(0, Math.min(200, e.clientY - rect.top));
|
||||||
|
|
||||||
|
const saturation = x / 200;
|
||||||
|
const brightness = 1 - (y / 200);
|
||||||
|
|
||||||
|
this.updateColorFromHSB(this.hue, saturation, brightness);
|
||||||
|
this.updateCursor(x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleHueClick(e) {
|
||||||
|
const rect = this.hueCanvas.getBoundingClientRect();
|
||||||
|
const y = Math.max(0, Math.min(200, e.clientY - rect.top));
|
||||||
|
const hue = (y / 200) * 360;
|
||||||
|
|
||||||
|
this.hue = hue;
|
||||||
|
this.drawMainCanvas(hue);
|
||||||
|
this.updateHueCursor(y);
|
||||||
|
|
||||||
|
// Recalculate color with new hue
|
||||||
|
const rect2 = this.mainCanvas.getBoundingClientRect();
|
||||||
|
const x = parseFloat(this.mainCursor.style.left) || 0;
|
||||||
|
const y2 = parseFloat(this.mainCursor.style.top) || 0;
|
||||||
|
const saturation = x / 200;
|
||||||
|
const brightness = 1 - (y2 / 200);
|
||||||
|
this.updateColorFromHSB(hue, saturation, brightness);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateColorFromHSB(h, s, v) {
|
||||||
|
const rgb = this.hsbToRgb(h, s, v);
|
||||||
|
const hex = this.rgbToHex(rgb.r, rgb.g, rgb.b);
|
||||||
|
this.updateColor(hex);
|
||||||
|
}
|
||||||
|
|
||||||
|
hsbToRgb(h, s, v) {
|
||||||
|
h = h / 360;
|
||||||
|
const i = Math.floor(h * 6);
|
||||||
|
const f = h * 6 - i;
|
||||||
|
const p = v * (1 - s);
|
||||||
|
const q = v * (1 - f * s);
|
||||||
|
const t = v * (1 - (1 - f) * s);
|
||||||
|
|
||||||
|
let r, g, b;
|
||||||
|
switch (i % 6) {
|
||||||
|
case 0: r = v; g = t; b = p; break;
|
||||||
|
case 1: r = q; g = v; b = p; break;
|
||||||
|
case 2: r = p; g = v; b = t; break;
|
||||||
|
case 3: r = p; g = q; b = v; break;
|
||||||
|
case 4: r = t; g = p; b = v; break;
|
||||||
|
case 5: r = v; g = p; b = q; break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
r: Math.round(r * 255),
|
||||||
|
g: Math.round(g * 255),
|
||||||
|
b: Math.round(b * 255)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
rgbToHex(r, g, b) {
|
||||||
|
return '#' + [r, g, b].map(x => {
|
||||||
|
const hex = x.toString(16);
|
||||||
|
return hex.length === 1 ? '0' + hex : hex;
|
||||||
|
}).join('').toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
hexToRgb(hex) {
|
||||||
|
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||||
|
return result ? {
|
||||||
|
r: parseInt(result[1], 16),
|
||||||
|
g: parseInt(result[2], 16),
|
||||||
|
b: parseInt(result[3], 16)
|
||||||
|
} : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
rgbToHsb(r, g, b) {
|
||||||
|
r /= 255;
|
||||||
|
g /= 255;
|
||||||
|
b /= 255;
|
||||||
|
|
||||||
|
const max = Math.max(r, g, b);
|
||||||
|
const min = Math.min(r, g, b);
|
||||||
|
const diff = max - min;
|
||||||
|
|
||||||
|
let h = 0;
|
||||||
|
if (diff !== 0) {
|
||||||
|
if (max === r) {
|
||||||
|
h = ((g - b) / diff) % 6) * 60;
|
||||||
|
} else if (max === g) {
|
||||||
|
h = ((b - r) / diff + 2) * 60;
|
||||||
|
} else {
|
||||||
|
h = ((r - g) / diff + 4) * 60;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (h < 0) h += 360;
|
||||||
|
|
||||||
|
const s = max === 0 ? 0 : diff / max;
|
||||||
|
const v = max;
|
||||||
|
|
||||||
|
return { h, s, v };
|
||||||
|
}
|
||||||
|
|
||||||
|
updateColor(hex, updateInputs = true) {
|
||||||
|
this.currentColor = hex.toUpperCase();
|
||||||
|
this.previewBtn.style.backgroundColor = this.currentColor;
|
||||||
|
|
||||||
|
const rgb = this.hexToRgb(this.currentColor);
|
||||||
|
if (!rgb) return;
|
||||||
|
|
||||||
|
const hsb = this.rgbToHsb(rgb.r, rgb.g, rgb.b);
|
||||||
|
this.hue = hsb.h;
|
||||||
|
|
||||||
|
// Update main canvas
|
||||||
|
this.drawMainCanvas(this.hue);
|
||||||
|
|
||||||
|
// Update cursors
|
||||||
|
const x = hsb.s * 200;
|
||||||
|
const y = (1 - hsb.v) * 200;
|
||||||
|
this.updateCursor(x, y);
|
||||||
|
this.updateHueCursor((this.hue / 360) * 200);
|
||||||
|
|
||||||
|
// Update inputs
|
||||||
|
if (updateInputs) {
|
||||||
|
if (this.hexInput) {
|
||||||
|
this.hexInput.value = this.currentColor;
|
||||||
|
}
|
||||||
|
if (this.rgbR) {
|
||||||
|
this.rgbR.value = rgb.r;
|
||||||
|
this.rgbG.value = rgb.g;
|
||||||
|
this.rgbB.value = rgb.b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Callback
|
||||||
|
if (this.options.onColorChange) {
|
||||||
|
this.options.onColorChange(this.currentColor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateCursor(x, y) {
|
||||||
|
this.mainCursor.style.left = `${x}px`;
|
||||||
|
this.mainCursor.style.top = `${y}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateHueCursor(y) {
|
||||||
|
this.hueCursor.style.top = `${y}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
toggle() {
|
||||||
|
this.isOpen ? this.close() : this.open();
|
||||||
|
}
|
||||||
|
|
||||||
|
open() {
|
||||||
|
this.panel.style.display = 'block';
|
||||||
|
this.isOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
this.panel.style.display = 'none';
|
||||||
|
this.isOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
getColor() {
|
||||||
|
return this.currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
setColor(color) {
|
||||||
|
this.updateColor(color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export for use in other scripts
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = ColorPickerChromium;
|
||||||
|
}
|
||||||
|
|
||||||
153
docs/mockups/color-picker-demo.html
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Color Picker Demo - Cross-Platform</title>
|
||||||
|
<link rel="stylesheet" href="color-picker.css">
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 40px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 40px;
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
color: #667eea;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-section {
|
||||||
|
margin-bottom: 40px;
|
||||||
|
padding: 24px;
|
||||||
|
background: #f7fafc;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-section h2 {
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-pickers {
|
||||||
|
display: flex;
|
||||||
|
gap: 24px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-display {
|
||||||
|
margin-top: 16px;
|
||||||
|
padding: 12px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-display strong {
|
||||||
|
color: #667eea;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>Custom Color Picker</h1>
|
||||||
|
<p>Consistent color picker that works the same across all operating systems and browsers</p>
|
||||||
|
|
||||||
|
<div class="demo-section">
|
||||||
|
<h2>Single Color Picker</h2>
|
||||||
|
<div class="color-pickers">
|
||||||
|
<div id="picker1"></div>
|
||||||
|
</div>
|
||||||
|
<div class="color-display">
|
||||||
|
Selected color: <strong id="color1-display">#FF0000</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="demo-section">
|
||||||
|
<h2>Multiple Color Pickers</h2>
|
||||||
|
<p style="margin-bottom: 16px;">Example: Multiple colors for LED patterns</p>
|
||||||
|
<div class="color-pickers">
|
||||||
|
<div id="picker2"></div>
|
||||||
|
<div id="picker3"></div>
|
||||||
|
<div id="picker4"></div>
|
||||||
|
</div>
|
||||||
|
<div class="color-display">
|
||||||
|
Colors: <strong id="colors-display">#FF0000, #00FF00, #0000FF</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="demo-section">
|
||||||
|
<h2>Features</h2>
|
||||||
|
<ul style="color: #666; line-height: 1.8;">
|
||||||
|
<li>✅ Consistent UI across Windows, macOS, Linux, iOS, Android</li>
|
||||||
|
<li>✅ Works in Chrome, Firefox, Safari, Edge, Opera</li>
|
||||||
|
<li>✅ Touch support for mobile devices</li>
|
||||||
|
<li>✅ HSB (Hue, Saturation, Brightness) color model</li>
|
||||||
|
<li>✅ Hex and RGB input support</li>
|
||||||
|
<li>✅ Keyboard accessible</li>
|
||||||
|
<li>✅ Customizable styling</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="color-picker.js"></script>
|
||||||
|
<script>
|
||||||
|
// Initialize color pickers
|
||||||
|
const picker1 = new ColorPicker('#picker1', {
|
||||||
|
initialColor: '#FF0000',
|
||||||
|
onColorChange: (color) => {
|
||||||
|
document.getElementById('color1-display').textContent = color;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const picker2 = new ColorPicker('#picker2', {
|
||||||
|
initialColor: '#FF0000',
|
||||||
|
onColorChange: updateColors
|
||||||
|
});
|
||||||
|
|
||||||
|
const picker3 = new ColorPicker('#picker3', {
|
||||||
|
initialColor: '#00FF00',
|
||||||
|
onColorChange: updateColors
|
||||||
|
});
|
||||||
|
|
||||||
|
const picker4 = new ColorPicker('#picker4', {
|
||||||
|
initialColor: '#0000FF',
|
||||||
|
onColorChange: updateColors
|
||||||
|
});
|
||||||
|
|
||||||
|
function updateColors() {
|
||||||
|
const colors = [
|
||||||
|
picker2.getColor(),
|
||||||
|
picker3.getColor(),
|
||||||
|
picker4.getColor()
|
||||||
|
];
|
||||||
|
document.getElementById('colors-display').textContent = colors.join(', ');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
282
docs/mockups/color-picker.css
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
/* Color Picker Styles - Consistent across all browsers and OS */
|
||||||
|
|
||||||
|
.color-picker-container {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-preview {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
border: 2px solid #e0e0e0;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
background: none;
|
||||||
|
transition: all 0.2s;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-preview:hover {
|
||||||
|
border-color: #667eea;
|
||||||
|
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-preview:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-panel {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 8px);
|
||||||
|
left: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
|
||||||
|
padding: 16px;
|
||||||
|
min-width: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-area {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-main {
|
||||||
|
position: relative;
|
||||||
|
width: 200px;
|
||||||
|
height: 200px;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: crosshair;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-canvas {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-moz-user-select: none;
|
||||||
|
-ms-user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-cursor {
|
||||||
|
position: absolute;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border: 2px solid white;
|
||||||
|
border-radius: 50%;
|
||||||
|
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.3);
|
||||||
|
pointer-events: none;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-hue {
|
||||||
|
position: relative;
|
||||||
|
width: 20px;
|
||||||
|
height: 200px;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-hue-cursor {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 4px;
|
||||||
|
border: 2px solid white;
|
||||||
|
border-radius: 2px;
|
||||||
|
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.3);
|
||||||
|
pointer-events: none;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-controls {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-hex {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-hex:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-rgb {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-rgb-item {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-rgb-item label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #666;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-rgb-slider {
|
||||||
|
width: 100%;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: #e0e0e0;
|
||||||
|
outline: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-rgb-slider::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #667eea;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 2px solid white;
|
||||||
|
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.2);
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-rgb-slider::-webkit-slider-thumb:hover {
|
||||||
|
transform: scale(1.1);
|
||||||
|
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-rgb-slider::-moz-range-thumb {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #667eea;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 2px solid white;
|
||||||
|
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.2);
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-rgb-slider::-moz-range-thumb:hover {
|
||||||
|
transform: scale(1.1);
|
||||||
|
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Color-specific slider backgrounds */
|
||||||
|
.color-picker-rgb-item[data-channel="r"] .color-picker-rgb-slider {
|
||||||
|
background: linear-gradient(to right, #000000, #ff0000);
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-rgb-item[data-channel="g"] .color-picker-rgb-slider {
|
||||||
|
background: linear-gradient(to right, #000000, #00ff00);
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-rgb-item[data-channel="b"] .color-picker-rgb-slider {
|
||||||
|
background: linear-gradient(to right, #000000, #0000ff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-rgb-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
text-align: center;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
-moz-appearance: textfield;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-rgb-input::-webkit-outer-spin-button,
|
||||||
|
.color-picker-rgb-input::-webkit-inner-spin-button {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-rgb-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive adjustments */
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.color-picker-panel {
|
||||||
|
left: auto;
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-main {
|
||||||
|
width: 180px;
|
||||||
|
height: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-hue {
|
||||||
|
height: 180px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode support (optional) */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.color-picker-panel {
|
||||||
|
background: #2d3748;
|
||||||
|
border-color: #4a5568;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-preview {
|
||||||
|
border-color: #4a5568;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-main,
|
||||||
|
.color-picker-hue {
|
||||||
|
border-color: #4a5568;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-hex,
|
||||||
|
.color-picker-rgb-input {
|
||||||
|
background: #1a202c;
|
||||||
|
border-color: #4a5568;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-rgb-item label {
|
||||||
|
color: #a0aec0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-rgb-slider {
|
||||||
|
background: #4a5568 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-rgb-slider::-webkit-slider-thumb,
|
||||||
|
.color-picker-rgb-slider::-moz-range-thumb {
|
||||||
|
background: #667eea;
|
||||||
|
border-color: #2d3748;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
474
docs/mockups/color-picker.js
Normal file
@@ -0,0 +1,474 @@
|
|||||||
|
/**
|
||||||
|
* Custom Color Picker Component
|
||||||
|
* Consistent across all operating systems and browsers
|
||||||
|
*/
|
||||||
|
|
||||||
|
class ColorPicker {
|
||||||
|
constructor(container, options = {}) {
|
||||||
|
this.container = typeof container === 'string' ? document.querySelector(container) : container;
|
||||||
|
this.options = {
|
||||||
|
initialColor: options.initialColor || '#FF0000',
|
||||||
|
onColorChange: options.onColorChange || null,
|
||||||
|
showHexInput: options.showHexInput !== false,
|
||||||
|
...options
|
||||||
|
};
|
||||||
|
this.currentColor = this.options.initialColor;
|
||||||
|
this.isOpen = false;
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this.createPicker();
|
||||||
|
this.setupEventListeners();
|
||||||
|
this.updateColor(this.options.initialColor);
|
||||||
|
}
|
||||||
|
|
||||||
|
createPicker() {
|
||||||
|
this.container.innerHTML = '';
|
||||||
|
this.container.className = 'color-picker-container';
|
||||||
|
|
||||||
|
// Color preview button
|
||||||
|
this.previewBtn = document.createElement('button');
|
||||||
|
this.previewBtn.className = 'color-picker-preview';
|
||||||
|
this.previewBtn.type = 'button';
|
||||||
|
this.previewBtn.style.backgroundColor = this.currentColor;
|
||||||
|
this.previewBtn.setAttribute('aria-label', 'Open color picker');
|
||||||
|
|
||||||
|
// Dropdown panel
|
||||||
|
this.panel = document.createElement('div');
|
||||||
|
this.panel.className = 'color-picker-panel';
|
||||||
|
this.panel.style.display = 'none';
|
||||||
|
|
||||||
|
// Main color area (hue/saturation)
|
||||||
|
this.mainArea = document.createElement('div');
|
||||||
|
this.mainArea.className = 'color-picker-main';
|
||||||
|
this.mainCanvas = document.createElement('canvas');
|
||||||
|
this.mainCanvas.width = 200;
|
||||||
|
this.mainCanvas.height = 200;
|
||||||
|
this.mainCanvas.className = 'color-picker-canvas';
|
||||||
|
this.mainArea.appendChild(this.mainCanvas);
|
||||||
|
|
||||||
|
// Main area cursor
|
||||||
|
this.mainCursor = document.createElement('div');
|
||||||
|
this.mainCursor.className = 'color-picker-cursor';
|
||||||
|
this.mainArea.appendChild(this.mainCursor);
|
||||||
|
|
||||||
|
// Hue slider
|
||||||
|
this.hueArea = document.createElement('div');
|
||||||
|
this.hueArea.className = 'color-picker-hue';
|
||||||
|
this.hueCanvas = document.createElement('canvas');
|
||||||
|
this.hueCanvas.width = 20;
|
||||||
|
this.hueCanvas.height = 200;
|
||||||
|
this.hueCanvas.className = 'color-picker-canvas';
|
||||||
|
this.hueArea.appendChild(this.hueCanvas);
|
||||||
|
|
||||||
|
// Hue slider cursor
|
||||||
|
this.hueCursor = document.createElement('div');
|
||||||
|
this.hueCursor.className = 'color-picker-hue-cursor';
|
||||||
|
this.hueArea.appendChild(this.hueCursor);
|
||||||
|
|
||||||
|
// Controls section
|
||||||
|
this.controls = document.createElement('div');
|
||||||
|
this.controls.className = 'color-picker-controls';
|
||||||
|
|
||||||
|
// Hex input
|
||||||
|
if (this.options.showHexInput) {
|
||||||
|
this.hexInput = document.createElement('input');
|
||||||
|
this.hexInput.type = 'text';
|
||||||
|
this.hexInput.className = 'color-picker-hex';
|
||||||
|
this.hexInput.placeholder = '#000000';
|
||||||
|
this.hexInput.maxLength = 7;
|
||||||
|
this.controls.appendChild(this.hexInput);
|
||||||
|
}
|
||||||
|
|
||||||
|
// RGB inputs and sliders
|
||||||
|
this.rgbContainer = document.createElement('div');
|
||||||
|
this.rgbContainer.className = 'color-picker-rgb';
|
||||||
|
|
||||||
|
['R', 'G', 'B'].forEach((label, index) => {
|
||||||
|
const wrapper = document.createElement('div');
|
||||||
|
wrapper.className = 'color-picker-rgb-item';
|
||||||
|
wrapper.dataset.channel = label.toLowerCase();
|
||||||
|
|
||||||
|
const labelEl = document.createElement('label');
|
||||||
|
labelEl.textContent = label;
|
||||||
|
|
||||||
|
const slider = document.createElement('input');
|
||||||
|
slider.type = 'range';
|
||||||
|
slider.className = 'color-picker-rgb-slider';
|
||||||
|
slider.min = 0;
|
||||||
|
slider.max = 255;
|
||||||
|
slider.value = 0;
|
||||||
|
slider.dataset.channel = label.toLowerCase();
|
||||||
|
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'number';
|
||||||
|
input.className = 'color-picker-rgb-input';
|
||||||
|
input.min = 0;
|
||||||
|
input.max = 255;
|
||||||
|
input.value = 0;
|
||||||
|
input.dataset.channel = label.toLowerCase();
|
||||||
|
|
||||||
|
wrapper.appendChild(labelEl);
|
||||||
|
wrapper.appendChild(slider);
|
||||||
|
wrapper.appendChild(input);
|
||||||
|
this.rgbContainer.appendChild(wrapper);
|
||||||
|
|
||||||
|
this[`rgb${label}Slider`] = slider;
|
||||||
|
this[`rgb${label}`] = input;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.controls.appendChild(this.rgbContainer);
|
||||||
|
|
||||||
|
// Assemble panel
|
||||||
|
const pickerArea = document.createElement('div');
|
||||||
|
pickerArea.className = 'color-picker-area';
|
||||||
|
pickerArea.appendChild(this.mainArea);
|
||||||
|
pickerArea.appendChild(this.hueArea);
|
||||||
|
|
||||||
|
this.panel.appendChild(pickerArea);
|
||||||
|
this.panel.appendChild(this.controls);
|
||||||
|
|
||||||
|
// Assemble container
|
||||||
|
this.container.appendChild(this.previewBtn);
|
||||||
|
this.container.appendChild(this.panel);
|
||||||
|
|
||||||
|
// Draw canvases
|
||||||
|
this.drawHueCanvas();
|
||||||
|
this.drawMainCanvas(1.0); // Start with full saturation
|
||||||
|
}
|
||||||
|
|
||||||
|
setupEventListeners() {
|
||||||
|
// Toggle panel
|
||||||
|
this.previewBtn.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
this.toggle();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close on outside click
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
if (!this.container.contains(e.target) && this.isOpen) {
|
||||||
|
this.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Main area interaction
|
||||||
|
let isMainDragging = false;
|
||||||
|
this.mainCanvas.addEventListener('mousedown', (e) => {
|
||||||
|
isMainDragging = true;
|
||||||
|
this.handleMainAreaClick(e);
|
||||||
|
});
|
||||||
|
this.mainCanvas.addEventListener('mousemove', (e) => {
|
||||||
|
if (isMainDragging) {
|
||||||
|
this.handleMainAreaClick(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.addEventListener('mouseup', () => {
|
||||||
|
isMainDragging = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Touch support for main area
|
||||||
|
this.mainCanvas.addEventListener('touchstart', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
isMainDragging = true;
|
||||||
|
this.handleMainAreaClick(e.touches[0]);
|
||||||
|
});
|
||||||
|
this.mainCanvas.addEventListener('touchmove', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (isMainDragging) {
|
||||||
|
this.handleMainAreaClick(e.touches[0]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.mainCanvas.addEventListener('touchend', () => {
|
||||||
|
isMainDragging = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Hue slider interaction
|
||||||
|
let isHueDragging = false;
|
||||||
|
this.hueCanvas.addEventListener('mousedown', (e) => {
|
||||||
|
isHueDragging = true;
|
||||||
|
this.handleHueClick(e);
|
||||||
|
});
|
||||||
|
this.hueCanvas.addEventListener('mousemove', (e) => {
|
||||||
|
if (isHueDragging) {
|
||||||
|
this.handleHueClick(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.addEventListener('mouseup', () => {
|
||||||
|
isHueDragging = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Touch support for hue slider
|
||||||
|
this.hueCanvas.addEventListener('touchstart', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
isHueDragging = true;
|
||||||
|
this.handleHueClick(e.touches[0]);
|
||||||
|
});
|
||||||
|
this.hueCanvas.addEventListener('touchmove', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (isHueDragging) {
|
||||||
|
this.handleHueClick(e.touches[0]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.hueCanvas.addEventListener('touchend', () => {
|
||||||
|
isHueDragging = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Hex input
|
||||||
|
if (this.hexInput) {
|
||||||
|
this.hexInput.addEventListener('input', (e) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
if (/^#[0-9A-Fa-f]{6}$/.test(value)) {
|
||||||
|
this.updateColor(value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.hexInput.addEventListener('blur', (e) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
if (!/^#[0-9A-Fa-f]{6}$/.test(value) && value.length > 0) {
|
||||||
|
e.target.value = this.currentColor;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// RGB inputs and sliders
|
||||||
|
['R', 'G', 'B'].forEach(label => {
|
||||||
|
// Slider change
|
||||||
|
this[`rgb${label}Slider`].addEventListener('input', (e) => {
|
||||||
|
const value = parseInt(e.target.value) || 0;
|
||||||
|
this[`rgb${label}`].value = value;
|
||||||
|
const r = parseInt(this.rgbR.value) || 0;
|
||||||
|
const g = parseInt(this.rgbG.value) || 0;
|
||||||
|
const b = parseInt(this.rgbB.value) || 0;
|
||||||
|
const hex = this.rgbToHex(r, g, b);
|
||||||
|
this.updateColor(hex, false); // Don't update RGB inputs/sliders to avoid loop
|
||||||
|
});
|
||||||
|
|
||||||
|
// Input change
|
||||||
|
this[`rgb${label}`].addEventListener('input', (e) => {
|
||||||
|
let value = parseInt(e.target.value) || 0;
|
||||||
|
value = Math.max(0, Math.min(255, value)); // Clamp to 0-255
|
||||||
|
e.target.value = value;
|
||||||
|
this[`rgb${label}Slider`].value = value;
|
||||||
|
const r = parseInt(this.rgbR.value) || 0;
|
||||||
|
const g = parseInt(this.rgbG.value) || 0;
|
||||||
|
const b = parseInt(this.rgbB.value) || 0;
|
||||||
|
const hex = this.rgbToHex(r, g, b);
|
||||||
|
this.updateColor(hex, false); // Don't update RGB inputs/sliders to avoid loop
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
drawHueCanvas() {
|
||||||
|
const ctx = this.hueCanvas.getContext('2d');
|
||||||
|
const gradient = ctx.createLinearGradient(0, 0, 0, 200);
|
||||||
|
|
||||||
|
for (let i = 0; i <= 6; i++) {
|
||||||
|
const hue = i * 60;
|
||||||
|
gradient.addColorStop(i / 6, `hsl(${hue}, 100%, 50%)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.fillStyle = gradient;
|
||||||
|
ctx.fillRect(0, 0, 20, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
drawMainCanvas(hue) {
|
||||||
|
const ctx = this.mainCanvas.getContext('2d');
|
||||||
|
|
||||||
|
// Saturation gradient (left to right)
|
||||||
|
const satGradient = ctx.createLinearGradient(0, 0, 200, 0);
|
||||||
|
satGradient.addColorStop(0, `hsl(${hue}, 0%, 50%)`);
|
||||||
|
satGradient.addColorStop(1, `hsl(${hue}, 100%, 50%)`);
|
||||||
|
ctx.fillStyle = satGradient;
|
||||||
|
ctx.fillRect(0, 0, 200, 200);
|
||||||
|
|
||||||
|
// Brightness gradient (top to bottom)
|
||||||
|
const brightGradient = ctx.createLinearGradient(0, 0, 0, 200);
|
||||||
|
brightGradient.addColorStop(0, 'rgba(255, 255, 255, 0)');
|
||||||
|
brightGradient.addColorStop(1, 'rgba(0, 0, 0, 1)');
|
||||||
|
ctx.fillStyle = brightGradient;
|
||||||
|
ctx.fillRect(0, 0, 200, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleMainAreaClick(e) {
|
||||||
|
const rect = this.mainCanvas.getBoundingClientRect();
|
||||||
|
const x = Math.max(0, Math.min(200, e.clientX - rect.left));
|
||||||
|
const y = Math.max(0, Math.min(200, e.clientY - rect.top));
|
||||||
|
|
||||||
|
const saturation = x / 200;
|
||||||
|
const brightness = 1 - (y / 200);
|
||||||
|
|
||||||
|
this.updateColorFromHSB(this.hue, saturation, brightness);
|
||||||
|
this.updateCursor(x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleHueClick(e) {
|
||||||
|
const rect = this.hueCanvas.getBoundingClientRect();
|
||||||
|
const y = Math.max(0, Math.min(200, e.clientY - rect.top));
|
||||||
|
const hue = (y / 200) * 360;
|
||||||
|
|
||||||
|
this.hue = hue;
|
||||||
|
this.drawMainCanvas(hue);
|
||||||
|
this.updateHueCursor(y);
|
||||||
|
|
||||||
|
// Recalculate color with new hue
|
||||||
|
const rect2 = this.mainCanvas.getBoundingClientRect();
|
||||||
|
const x = parseFloat(this.mainCursor.style.left) || 0;
|
||||||
|
const y2 = parseFloat(this.mainCursor.style.top) || 0;
|
||||||
|
const saturation = x / 200;
|
||||||
|
const brightness = 1 - (y2 / 200);
|
||||||
|
this.updateColorFromHSB(hue, saturation, brightness);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateColorFromHSB(h, s, v) {
|
||||||
|
const rgb = this.hsbToRgb(h, s, v);
|
||||||
|
const hex = this.rgbToHex(rgb.r, rgb.g, rgb.b);
|
||||||
|
this.updateColor(hex);
|
||||||
|
}
|
||||||
|
|
||||||
|
hsbToRgb(h, s, v) {
|
||||||
|
h = h / 360;
|
||||||
|
const i = Math.floor(h * 6);
|
||||||
|
const f = h * 6 - i;
|
||||||
|
const p = v * (1 - s);
|
||||||
|
const q = v * (1 - f * s);
|
||||||
|
const t = v * (1 - (1 - f) * s);
|
||||||
|
|
||||||
|
let r, g, b;
|
||||||
|
switch (i % 6) {
|
||||||
|
case 0: r = v; g = t; b = p; break;
|
||||||
|
case 1: r = q; g = v; b = p; break;
|
||||||
|
case 2: r = p; g = v; b = t; break;
|
||||||
|
case 3: r = p; g = q; b = v; break;
|
||||||
|
case 4: r = t; g = p; b = v; break;
|
||||||
|
case 5: r = v; g = p; b = q; break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
r: Math.round(r * 255),
|
||||||
|
g: Math.round(g * 255),
|
||||||
|
b: Math.round(b * 255)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
rgbToHex(r, g, b) {
|
||||||
|
return '#' + [r, g, b].map(x => {
|
||||||
|
const hex = x.toString(16);
|
||||||
|
return hex.length === 1 ? '0' + hex : hex;
|
||||||
|
}).join('').toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
hexToRgb(hex) {
|
||||||
|
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||||
|
return result ? {
|
||||||
|
r: parseInt(result[1], 16),
|
||||||
|
g: parseInt(result[2], 16),
|
||||||
|
b: parseInt(result[3], 16)
|
||||||
|
} : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
rgbToHsb(r, g, b) {
|
||||||
|
r /= 255;
|
||||||
|
g /= 255;
|
||||||
|
b /= 255;
|
||||||
|
|
||||||
|
const max = Math.max(r, g, b);
|
||||||
|
const min = Math.min(r, g, b);
|
||||||
|
const diff = max - min;
|
||||||
|
|
||||||
|
let h = 0;
|
||||||
|
if (diff !== 0) {
|
||||||
|
if (max === r) {
|
||||||
|
h = ((g - b) / diff) % 6) * 60;
|
||||||
|
} else if (max === g) {
|
||||||
|
h = ((b - r) / diff + 2) * 60;
|
||||||
|
} else {
|
||||||
|
h = ((r - g) / diff + 4) * 60;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (h < 0) h += 360;
|
||||||
|
|
||||||
|
const s = max === 0 ? 0 : diff / max;
|
||||||
|
const v = max;
|
||||||
|
|
||||||
|
return { h, s, v };
|
||||||
|
}
|
||||||
|
|
||||||
|
updateColor(hex, updateInputs = true) {
|
||||||
|
this.currentColor = hex.toUpperCase();
|
||||||
|
this.previewBtn.style.backgroundColor = this.currentColor;
|
||||||
|
|
||||||
|
const rgb = this.hexToRgb(this.currentColor);
|
||||||
|
if (!rgb) return;
|
||||||
|
|
||||||
|
const hsb = this.rgbToHsb(rgb.r, rgb.g, rgb.b);
|
||||||
|
this.hue = hsb.h;
|
||||||
|
|
||||||
|
// Update main canvas
|
||||||
|
this.drawMainCanvas(this.hue);
|
||||||
|
|
||||||
|
// Update cursors
|
||||||
|
const x = hsb.s * 200;
|
||||||
|
const y = (1 - hsb.v) * 200;
|
||||||
|
this.updateCursor(x, y);
|
||||||
|
this.updateHueCursor((this.hue / 360) * 200);
|
||||||
|
|
||||||
|
// Update inputs
|
||||||
|
if (updateInputs) {
|
||||||
|
if (this.hexInput) {
|
||||||
|
this.hexInput.value = this.currentColor;
|
||||||
|
}
|
||||||
|
if (this.rgbR) {
|
||||||
|
this.rgbR.value = rgb.r;
|
||||||
|
this.rgbG.value = rgb.g;
|
||||||
|
this.rgbB.value = rgb.b;
|
||||||
|
}
|
||||||
|
if (this.rgbRSlider) {
|
||||||
|
this.rgbRSlider.value = rgb.r;
|
||||||
|
this.rgbGSlider.value = rgb.g;
|
||||||
|
this.rgbBSlider.value = rgb.b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Callback
|
||||||
|
if (this.options.onColorChange) {
|
||||||
|
this.options.onColorChange(this.currentColor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateCursor(x, y) {
|
||||||
|
this.mainCursor.style.left = `${x}px`;
|
||||||
|
this.mainCursor.style.top = `${y}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateHueCursor(y) {
|
||||||
|
this.hueCursor.style.top = `${y}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
toggle() {
|
||||||
|
this.isOpen ? this.close() : this.open();
|
||||||
|
}
|
||||||
|
|
||||||
|
open() {
|
||||||
|
this.panel.style.display = 'block';
|
||||||
|
this.isOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
this.panel.style.display = 'none';
|
||||||
|
this.isOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
getColor() {
|
||||||
|
return this.currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
setColor(color) {
|
||||||
|
this.updateColor(color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export for use in other scripts
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = ColorPicker;
|
||||||
|
}
|
||||||
|
|
||||||
359
docs/mockups/dashboard.html
Normal file
@@ -0,0 +1,359 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>LED Driver - Dashboard</title>
|
||||||
|
<link rel="stylesheet" href="color-picker.css">
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 20px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 24px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h1 {
|
||||||
|
color: #667eea;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header p {
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||||
|
gap: 24px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 24px;
|
||||||
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 8px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card h2 {
|
||||||
|
color: #667eea;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pattern-selector {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pattern-btn {
|
||||||
|
padding: 16px;
|
||||||
|
border: 2px solid #e0e0e0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: white;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pattern-btn:hover {
|
||||||
|
border-color: #667eea;
|
||||||
|
background: #f0f0ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pattern-btn.active {
|
||||||
|
border-color: #667eea;
|
||||||
|
background: #667eea;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-group {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider {
|
||||||
|
width: 100%;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #e0e0e0;
|
||||||
|
outline: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #667eea;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider::-moz-range-thumb {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #667eea;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.value-display {
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-wrapper {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 12px 24px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: #667eea;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: #5568d3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: #e0e0e0;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: #d0d0d0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-list {
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-item {
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-status {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #4caf50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-status.offline {
|
||||||
|
background: #f44336;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-full {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<h1>LED Driver Control Panel</h1>
|
||||||
|
<p>Manage your LED devices and patterns</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
<!-- Pattern Selection -->
|
||||||
|
<div class="card">
|
||||||
|
<h2>Pattern Selection</h2>
|
||||||
|
<div class="pattern-selector">
|
||||||
|
<div class="pattern-btn active">On</div>
|
||||||
|
<div class="pattern-btn">Off</div>
|
||||||
|
<div class="pattern-btn">Blink</div>
|
||||||
|
<div class="pattern-btn">Chase</div>
|
||||||
|
<div class="pattern-btn">Circle</div>
|
||||||
|
<div class="pattern-btn">Pulse</div>
|
||||||
|
<div class="pattern-btn">Rainbow</div>
|
||||||
|
<div class="pattern-btn">Transition</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Brightness & Speed -->
|
||||||
|
<div class="card">
|
||||||
|
<h2>Brightness & Speed</h2>
|
||||||
|
<div class="slider-group">
|
||||||
|
<label>
|
||||||
|
Brightness
|
||||||
|
<span class="value-display" id="brightness-value">100</span>%
|
||||||
|
</label>
|
||||||
|
<input type="range" class="slider" id="brightness" min="0" max="100" value="100">
|
||||||
|
</div>
|
||||||
|
<div class="slider-group">
|
||||||
|
<label>
|
||||||
|
Delay
|
||||||
|
<span class="value-display" id="delay-value">100</span>ms
|
||||||
|
</label>
|
||||||
|
<input type="range" class="slider" id="delay" min="10" max="1000" value="100" step="10">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Color Selection -->
|
||||||
|
<div class="card">
|
||||||
|
<h2>Colors</h2>
|
||||||
|
<div class="color-picker-group">
|
||||||
|
<input type="color" class="color-input" value="#000000">
|
||||||
|
<input type="color" class="color-input" value="#FF0000">
|
||||||
|
<input type="color" class="color-input" value="#00FF00">
|
||||||
|
<input type="color" class="color-input" value="#0000FF">
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn btn-secondary btn-full">Add Color</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Device Status -->
|
||||||
|
<div class="card">
|
||||||
|
<h2>Connected Devices</h2>
|
||||||
|
<ul class="device-list">
|
||||||
|
<li class="device-item">
|
||||||
|
<div>
|
||||||
|
<strong>led-device1</strong>
|
||||||
|
<div style="font-size: 0.875rem; color: #666;">Group: group1</div>
|
||||||
|
</div>
|
||||||
|
<div class="device-status"></div>
|
||||||
|
</li>
|
||||||
|
<li class="device-item">
|
||||||
|
<div>
|
||||||
|
<strong>led-device2</strong>
|
||||||
|
<div style="font-size: 0.875rem; color: #666;">Group: group2</div>
|
||||||
|
</div>
|
||||||
|
<div class="device-status"></div>
|
||||||
|
</li>
|
||||||
|
<li class="device-item">
|
||||||
|
<div>
|
||||||
|
<strong>led-device3</strong>
|
||||||
|
<div style="font-size: 0.875rem; color: #666;">No group</div>
|
||||||
|
</div>
|
||||||
|
<div class="device-status offline"></div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action Buttons -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn btn-primary btn-full">Apply Settings</button>
|
||||||
|
<button class="btn btn-secondary btn-full">Save to Device</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Brightness slider
|
||||||
|
document.getElementById('brightness').addEventListener('input', function(e) {
|
||||||
|
document.getElementById('brightness-value').textContent = e.target.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delay slider
|
||||||
|
document.getElementById('delay').addEventListener('input', function(e) {
|
||||||
|
document.getElementById('delay-value').textContent = e.target.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pattern selection
|
||||||
|
document.querySelectorAll('.pattern-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', function() {
|
||||||
|
document.querySelectorAll('.pattern-btn').forEach(b => b.classList.remove('active'));
|
||||||
|
this.classList.add('active');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize color pickers
|
||||||
|
const colorPickers = [];
|
||||||
|
const initialColors = ['#000000', '#FF0000'];
|
||||||
|
|
||||||
|
function addColorPicker(color = '#000000') {
|
||||||
|
const container = document.createElement('div');
|
||||||
|
container.className = 'color-picker-wrapper';
|
||||||
|
document.getElementById('color-pickers').appendChild(container);
|
||||||
|
|
||||||
|
const picker = new ColorPicker(container, {
|
||||||
|
initialColor: color,
|
||||||
|
onColorChange: (newColor) => {
|
||||||
|
console.log('Color changed:', newColor);
|
||||||
|
// Update device colors
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
colorPickers.push(picker);
|
||||||
|
return picker;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add initial color pickers
|
||||||
|
initialColors.forEach(color => addColorPicker(color));
|
||||||
|
</script>
|
||||||
|
<script src="color-picker.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
418
docs/mockups/device-management.html
Normal file
@@ -0,0 +1,418 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>LED Driver - Device Management</title>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 24px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h1 {
|
||||||
|
color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 12px 24px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: #667eea;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: #5568d3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
background: white;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
flex: 1;
|
||||||
|
padding: 12px 24px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab.active {
|
||||||
|
background: #667eea;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 24px;
|
||||||
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card h2 {
|
||||||
|
color: #667eea;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-item, .group-item {
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-item:hover, .group-item:hover {
|
||||||
|
border-color: #667eea;
|
||||||
|
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-info, .group-info {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-name, .group-name {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-details, .group-details {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-right: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-online {
|
||||||
|
background: #d4edda;
|
||||||
|
color: #155724;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-offline {
|
||||||
|
background: #f8d7da;
|
||||||
|
color: #721c24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #4caf50;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator.offline {
|
||||||
|
background: #f44336;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-actions, .group-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
background: white;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon:hover {
|
||||||
|
border-color: #667eea;
|
||||||
|
color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input, .form-group select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input:focus, .form-group select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: #e0e0e0;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: #d0d0d0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-devices {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-device-tag {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: #f0f0f0;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
margin-right: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<h1>Device & Group Management</h1>
|
||||||
|
<button class="btn btn-primary" onclick="showAddDeviceModal()">+ Add Device</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tabs">
|
||||||
|
<button class="tab active" onclick="switchTab('devices')">Devices</button>
|
||||||
|
<button class="tab" onclick="switchTab('groups')">Groups</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Devices Tab -->
|
||||||
|
<div id="devices-tab" class="tab-content active">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Connected Devices</h2>
|
||||||
|
<div class="device-item">
|
||||||
|
<div class="device-info">
|
||||||
|
<div class="device-name">
|
||||||
|
<span class="status-indicator"></span>
|
||||||
|
led-device1
|
||||||
|
</div>
|
||||||
|
<div class="device-details">
|
||||||
|
<span class="status-badge status-online">Online</span>
|
||||||
|
MAC: AA:BB:CC:DD:EE:01 | Group: group1 | Pattern: Rainbow
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="device-actions">
|
||||||
|
<button class="btn-icon" title="Edit">✏️</button>
|
||||||
|
<button class="btn-icon" title="Settings">⚙️</button>
|
||||||
|
<button class="btn-icon" title="Remove">🗑️</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="device-item">
|
||||||
|
<div class="device-info">
|
||||||
|
<div class="device-name">
|
||||||
|
<span class="status-indicator"></span>
|
||||||
|
led-device2
|
||||||
|
</div>
|
||||||
|
<div class="device-details">
|
||||||
|
<span class="status-badge status-online">Online</span>
|
||||||
|
MAC: AA:BB:CC:DD:EE:02 | Group: group2 | Pattern: Chase
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="device-actions">
|
||||||
|
<button class="btn-icon" title="Edit">✏️</button>
|
||||||
|
<button class="btn-icon" title="Settings">⚙️</button>
|
||||||
|
<button class="btn-icon" title="Remove">🗑️</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="device-item">
|
||||||
|
<div class="device-info">
|
||||||
|
<div class="device-name">
|
||||||
|
<span class="status-indicator offline"></span>
|
||||||
|
led-device3
|
||||||
|
</div>
|
||||||
|
<div class="device-details">
|
||||||
|
<span class="status-badge status-offline">Offline</span>
|
||||||
|
MAC: AA:BB:CC:DD:EE:03 | No group | Pattern: On
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="device-actions">
|
||||||
|
<button class="btn-icon" title="Edit">✏️</button>
|
||||||
|
<button class="btn-icon" title="Settings">⚙️</button>
|
||||||
|
<button class="btn-icon" title="Remove">🗑️</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Groups Tab -->
|
||||||
|
<div id="groups-tab" class="tab-content">
|
||||||
|
<div class="card">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
|
||||||
|
<h2>Groups</h2>
|
||||||
|
<button class="btn btn-primary" onclick="showAddGroupModal()">+ Create Group</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="group-item">
|
||||||
|
<div class="group-info">
|
||||||
|
<div class="group-name">group1</div>
|
||||||
|
<div class="group-details">
|
||||||
|
Pattern: On | Brightness: 100% | Delay: 100ms
|
||||||
|
</div>
|
||||||
|
<div class="group-devices">
|
||||||
|
<span class="group-device-tag">led-device1</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="group-actions">
|
||||||
|
<button class="btn-icon" title="Edit">✏️</button>
|
||||||
|
<button class="btn-icon" title="Apply">▶️</button>
|
||||||
|
<button class="btn-icon" title="Delete">🗑️</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="group-item">
|
||||||
|
<div class="group-info">
|
||||||
|
<div class="group-name">group2</div>
|
||||||
|
<div class="group-details">
|
||||||
|
Pattern: Chase | Brightness: 75% | Delay: 200ms
|
||||||
|
</div>
|
||||||
|
<div class="group-devices">
|
||||||
|
<span class="group-device-tag">led-device2</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="group-actions">
|
||||||
|
<button class="btn-icon" title="Edit">✏️</button>
|
||||||
|
<button class="btn-icon" title="Apply">▶️</button>
|
||||||
|
<button class="btn-icon" title="Delete">🗑️</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal (simplified) -->
|
||||||
|
<div id="modal" style="display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); z-index: 1000; align-items: center; justify-content: center;">
|
||||||
|
<div class="card" style="max-width: 500px; margin: 20px;">
|
||||||
|
<h2 id="modal-title">Add Device</h2>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Device Name</label>
|
||||||
|
<input type="text" id="device-name" placeholder="led-device4">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>MAC Address</label>
|
||||||
|
<input type="text" id="device-mac" placeholder="AA:BB:CC:DD:EE:04">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Group</label>
|
||||||
|
<select id="device-group">
|
||||||
|
<option value="">No group</option>
|
||||||
|
<option value="group1">group1</option>
|
||||||
|
<option value="group2">group2</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="btn btn-secondary" onclick="closeModal()">Cancel</button>
|
||||||
|
<button class="btn btn-primary" onclick="saveDevice()">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function switchTab(tab) {
|
||||||
|
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||||||
|
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
|
||||||
|
|
||||||
|
event.target.classList.add('active');
|
||||||
|
document.getElementById(tab + '-tab').classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function showAddDeviceModal() {
|
||||||
|
document.getElementById('modal').style.display = 'flex';
|
||||||
|
document.getElementById('modal-title').textContent = 'Add Device';
|
||||||
|
}
|
||||||
|
|
||||||
|
function showAddGroupModal() {
|
||||||
|
document.getElementById('modal').style.display = 'flex';
|
||||||
|
document.getElementById('modal-title').textContent = 'Create Group';
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
document.getElementById('modal').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveDevice() {
|
||||||
|
alert('Device saved! (This is a mockup)');
|
||||||
|
closeModal();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
155
docs/mockups/generate_images.py
Executable file
@@ -0,0 +1,155 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Generate images from HTML mockup files
|
||||||
|
Uses Playwright to render HTML and take screenshots
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
try:
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
PLAYWRIGHT_AVAILABLE = True
|
||||||
|
except ImportError:
|
||||||
|
PLAYWRIGHT_AVAILABLE = False
|
||||||
|
|
||||||
|
try:
|
||||||
|
from selenium import webdriver
|
||||||
|
from selenium.webdriver.chrome.options import Options
|
||||||
|
from selenium.webdriver.chrome.service import Service
|
||||||
|
SELENIUM_AVAILABLE = True
|
||||||
|
except ImportError:
|
||||||
|
SELENIUM_AVAILABLE = False
|
||||||
|
|
||||||
|
try:
|
||||||
|
from html2image import Html2Image
|
||||||
|
HTML2IMAGE_AVAILABLE = True
|
||||||
|
except ImportError:
|
||||||
|
HTML2IMAGE_AVAILABLE = False
|
||||||
|
|
||||||
|
|
||||||
|
def generate_with_playwright(html_file, output_file, width=1920, height=1080):
|
||||||
|
"""Generate image using Playwright"""
|
||||||
|
with sync_playwright() as p:
|
||||||
|
browser = p.chromium.launch(headless=True)
|
||||||
|
page = browser.new_page(viewport={'width': width, 'height': height})
|
||||||
|
page.goto(f'file://{html_file.absolute()}')
|
||||||
|
# Wait for page to load
|
||||||
|
page.wait_for_timeout(1000)
|
||||||
|
page.screenshot(path=str(output_file), full_page=True)
|
||||||
|
browser.close()
|
||||||
|
print(f"✓ Generated {output_file.name} using Playwright")
|
||||||
|
|
||||||
|
|
||||||
|
def generate_with_selenium(html_file, output_file, width=1920, height=1080):
|
||||||
|
"""Generate image using Selenium"""
|
||||||
|
chrome_options = Options()
|
||||||
|
chrome_options.add_argument('--headless')
|
||||||
|
chrome_options.add_argument('--no-sandbox')
|
||||||
|
chrome_options.add_argument('--disable-dev-shm-usage')
|
||||||
|
chrome_options.add_argument(f'--window-size={width},{height}')
|
||||||
|
|
||||||
|
driver = webdriver.Chrome(options=chrome_options)
|
||||||
|
try:
|
||||||
|
driver.get(f'file://{html_file.absolute()}')
|
||||||
|
# Wait for page to load
|
||||||
|
import time
|
||||||
|
time.sleep(2)
|
||||||
|
driver.save_screenshot(str(output_file))
|
||||||
|
print(f"✓ Generated {output_file.name} using Selenium")
|
||||||
|
finally:
|
||||||
|
driver.quit()
|
||||||
|
|
||||||
|
|
||||||
|
def generate_with_html2image(html_file, output_file, width=1920, height=1080):
|
||||||
|
"""Generate image using html2image"""
|
||||||
|
hti = Html2Image(size=(width, height))
|
||||||
|
hti.screenshot(
|
||||||
|
html_file=str(html_file),
|
||||||
|
save_as=output_file.name,
|
||||||
|
size=(width, height)
|
||||||
|
)
|
||||||
|
print(f"✓ Generated {output_file.name} using html2image")
|
||||||
|
|
||||||
|
|
||||||
|
def generate_image(html_file, output_dir, width=1920, height=1080):
|
||||||
|
"""Generate image from HTML file using available method"""
|
||||||
|
html_path = Path(html_file)
|
||||||
|
output_path = output_dir / f"{html_path.stem}.png"
|
||||||
|
|
||||||
|
if PLAYWRIGHT_AVAILABLE:
|
||||||
|
try:
|
||||||
|
generate_with_playwright(html_path, output_path, width, height)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Playwright failed: {e}, trying alternatives...")
|
||||||
|
|
||||||
|
if SELENIUM_AVAILABLE:
|
||||||
|
try:
|
||||||
|
generate_with_selenium(html_path, output_path, width, height)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Selenium failed: {e}, trying alternatives...")
|
||||||
|
|
||||||
|
if HTML2IMAGE_AVAILABLE:
|
||||||
|
try:
|
||||||
|
generate_with_html2image(html_path, output_path, width, height)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f"html2image failed: {e}")
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Main function to generate images from all HTML files"""
|
||||||
|
script_dir = Path(__file__).parent
|
||||||
|
output_dir = script_dir / "images"
|
||||||
|
output_dir.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
html_files = list(script_dir.glob("*.html"))
|
||||||
|
|
||||||
|
if not html_files:
|
||||||
|
print("No HTML files found in mockups directory")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"Found {len(html_files)} HTML file(s)")
|
||||||
|
print(f"Output directory: {output_dir}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Check available libraries
|
||||||
|
if not any([PLAYWRIGHT_AVAILABLE, SELENIUM_AVAILABLE, HTML2IMAGE_AVAILABLE]):
|
||||||
|
print("ERROR: No screenshot library available!")
|
||||||
|
print("\nPlease install one of the following:")
|
||||||
|
print(" pip install playwright && playwright install chromium")
|
||||||
|
print(" pip install selenium")
|
||||||
|
print(" pip install html2image")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print("Available screenshot libraries:")
|
||||||
|
if PLAYWRIGHT_AVAILABLE:
|
||||||
|
print(" ✓ Playwright")
|
||||||
|
if SELENIUM_AVAILABLE:
|
||||||
|
print(" ✓ Selenium")
|
||||||
|
if HTML2IMAGE_AVAILABLE:
|
||||||
|
print(" ✓ html2image")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Generate images
|
||||||
|
success_count = 0
|
||||||
|
for html_file in html_files:
|
||||||
|
print(f"Generating image from {html_file.name}...")
|
||||||
|
if generate_image(html_file, output_dir):
|
||||||
|
success_count += 1
|
||||||
|
else:
|
||||||
|
print(f"✗ Failed to generate image from {html_file.name}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
print(f"Successfully generated {success_count}/{len(html_files)} images")
|
||||||
|
print(f"Images saved to: {output_dir}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
||||||
BIN
docs/mockups/images/color-picker-demo.png
Normal file
|
After Width: | Height: | Size: 625 KiB |
BIN
docs/mockups/images/dashboard.png
Normal file
|
After Width: | Height: | Size: 600 KiB |
BIN
docs/mockups/images/device-management.png
Normal file
|
After Width: | Height: | Size: 585 KiB |
BIN
docs/mockups/images/index.png
Normal file
|
After Width: | Height: | Size: 714 KiB |
BIN
docs/mockups/images/pattern-selector.png
Normal file
|
After Width: | Height: | Size: 508 KiB |
BIN
docs/mockups/images/presets.png
Normal file
|
After Width: | Height: | Size: 562 KiB |
BIN
docs/mockups/images/settings.png
Normal file
|
After Width: | Height: | Size: 904 KiB |
136
docs/mockups/index.html
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>LED Driver - UI Mockups</title>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 40px 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 800px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
text-align: center;
|
||||||
|
color: white;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h1 {
|
||||||
|
font-size: 3rem;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header p {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockups-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 32px;
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
|
||||||
|
transition: all 0.3s;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-card:hover {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-icon {
|
||||||
|
font-size: 3rem;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-title {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #667eea;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-description {
|
||||||
|
color: #666;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<h1>LED Driver UI Mockups</h1>
|
||||||
|
<p>Example user interfaces for the LED driver system</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mockups-grid">
|
||||||
|
<a href="dashboard.html" class="mockup-card">
|
||||||
|
<div class="mockup-icon">📊</div>
|
||||||
|
<div class="mockup-title">Dashboard</div>
|
||||||
|
<div class="mockup-description">
|
||||||
|
Main control panel for managing LED patterns, brightness, colors, and device status.
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a href="pattern-selector.html" class="mockup-card">
|
||||||
|
<div class="mockup-icon">🎨</div>
|
||||||
|
<div class="mockup-title">Pattern Selector</div>
|
||||||
|
<div class="mockup-description">
|
||||||
|
Visual interface for selecting from available LED patterns: On, Off, Blink, Chase, Circle, Pulse, Rainbow, and Transition.
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a href="device-management.html" class="mockup-card">
|
||||||
|
<div class="mockup-icon">🔧</div>
|
||||||
|
<div class="mockup-title">Device Management</div>
|
||||||
|
<div class="mockup-description">
|
||||||
|
Manage connected LED devices and groups. View device status, assign groups, and configure device settings.
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a href="settings.html" class="mockup-card">
|
||||||
|
<div class="mockup-icon">⚙️</div>
|
||||||
|
<div class="mockup-title">Settings</div>
|
||||||
|
<div class="mockup-description">
|
||||||
|
Comprehensive settings panel for configuring LED pin, color order, pattern parameters, and network settings.
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a href="presets.html" class="mockup-card">
|
||||||
|
<div class="mockup-icon">💾</div>
|
||||||
|
<div class="mockup-title">Presets</div>
|
||||||
|
<div class="mockup-description">
|
||||||
|
Save, load, and manage preset configurations with pattern, colors, delay, and all N1-N8 parameters for quick pattern switching.
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
310
docs/mockups/pattern-selector.html
Normal file
@@ -0,0 +1,310 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>LED Driver - Pattern Selector</title>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||||
|
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
color: #2d3748;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h1 {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header p {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
color: #718096;
|
||||||
|
}
|
||||||
|
|
||||||
|
.patterns-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pattern-card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 24px;
|
||||||
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s;
|
||||||
|
border: 3px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pattern-card:hover {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.15);
|
||||||
|
border-color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pattern-card.selected {
|
||||||
|
border-color: #667eea;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pattern-icon {
|
||||||
|
width: 100%;
|
||||||
|
height: 180px;
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 3rem;
|
||||||
|
background: #f7fafc;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pattern-card.selected .pattern-icon {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pattern-name {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pattern-description {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #718096;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pattern-card.selected .pattern-description {
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pattern-preview {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-dot {
|
||||||
|
flex: 1;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pattern-card.selected .preview-dot {
|
||||||
|
background: rgba(255, 255, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
margin-top: 40px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 16px 48px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
margin: 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: #667eea;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: #5568d3;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 8px 16px rgba(102, 126, 234, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: white;
|
||||||
|
color: #667eea;
|
||||||
|
border: 2px solid #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: #f0f0ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pattern-specific icons */
|
||||||
|
.icon-on { background: linear-gradient(135deg, #ffd700 0%, #ffed4e 100%); }
|
||||||
|
.icon-off { background: #2d3748; }
|
||||||
|
.icon-blink { background: linear-gradient(90deg, #ffd700 25%, #2d3748 25%, #2d3748 50%, #ffd700 50%); }
|
||||||
|
.icon-chase { background: linear-gradient(90deg, #ff0000 0%, #ff6666 50%, #ff0000 100%); }
|
||||||
|
.icon-circle { background: radial-gradient(circle, #00ff00 0%, #66ff66 50%, #00ff00 100%); }
|
||||||
|
.icon-pulse { background: radial-gradient(circle, #0000ff 0%, #6666ff 50%, #0000ff 100%); }
|
||||||
|
.icon-rainbow { background: linear-gradient(90deg, #ff0000, #ff7f00, #ffff00, #00ff00, #0000ff, #4b0082, #9400d3); }
|
||||||
|
.icon-transition { background: linear-gradient(135deg, #ff0000 0%, #0000ff 100%); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<h1>Select LED Pattern</h1>
|
||||||
|
<p>Choose a pattern to display on your LED devices</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="patterns-grid">
|
||||||
|
<div class="pattern-card" data-pattern="on">
|
||||||
|
<div class="pattern-icon icon-on">💡</div>
|
||||||
|
<div class="pattern-name">On</div>
|
||||||
|
<div class="pattern-description">Solid color display - LEDs stay on with selected color</div>
|
||||||
|
<div class="pattern-preview">
|
||||||
|
<div class="preview-dot"></div>
|
||||||
|
<div class="preview-dot"></div>
|
||||||
|
<div class="preview-dot"></div>
|
||||||
|
<div class="preview-dot"></div>
|
||||||
|
<div class="preview-dot"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pattern-card" data-pattern="off">
|
||||||
|
<div class="pattern-icon icon-off">⚫</div>
|
||||||
|
<div class="pattern-name">Off</div>
|
||||||
|
<div class="pattern-description">Turn all LEDs off</div>
|
||||||
|
<div class="pattern-preview">
|
||||||
|
<div class="preview-dot"></div>
|
||||||
|
<div class="preview-dot"></div>
|
||||||
|
<div class="preview-dot"></div>
|
||||||
|
<div class="preview-dot"></div>
|
||||||
|
<div class="preview-dot"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pattern-card" data-pattern="blink">
|
||||||
|
<div class="pattern-icon icon-blink">✨</div>
|
||||||
|
<div class="pattern-name">Blink</div>
|
||||||
|
<div class="pattern-description">All LEDs blink on and off together</div>
|
||||||
|
<div class="pattern-preview">
|
||||||
|
<div class="preview-dot"></div>
|
||||||
|
<div class="preview-dot"></div>
|
||||||
|
<div class="preview-dot"></div>
|
||||||
|
<div class="preview-dot"></div>
|
||||||
|
<div class="preview-dot"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pattern-card" data-pattern="chase">
|
||||||
|
<div class="pattern-icon icon-chase">🏃</div>
|
||||||
|
<div class="pattern-name">Chase</div>
|
||||||
|
<div class="pattern-description">Light chases along the LED strip</div>
|
||||||
|
<div class="pattern-preview">
|
||||||
|
<div class="preview-dot"></div>
|
||||||
|
<div class="preview-dot"></div>
|
||||||
|
<div class="preview-dot"></div>
|
||||||
|
<div class="preview-dot"></div>
|
||||||
|
<div class="preview-dot"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pattern-card" data-pattern="circle">
|
||||||
|
<div class="pattern-icon icon-circle">⭕</div>
|
||||||
|
<div class="pattern-name">Circle</div>
|
||||||
|
<div class="pattern-description">Circular pattern that rotates around the strip</div>
|
||||||
|
<div class="pattern-preview">
|
||||||
|
<div class="preview-dot"></div>
|
||||||
|
<div class="preview-dot"></div>
|
||||||
|
<div class="preview-dot"></div>
|
||||||
|
<div class="preview-dot"></div>
|
||||||
|
<div class="preview-dot"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pattern-card" data-pattern="pulse">
|
||||||
|
<div class="pattern-icon icon-pulse">💓</div>
|
||||||
|
<div class="pattern-name">Pulse</div>
|
||||||
|
<div class="pattern-description">Pulsing effect that fades in and out</div>
|
||||||
|
<div class="pattern-preview">
|
||||||
|
<div class="preview-dot"></div>
|
||||||
|
<div class="preview-dot"></div>
|
||||||
|
<div class="preview-dot"></div>
|
||||||
|
<div class="preview-dot"></div>
|
||||||
|
<div class="preview-dot"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pattern-card" data-pattern="rainbow">
|
||||||
|
<div class="pattern-icon icon-rainbow">🌈</div>
|
||||||
|
<div class="pattern-name">Rainbow</div>
|
||||||
|
<div class="pattern-description">Smooth rainbow color transition across LEDs</div>
|
||||||
|
<div class="pattern-preview">
|
||||||
|
<div class="preview-dot"></div>
|
||||||
|
<div class="preview-dot"></div>
|
||||||
|
<div class="preview-dot"></div>
|
||||||
|
<div class="preview-dot"></div>
|
||||||
|
<div class="preview-dot"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pattern-card" data-pattern="transition">
|
||||||
|
<div class="pattern-icon icon-transition">🔄</div>
|
||||||
|
<div class="pattern-name">Transition</div>
|
||||||
|
<div class="pattern-description">Smooth color transition between selected colors</div>
|
||||||
|
<div class="pattern-preview">
|
||||||
|
<div class="preview-dot"></div>
|
||||||
|
<div class="preview-dot"></div>
|
||||||
|
<div class="preview-dot"></div>
|
||||||
|
<div class="preview-dot"></div>
|
||||||
|
<div class="preview-dot"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn btn-secondary" onclick="window.history.back()">Cancel</button>
|
||||||
|
<button class="btn btn-primary" id="apply-btn">Apply Pattern</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let selectedPattern = null;
|
||||||
|
|
||||||
|
document.querySelectorAll('.pattern-card').forEach(card => {
|
||||||
|
card.addEventListener('click', function() {
|
||||||
|
document.querySelectorAll('.pattern-card').forEach(c => c.classList.remove('selected'));
|
||||||
|
this.classList.add('selected');
|
||||||
|
selectedPattern = this.dataset.pattern;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('apply-btn').addEventListener('click', function() {
|
||||||
|
if (selectedPattern) {
|
||||||
|
alert(`Applying pattern: ${selectedPattern}`);
|
||||||
|
// In real implementation, this would send the pattern to the device
|
||||||
|
} else {
|
||||||
|
alert('Please select a pattern first');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
968
docs/mockups/presets.html
Normal file
@@ -0,0 +1,968 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>LED Driver - Presets</title>
|
||||||
|
<link rel="stylesheet" href="color-picker.css">
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 20px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 24px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h1 {
|
||||||
|
color: #667eea;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header p {
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-box {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 200px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-box:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-select {
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
background: white;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-toggle {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-btn {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border: none;
|
||||||
|
background: white;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-btn.active {
|
||||||
|
background: #667eea;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.presets-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||||
|
gap: 24px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 24px;
|
||||||
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
transition: all 0.3s;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 3px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-card:hover {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.15);
|
||||||
|
border-color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-card.selected {
|
||||||
|
border-color: #667eea;
|
||||||
|
background: linear-gradient(135deg, rgba(102, 126, 234, 0.1) 0%, rgba(118, 75, 162, 0.1) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: start;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-name {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pattern-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
background: #667eea;
|
||||||
|
color: white;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pattern-badge.on { background: #4caf50; }
|
||||||
|
.pattern-badge.off { background: #757575; }
|
||||||
|
.pattern-badge.blink { background: #ff9800; }
|
||||||
|
.pattern-badge.chase { background: #f44336; }
|
||||||
|
.pattern-badge.circle { background: #00bcd4; }
|
||||||
|
.pattern-badge.pulse { background: #e91e63; }
|
||||||
|
.pattern-badge.rainbow { background: linear-gradient(90deg, #ff0000, #ff7f00, #ffff00, #00ff00, #0000ff, #4b0082, #9400d3); }
|
||||||
|
.pattern-badge.transition { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); }
|
||||||
|
|
||||||
|
.color-preview {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-swatch {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 2px solid #e0e0e0;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-info {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-label {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-value {
|
||||||
|
color: #667eea;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 16px;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px solid #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 10px 20px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: #667eea;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: #5568d3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: #e0e0e0;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: #d0d0d0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: #f44336;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover {
|
||||||
|
background: #d32f2f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon {
|
||||||
|
padding: 8px;
|
||||||
|
min-width: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-large {
|
||||||
|
padding: 16px 32px;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal Styles */
|
||||||
|
.modal {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
z-index: 1000;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal.active {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 32px;
|
||||||
|
max-width: 600px;
|
||||||
|
width: 100%;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header h2 {
|
||||||
|
color: #667eea;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input,
|
||||||
|
.form-group select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input:focus,
|
||||||
|
.form-group select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group small {
|
||||||
|
display: block;
|
||||||
|
margin-top: 4px;
|
||||||
|
color: #666;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-inputs {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-input-wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-input-wrapper {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.params-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.n-value-input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.n-value-input:focus {
|
||||||
|
border-color: #667eea;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.param-input {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.param-input label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.param-input input {
|
||||||
|
padding: 8px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 24px;
|
||||||
|
padding-top: 24px;
|
||||||
|
border-top: 1px solid #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 60px 40px;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state-icon {
|
||||||
|
font-size: 4rem;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state h2 {
|
||||||
|
color: #667eea;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state p {
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<div>
|
||||||
|
<h1>Preset Management</h1>
|
||||||
|
<p>Save and manage your favorite LED pattern configurations</p>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; gap: 12px; align-items: center;">
|
||||||
|
<button class="btn btn-secondary btn-large" onclick="syncPresets()" title="Sync all presets to all devices">🔄 Sync Presets to All Devices</button>
|
||||||
|
<button class="btn btn-primary btn-large" onclick="showCreateModal()">+ Create Preset</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="controls">
|
||||||
|
<input type="text" class="search-box" placeholder="Search presets..." id="search-input">
|
||||||
|
<div class="filter-group">
|
||||||
|
<select class="filter-select" id="pattern-filter">
|
||||||
|
<option value="">All Patterns</option>
|
||||||
|
<option value="on">On</option>
|
||||||
|
<option value="off">Off</option>
|
||||||
|
<option value="blink">Blink</option>
|
||||||
|
<option value="chase">Chase</option>
|
||||||
|
<option value="circle">Circle</option>
|
||||||
|
<option value="pulse">Pulse</option>
|
||||||
|
<option value="rainbow">Rainbow</option>
|
||||||
|
<option value="transition">Transition</option>
|
||||||
|
</select>
|
||||||
|
<select class="filter-select" id="sort-select">
|
||||||
|
<option value="name">Sort by Name</option>
|
||||||
|
<option value="recent">Recently Used</option>
|
||||||
|
<option value="created">Recently Created</option>
|
||||||
|
</select>
|
||||||
|
<div class="view-toggle">
|
||||||
|
<button class="view-btn active" onclick="setView('grid')" id="view-grid">Grid</button>
|
||||||
|
<button class="view-btn" onclick="setView('list')" id="view-list">List</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="presets-grid" id="presets-container">
|
||||||
|
<!-- Preset Card 1 -->
|
||||||
|
<div class="preset-card" data-pattern="rainbow" data-name="Fast Rainbow">
|
||||||
|
<div class="preset-header">
|
||||||
|
<div>
|
||||||
|
<div class="preset-name">Fast Rainbow</div>
|
||||||
|
<span class="pattern-badge rainbow">Rainbow</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="color-preview">
|
||||||
|
<div class="color-swatch" style="background: #FF0000;"></div>
|
||||||
|
<div class="color-swatch" style="background: #00FF00;"></div>
|
||||||
|
<div class="color-swatch" style="background: #0000FF;"></div>
|
||||||
|
</div>
|
||||||
|
<div class="preset-info">
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">Delay:</span>
|
||||||
|
<span class="info-value">30ms</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">N1:</span>
|
||||||
|
<span class="info-value">10</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="preset-actions">
|
||||||
|
<button class="btn btn-primary" onclick="applyPreset('Fast Rainbow')">Apply</button>
|
||||||
|
<button class="btn btn-secondary btn-icon" onclick="editPreset('Fast Rainbow')" title="Edit">✏️</button>
|
||||||
|
<button class="btn btn-danger btn-icon" onclick="deletePreset('Fast Rainbow')" title="Delete">🗑️</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Preset Card 2 -->
|
||||||
|
<div class="preset-card" data-pattern="pulse" data-name="Slow Pulse">
|
||||||
|
<div class="preset-header">
|
||||||
|
<div>
|
||||||
|
<div class="preset-name">Slow Pulse</div>
|
||||||
|
<span class="pattern-badge pulse">Pulse</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="color-preview">
|
||||||
|
<div class="color-swatch" style="background: #FF0000;"></div>
|
||||||
|
<div class="color-swatch" style="background: #0000FF;"></div>
|
||||||
|
</div>
|
||||||
|
<div class="preset-info">
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">Delay:</span>
|
||||||
|
<span class="info-value">200ms</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">N1:</span>
|
||||||
|
<span class="info-value">500</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="preset-actions">
|
||||||
|
<button class="btn btn-primary" onclick="applyPreset('Slow Pulse')">Apply</button>
|
||||||
|
<button class="btn btn-secondary btn-icon" onclick="editPreset('Slow Pulse')" title="Edit">✏️</button>
|
||||||
|
<button class="btn btn-danger btn-icon" onclick="deletePreset('Slow Pulse')" title="Delete">🗑️</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Preset Card 3 -->
|
||||||
|
<div class="preset-card" data-pattern="chase" data-name="Red Blue Chase">
|
||||||
|
<div class="preset-header">
|
||||||
|
<div>
|
||||||
|
<div class="preset-name">Red Blue Chase</div>
|
||||||
|
<span class="pattern-badge chase">Chase</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="color-preview">
|
||||||
|
<div class="color-swatch" style="background: #FF0000;"></div>
|
||||||
|
<div class="color-swatch" style="background: #0000FF;"></div>
|
||||||
|
</div>
|
||||||
|
<div class="preset-info">
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">Delay:</span>
|
||||||
|
<span class="info-value">100ms</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">N1:</span>
|
||||||
|
<span class="info-value">5</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="preset-actions">
|
||||||
|
<button class="btn btn-primary" onclick="applyPreset('Red Blue Chase')">Apply</button>
|
||||||
|
<button class="btn btn-secondary btn-icon" onclick="editPreset('Red Blue Chase')" title="Edit">✏️</button>
|
||||||
|
<button class="btn btn-danger btn-icon" onclick="deletePreset('Red Blue Chase')" title="Delete">🗑️</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Preset Card 4 -->
|
||||||
|
<div class="preset-card" data-pattern="circle" data-name="Loading Circle">
|
||||||
|
<div class="preset-header">
|
||||||
|
<div>
|
||||||
|
<div class="preset-name">Loading Circle</div>
|
||||||
|
<span class="pattern-badge circle">Circle</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="color-preview">
|
||||||
|
<div class="color-swatch" style="background: #00FF00;"></div>
|
||||||
|
</div>
|
||||||
|
<div class="preset-info">
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">Delay:</span>
|
||||||
|
<span class="info-value">50ms</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">N1:</span>
|
||||||
|
<span class="info-value">50</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="preset-actions">
|
||||||
|
<button class="btn btn-primary" onclick="applyPreset('Loading Circle')">Apply</button>
|
||||||
|
<button class="btn btn-secondary btn-icon" onclick="editPreset('Loading Circle')" title="Edit">✏️</button>
|
||||||
|
<button class="btn btn-danger btn-icon" onclick="deletePreset('Loading Circle')" title="Delete">🗑️</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Preset Card 5 -->
|
||||||
|
<div class="preset-card" data-pattern="blink" data-name="Party Blink">
|
||||||
|
<div class="preset-header">
|
||||||
|
<div>
|
||||||
|
<div class="preset-name">Party Blink</div>
|
||||||
|
<span class="pattern-badge blink">Blink</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="color-preview">
|
||||||
|
<div class="color-swatch" style="background: #FF00FF;"></div>
|
||||||
|
<div class="color-swatch" style="background: #00FFFF;"></div>
|
||||||
|
<div class="color-swatch" style="background: #FFFF00;"></div>
|
||||||
|
</div>
|
||||||
|
<div class="preset-info">
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">Delay:</span>
|
||||||
|
<span class="info-value">150ms</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">N1:</span>
|
||||||
|
<span class="info-value">0</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="preset-actions">
|
||||||
|
<button class="btn btn-primary" onclick="applyPreset('Party Blink')">Apply</button>
|
||||||
|
<button class="btn btn-secondary btn-icon" onclick="editPreset('Party Blink')" title="Edit">✏️</button>
|
||||||
|
<button class="btn btn-danger btn-icon" onclick="deletePreset('Party Blink')" title="Delete">🗑️</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Preset Card 6 -->
|
||||||
|
<div class="preset-card" data-pattern="transition" data-name="Smooth Transition">
|
||||||
|
<div class="preset-header">
|
||||||
|
<div>
|
||||||
|
<div class="preset-name">Smooth Transition</div>
|
||||||
|
<span class="pattern-badge transition">Transition</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="color-preview">
|
||||||
|
<div class="color-swatch" style="background: #FF0000;"></div>
|
||||||
|
<div class="color-swatch" style="background: #00FF00;"></div>
|
||||||
|
<div class="color-swatch" style="background: #0000FF;"></div>
|
||||||
|
<div class="color-swatch" style="background: #FFFF00;"></div>
|
||||||
|
</div>
|
||||||
|
<div class="preset-info">
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">Delay:</span>
|
||||||
|
<span class="info-value">100ms</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">N1:</span>
|
||||||
|
<span class="info-value">0</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="preset-actions">
|
||||||
|
<button class="btn btn-primary" onclick="applyPreset('Smooth Transition')">Apply</button>
|
||||||
|
<button class="btn btn-secondary btn-icon" onclick="editPreset('Smooth Transition')" title="Edit">✏️</button>
|
||||||
|
<button class="btn btn-danger btn-icon" onclick="deletePreset('Smooth Transition')" title="Delete">🗑️</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create/Edit Preset Modal -->
|
||||||
|
<div class="modal" id="preset-modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2 id="modal-title">Create Preset</h2>
|
||||||
|
<p>Configure your preset settings</p>
|
||||||
|
</div>
|
||||||
|
<form id="preset-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="preset-name">Preset Name *</label>
|
||||||
|
<input type="text" id="preset-name" required placeholder="Enter preset name">
|
||||||
|
<small>Unique identifier for this preset</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="preset-pattern">Pattern *</label>
|
||||||
|
<select id="preset-pattern" required>
|
||||||
|
<option value="on">On</option>
|
||||||
|
<option value="off">Off</option>
|
||||||
|
<option value="blink">Blink</option>
|
||||||
|
<option value="chase">Chase</option>
|
||||||
|
<option value="circle">Circle</option>
|
||||||
|
<option value="pulse">Pulse</option>
|
||||||
|
<option value="rainbow">Rainbow</option>
|
||||||
|
<option value="transition">Transition</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Colors *</label>
|
||||||
|
<div class="color-inputs" id="color-inputs">
|
||||||
|
<!-- Color pickers will be added here -->
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="addColorPicker()" style="margin-top: 8px;">+ Add Color</button>
|
||||||
|
<small>Minimum 2 colors required</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="preset-delay">
|
||||||
|
Delay (ms) *
|
||||||
|
<span id="delay-value-display" style="margin-left: 12px; color: #667eea; font-weight: 600;">100</span>
|
||||||
|
</label>
|
||||||
|
<input type="range" id="preset-delay" min="10" max="1000" value="100" step="10" required>
|
||||||
|
<small>Animation speed (10-1000 milliseconds)</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="step-offset">Step Offset</label>
|
||||||
|
<input type="number" id="step-offset" value="0" min="-1000" max="1000">
|
||||||
|
<small>Step offset for group synchronization. Applied per device when preset is used in a group.</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="step-increment">Step Increment</label>
|
||||||
|
<input type="number" id="step-increment" value="1" min="1" max="255">
|
||||||
|
<small>Amount step counter increments per cycle. Controls pattern advancement speed.</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Pattern Parameters (N1-N8)</label>
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px;">
|
||||||
|
<small style="margin: 0;">Pattern-specific parameters (0-255, varies by pattern)</small>
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="setAllNValues(0)" style="padding: 6px 12px; font-size: 0.75rem;">Reset All to 0</button>
|
||||||
|
</div>
|
||||||
|
<div class="params-grid">
|
||||||
|
<div class="param-input">
|
||||||
|
<label>N1</label>
|
||||||
|
<input type="number" id="n1" min="0" max="255" value="0" class="n-value-input">
|
||||||
|
</div>
|
||||||
|
<div class="param-input">
|
||||||
|
<label>N2</label>
|
||||||
|
<input type="number" id="n2" min="0" max="255" value="0" class="n-value-input">
|
||||||
|
</div>
|
||||||
|
<div class="param-input">
|
||||||
|
<label>N3</label>
|
||||||
|
<input type="number" id="n3" min="0" max="255" value="0" class="n-value-input">
|
||||||
|
</div>
|
||||||
|
<div class="param-input">
|
||||||
|
<label>N4</label>
|
||||||
|
<input type="number" id="n4" min="0" max="255" value="0" class="n-value-input">
|
||||||
|
</div>
|
||||||
|
<div class="param-input">
|
||||||
|
<label>N5</label>
|
||||||
|
<input type="number" id="n5" min="0" max="255" value="0" class="n-value-input">
|
||||||
|
</div>
|
||||||
|
<div class="param-input">
|
||||||
|
<label>N6</label>
|
||||||
|
<input type="number" id="n6" min="0" max="255" value="0" class="n-value-input">
|
||||||
|
</div>
|
||||||
|
<div class="param-input">
|
||||||
|
<label>N7</label>
|
||||||
|
<input type="number" id="n7" min="0" max="255" value="0" class="n-value-input">
|
||||||
|
</div>
|
||||||
|
<div class="param-input">
|
||||||
|
<label>N8</label>
|
||||||
|
<input type="number" id="n8" min="0" max="255" value="0" class="n-value-input">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top: 12px; display: flex; gap: 8px; flex-wrap: wrap;">
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="setAllNValues(0)" style="padding: 8px 16px; font-size: 0.875rem;">Set All to 0</button>
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="copyNValuesFromCurrent()" style="padding: 8px 16px; font-size: 0.875rem;">Copy from Current Settings</button>
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="showNValueHelp()" style="padding: 8px 16px; font-size: 0.875rem;">ℹ️ Parameter Help</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="closeModal()">Cancel</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Save Preset</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let currentView = 'grid';
|
||||||
|
let editingPreset = null;
|
||||||
|
|
||||||
|
// Delay slider update
|
||||||
|
document.getElementById('preset-delay').addEventListener('input', function(e) {
|
||||||
|
document.getElementById('delay-value-display').textContent = e.target.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Search functionality
|
||||||
|
document.getElementById('search-input').addEventListener('input', function(e) {
|
||||||
|
filterPresets();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filter functionality
|
||||||
|
document.getElementById('pattern-filter').addEventListener('change', function(e) {
|
||||||
|
filterPresets();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sort functionality
|
||||||
|
document.getElementById('sort-select').addEventListener('change', function(e) {
|
||||||
|
sortPresets(e.target.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
function filterPresets() {
|
||||||
|
const search = document.getElementById('search-input').value.toLowerCase();
|
||||||
|
const patternFilter = document.getElementById('pattern-filter').value;
|
||||||
|
const cards = document.querySelectorAll('.preset-card');
|
||||||
|
|
||||||
|
cards.forEach(card => {
|
||||||
|
const name = card.dataset.name.toLowerCase();
|
||||||
|
const pattern = card.dataset.pattern;
|
||||||
|
const matchesSearch = name.includes(search);
|
||||||
|
const matchesPattern = !patternFilter || pattern === patternFilter;
|
||||||
|
|
||||||
|
if (matchesSearch && matchesPattern) {
|
||||||
|
card.style.display = '';
|
||||||
|
} else {
|
||||||
|
card.style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortPresets(sortBy) {
|
||||||
|
const container = document.getElementById('presets-container');
|
||||||
|
const cards = Array.from(container.querySelectorAll('.preset-card'));
|
||||||
|
|
||||||
|
cards.sort((a, b) => {
|
||||||
|
if (sortBy === 'name') {
|
||||||
|
return a.dataset.name.localeCompare(b.dataset.name);
|
||||||
|
} else if (sortBy === 'recent') {
|
||||||
|
// In real implementation, would use actual usage data
|
||||||
|
return 0;
|
||||||
|
} else if (sortBy === 'created') {
|
||||||
|
// In real implementation, would use creation timestamps
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
cards.forEach(card => container.appendChild(card));
|
||||||
|
}
|
||||||
|
|
||||||
|
function setView(view) {
|
||||||
|
currentView = view;
|
||||||
|
const container = document.getElementById('presets-container');
|
||||||
|
const gridBtn = document.getElementById('view-grid');
|
||||||
|
const listBtn = document.getElementById('view-list');
|
||||||
|
|
||||||
|
if (view === 'grid') {
|
||||||
|
container.style.gridTemplateColumns = 'repeat(auto-fill, minmax(300px, 1fr))';
|
||||||
|
gridBtn.classList.add('active');
|
||||||
|
listBtn.classList.remove('active');
|
||||||
|
} else {
|
||||||
|
container.style.gridTemplateColumns = '1fr';
|
||||||
|
gridBtn.classList.remove('active');
|
||||||
|
listBtn.classList.add('active');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showCreateModal() {
|
||||||
|
editingPreset = null;
|
||||||
|
document.getElementById('modal-title').textContent = 'Create Preset';
|
||||||
|
document.getElementById('preset-form').reset();
|
||||||
|
document.getElementById('preset-delay').value = 100;
|
||||||
|
document.getElementById('delay-value-display').textContent = '100';
|
||||||
|
// Reset to 2 colors
|
||||||
|
initializeColorPickers();
|
||||||
|
document.getElementById('preset-modal').classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize color pickers function (defined before showCreateModal)
|
||||||
|
const presetColorPickers = [];
|
||||||
|
|
||||||
|
function initializeColorPickers() {
|
||||||
|
const colorInputs = document.getElementById('color-inputs');
|
||||||
|
colorInputs.innerHTML = '';
|
||||||
|
presetColorPickers.length = 0;
|
||||||
|
addColorPicker('#FF0000');
|
||||||
|
addColorPicker('#0000FF');
|
||||||
|
}
|
||||||
|
|
||||||
|
function addColorPicker(color = '#00FF00') {
|
||||||
|
const colorInputs = document.getElementById('color-inputs');
|
||||||
|
const wrapper = document.createElement('div');
|
||||||
|
wrapper.className = 'color-input-wrapper';
|
||||||
|
colorInputs.appendChild(wrapper);
|
||||||
|
|
||||||
|
const picker = new ColorPicker(wrapper, {
|
||||||
|
initialColor: color,
|
||||||
|
onColorChange: (newColor) => {
|
||||||
|
console.log('Preset color changed:', newColor);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
presetColorPickers.push(picker);
|
||||||
|
return picker;
|
||||||
|
}
|
||||||
|
|
||||||
|
function editPreset(name) {
|
||||||
|
editingPreset = name;
|
||||||
|
document.getElementById('modal-title').textContent = 'Edit Preset';
|
||||||
|
// In real implementation, would load preset data
|
||||||
|
document.getElementById('preset-name').value = name;
|
||||||
|
document.getElementById('preset-modal').classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
document.getElementById('preset-modal').classList.remove('active');
|
||||||
|
editingPreset = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function applyPreset(name) {
|
||||||
|
alert(`Applying preset: ${name}\n(In real implementation, this would send preset configuration to device(s))`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deletePreset(name) {
|
||||||
|
if (confirm(`Delete preset "${name}"?`)) {
|
||||||
|
alert(`Preset "${name}" deleted\n(In real implementation, this would remove the preset from storage)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncPresets() {
|
||||||
|
if (confirm('Sync all presets to all devices?\nThis will send all presets from master to all devices via ESPNow.')) {
|
||||||
|
alert('Syncing presets to all devices...\n(In real implementation, this would send all presets via ESPNow to all devices)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setAllNValues(value) {
|
||||||
|
for (let i = 1; i <= 8; i++) {
|
||||||
|
document.getElementById(`n${i}`).value = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyNValuesFromCurrent() {
|
||||||
|
// In real implementation, this would copy from current device settings
|
||||||
|
alert('Copying N values from current device settings...\n(In real implementation, this would load current N1-N8 values from the active device)');
|
||||||
|
// Example: would set values like this:
|
||||||
|
// document.getElementById('n1').value = currentSettings.n1;
|
||||||
|
// ... etc
|
||||||
|
}
|
||||||
|
|
||||||
|
function showNValueHelp() {
|
||||||
|
const helpText = `
|
||||||
|
Pattern Parameter Guide:
|
||||||
|
|
||||||
|
Rainbow:
|
||||||
|
N1: Step increment (1-255, default: 1)
|
||||||
|
|
||||||
|
Pulse:
|
||||||
|
N1: Attack time in ms (0-255)
|
||||||
|
N2: Hold time in ms (0-255)
|
||||||
|
N3: Decay time in ms (0-255)
|
||||||
|
|
||||||
|
Chase:
|
||||||
|
N1: LEDs of color 0 (1-255)
|
||||||
|
N2: LEDs of color 1 (1-255)
|
||||||
|
N3: Step movement on odd steps (can be negative)
|
||||||
|
N4: Step movement on even steps (can be negative)
|
||||||
|
|
||||||
|
Circle:
|
||||||
|
N1: Head moves per second (1-255)
|
||||||
|
N2: Max length in LEDs (1-255)
|
||||||
|
N3: Tail moves per second (1-255)
|
||||||
|
N4: Min length in LEDs (0-255)
|
||||||
|
|
||||||
|
Other patterns:
|
||||||
|
N1-N8: Reserved for future pattern enhancements
|
||||||
|
|
||||||
|
All values range from 0-255 unless otherwise specified.
|
||||||
|
`;
|
||||||
|
alert(helpText);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Form submission
|
||||||
|
document.getElementById('preset-form').addEventListener('submit', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const name = document.getElementById('preset-name').value;
|
||||||
|
const action = editingPreset ? 'updated' : 'created';
|
||||||
|
alert(`Preset "${name}" ${action}!\n(In real implementation, this would save the preset to storage)`);
|
||||||
|
closeModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close modal on outside click
|
||||||
|
document.getElementById('preset-modal').addEventListener('click', function(e) {
|
||||||
|
if (e.target === this) {
|
||||||
|
closeModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<script src="color-picker.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
491
docs/mockups/settings.html
Normal file
@@ -0,0 +1,491 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>LED Driver - Settings</title>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||||
|
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 1000px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 24px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h1 {
|
||||||
|
color: #667eea;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header p {
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 24px;
|
||||||
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card h2 {
|
||||||
|
color: #667eea;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
border-bottom: 2px solid #e0e0e0;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input[type="text"],
|
||||||
|
.form-group input[type="number"],
|
||||||
|
.form-group input[type="password"],
|
||||||
|
.form-group select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input:focus,
|
||||||
|
.form-group select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input[type="range"] {
|
||||||
|
width: 100%;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #e0e0e0;
|
||||||
|
outline: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input[type="range"]::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #667eea;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input[type="range"]::-moz-range-thumb {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #667eea;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.value-display {
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #667eea;
|
||||||
|
min-width: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group small {
|
||||||
|
display: block;
|
||||||
|
margin-top: 4px;
|
||||||
|
color: #666;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-group input[type="checkbox"] {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-order {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-order-option {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 120px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 2px solid #e0e0e0;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-order-option:hover {
|
||||||
|
border-color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-order-option.selected {
|
||||||
|
border-color: #667eea;
|
||||||
|
background: #667eea;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-order-option .color-boxes {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-box {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-box.r { background: #ff0000; }
|
||||||
|
.color-box.g { background: #00ff00; }
|
||||||
|
.color-box.b { background: #0000ff; }
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 32px;
|
||||||
|
padding-top: 24px;
|
||||||
|
border-top: 2px solid #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 12px 24px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: #667eea;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: #5568d3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: #e0e0e0;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: #d0d0d0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: #f44336;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover {
|
||||||
|
background: #d32f2f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-full {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-divider {
|
||||||
|
height: 1px;
|
||||||
|
background: #e0e0e0;
|
||||||
|
margin: 24px 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<h1>Device Settings</h1>
|
||||||
|
<p>Configure your LED driver device settings</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Basic Settings -->
|
||||||
|
<div class="card">
|
||||||
|
<h2>Basic Settings</h2>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Device Name</label>
|
||||||
|
<input type="text" id="device-name" value="led-device1" placeholder="led-device1">
|
||||||
|
<small>Unique identifier for this device</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>LED Pin</label>
|
||||||
|
<input type="number" id="led-pin" value="10" min="0" max="40">
|
||||||
|
<small>GPIO pin number connected to LED data line</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Number of LEDs</label>
|
||||||
|
<input type="number" id="num-leds" value="50" min="1" max="1000">
|
||||||
|
<small>Total number of LEDs in your strip</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Color Order</label>
|
||||||
|
<div class="color-order">
|
||||||
|
<div class="color-order-option selected" data-order="rgb">
|
||||||
|
RGB
|
||||||
|
<div class="color-boxes">
|
||||||
|
<div class="color-box r"></div>
|
||||||
|
<div class="color-box g"></div>
|
||||||
|
<div class="color-box b"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="color-order-option" data-order="rbg">
|
||||||
|
RBG
|
||||||
|
<div class="color-boxes">
|
||||||
|
<div class="color-box r"></div>
|
||||||
|
<div class="color-box b"></div>
|
||||||
|
<div class="color-box g"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="color-order-option" data-order="grb">
|
||||||
|
GRB
|
||||||
|
<div class="color-boxes">
|
||||||
|
<div class="color-box g"></div>
|
||||||
|
<div class="color-box r"></div>
|
||||||
|
<div class="color-box b"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="color-order-option" data-order="gbr">
|
||||||
|
GBR
|
||||||
|
<div class="color-boxes">
|
||||||
|
<div class="color-box g"></div>
|
||||||
|
<div class="color-box b"></div>
|
||||||
|
<div class="color-box r"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="color-order-option" data-order="brg">
|
||||||
|
BRG
|
||||||
|
<div class="color-boxes">
|
||||||
|
<div class="color-box b"></div>
|
||||||
|
<div class="color-box r"></div>
|
||||||
|
<div class="color-box g"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="color-order-option" data-order="bgr">
|
||||||
|
BGR
|
||||||
|
<div class="color-boxes">
|
||||||
|
<div class="color-box b"></div>
|
||||||
|
<div class="color-box g"></div>
|
||||||
|
<div class="color-box r"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pattern Settings -->
|
||||||
|
<div class="card">
|
||||||
|
<h2>Pattern Settings</h2>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Pattern</label>
|
||||||
|
<select id="pattern">
|
||||||
|
<option value="on">On</option>
|
||||||
|
<option value="off">Off</option>
|
||||||
|
<option value="blink">Blink</option>
|
||||||
|
<option value="chase">Chase</option>
|
||||||
|
<option value="circle">Circle</option>
|
||||||
|
<option value="pulse">Pulse</option>
|
||||||
|
<option value="rainbow">Rainbow</option>
|
||||||
|
<option value="transition">Transition</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>
|
||||||
|
Brightness
|
||||||
|
<span class="value-display" id="brightness-value">100</span>%
|
||||||
|
</label>
|
||||||
|
<input type="range" id="brightness" min="0" max="100" value="100">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>
|
||||||
|
Delay
|
||||||
|
<span class="value-display" id="delay-value">100</span>ms
|
||||||
|
</label>
|
||||||
|
<input type="range" id="delay" min="10" max="1000" value="100" step="10">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Advanced Settings -->
|
||||||
|
<div class="card">
|
||||||
|
<h2>Advanced Settings</h2>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Step Counter</label>
|
||||||
|
<input type="text" id="step-counter" value="0" readonly style="background: #f5f5f5; cursor: not-allowed;">
|
||||||
|
<small>Current step position in pattern (read-only)</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="step-increment">
|
||||||
|
Step Increment
|
||||||
|
</label>
|
||||||
|
<input type="number" id="step-increment" value="1" min="1" max="255">
|
||||||
|
<small>Amount step counter increments per cycle. Controls pattern advancement speed.</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Pattern Parameters</label>
|
||||||
|
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px;">
|
||||||
|
<div>
|
||||||
|
<label style="font-size: 0.875rem;">N1</label>
|
||||||
|
<input type="number" id="n1" value="0" min="0" max="255">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style="font-size: 0.875rem;">N2</label>
|
||||||
|
<input type="number" id="n2" value="0" min="0" max="255">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style="font-size: 0.875rem;">N3</label>
|
||||||
|
<input type="number" id="n3" value="0" min="0" max="255">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style="font-size: 0.875rem;">N4</label>
|
||||||
|
<input type="number" id="n4" value="0" min="0" max="255">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style="font-size: 0.875rem;">N5</label>
|
||||||
|
<input type="number" id="n5" value="0" min="0" max="255">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style="font-size: 0.875rem;">N6</label>
|
||||||
|
<input type="number" id="n6" value="0" min="0" max="255">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<small>Pattern-specific parameters (varies by pattern)</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Device ID</label>
|
||||||
|
<input type="number" id="device-id" value="1" min="0">
|
||||||
|
<small>Unique numeric identifier</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="checkbox-group">
|
||||||
|
<input type="checkbox" id="debug" checked>
|
||||||
|
<label for="debug" style="margin: 0;">Debug Mode</label>
|
||||||
|
</div>
|
||||||
|
<small>Enable debug logging</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Network Settings -->
|
||||||
|
<div class="card">
|
||||||
|
<h2>Network Settings</h2>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Access Point Name</label>
|
||||||
|
<input type="text" id="ap-name" value="led-AA:BB:CC:DD:EE:01" placeholder="led-device">
|
||||||
|
<small>WiFi access point name for device configuration</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Access Point Password</label>
|
||||||
|
<input type="password" id="ap-password" placeholder="Leave empty for open network">
|
||||||
|
<small>Password for the access point (optional)</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="checkbox-group">
|
||||||
|
<input type="checkbox" id="ap-enabled" checked>
|
||||||
|
<label for="ap-enabled" style="margin: 0;">Enable Access Point</label>
|
||||||
|
</div>
|
||||||
|
<small>Allow device to create its own WiFi network</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn btn-secondary btn-full" onclick="resetSettings()">Reset to Defaults</button>
|
||||||
|
<button class="btn btn-primary btn-full" onclick="saveSettings()">Save Settings</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Brightness slider
|
||||||
|
document.getElementById('brightness').addEventListener('input', function(e) {
|
||||||
|
document.getElementById('brightness-value').textContent = e.target.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delay slider
|
||||||
|
document.getElementById('delay').addEventListener('input', function(e) {
|
||||||
|
document.getElementById('delay-value').textContent = e.target.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Color order selection
|
||||||
|
document.querySelectorAll('.color-order-option').forEach(option => {
|
||||||
|
option.addEventListener('click', function() {
|
||||||
|
document.querySelectorAll('.color-order-option').forEach(o => o.classList.remove('selected'));
|
||||||
|
this.classList.add('selected');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function saveSettings() {
|
||||||
|
alert('Settings saved! (This is a mockup)');
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetSettings() {
|
||||||
|
if (confirm('Reset all settings to defaults?')) {
|
||||||
|
alert('Settings reset! (This is a mockup)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
30
docs/msg.json
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"grps": [
|
||||||
|
{
|
||||||
|
"n": "group1",
|
||||||
|
"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,
|
||||||
|
"dl": 100
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
244
flash.sh
Executable file
@@ -0,0 +1,244 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
# Environment variables:
|
||||||
|
# PORT - serial port (default: /dev/ttyUSB0)
|
||||||
|
# BAUD - baud rate (default: 460800)
|
||||||
|
# FIRMWARE - local path to firmware .bin
|
||||||
|
# FW_URL - URL to download firmware if FIRMWARE not provided or missing
|
||||||
|
|
||||||
|
PORT=${PORT:-}
|
||||||
|
BAUD=${BAUD:-460800}
|
||||||
|
CHIP=${CHIP:-esp32} # esp32 | esp32c3
|
||||||
|
|
||||||
|
# Map chip-specific settings
|
||||||
|
ESPT_CHIP="$CHIP"
|
||||||
|
FLASH_OFFSET=0x1000
|
||||||
|
DEFAULT_DOWNLOAD_PAGE="https://micropython.org/download/ESP32/"
|
||||||
|
BOARD_ID="ESP32_GENERIC"
|
||||||
|
BOARD_PAGE="https://micropython.org/download/${BOARD_ID}/"
|
||||||
|
case "$CHIP" in
|
||||||
|
esp32c3)
|
||||||
|
ESPT_CHIP="esp32c3"
|
||||||
|
FLASH_OFFSET=0x0
|
||||||
|
DEFAULT_DOWNLOAD_PAGE="https://micropython.org/download/ESP32C3/"
|
||||||
|
BOARD_ID="ESP32_GENERIC_C3"
|
||||||
|
BOARD_PAGE="https://micropython.org/download/${BOARD_ID}/"
|
||||||
|
;;
|
||||||
|
esp32)
|
||||||
|
ESPT_CHIP="esp32"
|
||||||
|
FLASH_OFFSET=0x1000
|
||||||
|
DEFAULT_DOWNLOAD_PAGE="https://micropython.org/download/ESP32/"
|
||||||
|
BOARD_ID="ESP32_GENERIC"
|
||||||
|
BOARD_PAGE="https://micropython.org/download/${BOARD_ID}/"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unsupported CHIP: $CHIP (supported: esp32, esp32c3)" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Download-only mode: fetch the appropriate firmware and exit
|
||||||
|
if [ -n "${DOWNLOAD_ONLY:-}" ]; then
|
||||||
|
# Prefer resolving latest if nothing provided
|
||||||
|
if [ -z "${FIRMWARE:-}" ] && [ -z "${FW_URL:-}" ]; then
|
||||||
|
LATEST=1
|
||||||
|
fi
|
||||||
|
if ! resolve_firmware; then
|
||||||
|
echo "Failed to resolve firmware for CHIP=$CHIP" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "$FIRMWARE"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Helper: resolve the latest firmware URL for a given board pattern with multiple fallbacks
|
||||||
|
resolve_latest_url() {
|
||||||
|
board_pattern="$1" # e.g., ESP32_GENERIC_C3-.*\.bin
|
||||||
|
# Candidate pages to try in order
|
||||||
|
pages="${BOARD_PAGE} ${DOWNLOAD_PAGE:-$DEFAULT_DOWNLOAD_PAGE} https://micropython.org/download/ https://micropython.org/resources/firmware/"
|
||||||
|
for page in $pages; do
|
||||||
|
echo "Trying to resolve latest from $page" >&2
|
||||||
|
html=$(curl -fsSL -H 'User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36' -e 'https://micropython.org/download/' "$page" || true)
|
||||||
|
[ -z "$html" ] && continue
|
||||||
|
# Prefer matching the board pattern
|
||||||
|
url=$(printf "%s" "$html" \
|
||||||
|
| sed -n 's/.*href=\"\([^\"]*\.bin\)\".*/\1/p' \
|
||||||
|
| grep -E "$board_pattern" \
|
||||||
|
| head -n1)
|
||||||
|
if [ -n "$url" ]; then
|
||||||
|
case "$url" in
|
||||||
|
http*) echo "$url"; return 0 ;;
|
||||||
|
/*) echo "https://micropython.org$url"; return 0 ;;
|
||||||
|
*) echo "$page$url"; return 0 ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# If LATEST is set and neither FIRMWARE nor FW_URL are provided, auto-detect latest URL
|
||||||
|
if [ -n "${LATEST:-}" ] && [ -z "${FIRMWARE:-}" ] && [ -z "${FW_URL:-}" ]; then
|
||||||
|
# Default board identifiers for each chip
|
||||||
|
case "$CHIP" in
|
||||||
|
esp32c3) BOARD_ID="ESP32_GENERIC_C3" ;;
|
||||||
|
esp32) BOARD_ID="ESP32_GENERIC" ;;
|
||||||
|
*) BOARD_ID="ESP32_GENERIC" ;;
|
||||||
|
esac
|
||||||
|
pattern="${BOARD_ID}-.*\\.bin"
|
||||||
|
echo "Resolving latest firmware for $BOARD_ID"
|
||||||
|
if FW_URL=$(resolve_latest_url "$pattern"); then
|
||||||
|
export FW_URL
|
||||||
|
echo "Latest firmware resolved to: $FW_URL"
|
||||||
|
else
|
||||||
|
echo "Failed to resolve latest firmware for pattern $pattern" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Resolve firmware path, downloading if needed
|
||||||
|
resolve_firmware() {
|
||||||
|
if [ -z "${FIRMWARE:-}" ]; then
|
||||||
|
if [ -n "${FW_URL:-}" ] || [ -n "${LATEST:-}" ]; then
|
||||||
|
# If FW_URL still unset, resolve latest using board-specific pattern
|
||||||
|
if [ -z "${FW_URL:-}" ]; then
|
||||||
|
case "$CHIP" in
|
||||||
|
esp32c3) BOARD_ID="ESP32_GENERIC_C3" ;;
|
||||||
|
esp32) BOARD_ID="ESP32_GENERIC" ;;
|
||||||
|
*) BOARD_ID="ESP32_GENERIC" ;;
|
||||||
|
esac
|
||||||
|
pattern="${BOARD_ID}-.*\\.bin"
|
||||||
|
echo "Resolving latest firmware for $BOARD_ID"
|
||||||
|
if ! FW_URL=$(resolve_latest_url "$pattern"); then
|
||||||
|
echo "Failed to resolve latest firmware for pattern $pattern" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
mkdir -p .cache
|
||||||
|
FIRMWARE=".cache/$(basename "$FW_URL")"
|
||||||
|
if [ ! -f "$FIRMWARE" ]; then
|
||||||
|
echo "Downloading firmware from $FW_URL to $FIRMWARE"
|
||||||
|
curl -L --fail -o "$FIRMWARE" "$FW_URL"
|
||||||
|
else
|
||||||
|
echo "Firmware already downloaded at $FIRMWARE"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
# Default fallback: fetch latest using board-specific pattern
|
||||||
|
case "$CHIP" in
|
||||||
|
esp32c3) BOARD_ID="ESP32_GENERIC_C3" ;;
|
||||||
|
esp32) BOARD_ID="ESP32_GENERIC" ;;
|
||||||
|
*) BOARD_ID="ESP32_GENERIC" ;;
|
||||||
|
esac
|
||||||
|
pattern="${BOARD_ID}-.*\\.bin"
|
||||||
|
echo "No FIRMWARE or FW_URL specified. Auto-fetching latest for $BOARD_ID"
|
||||||
|
if ! FW_URL=$(resolve_latest_url "$pattern"); then
|
||||||
|
echo "Failed to resolve latest firmware for pattern $pattern" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
mkdir -p .cache
|
||||||
|
FIRMWARE=".cache/$(basename "$FW_URL")"
|
||||||
|
if [ ! -f "$FIRMWARE" ]; then
|
||||||
|
echo "Downloading firmware from $FW_URL to $FIRMWARE"
|
||||||
|
curl -L --fail -o "$FIRMWARE" "$FW_URL"
|
||||||
|
else
|
||||||
|
echo "Firmware already downloaded at $FIRMWARE"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
if [ ! -f "$FIRMWARE" ]; then
|
||||||
|
if [ -n "${FW_URL:-}" ]; then
|
||||||
|
mkdir -p "$(dirname "$FIRMWARE")"
|
||||||
|
echo "Firmware not found at $FIRMWARE. Downloading from $FW_URL"
|
||||||
|
curl -L --fail -o "$FIRMWARE" "$FW_URL"
|
||||||
|
else
|
||||||
|
echo "Firmware file not found: $FIRMWARE. Provide FW_URL to download automatically." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Auto-detect PORT if not specified
|
||||||
|
if [ -z "$PORT" ]; then
|
||||||
|
candidates="$(ls /dev/tty/ACM* /dev/tty/USB* 2>/dev/null || true)"
|
||||||
|
# Some systems expose without /dev/tty/ prefix patterns; try common Linux paths
|
||||||
|
[ -z "$candidates" ] && candidates="$(ls /dev/ttyACM* /dev/ttyUSB* 2>/dev/null || true)"
|
||||||
|
# Prefer ACM (often for C3) then USB
|
||||||
|
PORT=$(printf "%s\n" $candidates | grep -E "/dev/ttyACM[0-9]+" | head -n1 || true)
|
||||||
|
[ -z "$PORT" ] && PORT=$(printf "%s\n" $candidates | grep -E "/dev/ttyUSB[0-9]+" | head -n1 || true)
|
||||||
|
if [ -z "$PORT" ]; then
|
||||||
|
echo "No serial port detected. Connect the board and set PORT=/dev/ttyACM0 (or /dev/ttyUSB0)." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Auto-detected PORT=$PORT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Preflight: ensure port exists
|
||||||
|
if [ ! -e "$PORT" ]; then
|
||||||
|
echo "Port $PORT does not exist. Detected candidates:" >&2
|
||||||
|
ls /dev/ttyACM* /dev/ttyUSB* 2>/dev/null || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
ESPL="python -m esptool"
|
||||||
|
|
||||||
|
detect_chip() {
|
||||||
|
# Try to detect actual connected chip using esptool and override if needed
|
||||||
|
out=$($ESPL --port "$PORT" --baud "$BAUD" chip_id 2>&1 || true)
|
||||||
|
case "$out" in
|
||||||
|
*"ESP32-C3"*) DETECTED_CHIP=esp32c3 ;;
|
||||||
|
*"ESP32"*) DETECTED_CHIP=esp32 ;;
|
||||||
|
*) DETECTED_CHIP="" ;;
|
||||||
|
esac
|
||||||
|
if [ -n "$DETECTED_CHIP" ] && [ "$DETECTED_CHIP" != "$ESPT_CHIP" ]; then
|
||||||
|
echo "Detected chip $DETECTED_CHIP differs from requested $ESPT_CHIP. Using detected chip."
|
||||||
|
ESPT_CHIP="$DETECTED_CHIP"
|
||||||
|
case "$ESPT_CHIP" in
|
||||||
|
esp32c3) FLASH_OFFSET=0x0 ;;
|
||||||
|
esp32) FLASH_OFFSET=0x1000 ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
detect_chip
|
||||||
|
|
||||||
|
# Now that we know the actual chip, resolve the correct firmware for it
|
||||||
|
resolve_firmware
|
||||||
|
|
||||||
|
# Validate firmware matches detected chip; if not, auto-correct by fetching the right image
|
||||||
|
EXPECTED_BOARD_ID="ESP32_GENERIC"
|
||||||
|
case "$ESPT_CHIP" in
|
||||||
|
esp32c3) EXPECTED_BOARD_ID="ESP32_GENERIC_C3" ;;
|
||||||
|
esp32) EXPECTED_BOARD_ID="ESP32_GENERIC" ;;
|
||||||
|
|
||||||
|
esac
|
||||||
|
|
||||||
|
FW_BASENAME="$(basename "$FIRMWARE")"
|
||||||
|
case "$FW_BASENAME" in
|
||||||
|
${EXPECTED_BOARD_ID}-*.bin) : ;; # ok
|
||||||
|
*)
|
||||||
|
echo "Firmware $FW_BASENAME does not match detected chip ($ESPT_CHIP). Fetching correct image for $EXPECTED_BOARD_ID..."
|
||||||
|
pattern="${EXPECTED_BOARD_ID}-.*\\.bin"
|
||||||
|
if ! FW_URL=$(resolve_latest_url "$pattern"); then
|
||||||
|
echo "Failed to resolve a firmware matching $EXPECTED_BOARD_ID" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
mkdir -p .cache
|
||||||
|
FIRMWARE=".cache/$(basename "$FW_URL")"
|
||||||
|
if [ ! -f "$FIRMWARE" ]; then
|
||||||
|
echo "Downloading firmware from $FW_URL to $FIRMWARE"
|
||||||
|
curl -L --fail -o "$FIRMWARE" "$FW_URL"
|
||||||
|
else
|
||||||
|
echo "Firmware already downloaded at $FIRMWARE"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
$ESPL --chip "$ESPT_CHIP" --port "$PORT" --baud "$BAUD" erase_flash
|
||||||
|
|
||||||
|
echo "Writing firmware $FIRMWARE to $FLASH_OFFSET..."
|
||||||
|
$ESPL --chip "$ESPT_CHIP" --port "$PORT" --baud "$BAUD" write_flash -z "$FLASH_OFFSET" "$FIRMWARE"
|
||||||
|
|
||||||
|
echo "Done."
|
||||||
|
|
||||||
|
|
||||||
4
install.sh
Executable file
@@ -0,0 +1,4 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Install script - runs pipenv install
|
||||||
|
|
||||||
|
pipenv install "$@"
|
||||||
225
lib/microdot/session.py
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
try:
|
||||||
|
import jwt
|
||||||
|
HAS_JWT = True
|
||||||
|
except ImportError:
|
||||||
|
HAS_JWT = False
|
||||||
|
try:
|
||||||
|
import ubinascii
|
||||||
|
except ImportError:
|
||||||
|
import binascii as ubinascii
|
||||||
|
try:
|
||||||
|
import uhashlib as hashlib
|
||||||
|
except ImportError:
|
||||||
|
import hashlib
|
||||||
|
try:
|
||||||
|
import uhmac as hmac
|
||||||
|
except ImportError:
|
||||||
|
try:
|
||||||
|
import hmac
|
||||||
|
except ImportError:
|
||||||
|
hmac = None
|
||||||
|
import json
|
||||||
|
|
||||||
|
from microdot.microdot import invoke_handler
|
||||||
|
from microdot.helpers import wraps
|
||||||
|
|
||||||
|
|
||||||
|
class SessionDict(dict):
|
||||||
|
"""A session dictionary.
|
||||||
|
|
||||||
|
The session dictionary is a standard Python dictionary that has been
|
||||||
|
extended with convenience ``save()`` and ``delete()`` methods.
|
||||||
|
"""
|
||||||
|
def __init__(self, request, session_dict):
|
||||||
|
super().__init__(session_dict)
|
||||||
|
self.request = request
|
||||||
|
|
||||||
|
def save(self):
|
||||||
|
"""Update the session cookie."""
|
||||||
|
self.request.app._session.update(self.request, self)
|
||||||
|
|
||||||
|
def delete(self):
|
||||||
|
"""Delete the session cookie."""
|
||||||
|
self.request.app._session.delete(self.request)
|
||||||
|
|
||||||
|
|
||||||
|
class Session:
|
||||||
|
"""Session handling
|
||||||
|
|
||||||
|
:param app: The application instance.
|
||||||
|
:param secret_key: The secret key, as a string or bytes object.
|
||||||
|
:param cookie_options: A dictionary with cookie options to pass as
|
||||||
|
arguments to :meth:`Response.set_cookie()
|
||||||
|
<microdot.Response.set_cookie>`.
|
||||||
|
"""
|
||||||
|
secret_key = None
|
||||||
|
|
||||||
|
def __init__(self, app=None, secret_key=None, cookie_options=None):
|
||||||
|
self.secret_key = secret_key
|
||||||
|
self.cookie_options = cookie_options or {}
|
||||||
|
if app is not None:
|
||||||
|
self.initialize(app)
|
||||||
|
|
||||||
|
def initialize(self, app, secret_key=None, cookie_options=None):
|
||||||
|
if secret_key is not None:
|
||||||
|
self.secret_key = secret_key
|
||||||
|
if cookie_options is not None:
|
||||||
|
self.cookie_options = cookie_options
|
||||||
|
if 'path' not in self.cookie_options:
|
||||||
|
self.cookie_options['path'] = '/'
|
||||||
|
if 'http_only' not in self.cookie_options:
|
||||||
|
self.cookie_options['http_only'] = True
|
||||||
|
app._session = self
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
"""Retrieve the user session.
|
||||||
|
|
||||||
|
:param request: The client request.
|
||||||
|
|
||||||
|
The return value is a session dictionary with the data stored in the
|
||||||
|
user's session, or ``{}`` if the session data is not available or
|
||||||
|
invalid.
|
||||||
|
"""
|
||||||
|
if not self.secret_key:
|
||||||
|
raise ValueError('The session secret key is not configured')
|
||||||
|
if hasattr(request.g, '_session'):
|
||||||
|
return request.g._session
|
||||||
|
session = request.cookies.get('session')
|
||||||
|
if session is None:
|
||||||
|
request.g._session = SessionDict(request, {})
|
||||||
|
return request.g._session
|
||||||
|
request.g._session = SessionDict(request, self.decode(session))
|
||||||
|
return request.g._session
|
||||||
|
|
||||||
|
def update(self, request, session):
|
||||||
|
"""Update the user session.
|
||||||
|
|
||||||
|
:param request: The client request.
|
||||||
|
:param session: A dictionary with the update session data for the user.
|
||||||
|
|
||||||
|
Applications would normally not call this method directly, instead they
|
||||||
|
would use the :meth:`SessionDict.save` method on the session
|
||||||
|
dictionary, which calls this method. For example::
|
||||||
|
|
||||||
|
@app.route('/')
|
||||||
|
@with_session
|
||||||
|
def index(request, session):
|
||||||
|
session['foo'] = 'bar'
|
||||||
|
session.save()
|
||||||
|
return 'Hello, World!'
|
||||||
|
|
||||||
|
Calling this method adds a cookie with the updated session to the
|
||||||
|
request currently being processed.
|
||||||
|
"""
|
||||||
|
if not self.secret_key:
|
||||||
|
raise ValueError('The session secret key is not configured')
|
||||||
|
|
||||||
|
encoded_session = self.encode(session)
|
||||||
|
|
||||||
|
@request.after_request
|
||||||
|
def _update_session(request, response):
|
||||||
|
response.set_cookie('session', encoded_session,
|
||||||
|
**self.cookie_options)
|
||||||
|
return response
|
||||||
|
|
||||||
|
def delete(self, request):
|
||||||
|
"""Remove the user session.
|
||||||
|
|
||||||
|
:param request: The client request.
|
||||||
|
|
||||||
|
Applications would normally not call this method directly, instead they
|
||||||
|
would use the :meth:`SessionDict.delete` method on the session
|
||||||
|
dictionary, which calls this method. For example::
|
||||||
|
|
||||||
|
@app.route('/')
|
||||||
|
@with_session
|
||||||
|
def index(request, session):
|
||||||
|
session.delete()
|
||||||
|
return 'Hello, World!'
|
||||||
|
|
||||||
|
Calling this method adds a cookie removal header to the request
|
||||||
|
currently being processed.
|
||||||
|
"""
|
||||||
|
@request.after_request
|
||||||
|
def _delete_session(request, response):
|
||||||
|
response.delete_cookie('session', **self.cookie_options)
|
||||||
|
return response
|
||||||
|
|
||||||
|
def encode(self, payload, secret_key=None):
|
||||||
|
"""Encode session data using JWT if available, otherwise use simple HMAC."""
|
||||||
|
if HAS_JWT:
|
||||||
|
return jwt.encode(payload, secret_key or self.secret_key,
|
||||||
|
algorithm='HS256')
|
||||||
|
else:
|
||||||
|
# Simple encoding for MicroPython: base64(json) + HMAC signature
|
||||||
|
key = (secret_key or self.secret_key).encode() if isinstance(secret_key or self.secret_key, str) else (secret_key or self.secret_key)
|
||||||
|
payload_json = json.dumps(payload)
|
||||||
|
payload_b64 = ubinascii.b2a_base64(payload_json.encode()).decode().strip()
|
||||||
|
|
||||||
|
# Create HMAC signature
|
||||||
|
if hmac:
|
||||||
|
# Use hmac module if available
|
||||||
|
h = hmac.new(key, payload_json.encode(), hashlib.sha256)
|
||||||
|
else:
|
||||||
|
# Fallback: simple SHA256(key + message)
|
||||||
|
h = hashlib.sha256(key + payload_json.encode())
|
||||||
|
signature = ubinascii.b2a_base64(h.digest()).decode().strip()
|
||||||
|
|
||||||
|
return f"{payload_b64}.{signature}"
|
||||||
|
|
||||||
|
def decode(self, session, secret_key=None):
|
||||||
|
"""Decode session data using JWT if available, otherwise use simple HMAC."""
|
||||||
|
if HAS_JWT:
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(session, secret_key or self.secret_key,
|
||||||
|
algorithms=['HS256'])
|
||||||
|
except jwt.exceptions.PyJWTError: # pragma: no cover
|
||||||
|
return {}
|
||||||
|
return payload
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
# Simple decoding for MicroPython
|
||||||
|
if '.' not in session:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
payload_b64, signature = session.rsplit('.', 1)
|
||||||
|
payload_json = ubinascii.a2b_base64(payload_b64).decode()
|
||||||
|
|
||||||
|
# Verify HMAC signature
|
||||||
|
key = (secret_key or self.secret_key).encode() if isinstance(secret_key or self.secret_key, str) else (secret_key or self.secret_key)
|
||||||
|
if hmac:
|
||||||
|
# Use hmac module if available
|
||||||
|
h = hmac.new(key, payload_json.encode(), hashlib.sha256)
|
||||||
|
else:
|
||||||
|
# Fallback: simple SHA256(key + message)
|
||||||
|
h = hashlib.sha256(key + payload_json.encode())
|
||||||
|
expected_signature = ubinascii.b2a_base64(h.digest()).decode().strip()
|
||||||
|
|
||||||
|
if signature != expected_signature:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
return json.loads(payload_json)
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def with_session(f):
|
||||||
|
"""Decorator that passes the user session to the route handler.
|
||||||
|
|
||||||
|
The session dictionary is passed to the decorated function as an argument
|
||||||
|
after the request object. Example::
|
||||||
|
|
||||||
|
@app.route('/')
|
||||||
|
@with_session
|
||||||
|
def index(request, session):
|
||||||
|
return 'Hello, World!'
|
||||||
|
|
||||||
|
Note that the decorator does not save the session. To update the session,
|
||||||
|
call the :func:`session.save() <microdot.session.SessionDict.save>` method.
|
||||||
|
"""
|
||||||
|
@wraps(f)
|
||||||
|
async def wrapper(request, *args, **kwargs):
|
||||||
|
return await invoke_handler(
|
||||||
|
f, request, request.app._session.get(request), *args, **kwargs)
|
||||||
|
|
||||||
|
return wrapper
|
||||||
23
msg.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"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
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
#!/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())
|
||||||
44
send_empty_json.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
#!/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
settings.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"session_secret_key": "06a2a6ac631cc8db0658373b37f7fe922a8a3ed3a27229e1adb5448cb368f2b7"}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import settings
|
import settings
|
||||||
import wifi
|
import util.wifi as wifi
|
||||||
from settings import Settings
|
from settings import Settings
|
||||||
|
|
||||||
s = Settings()
|
s = Settings()
|
||||||
|
|
||||||
name = s.get('name', 'led')
|
name = s.get('name', 'led-controller')
|
||||||
wifi.ap(name, '')
|
wifi.ap(name, '')
|
||||||
|
|||||||
1
src/controllers/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Controllers package
|
||||||
50
src/controllers/group.py
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
from microdot import Microdot
|
||||||
|
from models.group import Group
|
||||||
|
import json
|
||||||
|
|
||||||
|
controller = Microdot()
|
||||||
|
groups = Group()
|
||||||
|
|
||||||
|
@controller.get('')
|
||||||
|
async def list_groups(request):
|
||||||
|
"""List all groups."""
|
||||||
|
return json.dumps(groups), 200, {'Content-Type': 'application/json'}
|
||||||
|
|
||||||
|
@controller.get('/<id>')
|
||||||
|
async def get_group(request, id):
|
||||||
|
"""Get a specific group by ID."""
|
||||||
|
group = groups.read(id)
|
||||||
|
if group:
|
||||||
|
return json.dumps(group), 200, {'Content-Type': 'application/json'}
|
||||||
|
return json.dumps({"error": "Group not found"}), 404
|
||||||
|
|
||||||
|
@controller.post('')
|
||||||
|
async def create_group(request):
|
||||||
|
"""Create a new group."""
|
||||||
|
try:
|
||||||
|
data = request.json or {}
|
||||||
|
name = data.get("name", "")
|
||||||
|
group_id = groups.create(name)
|
||||||
|
if data:
|
||||||
|
groups.update(group_id, data)
|
||||||
|
return json.dumps(groups.read(group_id)), 201, {'Content-Type': 'application/json'}
|
||||||
|
except Exception as e:
|
||||||
|
return json.dumps({"error": str(e)}), 400
|
||||||
|
|
||||||
|
@controller.put('/<id>')
|
||||||
|
async def update_group(request, id):
|
||||||
|
"""Update an existing group."""
|
||||||
|
try:
|
||||||
|
data = request.json
|
||||||
|
if groups.update(id, data):
|
||||||
|
return json.dumps(groups.read(id)), 200, {'Content-Type': 'application/json'}
|
||||||
|
return json.dumps({"error": "Group not found"}), 404
|
||||||
|
except Exception as e:
|
||||||
|
return json.dumps({"error": str(e)}), 400
|
||||||
|
|
||||||
|
@controller.delete('/<id>')
|
||||||
|
async def delete_group(request, id):
|
||||||
|
"""Delete a group."""
|
||||||
|
if groups.delete(id):
|
||||||
|
return json.dumps({"message": "Group deleted successfully"}), 200
|
||||||
|
return json.dumps({"error": "Group not found"}), 404
|
||||||
63
src/controllers/palette.py
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
from microdot import Microdot
|
||||||
|
from models.pallet import Palette
|
||||||
|
import json
|
||||||
|
|
||||||
|
controller = Microdot()
|
||||||
|
palettes = Palette()
|
||||||
|
|
||||||
|
@controller.get('')
|
||||||
|
async def list_palettes(request):
|
||||||
|
"""List all palettes."""
|
||||||
|
data = {}
|
||||||
|
for pid in palettes.list():
|
||||||
|
colors = palettes.read(pid)
|
||||||
|
data[pid] = colors
|
||||||
|
return json.dumps(data), 200, {'Content-Type': 'application/json'}
|
||||||
|
|
||||||
|
@controller.get('/<id>')
|
||||||
|
async def get_palette(request, id):
|
||||||
|
"""Get a specific palette by ID."""
|
||||||
|
palette = palettes.read(id)
|
||||||
|
if palette:
|
||||||
|
return json.dumps({"colors": palette, "id": str(id)}), 200, {'Content-Type': 'application/json'}
|
||||||
|
return json.dumps({"error": "Palette not found"}), 404
|
||||||
|
|
||||||
|
@controller.post('')
|
||||||
|
async def create_palette(request):
|
||||||
|
"""Create a new palette."""
|
||||||
|
try:
|
||||||
|
data = request.json or {}
|
||||||
|
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'}
|
||||||
|
except Exception as e:
|
||||||
|
return json.dumps({"error": str(e)}), 400
|
||||||
|
|
||||||
|
@controller.put('/<id>')
|
||||||
|
async def update_palette(request, id):
|
||||||
|
"""Update an existing palette."""
|
||||||
|
try:
|
||||||
|
data = request.json or {}
|
||||||
|
# Ignore any name field; only colors are relevant.
|
||||||
|
if "name" in data:
|
||||||
|
data.pop("name", None)
|
||||||
|
if palettes.update(id, data):
|
||||||
|
palette = palettes.read(id) or {}
|
||||||
|
palette_with_id = {"id": str(id)}
|
||||||
|
palette_with_id.update(palette)
|
||||||
|
return json.dumps(palette_with_id), 200, {'Content-Type': 'application/json'}
|
||||||
|
return json.dumps({"error": "Palette not found"}), 404
|
||||||
|
except Exception as e:
|
||||||
|
return json.dumps({"error": str(e)}), 400
|
||||||
|
|
||||||
|
@controller.delete('/<id>')
|
||||||
|
async def delete_palette(request, id):
|
||||||
|
"""Delete a palette."""
|
||||||
|
if palettes.delete(id):
|
||||||
|
return json.dumps({"message": "Palette deleted successfully"}), 200
|
||||||
|
return json.dumps({"error": "Palette not found"}), 404
|
||||||
77
src/controllers/pattern.py
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
from microdot import Microdot
|
||||||
|
from models.pattern import Pattern
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
controller = Microdot()
|
||||||
|
patterns = Pattern()
|
||||||
|
|
||||||
|
def load_pattern_definitions():
|
||||||
|
"""Load pattern definitions from pattern.json file."""
|
||||||
|
try:
|
||||||
|
# Try different paths for local development vs MicroPython
|
||||||
|
paths = ['db/pattern.json', 'pattern.json', '/db/pattern.json']
|
||||||
|
for path in paths:
|
||||||
|
try:
|
||||||
|
with open(path, 'r') as f:
|
||||||
|
return json.load(f)
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
return {}
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error loading pattern.json: {e}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
@controller.get('/definitions')
|
||||||
|
async def get_pattern_definitions(request):
|
||||||
|
"""Get pattern definitions from pattern.json."""
|
||||||
|
definitions = load_pattern_definitions()
|
||||||
|
return json.dumps(definitions), 200, {'Content-Type': 'application/json'}
|
||||||
|
|
||||||
|
@controller.get('')
|
||||||
|
async def list_patterns(request):
|
||||||
|
"""List all patterns."""
|
||||||
|
return json.dumps(patterns), 200, {'Content-Type': 'application/json'}
|
||||||
|
|
||||||
|
|
||||||
|
@controller.get('/<id>')
|
||||||
|
async def get_pattern(request, id):
|
||||||
|
"""Get a specific pattern by ID."""
|
||||||
|
pattern = patterns.read(id)
|
||||||
|
if pattern is not None:
|
||||||
|
return json.dumps(pattern), 200, {'Content-Type': 'application/json'}
|
||||||
|
return json.dumps({"error": "Pattern not found"}), 404
|
||||||
|
|
||||||
|
|
||||||
|
@controller.post('')
|
||||||
|
async def create_pattern(request):
|
||||||
|
"""Create a new pattern."""
|
||||||
|
try:
|
||||||
|
data = request.json or {}
|
||||||
|
name = data.get("name", "")
|
||||||
|
pattern_id = patterns.create(name, data.get("data", {}))
|
||||||
|
if data:
|
||||||
|
patterns.update(pattern_id, data)
|
||||||
|
return json.dumps(patterns.read(pattern_id)), 201, {'Content-Type': 'application/json'}
|
||||||
|
except Exception as e:
|
||||||
|
return json.dumps({"error": str(e)}), 400
|
||||||
|
|
||||||
|
|
||||||
|
@controller.put('/<id>')
|
||||||
|
async def update_pattern(request, id):
|
||||||
|
"""Update an existing pattern."""
|
||||||
|
try:
|
||||||
|
data = request.json
|
||||||
|
if patterns.update(id, data):
|
||||||
|
return json.dumps(patterns.read(id)), 200, {'Content-Type': 'application/json'}
|
||||||
|
return json.dumps({"error": "Pattern not found"}), 404
|
||||||
|
except Exception as e:
|
||||||
|
return json.dumps({"error": str(e)}), 400
|
||||||
|
|
||||||
|
|
||||||
|
@controller.delete('/<id>')
|
||||||
|
async def delete_pattern(request, id):
|
||||||
|
"""Delete a pattern."""
|
||||||
|
if patterns.delete(id):
|
||||||
|
return json.dumps({"message": "Pattern deleted successfully"}), 200
|
||||||
|
return json.dumps({"error": "Pattern not found"}), 404
|
||||||
204
src/controllers/preset.py
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
from microdot import Microdot
|
||||||
|
from microdot.session import with_session
|
||||||
|
from models.preset import Preset
|
||||||
|
from models.profile import Profile
|
||||||
|
from models.espnow import ESPNow
|
||||||
|
from util.espnow_message import build_message, build_preset_dict, ESPNOW_MAX_PAYLOAD_BYTES
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
|
||||||
|
controller = Microdot()
|
||||||
|
presets = Preset()
|
||||||
|
profiles = Profile()
|
||||||
|
|
||||||
|
def get_current_profile_id(session=None):
|
||||||
|
"""Get the current active profile ID from session or fallback to first."""
|
||||||
|
profile_list = profiles.list()
|
||||||
|
session_profile = None
|
||||||
|
if session is not None:
|
||||||
|
session_profile = session.get('current_profile')
|
||||||
|
if session_profile and session_profile in profile_list:
|
||||||
|
return session_profile
|
||||||
|
if profile_list:
|
||||||
|
return profile_list[0]
|
||||||
|
return None
|
||||||
|
|
||||||
|
@controller.get('')
|
||||||
|
@with_session
|
||||||
|
async def list_presets(request, session):
|
||||||
|
"""List presets for the current profile."""
|
||||||
|
current_profile_id = get_current_profile_id(session)
|
||||||
|
if not current_profile_id:
|
||||||
|
return json.dumps({}), 200, {'Content-Type': 'application/json'}
|
||||||
|
scoped = {
|
||||||
|
pid: pdata for pid, pdata in presets.items()
|
||||||
|
if isinstance(pdata, dict) and str(pdata.get("profile_id")) == str(current_profile_id)
|
||||||
|
}
|
||||||
|
return json.dumps(scoped), 200, {'Content-Type': 'application/json'}
|
||||||
|
|
||||||
|
@controller.get('/<id>')
|
||||||
|
@with_session
|
||||||
|
async def get_preset(request, id, session):
|
||||||
|
"""Get a specific preset by ID (current profile only)."""
|
||||||
|
preset = presets.read(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'}
|
||||||
|
return json.dumps({"error": "Preset not found"}), 404
|
||||||
|
|
||||||
|
@controller.post('')
|
||||||
|
@with_session
|
||||||
|
async def create_preset(request, session):
|
||||||
|
"""Create a new preset for the current profile."""
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
data = request.json or {}
|
||||||
|
except Exception:
|
||||||
|
return json.dumps({"error": "Invalid JSON"}), 400, {'Content-Type': 'application/json'}
|
||||||
|
current_profile_id = get_current_profile_id(session)
|
||||||
|
if not current_profile_id:
|
||||||
|
return json.dumps({"error": "No profile available"}), 404
|
||||||
|
preset_id = presets.create(current_profile_id)
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
data = {}
|
||||||
|
data = dict(data)
|
||||||
|
data["profile_id"] = str(current_profile_id)
|
||||||
|
if presets.update(preset_id, data):
|
||||||
|
preset_data = presets.read(preset_id)
|
||||||
|
return json.dumps({preset_id: preset_data}), 201, {'Content-Type': 'application/json'}
|
||||||
|
return json.dumps({"error": "Failed to create preset"}), 400
|
||||||
|
except Exception as e:
|
||||||
|
return json.dumps({"error": str(e)}), 400
|
||||||
|
|
||||||
|
@controller.put('/<id>')
|
||||||
|
@with_session
|
||||||
|
async def update_preset(request, id, session):
|
||||||
|
"""Update an existing preset (current profile only)."""
|
||||||
|
try:
|
||||||
|
preset = presets.read(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
|
||||||
|
try:
|
||||||
|
data = request.json or {}
|
||||||
|
except Exception:
|
||||||
|
return json.dumps({"error": "Invalid JSON"}), 400, {'Content-Type': 'application/json'}
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
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'}
|
||||||
|
return json.dumps({"error": "Preset not found"}), 404
|
||||||
|
except Exception as e:
|
||||||
|
return json.dumps({"error": str(e)}), 400
|
||||||
|
|
||||||
|
@controller.delete('/<id>')
|
||||||
|
@with_session
|
||||||
|
async def delete_preset(request, id, session):
|
||||||
|
"""Delete a preset (current profile only)."""
|
||||||
|
preset = presets.read(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):
|
||||||
|
return json.dumps({"message": "Preset deleted successfully"}), 200
|
||||||
|
return json.dumps({"error": "Preset not found"}), 404
|
||||||
|
|
||||||
|
|
||||||
|
@controller.post('/send')
|
||||||
|
@with_session
|
||||||
|
async def send_presets(request, session):
|
||||||
|
"""
|
||||||
|
Send one or more presets over ESPNow.
|
||||||
|
|
||||||
|
Body JSON:
|
||||||
|
{"preset_ids": ["1", "2", ...]} or {"ids": ["1", "2", ...]}
|
||||||
|
|
||||||
|
The controller:
|
||||||
|
- looks up each preset in the Preset model
|
||||||
|
- converts them to API-compliant format
|
||||||
|
- splits into <= 240-byte ESPNow messages
|
||||||
|
- sends each message to all configured ESPNow peers.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
data = request.json or {}
|
||||||
|
except Exception:
|
||||||
|
return json.dumps({"error": "Invalid JSON"}), 400, {'Content-Type': 'application/json'}
|
||||||
|
|
||||||
|
preset_ids = data.get('preset_ids') or data.get('ids')
|
||||||
|
if not isinstance(preset_ids, list) or not preset_ids:
|
||||||
|
return json.dumps({"error": "preset_ids must be a non-empty list"}), 400, {'Content-Type': 'application/json'}
|
||||||
|
save_flag = data.get('save', True)
|
||||||
|
save_flag = bool(save_flag)
|
||||||
|
default_id = data.get('default')
|
||||||
|
|
||||||
|
# Build API-compliant preset map keyed by preset ID, include name
|
||||||
|
current_profile_id = get_current_profile_id(session)
|
||||||
|
presets_by_name = {}
|
||||||
|
for pid in preset_ids:
|
||||||
|
preset_data = presets.read(str(pid))
|
||||||
|
if not preset_data:
|
||||||
|
continue
|
||||||
|
if str(preset_data.get("profile_id")) != str(current_profile_id):
|
||||||
|
continue
|
||||||
|
preset_key = str(pid)
|
||||||
|
preset_payload = build_preset_dict(preset_data)
|
||||||
|
preset_payload["name"] = preset_data.get("name", "")
|
||||||
|
presets_by_name[preset_key] = preset_payload
|
||||||
|
|
||||||
|
if not presets_by_name:
|
||||||
|
return json.dumps({"error": "No matching presets found"}), 404, {'Content-Type': 'application/json'}
|
||||||
|
|
||||||
|
if default_id is not None and str(default_id) not in presets_by_name:
|
||||||
|
default_id = None
|
||||||
|
|
||||||
|
# Use shared ESPNow singleton
|
||||||
|
esp = ESPNow()
|
||||||
|
|
||||||
|
async def send_chunk(chunk_presets):
|
||||||
|
# Include save flag so the led-driver can persist when desired.
|
||||||
|
msg = build_message(presets=chunk_presets, save=save_flag, default=default_id)
|
||||||
|
await esp.send(msg)
|
||||||
|
|
||||||
|
MAX_BYTES = ESPNOW_MAX_PAYLOAD_BYTES
|
||||||
|
SEND_DELAY_MS = 100
|
||||||
|
entries = list(presets_by_name.items())
|
||||||
|
total_presets = len(entries)
|
||||||
|
messages_sent = 0
|
||||||
|
|
||||||
|
batch = {}
|
||||||
|
last_msg = None
|
||||||
|
for name, preset_obj in entries:
|
||||||
|
test_batch = dict(batch)
|
||||||
|
test_batch[name] = preset_obj
|
||||||
|
test_msg = build_message(presets=test_batch, save=save_flag, default=default_id)
|
||||||
|
size = len(test_msg)
|
||||||
|
|
||||||
|
if size <= MAX_BYTES or not batch:
|
||||||
|
batch = test_batch
|
||||||
|
last_msg = test_msg
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
await send_chunk(batch)
|
||||||
|
except Exception:
|
||||||
|
return json.dumps({"error": "ESP-NOW send failed"}), 503, {'Content-Type': 'application/json'}
|
||||||
|
await asyncio.sleep_ms(SEND_DELAY_MS)
|
||||||
|
messages_sent += 1
|
||||||
|
batch = {name: preset_obj}
|
||||||
|
last_msg = build_message(presets=batch, save=save_flag, default=default_id)
|
||||||
|
|
||||||
|
if batch:
|
||||||
|
try:
|
||||||
|
await send_chunk(batch)
|
||||||
|
except Exception:
|
||||||
|
return json.dumps({"error": "ESP-NOW send failed"}), 503, {'Content-Type': 'application/json'}
|
||||||
|
await asyncio.sleep_ms(SEND_DELAY_MS)
|
||||||
|
messages_sent += 1
|
||||||
|
|
||||||
|
return json.dumps({
|
||||||
|
"message": "Presets sent via ESPNow",
|
||||||
|
"presets_sent": total_presets,
|
||||||
|
"messages_sent": messages_sent
|
||||||
|
}), 200, {'Content-Type': 'application/json'}
|
||||||
|
|
||||||
242
src/controllers/profile.py
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
from microdot import Microdot
|
||||||
|
from microdot.session import with_session
|
||||||
|
from models.profile import Profile
|
||||||
|
from models.tab import Tab
|
||||||
|
from models.preset import Preset
|
||||||
|
import json
|
||||||
|
|
||||||
|
controller = Microdot()
|
||||||
|
profiles = Profile()
|
||||||
|
tabs = Tab()
|
||||||
|
presets = Preset()
|
||||||
|
|
||||||
|
@controller.get('')
|
||||||
|
@with_session
|
||||||
|
async def list_profiles(request, session):
|
||||||
|
"""List all profiles with current profile info."""
|
||||||
|
profile_list = profiles.list()
|
||||||
|
current_id = session.get('current_profile')
|
||||||
|
if current_id and current_id not in profile_list:
|
||||||
|
current_id = None
|
||||||
|
|
||||||
|
# If no current profile in session, use first one
|
||||||
|
if not current_id and profile_list:
|
||||||
|
current_id = profile_list[0]
|
||||||
|
session['current_profile'] = str(current_id)
|
||||||
|
session.save()
|
||||||
|
|
||||||
|
# Build profiles object
|
||||||
|
profiles_data = {}
|
||||||
|
for profile_id in profile_list:
|
||||||
|
profile_data = profiles.read(profile_id)
|
||||||
|
if profile_data:
|
||||||
|
profiles_data[profile_id] = profile_data
|
||||||
|
|
||||||
|
return json.dumps({
|
||||||
|
"profiles": profiles_data,
|
||||||
|
"current_profile_id": current_id
|
||||||
|
}), 200, {'Content-Type': 'application/json'}
|
||||||
|
|
||||||
|
@controller.get('/current')
|
||||||
|
@with_session
|
||||||
|
async def get_current_profile(request, session):
|
||||||
|
"""Get the current profile ID from session (or fallback)."""
|
||||||
|
profile_list = profiles.list()
|
||||||
|
current_id = session.get('current_profile')
|
||||||
|
if current_id and current_id not in profile_list:
|
||||||
|
current_id = None
|
||||||
|
if not current_id and profile_list:
|
||||||
|
current_id = profile_list[0]
|
||||||
|
session['current_profile'] = str(current_id)
|
||||||
|
session.save()
|
||||||
|
if current_id:
|
||||||
|
profile = profiles.read(current_id)
|
||||||
|
return json.dumps({"id": current_id, "profile": profile}), 200, {'Content-Type': 'application/json'}
|
||||||
|
return json.dumps({"error": "No profile available"}), 404
|
||||||
|
|
||||||
|
@controller.get('/<id>')
|
||||||
|
@with_session
|
||||||
|
async def get_profile(request, id, session):
|
||||||
|
"""Get a specific profile by ID."""
|
||||||
|
# Handle 'current' as a special case
|
||||||
|
if id == 'current':
|
||||||
|
return await get_current_profile(request, session)
|
||||||
|
|
||||||
|
profile = profiles.read(id)
|
||||||
|
if profile:
|
||||||
|
return json.dumps(profile), 200, {'Content-Type': 'application/json'}
|
||||||
|
return json.dumps({"error": "Profile not found"}), 404
|
||||||
|
|
||||||
|
@controller.post('/<id>/apply')
|
||||||
|
@with_session
|
||||||
|
async def apply_profile(request, session, id):
|
||||||
|
"""Apply a profile by saving it to session."""
|
||||||
|
if not profiles.read(id):
|
||||||
|
return json.dumps({"error": "Profile not found"}), 404
|
||||||
|
session['current_profile'] = str(id)
|
||||||
|
session.save()
|
||||||
|
return json.dumps({"message": "Profile applied", "id": str(id)}), 200, {'Content-Type': 'application/json'}
|
||||||
|
|
||||||
|
@controller.post('')
|
||||||
|
async def create_profile(request):
|
||||||
|
"""Create a new profile."""
|
||||||
|
try:
|
||||||
|
data = request.json or {}
|
||||||
|
name = data.get("name", "")
|
||||||
|
profile_id = profiles.create(name)
|
||||||
|
if data:
|
||||||
|
profiles.update(profile_id, data)
|
||||||
|
profile_data = profiles.read(profile_id)
|
||||||
|
return json.dumps({profile_id: profile_data}), 201, {'Content-Type': 'application/json'}
|
||||||
|
except Exception as e:
|
||||||
|
return json.dumps({"error": str(e)}), 400
|
||||||
|
|
||||||
|
@controller.post('/<id>/clone')
|
||||||
|
async def clone_profile(request, id):
|
||||||
|
"""Clone an existing profile along with its tabs and palette."""
|
||||||
|
try:
|
||||||
|
source = profiles.read(id)
|
||||||
|
if not source:
|
||||||
|
return json.dumps({"error": "Profile not found"}), 404
|
||||||
|
|
||||||
|
data = request.json or {}
|
||||||
|
source_name = source.get("name") or f"Profile {id}"
|
||||||
|
new_name = data.get("name") or source_name
|
||||||
|
profile_type = source.get("type", "tabs")
|
||||||
|
|
||||||
|
def allocate_id(model, cache):
|
||||||
|
if "next" not in cache:
|
||||||
|
max_id = max((int(k) for k in model.keys() if str(k).isdigit()), default=0)
|
||||||
|
cache["next"] = max_id + 1
|
||||||
|
next_id = str(cache["next"])
|
||||||
|
cache["next"] += 1
|
||||||
|
return next_id
|
||||||
|
|
||||||
|
def map_preset_container(value, id_map, preset_cache, new_profile_id, new_presets):
|
||||||
|
if isinstance(value, list):
|
||||||
|
return [map_preset_container(v, id_map, preset_cache, new_profile_id, new_presets) for v in value]
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
preset_id = str(value)
|
||||||
|
if preset_id in id_map:
|
||||||
|
return id_map[preset_id]
|
||||||
|
preset_data = presets.read(preset_id)
|
||||||
|
if not preset_data:
|
||||||
|
return None
|
||||||
|
new_preset_id = allocate_id(presets, preset_cache)
|
||||||
|
clone_data = dict(preset_data)
|
||||||
|
clone_data["profile_id"] = str(new_profile_id)
|
||||||
|
new_presets[new_preset_id] = clone_data
|
||||||
|
id_map[preset_id] = new_preset_id
|
||||||
|
return new_preset_id
|
||||||
|
|
||||||
|
# Prepare new IDs without writing until everything is ready.
|
||||||
|
profile_cache = {}
|
||||||
|
palette_cache = {}
|
||||||
|
tab_cache = {}
|
||||||
|
preset_cache = {}
|
||||||
|
|
||||||
|
new_profile_id = allocate_id(profiles, profile_cache)
|
||||||
|
new_palette_id = allocate_id(profiles._palette_model, palette_cache)
|
||||||
|
|
||||||
|
# Clone palette colors into the new profile's palette
|
||||||
|
src_palette_id = source.get("palette_id")
|
||||||
|
palette_colors = []
|
||||||
|
if src_palette_id:
|
||||||
|
try:
|
||||||
|
palette_colors = profiles._palette_model.read(src_palette_id)
|
||||||
|
except Exception:
|
||||||
|
palette_colors = []
|
||||||
|
|
||||||
|
# Clone tabs and presets used by those tabs
|
||||||
|
source_tabs = source.get("tabs")
|
||||||
|
if not isinstance(source_tabs, list) or len(source_tabs) == 0:
|
||||||
|
source_tabs = source.get("tab_order", [])
|
||||||
|
source_tabs = source_tabs or []
|
||||||
|
cloned_tab_ids = []
|
||||||
|
preset_id_map = {}
|
||||||
|
new_tabs = {}
|
||||||
|
new_presets = {}
|
||||||
|
for tab_id in source_tabs:
|
||||||
|
tab = tabs.read(tab_id)
|
||||||
|
if not tab:
|
||||||
|
continue
|
||||||
|
tab_name = tab.get("name") or f"Tab {tab_id}"
|
||||||
|
clone_name = tab_name
|
||||||
|
mapped_presets = map_preset_container(tab.get("presets"), preset_id_map, preset_cache, new_profile_id, new_presets)
|
||||||
|
clone_id = allocate_id(tabs, tab_cache)
|
||||||
|
clone_data = {
|
||||||
|
"name": clone_name,
|
||||||
|
"names": tab.get("names") or [],
|
||||||
|
"presets": mapped_presets if mapped_presets is not None else []
|
||||||
|
}
|
||||||
|
extra = {k: v for k, v in tab.items() if k not in ("name", "names", "presets")}
|
||||||
|
if "presets_flat" in extra:
|
||||||
|
extra["presets_flat"] = map_preset_container(extra.get("presets_flat"), preset_id_map, preset_cache, new_profile_id, new_presets)
|
||||||
|
if extra:
|
||||||
|
clone_data.update(extra)
|
||||||
|
new_tabs[clone_id] = clone_data
|
||||||
|
cloned_tab_ids.append(clone_id)
|
||||||
|
|
||||||
|
new_profile_data = {
|
||||||
|
"name": new_name,
|
||||||
|
"type": profile_type,
|
||||||
|
"tabs": cloned_tab_ids,
|
||||||
|
"scenes": list(source.get("scenes", [])) if isinstance(source.get("scenes", []), list) else [],
|
||||||
|
"palette_id": str(new_palette_id),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Commit all changes and save once per model.
|
||||||
|
profiles._palette_model[str(new_palette_id)] = list(palette_colors) if palette_colors else []
|
||||||
|
for pid, pdata in new_presets.items():
|
||||||
|
presets[pid] = pdata
|
||||||
|
for tid, tdata in new_tabs.items():
|
||||||
|
tabs[tid] = tdata
|
||||||
|
profiles[str(new_profile_id)] = new_profile_data
|
||||||
|
|
||||||
|
profiles._palette_model.save()
|
||||||
|
presets.save()
|
||||||
|
tabs.save()
|
||||||
|
profiles.save()
|
||||||
|
|
||||||
|
return json.dumps({new_profile_id: new_profile_data}), 201, {'Content-Type': 'application/json'}
|
||||||
|
except Exception as e:
|
||||||
|
return json.dumps({"error": str(e)}), 400
|
||||||
|
|
||||||
|
@controller.put('/current')
|
||||||
|
@with_session
|
||||||
|
async def update_current_profile(request, session):
|
||||||
|
"""Update the current profile using session (or fallback)."""
|
||||||
|
try:
|
||||||
|
data = request.json or {}
|
||||||
|
profile_list = profiles.list()
|
||||||
|
current_id = session.get('current_profile')
|
||||||
|
if not current_id and profile_list:
|
||||||
|
current_id = profile_list[0]
|
||||||
|
session['current_profile'] = str(current_id)
|
||||||
|
session.save()
|
||||||
|
if not current_id:
|
||||||
|
return json.dumps({"error": "No profile available"}), 404
|
||||||
|
if profiles.update(current_id, data):
|
||||||
|
return json.dumps(profiles.read(current_id)), 200, {'Content-Type': 'application/json'}
|
||||||
|
return json.dumps({"error": "Profile not found"}), 404
|
||||||
|
except Exception as e:
|
||||||
|
return json.dumps({"error": str(e)}), 400
|
||||||
|
|
||||||
|
@controller.put('/<id>')
|
||||||
|
async def update_profile(request, id):
|
||||||
|
"""Update an existing profile."""
|
||||||
|
try:
|
||||||
|
data = request.json
|
||||||
|
if profiles.update(id, data):
|
||||||
|
return json.dumps(profiles.read(id)), 200, {'Content-Type': 'application/json'}
|
||||||
|
return json.dumps({"error": "Profile not found"}), 404
|
||||||
|
except Exception as e:
|
||||||
|
return json.dumps({"error": str(e)}), 400
|
||||||
|
|
||||||
|
@controller.delete('/<id>')
|
||||||
|
async def delete_profile(request, id):
|
||||||
|
"""Delete a profile."""
|
||||||
|
if profiles.delete(id):
|
||||||
|
return json.dumps({"message": "Profile deleted successfully"}), 200
|
||||||
|
return json.dumps({"error": "Profile not found"}), 404
|
||||||
49
src/controllers/scene.py
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
from microdot import Microdot
|
||||||
|
from models.scene import Scene
|
||||||
|
import json
|
||||||
|
|
||||||
|
controller = Microdot()
|
||||||
|
scenes = Scene()
|
||||||
|
|
||||||
|
@controller.get('')
|
||||||
|
async def list_scenes(request):
|
||||||
|
"""List all scenes."""
|
||||||
|
return json.dumps(scenes), 200, {'Content-Type': 'application/json'}
|
||||||
|
|
||||||
|
@controller.get('/<id>')
|
||||||
|
async def get_scene(request, id):
|
||||||
|
"""Get a specific scene by ID."""
|
||||||
|
scene = scenes.read(id)
|
||||||
|
if scene:
|
||||||
|
return json.dumps(scene), 200, {'Content-Type': 'application/json'}
|
||||||
|
return json.dumps({"error": "Scene not found"}), 404
|
||||||
|
|
||||||
|
@controller.post('')
|
||||||
|
async def create_scene(request):
|
||||||
|
"""Create a new scene."""
|
||||||
|
try:
|
||||||
|
data = request.json
|
||||||
|
scene_id = scenes.create()
|
||||||
|
if scenes.update(scene_id, data):
|
||||||
|
return json.dumps(scenes.read(scene_id)), 201, {'Content-Type': 'application/json'}
|
||||||
|
return json.dumps({"error": "Failed to create scene"}), 400
|
||||||
|
except Exception as e:
|
||||||
|
return json.dumps({"error": str(e)}), 400
|
||||||
|
|
||||||
|
@controller.put('/<id>')
|
||||||
|
async def update_scene(request, id):
|
||||||
|
"""Update an existing scene."""
|
||||||
|
try:
|
||||||
|
data = request.json
|
||||||
|
if scenes.update(id, data):
|
||||||
|
return json.dumps(scenes.read(id)), 200, {'Content-Type': 'application/json'}
|
||||||
|
return json.dumps({"error": "Scene not found"}), 404
|
||||||
|
except Exception as e:
|
||||||
|
return json.dumps({"error": str(e)}), 400
|
||||||
|
|
||||||
|
@controller.delete('/<id>')
|
||||||
|
async def delete_scene(request, id):
|
||||||
|
"""Delete a scene."""
|
||||||
|
if scenes.delete(id):
|
||||||
|
return json.dumps({"message": "Scene deleted successfully"}), 200
|
||||||
|
return json.dumps({"error": "Scene not found"}), 404
|
||||||
51
src/controllers/sequence.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
from microdot import Microdot
|
||||||
|
from models.squence import Sequence
|
||||||
|
import json
|
||||||
|
|
||||||
|
controller = Microdot()
|
||||||
|
sequences = Sequence()
|
||||||
|
|
||||||
|
@controller.get('')
|
||||||
|
async def list_sequences(request):
|
||||||
|
"""List all sequences."""
|
||||||
|
return json.dumps(sequences), 200, {'Content-Type': 'application/json'}
|
||||||
|
|
||||||
|
@controller.get('/<id>')
|
||||||
|
async def get_sequence(request, id):
|
||||||
|
"""Get a specific sequence by ID."""
|
||||||
|
sequence = sequences.read(id)
|
||||||
|
if sequence:
|
||||||
|
return json.dumps(sequence), 200, {'Content-Type': 'application/json'}
|
||||||
|
return json.dumps({"error": "Sequence not found"}), 404
|
||||||
|
|
||||||
|
@controller.post('')
|
||||||
|
async def create_sequence(request):
|
||||||
|
"""Create a new sequence."""
|
||||||
|
try:
|
||||||
|
data = request.json or {}
|
||||||
|
group_name = data.get("group_name", "")
|
||||||
|
preset_names = data.get("presets", None)
|
||||||
|
sequence_id = sequences.create(group_name, preset_names)
|
||||||
|
if data:
|
||||||
|
sequences.update(sequence_id, data)
|
||||||
|
return json.dumps(sequences.read(sequence_id)), 201, {'Content-Type': 'application/json'}
|
||||||
|
except Exception as e:
|
||||||
|
return json.dumps({"error": str(e)}), 400
|
||||||
|
|
||||||
|
@controller.put('/<id>')
|
||||||
|
async def update_sequence(request, id):
|
||||||
|
"""Update an existing sequence."""
|
||||||
|
try:
|
||||||
|
data = request.json
|
||||||
|
if sequences.update(id, data):
|
||||||
|
return json.dumps(sequences.read(id)), 200, {'Content-Type': 'application/json'}
|
||||||
|
return json.dumps({"error": "Sequence not found"}), 404
|
||||||
|
except Exception as e:
|
||||||
|
return json.dumps({"error": str(e)}), 400
|
||||||
|
|
||||||
|
@controller.delete('/<id>')
|
||||||
|
async def delete_sequence(request, id):
|
||||||
|
"""Delete a sequence."""
|
||||||
|
if sequences.delete(id):
|
||||||
|
return json.dumps({"message": "Sequence deleted successfully"}), 200
|
||||||
|
return json.dumps({"error": "Sequence not found"}), 404
|
||||||
80
src/controllers/settings.py
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
from microdot import Microdot, send_file
|
||||||
|
from settings import Settings
|
||||||
|
import util.wifi as wifi
|
||||||
|
import json
|
||||||
|
|
||||||
|
controller = Microdot()
|
||||||
|
settings = Settings()
|
||||||
|
|
||||||
|
@controller.get('')
|
||||||
|
async def get_settings(request):
|
||||||
|
"""Get all settings."""
|
||||||
|
# Settings is already a dict subclass; avoid dict() wrapper which can
|
||||||
|
# trigger MicroPython's "dict update sequence has wrong length" quirk.
|
||||||
|
return json.dumps(settings), 200, {'Content-Type': 'application/json'}
|
||||||
|
|
||||||
|
@controller.get('/wifi/ap')
|
||||||
|
async def get_ap_config(request):
|
||||||
|
"""Get Access Point configuration."""
|
||||||
|
config = wifi.get_ap_config()
|
||||||
|
if config:
|
||||||
|
# Also get saved settings
|
||||||
|
config['saved_ssid'] = settings.get('wifi_ap_ssid')
|
||||||
|
config['saved_password'] = settings.get('wifi_ap_password')
|
||||||
|
config['saved_channel'] = settings.get('wifi_ap_channel')
|
||||||
|
return json.dumps(config), 200, {'Content-Type': 'application/json'}
|
||||||
|
return json.dumps({"error": "Failed to get AP config"}), 500
|
||||||
|
|
||||||
|
@controller.post('/wifi/ap')
|
||||||
|
async def configure_ap(request):
|
||||||
|
"""Configure Access Point."""
|
||||||
|
try:
|
||||||
|
data = request.json
|
||||||
|
ssid = data.get('ssid')
|
||||||
|
password = data.get('password', '')
|
||||||
|
channel = data.get('channel')
|
||||||
|
|
||||||
|
if not ssid:
|
||||||
|
return json.dumps({"error": "SSID is required"}), 400
|
||||||
|
|
||||||
|
# Validate channel (1-11 for 2.4GHz)
|
||||||
|
if channel is not None:
|
||||||
|
channel = int(channel)
|
||||||
|
if channel < 1 or channel > 11:
|
||||||
|
return json.dumps({"error": "Channel must be between 1 and 11"}), 400
|
||||||
|
|
||||||
|
# Save to settings
|
||||||
|
settings['wifi_ap_ssid'] = ssid
|
||||||
|
settings['wifi_ap_password'] = password
|
||||||
|
if channel is not None:
|
||||||
|
settings['wifi_ap_channel'] = channel
|
||||||
|
settings.save()
|
||||||
|
|
||||||
|
# Configure AP
|
||||||
|
wifi.ap(ssid, password, channel)
|
||||||
|
|
||||||
|
return json.dumps({
|
||||||
|
"message": "AP configured successfully",
|
||||||
|
"ssid": ssid,
|
||||||
|
"channel": channel
|
||||||
|
}), 200, {'Content-Type': 'application/json'}
|
||||||
|
except Exception as e:
|
||||||
|
return json.dumps({"error": str(e)}), 500
|
||||||
|
|
||||||
|
@controller.put('/settings')
|
||||||
|
async def update_settings(request):
|
||||||
|
"""Update general settings."""
|
||||||
|
try:
|
||||||
|
data = request.json
|
||||||
|
for key, value in data.items():
|
||||||
|
settings[key] = value
|
||||||
|
settings.save()
|
||||||
|
return json.dumps({"message": "Settings updated successfully"}), 200, {'Content-Type': 'application/json'}
|
||||||
|
except Exception as e:
|
||||||
|
return json.dumps({"error": str(e)}), 500
|
||||||
|
|
||||||
|
@controller.get('/page')
|
||||||
|
async def settings_page(request):
|
||||||
|
"""Serve the settings page."""
|
||||||
|
return send_file('templates/settings.html')
|
||||||
|
|
||||||
346
src/controllers/tab.py
Normal file
@@ -0,0 +1,346 @@
|
|||||||
|
from microdot import Microdot, send_file
|
||||||
|
from microdot.session import with_session
|
||||||
|
from models.tab import Tab
|
||||||
|
from models.profile import Profile
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
|
controller = Microdot()
|
||||||
|
tabs = Tab()
|
||||||
|
profiles = Profile()
|
||||||
|
|
||||||
|
def get_current_profile_id(session=None):
|
||||||
|
"""Get the current active profile ID from session or fallback to first."""
|
||||||
|
profile_list = profiles.list()
|
||||||
|
session_profile = None
|
||||||
|
if session is not None:
|
||||||
|
session_profile = session.get('current_profile')
|
||||||
|
if session_profile and session_profile in profile_list:
|
||||||
|
return session_profile
|
||||||
|
if profile_list:
|
||||||
|
return profile_list[0]
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_profile_tab_order(profile_id):
|
||||||
|
"""Get the tab order for a profile."""
|
||||||
|
if not profile_id:
|
||||||
|
return []
|
||||||
|
profile = profiles.read(profile_id)
|
||||||
|
if profile:
|
||||||
|
# Support both "tab_order" (old) and "tabs" (new) format
|
||||||
|
return profile.get("tabs", profile.get("tab_order", []))
|
||||||
|
return []
|
||||||
|
|
||||||
|
def get_current_tab_id(request, session=None):
|
||||||
|
"""Get the current tab ID from cookie."""
|
||||||
|
# Read from cookie first
|
||||||
|
current_tab = request.cookies.get('current_tab')
|
||||||
|
if current_tab:
|
||||||
|
return current_tab
|
||||||
|
|
||||||
|
# Fallback to first tab in current profile
|
||||||
|
profile_id = get_current_profile_id(session)
|
||||||
|
if profile_id:
|
||||||
|
profile = profiles.read(profile_id)
|
||||||
|
if profile:
|
||||||
|
# Support both "tabs" (new) and "tab_order" (old) format
|
||||||
|
tabs_list = profile.get("tabs", profile.get("tab_order", []))
|
||||||
|
if tabs_list:
|
||||||
|
return tabs_list[0]
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _render_tabs_list_fragment(request, session):
|
||||||
|
"""Helper function to render tabs list HTML fragment."""
|
||||||
|
profile_id = get_current_profile_id(session)
|
||||||
|
# #region agent log
|
||||||
|
try:
|
||||||
|
os.makedirs('/home/pi/led-controller/.cursor', exist_ok=True)
|
||||||
|
with open('/home/pi/led-controller/.cursor/debug.log', 'a') as _log:
|
||||||
|
_log.write(json.dumps({
|
||||||
|
"sessionId": "debug-session",
|
||||||
|
"runId": "tabs-pre-fix",
|
||||||
|
"hypothesisId": "H1",
|
||||||
|
"location": "src/controllers/tab.py:_render_tabs_list_fragment",
|
||||||
|
"message": "tabs list fragment",
|
||||||
|
"data": {
|
||||||
|
"profile_id": profile_id,
|
||||||
|
"profile_count": len(profiles.list())
|
||||||
|
},
|
||||||
|
"timestamp": int(time.time() * 1000)
|
||||||
|
}) + "\n")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# #endregion
|
||||||
|
if not profile_id:
|
||||||
|
return '<div class="tabs-list">No profile selected</div>', 200, {'Content-Type': 'text/html'}
|
||||||
|
|
||||||
|
tab_order = get_profile_tab_order(profile_id)
|
||||||
|
current_tab_id = get_current_tab_id(request, session)
|
||||||
|
|
||||||
|
html = '<div class="tabs-list">'
|
||||||
|
for tab_id in tab_order:
|
||||||
|
tab_data = tabs.read(tab_id)
|
||||||
|
if tab_data:
|
||||||
|
active_class = 'active' if str(tab_id) == str(current_tab_id) else ''
|
||||||
|
tab_name = tab_data.get('name', 'Tab ' + str(tab_id))
|
||||||
|
html += (
|
||||||
|
'<button class="tab-button ' + active_class + '" '
|
||||||
|
'hx-get="/tabs/' + str(tab_id) + '/content-fragment" '
|
||||||
|
'hx-target="#tab-content" '
|
||||||
|
'hx-swap="innerHTML" '
|
||||||
|
'hx-push-url="true" '
|
||||||
|
'hx-trigger="click" '
|
||||||
|
'onclick="document.querySelectorAll(\'.tab-button\').forEach(b => b.classList.remove(\'active\')); this.classList.add(\'active\');">'
|
||||||
|
+ tab_name +
|
||||||
|
'</button>'
|
||||||
|
)
|
||||||
|
html += '</div>'
|
||||||
|
return html, 200, {'Content-Type': 'text/html'}
|
||||||
|
|
||||||
|
def _render_tab_content_fragment(request, session, id):
|
||||||
|
"""Helper function to render tab content HTML fragment."""
|
||||||
|
# Handle 'current' as a special case
|
||||||
|
if id == 'current':
|
||||||
|
current_tab_id = get_current_tab_id(request, session)
|
||||||
|
if not current_tab_id:
|
||||||
|
accept_header = request.headers.get('Accept', '')
|
||||||
|
wants_html = 'text/html' in accept_header
|
||||||
|
if wants_html:
|
||||||
|
return '<div class="error">No current tab set</div>', 404, {'Content-Type': 'text/html'}
|
||||||
|
return json.dumps({"error": "No current tab set"}), 404
|
||||||
|
id = current_tab_id
|
||||||
|
|
||||||
|
tab = tabs.read(id)
|
||||||
|
if not tab:
|
||||||
|
return '<div>Tab not found</div>', 404, {'Content-Type': 'text/html'}
|
||||||
|
|
||||||
|
# Set this tab as the current tab in session
|
||||||
|
session['current_tab'] = str(id)
|
||||||
|
session.save()
|
||||||
|
|
||||||
|
# If this is a direct page load (not HTMX), return full UI so CSS loads.
|
||||||
|
if not request.headers.get('HX-Request'):
|
||||||
|
return send_file('templates/index.html')
|
||||||
|
|
||||||
|
tab_name = tab.get('name', 'Tab ' + str(id))
|
||||||
|
|
||||||
|
html = (
|
||||||
|
'<div class="presets-section" data-tab-id="' + str(id) + '">'
|
||||||
|
'<h3>Presets</h3>'
|
||||||
|
'<div class="profiles-actions" style="margin-bottom: 1rem;"></div>'
|
||||||
|
'<div id="presets-list-tab" class="presets-list">'
|
||||||
|
'<!-- Presets will be loaded here -->'
|
||||||
|
'</div>'
|
||||||
|
'</div>'
|
||||||
|
)
|
||||||
|
return html, 200, {'Content-Type': 'text/html'}
|
||||||
|
|
||||||
|
@controller.get('')
|
||||||
|
@with_session
|
||||||
|
async def list_tabs(request, session):
|
||||||
|
"""List all tabs with current tab info."""
|
||||||
|
profile_id = get_current_profile_id(session)
|
||||||
|
current_tab_id = get_current_tab_id(request, session)
|
||||||
|
|
||||||
|
# Get tab order for current profile
|
||||||
|
tab_order = get_profile_tab_order(profile_id) if profile_id else []
|
||||||
|
|
||||||
|
# Build tabs list with metadata
|
||||||
|
tabs_data = {}
|
||||||
|
for tab_id in tabs.list():
|
||||||
|
tab_data = tabs.read(tab_id)
|
||||||
|
if tab_data:
|
||||||
|
tabs_data[tab_id] = tab_data
|
||||||
|
|
||||||
|
return json.dumps({
|
||||||
|
"tabs": tabs_data,
|
||||||
|
"tab_order": tab_order,
|
||||||
|
"current_tab_id": current_tab_id,
|
||||||
|
"profile_id": profile_id
|
||||||
|
}), 200, {'Content-Type': 'application/json'}
|
||||||
|
|
||||||
|
# Get current tab - returns JSON with tab data and content info
|
||||||
|
@controller.get('/current')
|
||||||
|
@with_session
|
||||||
|
async def get_current_tab(request, session):
|
||||||
|
"""Get the current tab from session."""
|
||||||
|
current_tab_id = get_current_tab_id(request, session)
|
||||||
|
if not current_tab_id:
|
||||||
|
return json.dumps({"error": "No current tab set", "tab": None, "tab_id": None}), 404
|
||||||
|
|
||||||
|
tab = tabs.read(current_tab_id)
|
||||||
|
if tab:
|
||||||
|
return json.dumps({
|
||||||
|
"tab": tab,
|
||||||
|
"tab_id": current_tab_id
|
||||||
|
}), 200, {'Content-Type': 'application/json'}
|
||||||
|
return json.dumps({"error": "Tab not found", "tab": None, "tab_id": None}), 404
|
||||||
|
|
||||||
|
@controller.post('/<id>/set-current')
|
||||||
|
async def set_current_tab(request, id):
|
||||||
|
"""Set a tab as the current tab in cookie."""
|
||||||
|
tab = tabs.read(id)
|
||||||
|
if not tab:
|
||||||
|
return json.dumps({"error": "Tab not found"}), 404
|
||||||
|
|
||||||
|
# Set cookie with current tab
|
||||||
|
response_data = json.dumps({"message": "Current tab set", "tab_id": id})
|
||||||
|
response = response_data, 200, {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Set-Cookie': f'current_tab={id}; Path=/; Max-Age=31536000' # 1 year expiry
|
||||||
|
}
|
||||||
|
return response
|
||||||
|
|
||||||
|
@controller.get('/<id>')
|
||||||
|
async def get_tab(request, id):
|
||||||
|
"""Get a specific tab by ID."""
|
||||||
|
tab = tabs.read(id)
|
||||||
|
if tab:
|
||||||
|
return json.dumps(tab), 200, {'Content-Type': 'application/json'}
|
||||||
|
return json.dumps({"error": "Tab not found"}), 404
|
||||||
|
|
||||||
|
@controller.put('/<id>')
|
||||||
|
async def update_tab(request, id):
|
||||||
|
"""Update an existing tab."""
|
||||||
|
try:
|
||||||
|
data = request.json
|
||||||
|
if tabs.update(id, data):
|
||||||
|
return json.dumps(tabs.read(id)), 200, {'Content-Type': 'application/json'}
|
||||||
|
return json.dumps({"error": "Tab not found"}), 404
|
||||||
|
except Exception as e:
|
||||||
|
return json.dumps({"error": str(e)}), 400
|
||||||
|
|
||||||
|
@controller.delete('/<id>')
|
||||||
|
@with_session
|
||||||
|
async def delete_tab(request, session, id):
|
||||||
|
"""Delete a tab."""
|
||||||
|
try:
|
||||||
|
# Handle 'current' tab ID
|
||||||
|
if id == 'current':
|
||||||
|
current_tab_id = get_current_tab_id(request, session)
|
||||||
|
if current_tab_id:
|
||||||
|
id = current_tab_id
|
||||||
|
else:
|
||||||
|
return json.dumps({"error": "No current tab to delete"}), 404
|
||||||
|
|
||||||
|
if tabs.delete(id):
|
||||||
|
# Remove from profile's tabs
|
||||||
|
profile_id = get_current_profile_id(session)
|
||||||
|
if profile_id:
|
||||||
|
profile = profiles.read(profile_id)
|
||||||
|
if profile:
|
||||||
|
# Support both "tabs" (new) and "tab_order" (old) format
|
||||||
|
tabs_list = profile.get('tabs', profile.get('tab_order', []))
|
||||||
|
if id in tabs_list:
|
||||||
|
tabs_list.remove(id)
|
||||||
|
profile['tabs'] = tabs_list
|
||||||
|
# Remove old tab_order if it exists
|
||||||
|
if 'tab_order' in profile:
|
||||||
|
del profile['tab_order']
|
||||||
|
profiles.update(profile_id, profile)
|
||||||
|
|
||||||
|
# Clear cookie if the deleted tab was the current tab
|
||||||
|
current_tab_id = get_current_tab_id(request, session)
|
||||||
|
if current_tab_id == id:
|
||||||
|
response_data = json.dumps({"message": "Tab deleted successfully"})
|
||||||
|
response = response_data, 200, {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Set-Cookie': 'current_tab=; Path=/; Max-Age=0' # Clear cookie
|
||||||
|
}
|
||||||
|
return response
|
||||||
|
|
||||||
|
return json.dumps({"message": "Tab deleted successfully"}), 200, {'Content-Type': 'application/json'}
|
||||||
|
|
||||||
|
return json.dumps({"error": "Tab not found"}), 404
|
||||||
|
except Exception as e:
|
||||||
|
import sys
|
||||||
|
try:
|
||||||
|
sys.print_exception(e)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
return json.dumps({"error": str(e)}), 500, {'Content-Type': 'application/json'}
|
||||||
|
|
||||||
|
@controller.post('')
|
||||||
|
@with_session
|
||||||
|
async def create_tab(request, session):
|
||||||
|
"""Create a new tab."""
|
||||||
|
try:
|
||||||
|
# Handle form data or JSON
|
||||||
|
if request.form:
|
||||||
|
name = request.form.get('name', '').strip()
|
||||||
|
ids_str = request.form.get('ids', '1').strip()
|
||||||
|
names = [id.strip() for id in ids_str.split(',') if id.strip()]
|
||||||
|
preset_ids = None
|
||||||
|
else:
|
||||||
|
data = request.json or {}
|
||||||
|
name = data.get("name", "")
|
||||||
|
names = data.get("names", None)
|
||||||
|
preset_ids = data.get("presets", None)
|
||||||
|
|
||||||
|
if not name:
|
||||||
|
return json.dumps({"error": "Tab name cannot be empty"}), 400
|
||||||
|
|
||||||
|
tab_id = tabs.create(name, names, preset_ids)
|
||||||
|
|
||||||
|
# Add to current profile's tabs
|
||||||
|
profile_id = get_current_profile_id(session)
|
||||||
|
if profile_id:
|
||||||
|
profile = profiles.read(profile_id)
|
||||||
|
if profile:
|
||||||
|
# Support both "tabs" (new) and "tab_order" (old) format
|
||||||
|
tabs_list = profile.get('tabs', profile.get('tab_order', []))
|
||||||
|
if tab_id not in tabs_list:
|
||||||
|
tabs_list.append(tab_id)
|
||||||
|
profile['tabs'] = tabs_list
|
||||||
|
# Remove old tab_order if it exists
|
||||||
|
if 'tab_order' in profile:
|
||||||
|
del profile['tab_order']
|
||||||
|
profiles.update(profile_id, profile)
|
||||||
|
|
||||||
|
# Return JSON response with tab ID
|
||||||
|
tab_data = tabs.read(tab_id)
|
||||||
|
return json.dumps({tab_id: tab_data}), 201, {'Content-Type': 'application/json'}
|
||||||
|
except Exception as e:
|
||||||
|
import sys
|
||||||
|
sys.print_exception(e)
|
||||||
|
return json.dumps({"error": str(e)}), 400
|
||||||
|
|
||||||
|
@controller.post('/<id>/clone')
|
||||||
|
@with_session
|
||||||
|
async def clone_tab(request, session, id):
|
||||||
|
"""Clone an existing tab and add it to the current profile."""
|
||||||
|
try:
|
||||||
|
source = tabs.read(id)
|
||||||
|
if not source:
|
||||||
|
return json.dumps({"error": "Tab not found"}), 404
|
||||||
|
|
||||||
|
data = request.json or {}
|
||||||
|
source_name = source.get("name") or f"Tab {id}"
|
||||||
|
new_name = data.get("name") or f"{source_name} Copy"
|
||||||
|
clone_id = tabs.create(new_name, source.get("names"), source.get("presets"))
|
||||||
|
extra = {k: v for k, v in source.items() if k not in ("name", "names", "presets")}
|
||||||
|
if extra:
|
||||||
|
tabs.update(clone_id, extra)
|
||||||
|
|
||||||
|
profile_id = get_current_profile_id(session)
|
||||||
|
if profile_id:
|
||||||
|
profile = profiles.read(profile_id)
|
||||||
|
if profile:
|
||||||
|
tabs_list = profile.get('tabs', profile.get('tab_order', []))
|
||||||
|
if clone_id not in tabs_list:
|
||||||
|
tabs_list.append(clone_id)
|
||||||
|
profile['tabs'] = tabs_list
|
||||||
|
if 'tab_order' in profile:
|
||||||
|
del profile['tab_order']
|
||||||
|
profiles.update(profile_id, profile)
|
||||||
|
|
||||||
|
tab_data = tabs.read(clone_id)
|
||||||
|
return json.dumps({clone_id: tab_data}), 201, {'Content-Type': 'application/json'}
|
||||||
|
except Exception as e:
|
||||||
|
import sys
|
||||||
|
try:
|
||||||
|
sys.print_exception(e)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
return json.dumps({"error": str(e)}), 400
|
||||||
171
src/main.py
@@ -1,63 +1,146 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import aioespnow
|
|
||||||
from settings import Settings
|
|
||||||
from patterns import Patterns
|
|
||||||
from web import web
|
|
||||||
import gc
|
import gc
|
||||||
import utime
|
|
||||||
import machine
|
|
||||||
import ntptime
|
|
||||||
import time
|
|
||||||
import wifi
|
|
||||||
import json
|
import json
|
||||||
import network
|
import machine
|
||||||
|
from machine import Pin
|
||||||
|
from microdot import Microdot, send_file
|
||||||
|
from microdot.websocket import with_websocket
|
||||||
|
from microdot.session import Session
|
||||||
|
from settings import Settings
|
||||||
|
|
||||||
|
import aioespnow
|
||||||
|
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
|
||||||
|
from models.espnow import ESPNow
|
||||||
|
from util.espnow_message import split_espnow_message
|
||||||
|
|
||||||
|
|
||||||
|
async def main(port=80):
|
||||||
async def main():
|
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
patterns = Patterns(10, settings["num_leds"], selected=settings["pattern"])
|
|
||||||
network.WLAN(network.WLAN.IF_STA).active(True)
|
|
||||||
|
|
||||||
e = aioespnow.AIOESPNow() # Returns AIOESPNow enhanced with async support
|
|
||||||
e.active(True)
|
|
||||||
w = web(settings, patterns)
|
|
||||||
print(settings)
|
print(settings)
|
||||||
# start the server in a bacakground task
|
|
||||||
print("Starting")
|
print("Starting")
|
||||||
server = asyncio.create_task(w.start_server(host="0.0.0.0", port=80))
|
|
||||||
|
|
||||||
async def send():
|
# Initialize ESPNow singleton (config + peers)
|
||||||
peer = bytes.fromhex("e4b323c5411d")
|
esp = ESPNow()
|
||||||
perr = b'\xbb\xbb\xbb\xbb\xbb\xbb'
|
|
||||||
e.add_peer(peer)
|
app = Microdot()
|
||||||
print(peer)
|
|
||||||
msg = {"delay": 1000}
|
# 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
|
||||||
|
# Verify controllers are Microdot instances before mounting
|
||||||
|
controllers_to_mount = [
|
||||||
|
('/presets', preset, 'preset'),
|
||||||
|
('/profiles', profile, 'profile'),
|
||||||
|
('/groups', group, 'group'),
|
||||||
|
('/sequences', sequence, 'sequence'),
|
||||||
|
('/tabs', tab, 'tab'),
|
||||||
|
('/palettes', palette, 'palette'),
|
||||||
|
('/scenes', scene, 'scene'),
|
||||||
|
]
|
||||||
|
|
||||||
|
# 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('templates/index.html')
|
||||||
|
|
||||||
|
# Serve settings page
|
||||||
|
@app.route('/settings')
|
||||||
|
def settings_page(request):
|
||||||
|
"""Serve the settings page."""
|
||||||
|
return send_file('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:
|
||||||
|
# Directory traversal is not allowed
|
||||||
|
return 'Not found', 404
|
||||||
|
return send_file('static/' + path)
|
||||||
|
|
||||||
|
@app.route('/ws')
|
||||||
|
@with_websocket
|
||||||
|
async def ws(request, ws):
|
||||||
while True:
|
while True:
|
||||||
if not await e.asend(peer, json.dumps(msg).encode()):
|
data = await ws.receive()
|
||||||
print("Heartbeat: peer not responding:", peer)
|
print(data)
|
||||||
|
if data:
|
||||||
|
# Debug: log incoming WebSocket data
|
||||||
|
try:
|
||||||
|
parsed = json.loads(data)
|
||||||
|
print("WS received JSON:", parsed)
|
||||||
|
except Exception:
|
||||||
|
print("WS received raw:", data)
|
||||||
|
|
||||||
|
# Forward JSON over ESPNow; split into multiple frames if > 250 bytes
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
parsed = json.loads(data)
|
||||||
|
chunks = split_espnow_message(parsed)
|
||||||
|
except (json.JSONDecodeError, ValueError):
|
||||||
|
chunks = [data]
|
||||||
|
for i, chunk in enumerate(chunks):
|
||||||
|
if i > 0:
|
||||||
|
await asyncio.sleep_ms(100)
|
||||||
|
await esp.send(chunk)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
await ws.send(json.dumps({"error": "ESP-NOW send failed"}))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
else:
|
else:
|
||||||
print("Heartbeat: ping", peer)
|
break
|
||||||
await asyncio.sleep(1)
|
|
||||||
#asyncio.create_task(send())
|
|
||||||
|
|
||||||
|
|
||||||
wdt = machine.WDT(timeout=10000)
|
|
||||||
wdt.feed()
|
server = asyncio.create_task(app.start_server(host="0.0.0.0", port=port))
|
||||||
|
|
||||||
|
#wdt = machine.WDT(timeout=10000)
|
||||||
|
#wdt.feed()
|
||||||
|
|
||||||
|
# Initialize heartbeat LED (XIAO ESP32S3 built-in LED on GPIO 21)
|
||||||
|
|
||||||
|
led = Pin(15, Pin.OUT)
|
||||||
|
|
||||||
|
|
||||||
|
led_state = False
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
#print(time.localtime())
|
gc.collect()
|
||||||
|
|
||||||
# gc.collect()
|
|
||||||
for i in range(60):
|
for i in range(60):
|
||||||
wdt.feed()
|
#wdt.feed()
|
||||||
|
# Heartbeat: toggle LED every 500 ms
|
||||||
|
|
||||||
|
led.value(not led.value())
|
||||||
await asyncio.sleep_ms(500)
|
await asyncio.sleep_ms(500)
|
||||||
|
|
||||||
|
|
||||||
# cleanup before ending the application
|
# cleanup before ending the application
|
||||||
await server
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
asyncio.run(main())
|
|
||||||
|
|||||||
1
src/models/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Models package
|
||||||
69
src/models/espnow.py
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import network
|
||||||
|
|
||||||
|
import aioespnow
|
||||||
|
|
||||||
|
|
||||||
|
class ESPNow:
|
||||||
|
"""
|
||||||
|
Singleton ESPNow helper:
|
||||||
|
- Manages a single AIOESPNow instance
|
||||||
|
- Adds a single broadcast-like peer
|
||||||
|
- Exposes async send(data) to send to that peer.
|
||||||
|
"""
|
||||||
|
|
||||||
|
_instance = None
|
||||||
|
|
||||||
|
def __new__(cls, *args, **kwargs):
|
||||||
|
if cls._instance is None:
|
||||||
|
cls._instance = super().__new__(cls)
|
||||||
|
return cls._instance
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
if getattr(self, "_initialized", False):
|
||||||
|
return
|
||||||
|
|
||||||
|
# ESP-NOW requires a WiFi interface to be active (STA or AP). Activate STA
|
||||||
|
# so ESP-NOW has an interface to use; we don't need to connect to an AP.
|
||||||
|
try:
|
||||||
|
sta = network.WLAN(network.STA_IF)
|
||||||
|
sta.active(True)
|
||||||
|
except Exception as e:
|
||||||
|
print("ESPNow: STA active failed:", e)
|
||||||
|
|
||||||
|
self._esp = aioespnow.AIOESPNow()
|
||||||
|
self._esp.active(True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._esp.add_peer(b"\xff\xff\xff\xff\xff\xff")
|
||||||
|
except Exception:
|
||||||
|
# Ignore add_peer failures (e.g. duplicate)
|
||||||
|
pass
|
||||||
|
|
||||||
|
self._initialized = True
|
||||||
|
|
||||||
|
|
||||||
|
async def send(self, data):
|
||||||
|
"""
|
||||||
|
Async send to the broadcast peer.
|
||||||
|
- data: bytes or str (JSON)
|
||||||
|
"""
|
||||||
|
if isinstance(data, str):
|
||||||
|
payload = data.encode()
|
||||||
|
else:
|
||||||
|
payload = data
|
||||||
|
|
||||||
|
# Debug: show what we're sending and its size
|
||||||
|
try:
|
||||||
|
preview = payload.decode('utf-8')
|
||||||
|
except Exception:
|
||||||
|
preview = str(payload)
|
||||||
|
if len(preview) > 200:
|
||||||
|
preview = preview[:200] + "...(truncated)"
|
||||||
|
print("ESPNow.send len=", len(payload), "payload=", preview)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self._esp.asend(b"\xff\xff\xff\xff\xff\xff", payload)
|
||||||
|
except Exception as e:
|
||||||
|
print("ESPNow.send error:", e)
|
||||||
|
raise
|
||||||
|
|
||||||
51
src/models/group.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
from models.model import Model
|
||||||
|
|
||||||
|
class Group(Model):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
def create(self, name=""):
|
||||||
|
next_id = self.get_next_id()
|
||||||
|
self[next_id] = {
|
||||||
|
"name": name,
|
||||||
|
"devices": [],
|
||||||
|
"pattern": "on",
|
||||||
|
"colors": ["000000", "FF0000"],
|
||||||
|
"brightness": 100,
|
||||||
|
"delay": 100,
|
||||||
|
"step_offset": 0,
|
||||||
|
"step_increment": 1,
|
||||||
|
"n1": 0,
|
||||||
|
"n2": 0,
|
||||||
|
"n3": 0,
|
||||||
|
"n4": 0,
|
||||||
|
"n5": 0,
|
||||||
|
"n6": 0,
|
||||||
|
"n7": 0,
|
||||||
|
"n8": 0
|
||||||
|
}
|
||||||
|
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
|
||||||
|
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())
|
||||||
104
src/models/model.py
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
class Model(dict):
|
||||||
|
def __new__(cls, *args, **kwargs):
|
||||||
|
# Singleton pattern: return existing instance if it exists
|
||||||
|
if not hasattr(cls, '_instance'):
|
||||||
|
cls._instance = super().__new__(cls)
|
||||||
|
return cls._instance
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
# Only initialize once (check if already initialized)
|
||||||
|
if hasattr(self, '_initialized'):
|
||||||
|
return
|
||||||
|
|
||||||
|
# Create /db directory if it doesn't exist (MicroPython compatible)
|
||||||
|
try:
|
||||||
|
os.mkdir("/db")
|
||||||
|
except OSError:
|
||||||
|
pass # Directory already exists, which is fine
|
||||||
|
self.class_name = self.__class__.__name__
|
||||||
|
self.file = f"/db/{self.class_name.lower()}.json"
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
self.load() # Load settings from file during initialization
|
||||||
|
self._initialized = True
|
||||||
|
|
||||||
|
def set_defaults(self):
|
||||||
|
self.clear()
|
||||||
|
|
||||||
|
def get_next_id(self):
|
||||||
|
"""Get the next available ID for creating a new record."""
|
||||||
|
if not self:
|
||||||
|
return "1"
|
||||||
|
max_id = max((int(k) for k in self.keys() if k.isdigit()), default=0)
|
||||||
|
return str(max_id + 1)
|
||||||
|
|
||||||
|
def save(self):
|
||||||
|
try:
|
||||||
|
# Ensure directory exists
|
||||||
|
try:
|
||||||
|
os.mkdir("/db")
|
||||||
|
except OSError:
|
||||||
|
pass # Directory already exists
|
||||||
|
j = json.dumps(self)
|
||||||
|
with open(self.file, 'w') as file:
|
||||||
|
file.write(j)
|
||||||
|
file.flush() # Ensure data is written to buffer
|
||||||
|
# Try to sync filesystem if available (MicroPython)
|
||||||
|
try:
|
||||||
|
os.sync()
|
||||||
|
except (AttributeError, OSError):
|
||||||
|
pass # os.sync() not available on all platforms
|
||||||
|
print(f"{self.class_name} saved successfully to {self.file}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error saving {self.class_name} to {self.file}: {e}")
|
||||||
|
import sys
|
||||||
|
sys.print_exception(e)
|
||||||
|
|
||||||
|
def load(self):
|
||||||
|
try:
|
||||||
|
# Check if file exists first
|
||||||
|
try:
|
||||||
|
with open(self.file, 'r') as file:
|
||||||
|
content = file.read().strip()
|
||||||
|
except OSError:
|
||||||
|
# File doesn't exist
|
||||||
|
raise
|
||||||
|
|
||||||
|
if not content:
|
||||||
|
# Empty file
|
||||||
|
loaded_settings = {}
|
||||||
|
else:
|
||||||
|
# Parse JSON content
|
||||||
|
loaded_settings = json.loads(content)
|
||||||
|
|
||||||
|
# Verify it's a dictionary
|
||||||
|
if not isinstance(loaded_settings, dict):
|
||||||
|
raise ValueError(f"File does not contain a dictionary, got {type(loaded_settings)}")
|
||||||
|
|
||||||
|
# Clear and update with loaded data
|
||||||
|
# Clear first
|
||||||
|
self.clear()
|
||||||
|
# Manually copy items to avoid any update() method issues
|
||||||
|
for key, value in loaded_settings.items():
|
||||||
|
self[key] = value
|
||||||
|
print(f"{self.class_name} loaded successfully.")
|
||||||
|
except OSError as e:
|
||||||
|
# File doesn't exist yet - this is normal on first run
|
||||||
|
# Create an empty file with defaults
|
||||||
|
self.set_defaults()
|
||||||
|
self.save()
|
||||||
|
print(f"{self.class_name} initialized (new file created).")
|
||||||
|
except ValueError:
|
||||||
|
# JSON parsing error - file exists but is corrupted
|
||||||
|
# Note: MicroPython uses ValueError for JSON errors, not JSONDecodeError
|
||||||
|
print(f"Error loading {self.class_name}: Invalid JSON format. Resetting to defaults.")
|
||||||
|
self.set_defaults()
|
||||||
|
self.save()
|
||||||
|
except Exception:
|
||||||
|
# Other unexpected errors - avoid trying to format exception to prevent further errors
|
||||||
|
print(f"Error loading {self.class_name}. Resetting to defaults.")
|
||||||
|
self.set_defaults()
|
||||||
|
self.save()
|
||||||
45
src/models/pallet.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
from models.model import Model
|
||||||
|
|
||||||
|
class Palette(Model):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
def create(self, name="", colors=None):
|
||||||
|
next_id = self.get_next_id()
|
||||||
|
# Store palette as a simple list of colors; name is ignored.
|
||||||
|
self[next_id] = list(colors) if colors else []
|
||||||
|
self.save()
|
||||||
|
return next_id
|
||||||
|
|
||||||
|
def read(self, id):
|
||||||
|
id_str = str(id)
|
||||||
|
value = self.get(id_str, None)
|
||||||
|
# Backwards compatibility: if stored as {"colors": [...]}, unwrap.
|
||||||
|
if isinstance(value, dict) and "colors" in value:
|
||||||
|
return value.get("colors") or []
|
||||||
|
# Otherwise, expect a list of colors.
|
||||||
|
return value or []
|
||||||
|
|
||||||
|
def update(self, id, data):
|
||||||
|
id_str = str(id)
|
||||||
|
if id_str not in self:
|
||||||
|
return False
|
||||||
|
# Accept either {"colors": [...]} or a raw list.
|
||||||
|
if isinstance(data, dict):
|
||||||
|
colors = data.get("colors", [])
|
||||||
|
else:
|
||||||
|
colors = data
|
||||||
|
self[id_str] = list(colors) if colors else []
|
||||||
|
self.save()
|
||||||
|
return True
|
||||||
|
|
||||||
|
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())
|
||||||
38
src/models/pattern.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
from models.model import Model
|
||||||
|
|
||||||
|
|
||||||
|
class Pattern(Model):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
def create(self, name="", data=None):
|
||||||
|
pattern_name = str(name).strip()
|
||||||
|
if not pattern_name:
|
||||||
|
pattern_name = self.get_next_id()
|
||||||
|
self[pattern_name] = data if isinstance(data, dict) else {}
|
||||||
|
self.save()
|
||||||
|
return pattern_name
|
||||||
|
|
||||||
|
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 isinstance(data, dict):
|
||||||
|
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())
|
||||||
69
src/models/preset.py
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
from models.model import Model
|
||||||
|
from models.profile import Profile
|
||||||
|
|
||||||
|
class Preset(Model):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
# Backfill profile ownership for existing presets.
|
||||||
|
try:
|
||||||
|
profiles = Profile()
|
||||||
|
profile_list = profiles.list()
|
||||||
|
default_profile_id = profile_list[0] if profile_list else None
|
||||||
|
changed = False
|
||||||
|
for preset_id, preset_data in list(self.items()):
|
||||||
|
if isinstance(preset_data, dict) and "profile_id" not in preset_data:
|
||||||
|
if default_profile_id is not None:
|
||||||
|
preset_data["profile_id"] = str(default_profile_id)
|
||||||
|
changed = True
|
||||||
|
if changed:
|
||||||
|
self.save()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def create(self, profile_id=None):
|
||||||
|
next_id = self.get_next_id()
|
||||||
|
self[next_id] = {
|
||||||
|
"name": "",
|
||||||
|
"pattern": "",
|
||||||
|
"colors": [],
|
||||||
|
"brightness": 0,
|
||||||
|
"delay": 0,
|
||||||
|
"n1": 0,
|
||||||
|
"n2": 0,
|
||||||
|
"n3": 0,
|
||||||
|
"n4": 0,
|
||||||
|
"n5": 0,
|
||||||
|
"n6": 0,
|
||||||
|
"n7": 0,
|
||||||
|
"n8": 0,
|
||||||
|
"profile_id": str(profile_id) if profile_id is not None else None,
|
||||||
|
}
|
||||||
|
self.save()
|
||||||
|
return next_id
|
||||||
|
|
||||||
|
|
||||||
|
def read(self, id):
|
||||||
|
id_str = str(id)
|
||||||
|
if id_str not in self:
|
||||||
|
return None
|
||||||
|
return self[id_str]
|
||||||
|
|
||||||
|
def update(self, id, data):
|
||||||
|
id_str = str(id)
|
||||||
|
if id_str not in self:
|
||||||
|
return False
|
||||||
|
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())
|
||||||
|
|
||||||
69
src/models/profile.py
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
from models.model import Model
|
||||||
|
from models.pallet import Palette
|
||||||
|
|
||||||
|
|
||||||
|
class Profile(Model):
|
||||||
|
def __init__(self):
|
||||||
|
"""Profile model.
|
||||||
|
|
||||||
|
Each profile owns a single, unique palette stored in the Palette model.
|
||||||
|
The profile stores a `palette_id` that points to its palette; any legacy
|
||||||
|
inline `palette` arrays are migrated to a dedicated Palette entry.
|
||||||
|
"""
|
||||||
|
super().__init__()
|
||||||
|
self._palette_model = Palette()
|
||||||
|
|
||||||
|
# Migrate legacy inline palettes to separate Palette entries.
|
||||||
|
changed = False
|
||||||
|
for pid, pdata in list(self.items()):
|
||||||
|
if isinstance(pdata, dict):
|
||||||
|
if "palette" in pdata and "palette_id" not in pdata:
|
||||||
|
colors = pdata.get("palette") or []
|
||||||
|
palette_id = self._palette_model.create(colors=colors)
|
||||||
|
pdata.pop("palette", None)
|
||||||
|
pdata["palette_id"] = str(palette_id)
|
||||||
|
changed = True
|
||||||
|
if changed:
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
def create(self, name="", profile_type="tabs"):
|
||||||
|
"""Create a new profile and its own empty palette.
|
||||||
|
|
||||||
|
profile_type: "tabs" or "scenes" (ignoring scenes for now)
|
||||||
|
"""
|
||||||
|
next_id = self.get_next_id()
|
||||||
|
# Create a unique palette for this profile.
|
||||||
|
palette_id = self._palette_model.create(colors=[])
|
||||||
|
self[next_id] = {
|
||||||
|
"name": name,
|
||||||
|
"type": profile_type, # "tabs" or "scenes"
|
||||||
|
"tabs": [], # Array of tab IDs
|
||||||
|
"scenes": [], # Array of scene IDs (for future use)
|
||||||
|
"palette_id": str(palette_id),
|
||||||
|
}
|
||||||
|
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
|
||||||
|
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())
|
||||||
|
|
||||||
38
src/models/scene.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
from models.model import Model
|
||||||
|
|
||||||
|
class Scene(Model):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
def create(self, name="", sequences=None, groups=None):
|
||||||
|
next_id = self.get_next_id()
|
||||||
|
self[next_id] = {
|
||||||
|
"name": name,
|
||||||
|
"sequences": sequences if sequences else [],
|
||||||
|
"groups": groups if groups 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
|
||||||
|
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())
|
||||||
44
src/models/squence.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
from models.model import Model
|
||||||
|
|
||||||
|
class Sequence(Model):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
def create(self, group_name="", preset_names=None):
|
||||||
|
next_id = self.get_next_id()
|
||||||
|
self[next_id] = {
|
||||||
|
"group_name": group_name,
|
||||||
|
"presets": preset_names if preset_names else [],
|
||||||
|
"sequence_duration": 3000, # Duration per preset in ms
|
||||||
|
"sequence_transition": 500, # Transition time in ms
|
||||||
|
"sequence_loop": False,
|
||||||
|
"sequence_repeat_count": 0, # 0 = infinite
|
||||||
|
"sequence_active": False,
|
||||||
|
"sequence_index": 0,
|
||||||
|
"sequence_start_time": 0
|
||||||
|
}
|
||||||
|
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
|
||||||
|
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())
|
||||||
39
src/models/tab.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
from models.model import Model
|
||||||
|
|
||||||
|
class Tab(Model):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
def create(self, name="", names=None, presets=None):
|
||||||
|
next_id = self.get_next_id()
|
||||||
|
self[next_id] = {
|
||||||
|
"name": name,
|
||||||
|
"names": names if names else [],
|
||||||
|
"presets": presets if presets else [],
|
||||||
|
"default_preset": None
|
||||||
|
}
|
||||||
|
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
|
||||||
|
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())
|
||||||
39
src/p2p.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import network
|
||||||
|
import aioespnow
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from time import sleep
|
||||||
|
|
||||||
|
|
||||||
|
class P2P:
|
||||||
|
def __init__(self):
|
||||||
|
network.WLAN(network.STA_IF).active(True)
|
||||||
|
self.broadcast = bytes.fromhex("ffffffffffff")
|
||||||
|
self.e = aioespnow.AIOESPNow()
|
||||||
|
self.e.active(True)
|
||||||
|
try:
|
||||||
|
self.e.add_peer(self.broadcast)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def send(self, data):
|
||||||
|
# Convert data to bytes if it's a string or dict
|
||||||
|
if isinstance(data, str):
|
||||||
|
payload = data.encode()
|
||||||
|
elif isinstance(data, dict):
|
||||||
|
payload = json.dumps(data).encode()
|
||||||
|
else:
|
||||||
|
payload = data # Assume it's already bytes
|
||||||
|
|
||||||
|
# Use asend for async sending - returns boolean indicating success
|
||||||
|
result = await self.e.asend(self.broadcast, payload)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
p = P2P()
|
||||||
|
await p.send(json.dumps({"dj": {"p": "on", "colors": ["#ff0000"]}}))
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
291
src/patterns.py
@@ -1,291 +0,0 @@
|
|||||||
from machine import Pin
|
|
||||||
from neopixel import NeoPixel
|
|
||||||
import utime
|
|
||||||
import random
|
|
||||||
|
|
||||||
class Patterns:
|
|
||||||
def __init__(self, pin, num_leds, color1=(0,0,0), color2=(0,0,0), brightness=127, selected="rainbow_cycle", delay=100):
|
|
||||||
self.n = NeoPixel(Pin(pin, Pin.OUT), num_leds)
|
|
||||||
self.num_leds = num_leds
|
|
||||||
self.pattern_step = 0
|
|
||||||
self.last_update = utime.ticks_ms()
|
|
||||||
self.delay = delay
|
|
||||||
self.brightness = brightness
|
|
||||||
self.patterns = {
|
|
||||||
"off": self.off,
|
|
||||||
"on" : self.on,
|
|
||||||
"color_wipe": self.color_wipe_step,
|
|
||||||
"rainbow_cycle": self.rainbow_cycle_step,
|
|
||||||
"theater_chase": self.theater_chase_step,
|
|
||||||
"blink": self.blink_step,
|
|
||||||
"random_color_wipe": self.random_color_wipe_step,
|
|
||||||
"random_rainbow_cycle": self.random_rainbow_cycle_step,
|
|
||||||
"random_theater_chase": self.random_theater_chase_step,
|
|
||||||
"random_blink": self.random_blink_step,
|
|
||||||
"color_transition": self.color_transition_step,
|
|
||||||
"external": None
|
|
||||||
}
|
|
||||||
self.selected = selected
|
|
||||||
self.color1 = color1
|
|
||||||
self.color2 = color2
|
|
||||||
self.transition_duration = 50 # Duration of color transition in milliseconds
|
|
||||||
self.transition_step = 0
|
|
||||||
|
|
||||||
def sync(self):
|
|
||||||
self.pattern_step=0
|
|
||||||
self.last_update = utime.ticks_ms()
|
|
||||||
|
|
||||||
def tick(self):
|
|
||||||
if self.patterns[self.selected]:
|
|
||||||
self.patterns[self.selected]()
|
|
||||||
|
|
||||||
def update_num_leds(self, pin, num_leds):
|
|
||||||
self.n = NeoPixel(Pin(pin, Pin.OUT), num_leds)
|
|
||||||
self.num_leds = num_leds
|
|
||||||
self.pattern_step = 0
|
|
||||||
|
|
||||||
def set_delay(self, delay):
|
|
||||||
self.delay = delay
|
|
||||||
|
|
||||||
def set_brightness(self, brightness):
|
|
||||||
self.brightness = brightness
|
|
||||||
|
|
||||||
def set_color1(self, color):
|
|
||||||
print(color)
|
|
||||||
self.color1 = self.apply_brightness(color)
|
|
||||||
|
|
||||||
def set_color2(self, color):
|
|
||||||
self.color2 = self.apply_brightness(color)
|
|
||||||
|
|
||||||
def apply_brightness(self, color):
|
|
||||||
return tuple(int(c * self.brightness / 255) for c in color)
|
|
||||||
|
|
||||||
def select(self, pattern):
|
|
||||||
if pattern in self.patterns:
|
|
||||||
self.selected = pattern
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
def set(self, i, color):
|
|
||||||
self.n[i] = color
|
|
||||||
|
|
||||||
def write(self):
|
|
||||||
self.n.write()
|
|
||||||
|
|
||||||
def fill(self):
|
|
||||||
for i in range(self.num_leds):
|
|
||||||
self.n[i] = self.color1
|
|
||||||
self.n.write()
|
|
||||||
|
|
||||||
def off(self):
|
|
||||||
color = self.color1
|
|
||||||
self.color1 = (0,0,0)
|
|
||||||
self.fill()
|
|
||||||
self.color1 = color
|
|
||||||
|
|
||||||
def on(self):
|
|
||||||
color = self.color1
|
|
||||||
self.color1 = self.apply_brightness(self.color1)
|
|
||||||
self.fill()
|
|
||||||
self.color1 = color
|
|
||||||
|
|
||||||
|
|
||||||
def color_wipe_step(self):
|
|
||||||
color = self.apply_brightness(self.color1)
|
|
||||||
current_time = utime.ticks_ms()
|
|
||||||
if utime.ticks_diff(current_time, self.last_update) >= self.delay:
|
|
||||||
if self.pattern_step < self.num_leds:
|
|
||||||
for i in range(self.num_leds):
|
|
||||||
self.n[i] = (0, 0, 0)
|
|
||||||
self.n[self.pattern_step] = self.apply_brightness(color)
|
|
||||||
self.n.write()
|
|
||||||
self.pattern_step += 1
|
|
||||||
else:
|
|
||||||
self.pattern_step = 0
|
|
||||||
self.last_update = current_time
|
|
||||||
|
|
||||||
def rainbow_cycle_step(self):
|
|
||||||
current_time = utime.ticks_ms()
|
|
||||||
if utime.ticks_diff(current_time, self.last_update) >= self.delay/5:
|
|
||||||
def wheel(pos):
|
|
||||||
if pos < 85:
|
|
||||||
return (pos * 3, 255 - pos * 3, 0)
|
|
||||||
elif pos < 170:
|
|
||||||
pos -= 85
|
|
||||||
return (255 - pos * 3, 0, pos * 3)
|
|
||||||
else:
|
|
||||||
pos -= 170
|
|
||||||
return (0, pos * 3, 255 - pos * 3)
|
|
||||||
|
|
||||||
for i in range(self.num_leds):
|
|
||||||
rc_index = (i * 256 // self.num_leds) + self.pattern_step
|
|
||||||
self.n[i] = self.apply_brightness(wheel(rc_index & 255))
|
|
||||||
self.n.write()
|
|
||||||
self.pattern_step = (self.pattern_step + 1) % 256
|
|
||||||
self.last_update = current_time
|
|
||||||
|
|
||||||
def theater_chase_step(self):
|
|
||||||
current_time = utime.ticks_ms()
|
|
||||||
if utime.ticks_diff(current_time, self.last_update) >= self.delay:
|
|
||||||
for i in range(self.num_leds):
|
|
||||||
if (i + self.pattern_step) % 3 == 0:
|
|
||||||
self.n[i] = self.apply_brightness(self.color1)
|
|
||||||
else:
|
|
||||||
self.n[i] = (0, 0, 0)
|
|
||||||
self.n.write()
|
|
||||||
self.pattern_step = (self.pattern_step + 1) % 3
|
|
||||||
self.last_update = current_time
|
|
||||||
|
|
||||||
def blink_step(self):
|
|
||||||
current_time = utime.ticks_ms()
|
|
||||||
if utime.ticks_diff(current_time, self.last_update) >= self.delay:
|
|
||||||
if self.pattern_step % 2 == 0:
|
|
||||||
for i in range(self.num_leds):
|
|
||||||
self.n[i] = self.apply_brightness(self.color1)
|
|
||||||
else:
|
|
||||||
for i in range(self.num_leds):
|
|
||||||
self.n[i] = (0, 0, 0)
|
|
||||||
self.n.write()
|
|
||||||
self.pattern_step = (self.pattern_step + 1) % 2
|
|
||||||
self.last_update = current_time
|
|
||||||
|
|
||||||
def random_color_wipe_step(self):
|
|
||||||
current_time = utime.ticks_ms()
|
|
||||||
if utime.ticks_diff(current_time, self.last_update) >= self.delay:
|
|
||||||
color = (random.randint(0, 255), random.randint(0, 255), random.randint(0, 255))
|
|
||||||
if self.pattern_step < self.num_leds:
|
|
||||||
for i in range(self.num_leds):
|
|
||||||
self.n[i] = (0, 0, 0)
|
|
||||||
self.n[self.pattern_step] = self.apply_brightness(color)
|
|
||||||
self.n.write()
|
|
||||||
self.pattern_step += 1
|
|
||||||
else:
|
|
||||||
self.pattern_step = 0
|
|
||||||
self.last_update = current_time
|
|
||||||
|
|
||||||
def random_rainbow_cycle_step(self):
|
|
||||||
current_time = utime.ticks_ms()
|
|
||||||
if utime.ticks_diff(current_time, self.last_update) >= self.delay:
|
|
||||||
def wheel(pos):
|
|
||||||
if pos < 85:
|
|
||||||
return (pos * 3, 255 - pos * 3, 0)
|
|
||||||
elif pos < 170:
|
|
||||||
pos -= 85
|
|
||||||
return (255 - pos * 3, 0, pos * 3)
|
|
||||||
else:
|
|
||||||
pos -= 170
|
|
||||||
return (0, pos * 3, 255 - pos * 3)
|
|
||||||
|
|
||||||
random_offset = random.randint(0, 255)
|
|
||||||
for i in range(self.num_leds):
|
|
||||||
rc_index = (i * 256 // self.num_leds) + self.pattern_step + random_offset
|
|
||||||
self.n[i] = self.apply_brightness(wheel(rc_index & 255))
|
|
||||||
self.n.write()
|
|
||||||
self.pattern_step = (self.pattern_step + 1) % 256
|
|
||||||
self.last_update = current_time
|
|
||||||
|
|
||||||
def random_theater_chase_step(self):
|
|
||||||
current_time = utime.ticks_ms()
|
|
||||||
if utime.ticks_diff(current_time, self.last_update) >= self.delay:
|
|
||||||
color = (random.randint(0, 255), random.randint(0, 255), random.randint(0, 255))
|
|
||||||
for i in range(self.num_leds):
|
|
||||||
if (i + self.pattern_step) % 3 == 0:
|
|
||||||
self.n[i] = self.apply_brightness(color)
|
|
||||||
else:
|
|
||||||
self.n[i] = (0, 0, 0)
|
|
||||||
self.n.write()
|
|
||||||
self.pattern_step = (self.pattern_step + 1) % 3
|
|
||||||
self.last_update = current_time
|
|
||||||
|
|
||||||
def random_blink_step(self):
|
|
||||||
current_time = utime.ticks_ms()
|
|
||||||
if utime.ticks_diff(current_time, self.last_update) >= self.delay:
|
|
||||||
color = (random.randint(0, 255), random.randint(0, 255), random.randint(0, 255))
|
|
||||||
if self.pattern_step % 2 == 0:
|
|
||||||
for i in range(self.num_leds):
|
|
||||||
self.n[i] = self.apply_brightness(color)
|
|
||||||
else:
|
|
||||||
for i in range(self.num_leds):
|
|
||||||
self.n[i] = (0, 0, 0)
|
|
||||||
self.n.write()
|
|
||||||
self.pattern_step = (self.pattern_step + 1) % 2
|
|
||||||
self.last_update = current_time
|
|
||||||
|
|
||||||
def color_transition_step(self):
|
|
||||||
current_time = utime.ticks_ms()
|
|
||||||
if utime.ticks_diff(current_time, self.last_update) >= self.delay:
|
|
||||||
# Calculate transition factor based on elapsed time
|
|
||||||
transition_factor = (self.pattern_step * 100) / self.transition_duration
|
|
||||||
if transition_factor > 100:
|
|
||||||
transition_factor = 100
|
|
||||||
color = self.interpolate_color(self.color1, self.color2, transition_factor / 100)
|
|
||||||
|
|
||||||
# Apply the interpolated color to all LEDs
|
|
||||||
for i in range(self.num_leds):
|
|
||||||
self.n[i] = self.apply_brightness(color)
|
|
||||||
self.n.write()
|
|
||||||
|
|
||||||
self.pattern_step += self.delay
|
|
||||||
if self.pattern_step > self.transition_duration:
|
|
||||||
self.pattern_step = 0
|
|
||||||
|
|
||||||
self.last_update = current_time
|
|
||||||
|
|
||||||
def interpolate_color(self, color1, color2, factor):
|
|
||||||
return (
|
|
||||||
int(color1[0] + (color2[0] - color1[0]) * factor),
|
|
||||||
int(color1[1] + (color2[1] - color1[1]) * factor),
|
|
||||||
int(color1[2] + (color2[2] - color1[2]) * factor)
|
|
||||||
)
|
|
||||||
|
|
||||||
def two_steps_forward_one_step_back_step(self):
|
|
||||||
current_time = utime.ticks_ms()
|
|
||||||
if utime.ticks_diff(current_time, self.last_update) >= self.delay:
|
|
||||||
# Move forward 2 steps and backward 1 step
|
|
||||||
if self.direction == 1: # Moving forward
|
|
||||||
if self.scanner_position < self.num_leds - 2:
|
|
||||||
self.scanner_position += 2 # Move forward 2 steps
|
|
||||||
else:
|
|
||||||
self.direction = -1 # Change direction to backward
|
|
||||||
else: # Moving backward
|
|
||||||
if self.scanner_position > 0:
|
|
||||||
self.scanner_position -= 1 # Move backward 1 step
|
|
||||||
else:
|
|
||||||
self.direction = 1 # Change direction to forward
|
|
||||||
|
|
||||||
# Set all LEDs to off
|
|
||||||
for i in range(self.num_leds):
|
|
||||||
self.n[i] = (0, 0, 0)
|
|
||||||
|
|
||||||
# Set the current position to the color
|
|
||||||
self.n[self.scanner_position] = self.apply_brightness(self.color1)
|
|
||||||
|
|
||||||
# Apply the color transition
|
|
||||||
transition_factor = (self.pattern_step * 100) / self.transition_duration
|
|
||||||
if transition_factor > 100:
|
|
||||||
transition_factor = 100
|
|
||||||
color = self.interpolate_color(self.color1, self.color2, transition_factor / 100)
|
|
||||||
self.n[self.scanner_position] = self.apply_brightness(color)
|
|
||||||
|
|
||||||
self.n.write()
|
|
||||||
self.pattern_step += self.delay
|
|
||||||
if self.pattern_step > self.transition_duration:
|
|
||||||
self.pattern_step = 0
|
|
||||||
|
|
||||||
self.last_update = current_time
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
p = Patterns(4, 180)
|
|
||||||
p.set_color1((255,0,0))
|
|
||||||
p.set_color2((0,255,0))
|
|
||||||
#p.set_delay(10)
|
|
||||||
try:
|
|
||||||
while True:
|
|
||||||
for key in p.patterns:
|
|
||||||
print(key)
|
|
||||||
p.select(key)
|
|
||||||
for _ in range(2000):
|
|
||||||
p.tick()
|
|
||||||
utime.sleep_ms(1)
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
p.fill((0, 0, 0))
|
|
||||||
0
src/profile.py
Normal file
@@ -1,7 +1,6 @@
|
|||||||
import json
|
import json
|
||||||
import wifi
|
import os
|
||||||
import ubinascii
|
import binascii
|
||||||
import machine
|
|
||||||
|
|
||||||
class Settings(dict):
|
class Settings(dict):
|
||||||
SETTINGS_FILE = "/settings.json"
|
SETTINGS_FILE = "/settings.json"
|
||||||
@@ -10,15 +9,30 @@ class Settings(dict):
|
|||||||
super().__init__()
|
super().__init__()
|
||||||
self.load() # Load settings from file during initialization
|
self.load() # Load settings from file during initialization
|
||||||
|
|
||||||
|
def generate_secret_key(self):
|
||||||
|
"""Generate a random secret key for session signing."""
|
||||||
|
try:
|
||||||
|
# Try to use os.urandom for secure random bytes
|
||||||
|
random_bytes = os.urandom(32)
|
||||||
|
return binascii.hexlify(random_bytes).decode('utf-8')
|
||||||
|
except (AttributeError, NotImplementedError):
|
||||||
|
# Fallback for MicroPython or systems without os.urandom
|
||||||
|
try:
|
||||||
|
import secrets
|
||||||
|
return secrets.token_hex(32)
|
||||||
|
except ImportError:
|
||||||
|
# Last resort: use a combination of time and random
|
||||||
|
import time
|
||||||
|
import random
|
||||||
|
random.seed(time.time())
|
||||||
|
return binascii.hexlify(bytes([random.randint(0, 255) for _ in range(32)])).decode('utf-8')
|
||||||
|
|
||||||
def set_defaults(self):
|
def set_defaults(self):
|
||||||
self["num_leds"] = 50
|
"""Set default settings if they don't exist."""
|
||||||
self["pattern"] = "on"
|
if 'session_secret_key' not in self:
|
||||||
self["color1"] = "#00ff00"
|
self['session_secret_key'] = self.generate_secret_key()
|
||||||
self["color2"] = "#ff0000"
|
# Save immediately when generating a new key
|
||||||
self["delay"] = 100
|
self.save()
|
||||||
self["brightness"] = 10
|
|
||||||
self["name"] = "led-controller"
|
|
||||||
self["ap_password"] = ""
|
|
||||||
|
|
||||||
def save(self):
|
def save(self):
|
||||||
try:
|
try:
|
||||||
@@ -30,66 +44,19 @@ class Settings(dict):
|
|||||||
print(f"Error saving settings: {e}")
|
print(f"Error saving settings: {e}")
|
||||||
|
|
||||||
def load(self):
|
def load(self):
|
||||||
|
loaded_from_file = False
|
||||||
try:
|
try:
|
||||||
with open(self.SETTINGS_FILE, 'r') as file:
|
with open(self.SETTINGS_FILE, 'r') as file:
|
||||||
loaded_settings = json.load(file)
|
loaded_settings = json.load(file)
|
||||||
self.update(loaded_settings)
|
self.update(loaded_settings)
|
||||||
|
loaded_from_file = True
|
||||||
print("Settings loaded successfully.")
|
print("Settings loaded successfully.")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error loading settings")
|
print(f"Error loading settings")
|
||||||
|
self.clear()
|
||||||
|
finally:
|
||||||
|
# Ensure defaults are set even if file exists but is missing keys
|
||||||
self.set_defaults()
|
self.set_defaults()
|
||||||
self.save()
|
# Only save if file didn't exist or was invalid
|
||||||
|
if not loaded_from_file:
|
||||||
# Example usage
|
self.save()
|
||||||
def main():
|
|
||||||
settings = Settings()
|
|
||||||
print(f"Number of LEDs: {settings['num_leds']}")
|
|
||||||
settings['num_leds'] = 100
|
|
||||||
print(f"Updated number of LEDs: {settings['num_leds']}")
|
|
||||||
settings.save()
|
|
||||||
|
|
||||||
# Create a new Settings object to test loading
|
|
||||||
new_settings = Settings()
|
|
||||||
print(f"Loaded number of LEDs: {new_settings['num_leds']}")
|
|
||||||
print(settings)
|
|
||||||
|
|
||||||
def set_settings(raw_json, settings, patterns):
|
|
||||||
try:
|
|
||||||
data = json.loads(raw_json)
|
|
||||||
print(data)
|
|
||||||
for key, value in data.items():
|
|
||||||
print(key, value)
|
|
||||||
if key == "color1":
|
|
||||||
patterns.set_color1(tuple(int(value[i:i+2], 16) for i in (1, 3, 5))) # Convert hex to RGB
|
|
||||||
elif key == "color2":
|
|
||||||
patterns.set_color2(tuple(int(value[i:i+2], 16) for i in (1, 3, 5))) # Convert hex to RGB
|
|
||||||
elif key == "num_leds":
|
|
||||||
patterns.update_num_leds(4, value)
|
|
||||||
elif key == "pattern":
|
|
||||||
if not patterns.select(value):
|
|
||||||
return "Pattern doesn't exist", 400
|
|
||||||
elif key == "delay":
|
|
||||||
delay = int(data["delay"])
|
|
||||||
patterns.set_delay(delay)
|
|
||||||
elif key == "brightness":
|
|
||||||
brightness = int(data["brightness"])
|
|
||||||
patterns.set_brightness(brightness)
|
|
||||||
elif key == "name":
|
|
||||||
settings[key] = value
|
|
||||||
settings.save()
|
|
||||||
machine.reset()
|
|
||||||
elif key == "sync":
|
|
||||||
patterns.sync()
|
|
||||||
return "OK", 200
|
|
||||||
else:
|
|
||||||
return "Invalid key", 400
|
|
||||||
settings[key] = value
|
|
||||||
settings.save()
|
|
||||||
return "OK", 200
|
|
||||||
|
|
||||||
except (KeyError, ValueError):
|
|
||||||
return "Bad request", 400
|
|
||||||
|
|
||||||
# Run the example
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
|
|||||||
1750
src/static/app.js
Normal file
198
src/static/color_palette.js
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const paletteButton = document.getElementById('color-palette-btn');
|
||||||
|
const paletteModal = document.getElementById('color-palette-modal');
|
||||||
|
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) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentProfileId = null;
|
||||||
|
let currentPaletteId = null;
|
||||||
|
let currentPalette = [];
|
||||||
|
let currentProfileName = null;
|
||||||
|
|
||||||
|
const renderPalette = () => {
|
||||||
|
paletteContainer.innerHTML = '';
|
||||||
|
if (!currentPalette.length) {
|
||||||
|
const empty = document.createElement('p');
|
||||||
|
empty.className = 'muted-text';
|
||||||
|
empty.textContent = 'No colors in palette.';
|
||||||
|
paletteContainer.appendChild(empty);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
currentPalette.forEach((color, index) => {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'profiles-row';
|
||||||
|
row.dataset.color = color;
|
||||||
|
row.style.cssText = 'display: flex; align-items: center; gap: 1rem;';
|
||||||
|
// Ensure no text content
|
||||||
|
row.textContent = '';
|
||||||
|
|
||||||
|
const swatch = document.createElement('div');
|
||||||
|
swatch.style.cssText = `
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background-color: ${color};
|
||||||
|
border: 2px solid #4a4a4a;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
|
||||||
|
`;
|
||||||
|
swatch.title = color; // Show hex code on hover only
|
||||||
|
swatch.setAttribute('aria-label', `Color ${color}`);
|
||||||
|
|
||||||
|
const removeButton = document.createElement('button');
|
||||||
|
removeButton.className = 'btn btn-danger btn-small';
|
||||||
|
removeButton.textContent = 'Remove';
|
||||||
|
removeButton.style.fontSize = '0.8rem'; // Restore font size for button
|
||||||
|
removeButton.addEventListener('click', async (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const updated = currentPalette.filter((_, i) => i !== index);
|
||||||
|
await savePalette(updated);
|
||||||
|
});
|
||||||
|
|
||||||
|
row.appendChild(swatch);
|
||||||
|
row.appendChild(removeButton);
|
||||||
|
paletteContainer.appendChild(row);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadPalette = async () => {
|
||||||
|
try {
|
||||||
|
const currentResponse = await fetch('/profiles/current', {
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
});
|
||||||
|
if (!currentResponse.ok) {
|
||||||
|
throw new Error('Failed to load current profile');
|
||||||
|
}
|
||||||
|
const currentData = await currentResponse.json();
|
||||||
|
currentProfileId = currentData.id || null;
|
||||||
|
const profile = currentData.profile || null;
|
||||||
|
currentProfileName = profile ? profile.name : null;
|
||||||
|
if (profileNameDisplay) {
|
||||||
|
profileNameDisplay.textContent = currentProfileName || currentProfileId || 'None';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!currentProfileId || !profile) {
|
||||||
|
currentPalette = [];
|
||||||
|
renderPalette();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefer palette_id-based storage; fall back to legacy inline palette.
|
||||||
|
currentPaletteId = profile.palette_id || profile.paletteId || null;
|
||||||
|
if (currentPaletteId) {
|
||||||
|
try {
|
||||||
|
const palResponse = await fetch(`/palettes/${currentPaletteId}`, {
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
});
|
||||||
|
if (palResponse.ok) {
|
||||||
|
const palData = await palResponse.json();
|
||||||
|
currentPalette = (palData.colors) || [];
|
||||||
|
} else {
|
||||||
|
currentPalette = [];
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load palette by id:', e);
|
||||||
|
currentPalette = [];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Legacy: palette stored directly on profile
|
||||||
|
currentPalette = profile.palette || profile.color_palette || [];
|
||||||
|
}
|
||||||
|
renderPalette();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load palette:', error);
|
||||||
|
currentPalette = [];
|
||||||
|
renderPalette();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const savePalette = async (newPalette) => {
|
||||||
|
if (!currentProfileId) {
|
||||||
|
alert('No profile selected.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// Ensure we have a palette ID for this profile.
|
||||||
|
if (!currentPaletteId) {
|
||||||
|
const createResponse = await fetch('/palettes', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ colors: newPalette }),
|
||||||
|
});
|
||||||
|
if (!createResponse.ok) {
|
||||||
|
throw new Error('Failed to create palette');
|
||||||
|
}
|
||||||
|
const pal = await createResponse.json();
|
||||||
|
currentPaletteId = pal.id || Object.keys(pal)[0];
|
||||||
|
|
||||||
|
// Link the new palette to the current profile.
|
||||||
|
const linkResponse = await fetch('/profiles/current', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
palette_id: currentPaletteId,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!linkResponse.ok) {
|
||||||
|
throw new Error('Failed to link palette to profile');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Update existing palette colors
|
||||||
|
const updateResponse = await fetch(`/palettes/${currentPaletteId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ colors: newPalette }),
|
||||||
|
});
|
||||||
|
if (!updateResponse.ok) {
|
||||||
|
throw new Error('Failed to save palette');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
currentPalette = newPalette;
|
||||||
|
renderPalette();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save palette:', error);
|
||||||
|
alert('Failed to save palette.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openModal = () => {
|
||||||
|
paletteModal.classList.add('active');
|
||||||
|
loadPalette();
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
paletteModal.classList.remove('active');
|
||||||
|
};
|
||||||
|
|
||||||
|
paletteButton.addEventListener('click', openModal);
|
||||||
|
if (closeButton) {
|
||||||
|
closeButton.addEventListener('click', closeModal);
|
||||||
|
}
|
||||||
|
if (paletteAddButton && paletteNewColor) {
|
||||||
|
paletteAddButton.addEventListener('click', async () => {
|
||||||
|
const color = paletteNewColor.value;
|
||||||
|
if (!color) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (currentPalette.includes(color)) {
|
||||||
|
alert('Color already in palette.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await savePalette([...currentPalette, color]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
paletteModal.addEventListener('click', (event) => {
|
||||||
|
if (event.target === paletteModal) {
|
||||||
|
closeModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
200
src/static/help.js
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
// Help modal
|
||||||
|
const helpBtn = document.getElementById('help-btn');
|
||||||
|
const helpModal = document.getElementById('help-modal');
|
||||||
|
const helpCloseBtn = document.getElementById('help-close-btn');
|
||||||
|
const mainMenuBtn = document.getElementById('main-menu-btn');
|
||||||
|
const mainMenuDropdown = document.getElementById('main-menu-dropdown');
|
||||||
|
|
||||||
|
if (helpBtn && helpModal) {
|
||||||
|
helpBtn.addEventListener('click', () => {
|
||||||
|
helpModal.classList.add('active');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (helpCloseBtn && helpModal) {
|
||||||
|
helpCloseBtn.addEventListener('click', () => {
|
||||||
|
helpModal.classList.remove('active');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (helpModal) {
|
||||||
|
helpModal.addEventListener('click', (event) => {
|
||||||
|
if (event.target === helpModal) {
|
||||||
|
helpModal.classList.remove('active');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mobile main menu: forward clicks to existing header buttons
|
||||||
|
if (mainMenuBtn && mainMenuDropdown) {
|
||||||
|
mainMenuBtn.addEventListener('click', () => {
|
||||||
|
mainMenuDropdown.classList.toggle('open');
|
||||||
|
});
|
||||||
|
|
||||||
|
mainMenuDropdown.addEventListener('click', (event) => {
|
||||||
|
const target = event.target;
|
||||||
|
if (target && target.matches('button[data-target]')) {
|
||||||
|
const id = target.getAttribute('data-target');
|
||||||
|
const realBtn = document.getElementById(id);
|
||||||
|
if (realBtn) {
|
||||||
|
realBtn.click();
|
||||||
|
}
|
||||||
|
mainMenuDropdown.classList.remove('open');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close menu when clicking outside
|
||||||
|
document.addEventListener('click', (event) => {
|
||||||
|
if (!mainMenuDropdown.contains(event.target) && event.target !== mainMenuBtn) {
|
||||||
|
mainMenuDropdown.classList.remove('open');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settings modal wiring (reusing existing settings endpoints).
|
||||||
|
const settingsButton = document.getElementById('settings-btn');
|
||||||
|
const settingsModal = document.getElementById('settings-modal');
|
||||||
|
const settingsCloseButton = document.getElementById('settings-close-btn');
|
||||||
|
|
||||||
|
const showSettingsMessage = (text, type = 'success') => {
|
||||||
|
const messageEl = document.getElementById('settings-message');
|
||||||
|
if (!messageEl) return;
|
||||||
|
messageEl.textContent = text;
|
||||||
|
messageEl.className = `message ${type} show`;
|
||||||
|
setTimeout(() => {
|
||||||
|
messageEl.classList.remove('show');
|
||||||
|
}, 5000);
|
||||||
|
};
|
||||||
|
|
||||||
|
async function loadDeviceSettings() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/settings');
|
||||||
|
const data = await response.json();
|
||||||
|
const nameInput = document.getElementById('device-name-input');
|
||||||
|
if (nameInput && data && typeof data === 'object') {
|
||||||
|
nameInput.value = data.device_name || 'led-controller';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading device settings:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAPStatus() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/settings/wifi/ap');
|
||||||
|
const config = await response.json();
|
||||||
|
const statusEl = document.getElementById('ap-status');
|
||||||
|
if (!statusEl) return;
|
||||||
|
if (config.active) {
|
||||||
|
statusEl.innerHTML = `
|
||||||
|
<h4>AP Status: <span class="status-connected">Active</span></h4>
|
||||||
|
<p><strong>SSID:</strong> ${config.ssid || 'N/A'}</p>
|
||||||
|
<p><strong>Channel:</strong> ${config.channel || 'Auto'}</p>
|
||||||
|
<p><strong>IP Address:</strong> ${config.ip || 'N/A'}</p>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
statusEl.innerHTML = `
|
||||||
|
<h4>AP Status: <span class="status-disconnected">Inactive</span></h4>
|
||||||
|
<p>Access Point is not currently active</p>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
if (config.saved_ssid) document.getElementById('ap-ssid').value = config.saved_ssid;
|
||||||
|
if (config.saved_channel) document.getElementById('ap-channel').value = config.saved_channel;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading AP status:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settingsButton && settingsModal) {
|
||||||
|
settingsButton.addEventListener('click', () => {
|
||||||
|
settingsModal.classList.add('active');
|
||||||
|
// Load current WiFi status/config when opening
|
||||||
|
loadDeviceSettings();
|
||||||
|
loadAPStatus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settingsCloseButton && settingsModal) {
|
||||||
|
settingsCloseButton.addEventListener('click', () => {
|
||||||
|
settingsModal.classList.remove('active');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settingsModal) {
|
||||||
|
settingsModal.addEventListener('click', (event) => {
|
||||||
|
if (event.target === settingsModal) {
|
||||||
|
settingsModal.classList.remove('active');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const deviceForm = document.getElementById('device-form');
|
||||||
|
if (deviceForm) {
|
||||||
|
deviceForm.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const nameInput = document.getElementById('device-name-input');
|
||||||
|
const deviceName = nameInput ? nameInput.value.trim() : '';
|
||||||
|
if (!deviceName) {
|
||||||
|
showSettingsMessage('Device name is required', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await fetch('/settings/settings', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ device_name: deviceName }),
|
||||||
|
});
|
||||||
|
const result = await response.json();
|
||||||
|
if (response.ok) {
|
||||||
|
showSettingsMessage('Device name saved. It will be used on next restart.', 'success');
|
||||||
|
} else {
|
||||||
|
showSettingsMessage(`Error: ${result.error || 'Failed to save device name'}`, 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showSettingsMessage(`Error: ${error.message}`, 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const apForm = document.getElementById('ap-form');
|
||||||
|
if (apForm) {
|
||||||
|
apForm.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const formData = {
|
||||||
|
ssid: document.getElementById('ap-ssid').value,
|
||||||
|
password: document.getElementById('ap-password').value,
|
||||||
|
channel: document.getElementById('ap-channel').value || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (formData.password && formData.password.length > 0 && formData.password.length < 8) {
|
||||||
|
showSettingsMessage('AP password must be at least 8 characters', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (formData.channel) {
|
||||||
|
formData.channel = parseInt(formData.channel, 10);
|
||||||
|
if (formData.channel < 1 || formData.channel > 11) {
|
||||||
|
showSettingsMessage('Channel must be between 1 and 11', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/settings/wifi/ap', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(formData),
|
||||||
|
});
|
||||||
|
const result = await response.json();
|
||||||
|
if (response.ok) {
|
||||||
|
showSettingsMessage('Access Point configured successfully!', 'success');
|
||||||
|
setTimeout(loadAPStatus, 1000);
|
||||||
|
} else {
|
||||||
|
showSettingsMessage(`Error: ${result.error || 'Failed to configure AP'}`, 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showSettingsMessage(`Error: ${error.message}`, 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
1
src/static/htmx.min.js
vendored
Normal file
@@ -1,100 +0,0 @@
|
|||||||
body {
|
|
||||||
font-family: Arial, sans-serif;
|
|
||||||
max-width: 600px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 20px;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
h1 {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
form {
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
label {
|
|
||||||
display: block;
|
|
||||||
margin-bottom: 5px;
|
|
||||||
}
|
|
||||||
input[type="text"],
|
|
||||||
input[type="submit"],
|
|
||||||
input[type="range"],
|
|
||||||
input[type="color"] {
|
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
margin-bottom: 10px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
input[type="range"] {
|
|
||||||
-webkit-appearance: none;
|
|
||||||
appearance: none;
|
|
||||||
height: 25px;
|
|
||||||
background: #d3d3d3;
|
|
||||||
outline: none;
|
|
||||||
opacity: 0.7;
|
|
||||||
transition: opacity 0.2s;
|
|
||||||
}
|
|
||||||
input[type="range"]:hover {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
input[type="range"]::-webkit-slider-thumb {
|
|
||||||
-webkit-appearance: none;
|
|
||||||
appearance: none;
|
|
||||||
width: 25px;
|
|
||||||
height: 25px;
|
|
||||||
background: #4caf50;
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: 50%;
|
|
||||||
}
|
|
||||||
input[type="range"]::-moz-range-thumb {
|
|
||||||
width: 25px;
|
|
||||||
height: 25px;
|
|
||||||
background: #4caf50;
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: 50%;
|
|
||||||
}
|
|
||||||
#pattern_buttons {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 10px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
#pattern_buttons button {
|
|
||||||
flex: 1 0 calc(33.333% - 10px);
|
|
||||||
padding: 10px;
|
|
||||||
background-color: #4caf50;
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background-color 0.3s;
|
|
||||||
}
|
|
||||||
#pattern_buttons button:hover {
|
|
||||||
background-color: #45a049;
|
|
||||||
}
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
#pattern_buttons button {
|
|
||||||
flex: 1 0 calc(50% - 10px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#connection-status {
|
|
||||||
width: 15px;
|
|
||||||
height: 15px;
|
|
||||||
border-radius: 50%;
|
|
||||||
display: inline-block; /* Or block, depending on where you put it */
|
|
||||||
margin-left: 10px; /* Adjust spacing as needed */
|
|
||||||
vertical-align: middle; /* Align with nearby text */
|
|
||||||
background-color: grey; /* Default: Unknown */
|
|
||||||
}
|
|
||||||
|
|
||||||
#connection-status.connecting {
|
|
||||||
background-color: yellow;
|
|
||||||
}
|
|
||||||
|
|
||||||
#connection-status.open {
|
|
||||||
background-color: green;
|
|
||||||
}
|
|
||||||
|
|
||||||
#connection-status.closing,
|
|
||||||
#connection-status.closed {
|
|
||||||
background-color: red;
|
|
||||||
}
|
|
||||||
@@ -1,214 +1,81 @@
|
|||||||
let delayTimeout;
|
import "./rgb-slider.js";
|
||||||
let brightnessTimeout;
|
|
||||||
let colorTimeout;
|
|
||||||
let color2Timeout;
|
|
||||||
let ws; // Variable to hold the WebSocket connection
|
|
||||||
let connectionStatusElement; // Variable to hold the connection status element
|
|
||||||
|
|
||||||
// Function to update the connection status indicator
|
const ws = new WebSocket("ws://localhost:8000/ws");
|
||||||
function updateConnectionStatus(status) {
|
|
||||||
if (!connectionStatusElement) {
|
|
||||||
connectionStatusElement = document.getElementById("connection-status");
|
|
||||||
}
|
|
||||||
if (connectionStatusElement) {
|
|
||||||
connectionStatusElement.className = ""; // Clear existing classes
|
|
||||||
connectionStatusElement.classList.add(status);
|
|
||||||
// Optionally, you could also update text content based on status
|
|
||||||
// connectionStatusElement.textContent = status.charAt(0).toUpperCase() + status.slice(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Function to establish WebSocket connection
|
ws.onopen = () => {
|
||||||
function connectWebSocket() {
|
console.log("WebSocket connection established");
|
||||||
// Determine the WebSocket URL based on the current location
|
};
|
||||||
const wsUrl = `ws://${window.location.host}/ws`;
|
|
||||||
ws = new WebSocket(wsUrl);
|
|
||||||
|
|
||||||
updateConnectionStatus("connecting"); // Indicate connecting state
|
ws.onclose = () => {
|
||||||
|
console.log("WebSocket connection closed");
|
||||||
|
};
|
||||||
|
|
||||||
ws.onopen = function (event) {
|
ws.onerror = (error) => {
|
||||||
console.log("WebSocket connection opened:", event);
|
console.error("WebSocket error:", error);
|
||||||
updateConnectionStatus("open"); // Indicate open state
|
};
|
||||||
// Optionally, you could send an initial message here
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onmessage = function (event) {
|
// Number of sliders (tabs) you want to create
|
||||||
console.log("WebSocket message received:", event.data);
|
const numTabs = 3;
|
||||||
};
|
|
||||||
|
|
||||||
ws.onerror = function (event) {
|
// Select the container for tabs and content
|
||||||
console.error("WebSocket error:", event);
|
const tabsContainer = document.querySelector(".tabs");
|
||||||
updateConnectionStatus("closed"); // Indicate error state (treat as closed)
|
const tabContentContainer = document.querySelector(".tab-content");
|
||||||
};
|
|
||||||
|
|
||||||
ws.onclose = function (event) {
|
// Create tabs dynamically
|
||||||
if (event.wasClean) {
|
for (let i = 1; i <= numTabs; i++) {
|
||||||
console.log(
|
// Create the tab button
|
||||||
`WebSocket connection closed cleanly, code=${event.code}, reason=${event.reason}`,
|
const tabButton = document.createElement("button");
|
||||||
);
|
tabButton.classList.add("tab");
|
||||||
updateConnectionStatus("closed"); // Indicate closed state
|
tabButton.id = `tab${i}`;
|
||||||
} else {
|
tabButton.textContent = `Tab ${i}`;
|
||||||
console.error("WebSocket connection died");
|
|
||||||
updateConnectionStatus("closed"); // Indicate closed state
|
// Add the tab button to the container
|
||||||
|
tabsContainer.appendChild(tabButton);
|
||||||
|
|
||||||
|
// Create the corresponding tab content (RGB slider)
|
||||||
|
const tabContent = document.createElement("div");
|
||||||
|
tabContent.classList.add("tab-pane");
|
||||||
|
tabContent.id = `content${i}`;
|
||||||
|
const slider = document.createElement("rgb-slider");
|
||||||
|
slider.id = i;
|
||||||
|
tabContent.appendChild(slider);
|
||||||
|
|
||||||
|
// Add the tab content to the container
|
||||||
|
tabContentContainer.appendChild(tabContent);
|
||||||
|
|
||||||
|
// Listen for color change on each RGB slider
|
||||||
|
slider.addEventListener("color-change", (e) => {
|
||||||
|
const { r, g, b } = e.detail;
|
||||||
|
console.log(`Color changed in tab ${i}:`, e.detail);
|
||||||
|
// Send RGB data to WebSocket server
|
||||||
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
|
const colorData = { r, g, b };
|
||||||
|
ws.send(JSON.stringify(colorData));
|
||||||
}
|
}
|
||||||
// Attempt to reconnect after a delay
|
|
||||||
setTimeout(connectWebSocket, 1000);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Function to send data over WebSocket
|
|
||||||
function sendWebSocketData(data) {
|
|
||||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
||||||
console.log("Sending data over WebSocket:", data);
|
|
||||||
ws.send(JSON.stringify(data));
|
|
||||||
} else {
|
|
||||||
console.error("WebSocket is not connected. Cannot send data:", data);
|
|
||||||
// You might want to queue messages or handle this in a different way
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keep the post and get functions for now, they might still be useful
|
|
||||||
async function post(path, data) {
|
|
||||||
console.log(`POST to ${path}`, data);
|
|
||||||
try {
|
|
||||||
const response = await fetch(path, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error during POST request:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function get(path) {
|
|
||||||
try {
|
|
||||||
const response = await fetch(path);
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
|
||||||
}
|
|
||||||
return await response.json();
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error during GET request:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function updateColor(event) {
|
|
||||||
event.preventDefault();
|
|
||||||
clearTimeout(colorTimeout);
|
|
||||||
colorTimeout = setTimeout(function () {
|
|
||||||
const color = document.getElementById("color").value;
|
|
||||||
sendWebSocketData({ color1: color });
|
|
||||||
}, 500);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function updateColor2(event) {
|
|
||||||
event.preventDefault();
|
|
||||||
clearTimeout(color2Timeout);
|
|
||||||
color2Timeout = setTimeout(function () {
|
|
||||||
const color = document.getElementById("color2").value;
|
|
||||||
sendWebSocketData({ color2: color });
|
|
||||||
}, 500);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function updatePattern(pattern) {
|
|
||||||
sendWebSocketData({ pattern: pattern });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function updateBrightness(event) {
|
|
||||||
event.preventDefault();
|
|
||||||
clearTimeout(brightnessTimeout);
|
|
||||||
brightnessTimeout = setTimeout(function () {
|
|
||||||
const brightness = document.getElementById("brightness").value;
|
|
||||||
sendWebSocketData({ brightness: brightness });
|
|
||||||
}, 500);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function updateDelay(event) {
|
|
||||||
event.preventDefault();
|
|
||||||
clearTimeout(delayTimeout);
|
|
||||||
delayTimeout = setTimeout(function () {
|
|
||||||
const delay = document.getElementById("delay").value;
|
|
||||||
sendWebSocketData({ delay: delay });
|
|
||||||
}, 500);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function updateNumLeds(event) {
|
|
||||||
event.preventDefault();
|
|
||||||
const numLeds = document.getElementById("num_leds").value;
|
|
||||||
sendWebSocketData({ num_leds: parseInt(numLeds) });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function updateName(event) {
|
|
||||||
event.preventDefault();
|
|
||||||
const name = document.getElementById("name").value;
|
|
||||||
sendWebSocketData({ name: name });
|
|
||||||
}
|
|
||||||
|
|
||||||
function createPatternButtons(patterns) {
|
|
||||||
const container = document.getElementById("pattern_buttons");
|
|
||||||
container.innerHTML = ""; // Clear previous buttons
|
|
||||||
|
|
||||||
patterns.forEach((pattern) => {
|
|
||||||
const button = document.createElement("button");
|
|
||||||
button.type = "button";
|
|
||||||
button.textContent = pattern;
|
|
||||||
button.value = pattern;
|
|
||||||
button.addEventListener("click", async function (event) {
|
|
||||||
event.preventDefault();
|
|
||||||
await updatePattern(pattern);
|
|
||||||
});
|
|
||||||
container.appendChild(button);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", async function () {
|
// Function to switch tabs
|
||||||
// Get the connection status element once the DOM is ready
|
function switchTab(tabId) {
|
||||||
connectionStatusElement = document.getElementById("connection-status");
|
const tabs = document.querySelectorAll(".tab");
|
||||||
|
const tabContents = document.querySelectorAll(".tab-pane");
|
||||||
|
|
||||||
// Establish WebSocket connection on page load
|
tabs.forEach((tab) => tab.classList.remove("active"));
|
||||||
connectWebSocket();
|
tabContents.forEach((content) => content.classList.remove("active"));
|
||||||
|
|
||||||
document.getElementById("color").addEventListener("input", updateColor);
|
// Activate the clicked tab and corresponding content
|
||||||
document.getElementById("color2").addEventListener("input", updateColor2);
|
document.getElementById(tabId).classList.add("active");
|
||||||
document.getElementById("delay").addEventListener("input", updateDelay);
|
|
||||||
document
|
document
|
||||||
.getElementById("brightness")
|
.getElementById("content" + tabId.replace("tab", ""))
|
||||||
.addEventListener("input", updateBrightness);
|
.classList.add("active");
|
||||||
document
|
}
|
||||||
.getElementById("num_leds_form")
|
|
||||||
.addEventListener("submit", updateNumLeds);
|
|
||||||
document.getElementById("name_form").addEventListener("submit", updateName);
|
|
||||||
document.getElementById("delay").addEventListener("touchend", updateDelay);
|
|
||||||
document
|
|
||||||
.getElementById("brightness")
|
|
||||||
.addEventListener("touchend", updateBrightness);
|
|
||||||
|
|
||||||
document.querySelectorAll(".pattern_button").forEach((button) => {
|
// Add event listeners to tabs
|
||||||
console.log(button.value);
|
tabsContainer.addEventListener("click", (e) => {
|
||||||
button.addEventListener("click", async (event) => {
|
if (e.target.classList.contains("tab")) {
|
||||||
event.preventDefault();
|
switchTab(e.target.id);
|
||||||
await updatePattern(button.value);
|
}
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Function to toggle the display of the settings menu
|
// Initially set the first tab as active
|
||||||
function selectSettings() {
|
switchTab("tab1");
|
||||||
const settingsMenu = document.getElementById("settings_menu");
|
|
||||||
controls = document.getElementById("controls");
|
|
||||||
settingsMenu.style.display = "block";
|
|
||||||
controls.style.display = "none";
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectControls() {
|
|
||||||
const settingsMenu = document.getElementById("settings_menu");
|
|
||||||
controls = document.getElementById("controls");
|
|
||||||
settingsMenu.style.display = "none";
|
|
||||||
controls.style.display = "block";
|
|
||||||
}
|
|
||||||
|
|||||||
86
src/static/patterns.js
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const patternsButton = document.getElementById('patterns-btn');
|
||||||
|
const patternsModal = document.getElementById('patterns-modal');
|
||||||
|
const patternsCloseButton = document.getElementById('patterns-close-btn');
|
||||||
|
const patternsList = document.getElementById('patterns-list');
|
||||||
|
|
||||||
|
if (!patternsButton || !patternsModal || !patternsList) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderPatterns = (patterns) => {
|
||||||
|
patternsList.innerHTML = '';
|
||||||
|
const entries = Object.entries(patterns || {});
|
||||||
|
if (!entries.length) {
|
||||||
|
const empty = document.createElement('p');
|
||||||
|
empty.className = 'muted-text';
|
||||||
|
empty.textContent = 'No patterns found.';
|
||||||
|
patternsList.appendChild(empty);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
entries.forEach(([patternName, data]) => {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'profiles-row';
|
||||||
|
|
||||||
|
const label = document.createElement('span');
|
||||||
|
label.textContent = patternName;
|
||||||
|
|
||||||
|
const details = document.createElement('span');
|
||||||
|
const minDelay = data && data.min_delay !== undefined ? data.min_delay : '-';
|
||||||
|
const maxDelay = data && data.max_delay !== undefined ? data.max_delay : '-';
|
||||||
|
details.textContent = `${minDelay}–${maxDelay} ms`;
|
||||||
|
details.style.color = '#aaa';
|
||||||
|
details.style.fontSize = '0.85em';
|
||||||
|
|
||||||
|
row.appendChild(label);
|
||||||
|
row.appendChild(details);
|
||||||
|
patternsList.appendChild(row);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadPatterns = async () => {
|
||||||
|
patternsList.innerHTML = '';
|
||||||
|
const loading = document.createElement('p');
|
||||||
|
loading.className = 'muted-text';
|
||||||
|
loading.textContent = 'Loading patterns...';
|
||||||
|
patternsList.appendChild(loading);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/patterns', {
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to load patterns');
|
||||||
|
}
|
||||||
|
const patterns = await response.json();
|
||||||
|
renderPatterns(patterns);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Load patterns failed:', error);
|
||||||
|
patternsList.innerHTML = '';
|
||||||
|
const errorMessage = document.createElement('p');
|
||||||
|
errorMessage.className = 'muted-text';
|
||||||
|
errorMessage.textContent = 'Failed to load patterns.';
|
||||||
|
patternsList.appendChild(errorMessage);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openModal = () => {
|
||||||
|
patternsModal.classList.add('active');
|
||||||
|
loadPatterns();
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
patternsModal.classList.remove('active');
|
||||||
|
};
|
||||||
|
|
||||||
|
patternsButton.addEventListener('click', openModal);
|
||||||
|
if (patternsCloseButton) {
|
||||||
|
patternsCloseButton.addEventListener('click', closeModal);
|
||||||
|
}
|
||||||
|
|
||||||
|
patternsModal.addEventListener('click', (event) => {
|
||||||
|
if (event.target === patternsModal) {
|
||||||
|
closeModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
1733
src/static/presets.js
Normal file
282
src/static/profiles.js
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
const profilesButton = document.getElementById("profiles-btn");
|
||||||
|
const profilesModal = document.getElementById("profiles-modal");
|
||||||
|
const profilesCloseButton = document.getElementById("profiles-close-btn");
|
||||||
|
const profilesList = document.getElementById("profiles-list");
|
||||||
|
const newProfileInput = document.getElementById("new-profile-name");
|
||||||
|
const createProfileButton = document.getElementById("create-profile-btn");
|
||||||
|
|
||||||
|
if (!profilesButton || !profilesModal || !profilesList) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const openModal = () => {
|
||||||
|
profilesModal.classList.add("active");
|
||||||
|
loadProfiles();
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
profilesModal.classList.remove("active");
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderProfiles = (profiles, currentProfileId) => {
|
||||||
|
profilesList.innerHTML = "";
|
||||||
|
let entries = [];
|
||||||
|
|
||||||
|
if (Array.isArray(profiles)) {
|
||||||
|
entries = profiles.map((profileId) => [profileId, {}]);
|
||||||
|
} else if (profiles && typeof profiles === "object") {
|
||||||
|
// Make sure we're iterating over profile entries, not metadata
|
||||||
|
entries = Object.entries(profiles).filter(([key]) => {
|
||||||
|
// Skip metadata keys like 'current_profile_id' if they exist
|
||||||
|
return key !== 'current_profile_id' && key !== 'profiles';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entries.length === 0) {
|
||||||
|
const empty = document.createElement("p");
|
||||||
|
empty.className = "muted-text";
|
||||||
|
empty.textContent = "No profiles found.";
|
||||||
|
profilesList.appendChild(empty);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
entries.forEach(([profileId, profile]) => {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "profiles-row";
|
||||||
|
|
||||||
|
const label = document.createElement("span");
|
||||||
|
label.textContent = (profile && profile.name) || profileId;
|
||||||
|
if (String(profileId) === String(currentProfileId)) {
|
||||||
|
label.textContent = `✓ ${label.textContent}`;
|
||||||
|
label.style.fontWeight = "bold";
|
||||||
|
label.style.color = "#FFD700";
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyButton = document.createElement("button");
|
||||||
|
applyButton.className = "btn btn-secondary btn-small profiles-apply-btn";
|
||||||
|
applyButton.textContent = "Apply";
|
||||||
|
applyButton.addEventListener("click", async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/profiles/${profileId}/apply`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Failed to apply profile");
|
||||||
|
}
|
||||||
|
await loadProfiles();
|
||||||
|
document.body.dispatchEvent(new Event("tabs-updated"));
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Apply profile failed:", error);
|
||||||
|
alert("Failed to apply profile.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const cloneButton = document.createElement("button");
|
||||||
|
cloneButton.className = "btn btn-secondary btn-small";
|
||||||
|
cloneButton.textContent = "Clone";
|
||||||
|
cloneButton.addEventListener("click", async () => {
|
||||||
|
const baseName = (profile && profile.name) || profileId;
|
||||||
|
const suggested = `${baseName}`;
|
||||||
|
const name = prompt("New profile name:", suggested);
|
||||||
|
if (name === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const trimmed = String(name).trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
alert("Profile name cannot be empty.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/profiles/${profileId}/clone`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||||||
|
body: JSON.stringify({ name: trimmed }),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Failed to clone profile");
|
||||||
|
}
|
||||||
|
const data = await response.json().catch(() => null);
|
||||||
|
let newProfileId = null;
|
||||||
|
if (data && typeof data === "object") {
|
||||||
|
if (data.id) {
|
||||||
|
newProfileId = String(data.id);
|
||||||
|
} else {
|
||||||
|
const ids = Object.keys(data);
|
||||||
|
if (ids.length > 0) {
|
||||||
|
newProfileId = String(ids[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (newProfileId) {
|
||||||
|
await fetch(`/profiles/${newProfileId}/apply`, {
|
||||||
|
method: "POST",
|
||||||
|
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>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Clone profile failed:", error);
|
||||||
|
alert("Failed to clone profile.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteButton = document.createElement("button");
|
||||||
|
deleteButton.className = "btn btn-danger btn-small";
|
||||||
|
deleteButton.textContent = "Delete";
|
||||||
|
deleteButton.addEventListener("click", async () => {
|
||||||
|
const confirmed = confirm(`Delete profile "${label.textContent}"?`);
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/profiles/${profileId}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Failed to delete profile");
|
||||||
|
}
|
||||||
|
await loadProfiles();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Delete profile failed:", error);
|
||||||
|
alert("Failed to delete profile.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
row.appendChild(label);
|
||||||
|
row.appendChild(applyButton);
|
||||||
|
row.appendChild(cloneButton);
|
||||||
|
row.appendChild(deleteButton);
|
||||||
|
profilesList.appendChild(row);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadProfiles = async () => {
|
||||||
|
profilesList.innerHTML = "";
|
||||||
|
const loading = document.createElement("p");
|
||||||
|
loading.className = "muted-text";
|
||||||
|
loading.textContent = "Loading profiles...";
|
||||||
|
profilesList.appendChild(loading);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch("/profiles", {
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Failed to load profiles");
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
// Handle both old format (just profiles object) and new format (with current_profile_id)
|
||||||
|
const profiles = data.profiles || data;
|
||||||
|
const currentProfileId = data.current_profile_id || null;
|
||||||
|
renderProfiles(profiles, currentProfileId);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Load profiles failed:", error);
|
||||||
|
profilesList.innerHTML = "";
|
||||||
|
const errorMessage = document.createElement("p");
|
||||||
|
errorMessage.className = "muted-text";
|
||||||
|
errorMessage.textContent = "Failed to load profiles.";
|
||||||
|
profilesList.appendChild(errorMessage);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const createProfile = async () => {
|
||||||
|
if (!newProfileInput) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const name = newProfileInput.value.trim();
|
||||||
|
if (!name) {
|
||||||
|
alert("Profile name cannot be empty.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await fetch("/profiles", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ name }),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Failed to create profile");
|
||||||
|
}
|
||||||
|
const data = await response.json().catch(() => null);
|
||||||
|
let newProfileId = null;
|
||||||
|
if (data && typeof data === "object") {
|
||||||
|
if (data.id) {
|
||||||
|
newProfileId = String(data.id);
|
||||||
|
} else {
|
||||||
|
const ids = Object.keys(data);
|
||||||
|
if (ids.length > 0) {
|
||||||
|
newProfileId = String(ids[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newProfileId) {
|
||||||
|
await fetch(`/profiles/${newProfileId}/apply`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
newProfileInput.value = "";
|
||||||
|
// Clear current tab and refresh the UI so the new profile starts empty.
|
||||||
|
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>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Create profile failed:", error);
|
||||||
|
alert("Failed to create profile.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
profilesButton.addEventListener("click", openModal);
|
||||||
|
if (profilesCloseButton) {
|
||||||
|
profilesCloseButton.addEventListener("click", closeModal);
|
||||||
|
}
|
||||||
|
if (createProfileButton) {
|
||||||
|
createProfileButton.addEventListener("click", createProfile);
|
||||||
|
}
|
||||||
|
if (newProfileInput) {
|
||||||
|
newProfileInput.addEventListener("keypress", (event) => {
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
createProfile();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
profilesModal.addEventListener("click", (event) => {
|
||||||
|
if (event.target === profilesModal) {
|
||||||
|
closeModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
1052
src/static/style.css
Normal file
37
src/static/styles.css
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
/* General tab styles */
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
padding: 10px 20px;
|
||||||
|
margin: 0 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
background-color: #f1f1f1;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: background-color 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab:hover {
|
||||||
|
background-color: #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab.active {
|
||||||
|
background-color: #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-pane {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-pane.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
262
src/static/tab_palette.js
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
let selectedIndex = null;
|
||||||
|
|
||||||
|
const getTab = async (tabId) => {
|
||||||
|
const response = await fetch(`/tabs/${tabId}`, {
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('No tab found');
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveTabColors = async (tabId, colors) => {
|
||||||
|
const response = await fetch(`/tabs/${tabId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ colors }),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to save tab colors');
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderPalette = (paletteContainer, colors, onColorChange, onRemoveColor, onReorder) => {
|
||||||
|
paletteContainer.innerHTML = '';
|
||||||
|
if (!colors.length) {
|
||||||
|
const empty = document.createElement('div');
|
||||||
|
empty.className = 'muted-text';
|
||||||
|
empty.textContent = 'No colors in palette.';
|
||||||
|
paletteContainer.appendChild(empty);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
colors.forEach((color, index) => {
|
||||||
|
const swatch = document.createElement('div');
|
||||||
|
swatch.className = 'color-swatch';
|
||||||
|
swatch.draggable = true;
|
||||||
|
swatch.dataset.index = String(index);
|
||||||
|
if (index === selectedIndex) {
|
||||||
|
swatch.classList.add('selected');
|
||||||
|
}
|
||||||
|
|
||||||
|
const preview = document.createElement('div');
|
||||||
|
preview.className = 'color-swatch-preview';
|
||||||
|
preview.style.backgroundColor = color;
|
||||||
|
|
||||||
|
const label = document.createElement('span');
|
||||||
|
label.className = 'color-swatch-label';
|
||||||
|
label.textContent = color;
|
||||||
|
|
||||||
|
const colorPicker = document.createElement('input');
|
||||||
|
colorPicker.type = 'color';
|
||||||
|
colorPicker.className = 'color-picker-input';
|
||||||
|
colorPicker.value = color;
|
||||||
|
colorPicker.addEventListener('change', async (event) => {
|
||||||
|
const newColor = event.target.value;
|
||||||
|
await onColorChange(index, newColor);
|
||||||
|
});
|
||||||
|
|
||||||
|
const removeButton = document.createElement('button');
|
||||||
|
removeButton.className = 'btn btn-danger btn-small';
|
||||||
|
removeButton.textContent = 'Remove';
|
||||||
|
removeButton.addEventListener('click', async (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
await onRemoveColor(index);
|
||||||
|
});
|
||||||
|
|
||||||
|
swatch.addEventListener('dragstart', (event) => {
|
||||||
|
event.dataTransfer.setData('text/plain', String(index));
|
||||||
|
event.dataTransfer.effectAllowed = 'move';
|
||||||
|
});
|
||||||
|
swatch.addEventListener('dragover', (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.dataTransfer.dropEffect = 'move';
|
||||||
|
});
|
||||||
|
swatch.addEventListener('drop', async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const fromIndex = parseInt(event.dataTransfer.getData('text/plain'), 10);
|
||||||
|
const toIndex = parseInt(swatch.dataset.index || '-1', 10);
|
||||||
|
if (Number.isNaN(fromIndex) || Number.isNaN(toIndex) || fromIndex === toIndex) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await onReorder(fromIndex, toIndex);
|
||||||
|
});
|
||||||
|
|
||||||
|
swatch.appendChild(preview);
|
||||||
|
swatch.appendChild(label);
|
||||||
|
swatch.appendChild(colorPicker);
|
||||||
|
swatch.appendChild(removeButton);
|
||||||
|
swatch.addEventListener('click', () => {
|
||||||
|
selectedIndex = index;
|
||||||
|
renderPalette(paletteContainer, colors, onColorChange, onRemoveColor, onReorder);
|
||||||
|
colorPicker.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
paletteContainer.appendChild(swatch);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const initTabPalette = async () => {
|
||||||
|
const paletteContainer = document.getElementById('color-palette');
|
||||||
|
const addButton = document.getElementById('tab-color-add-btn');
|
||||||
|
const addFromPaletteButton = document.getElementById('tab-color-add-from-palette-btn');
|
||||||
|
const colorInput = document.getElementById('tab-color-input');
|
||||||
|
|
||||||
|
if (!paletteContainer || !addButton || !colorInput) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabId = paletteContainer.dataset.tabId;
|
||||||
|
if (!tabId) {
|
||||||
|
renderPalette(paletteContainer, []);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let tabData;
|
||||||
|
try {
|
||||||
|
tabData = await getTab(tabId);
|
||||||
|
} catch (error) {
|
||||||
|
renderPalette(paletteContainer, []);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let colors = tabData.colors || [];
|
||||||
|
if (!Array.isArray(colors)) {
|
||||||
|
colors = [];
|
||||||
|
}
|
||||||
|
const onRemoveColor = async (index) => {
|
||||||
|
if (index === null || index < 0 || index >= colors.length) {
|
||||||
|
alert('Select a color to remove.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const updated = colors.filter((_, i) => i !== index);
|
||||||
|
const saved = await saveTabColors(tabId, updated);
|
||||||
|
colors = saved.colors || updated;
|
||||||
|
selectedIndex = null;
|
||||||
|
renderPalette(paletteContainer, colors, onColorChange, onRemoveColor, onReorder);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to remove color:', error);
|
||||||
|
alert('Failed to remove color.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onReorder = async (fromIndex, toIndex) => {
|
||||||
|
if (fromIndex < 0 || fromIndex >= colors.length || toIndex < 0 || toIndex >= colors.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const updated = [...colors];
|
||||||
|
const [moved] = updated.splice(fromIndex, 1);
|
||||||
|
updated.splice(toIndex, 0, moved);
|
||||||
|
const saved = await saveTabColors(tabId, updated);
|
||||||
|
colors = saved.colors || updated;
|
||||||
|
selectedIndex = toIndex;
|
||||||
|
renderPalette(paletteContainer, colors, onColorChange, onRemoveColor, onReorder);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to reorder colors:', error);
|
||||||
|
alert('Failed to reorder colors.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onColorChange = async (index, newColor) => {
|
||||||
|
if (!newColor || index < 0 || index >= colors.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const updated = [...colors];
|
||||||
|
updated[index] = newColor;
|
||||||
|
const saved = await saveTabColors(tabId, updated);
|
||||||
|
colors = saved.colors || updated;
|
||||||
|
selectedIndex = index;
|
||||||
|
renderPalette(paletteContainer, colors, onColorChange, onRemoveColor, onReorder);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update color:', error);
|
||||||
|
alert('Failed to update color.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
renderPalette(paletteContainer, colors, onColorChange, onRemoveColor, onReorder);
|
||||||
|
|
||||||
|
addButton.onclick = async () => {
|
||||||
|
const newColor = colorInput.value;
|
||||||
|
if (!newColor) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (colors.includes(newColor)) {
|
||||||
|
alert('Color already in palette.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const updated = [...colors, newColor];
|
||||||
|
const saved = await saveTabColors(tabId, updated);
|
||||||
|
colors = saved.colors || updated;
|
||||||
|
selectedIndex = colors.length - 1;
|
||||||
|
renderPalette(paletteContainer, colors, onColorChange, onRemoveColor, onReorder);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to add color:', error);
|
||||||
|
alert('Failed to add color.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (addFromPaletteButton) {
|
||||||
|
addFromPaletteButton.onclick = () => {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePick = async (event) => {
|
||||||
|
const row = event.target.closest('[data-color]');
|
||||||
|
if (!row) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const picked = row.dataset.color;
|
||||||
|
if (!picked) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (!colors.includes(picked)) {
|
||||||
|
const updated = [...colors, picked];
|
||||||
|
const saved = await saveTabColors(tabId, updated);
|
||||||
|
colors = saved.colors || updated;
|
||||||
|
selectedIndex = colors.indexOf(picked);
|
||||||
|
renderPalette(paletteContainer, colors, onColorChange, onRemoveColor, onReorder);
|
||||||
|
}
|
||||||
|
if (modal) {
|
||||||
|
modal.classList.remove('active');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to add palette color:', error);
|
||||||
|
alert('Failed to add palette color.');
|
||||||
|
} finally {
|
||||||
|
modalList.removeEventListener('click', handlePick);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
modalList.addEventListener('click', handlePick);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
document.body.addEventListener('htmx:afterSwap', (event) => {
|
||||||
|
if (event.target && event.target.id === 'tab-content') {
|
||||||
|
selectedIndex = null;
|
||||||
|
initTabPalette();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
initTabPalette();
|
||||||
|
});
|
||||||
809
src/static/tabs.js
Normal file
@@ -0,0 +1,809 @@
|
|||||||
|
// Tab management JavaScript
|
||||||
|
let currentTabId = null;
|
||||||
|
|
||||||
|
// Get current tab from cookie
|
||||||
|
function getCurrentTabFromCookie() {
|
||||||
|
const cookies = document.cookie.split(';');
|
||||||
|
for (let cookie of cookies) {
|
||||||
|
const [name, value] = cookie.trim().split('=');
|
||||||
|
if (name === 'current_tab') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load tabs list
|
||||||
|
async function loadTabs() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/tabs');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
// Get current tab from cookie first, then from server response
|
||||||
|
const cookieTabId = getCurrentTabFromCookie();
|
||||||
|
const serverCurrent = data.current_tab_id;
|
||||||
|
const tabs = data.tabs || {};
|
||||||
|
const tabIds = Object.keys(tabs);
|
||||||
|
|
||||||
|
let candidateId = cookieTabId || serverCurrent || null;
|
||||||
|
// If the candidate doesn't exist anymore (e.g. after DB reset), fall back to first tab.
|
||||||
|
if (candidateId && !tabIds.includes(String(candidateId))) {
|
||||||
|
candidateId = tabIds.length > 0 ? tabIds[0] : null;
|
||||||
|
// Clear stale cookie
|
||||||
|
document.cookie = 'current_tab=; path=/; max-age=0';
|
||||||
|
}
|
||||||
|
|
||||||
|
currentTabId = candidateId;
|
||||||
|
renderTabsList(data.tabs, data.tab_order, currentTabId);
|
||||||
|
|
||||||
|
// Load current tab content if available
|
||||||
|
if (currentTabId) {
|
||||||
|
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]);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load tabs:', error);
|
||||||
|
const container = document.getElementById('tabs-list');
|
||||||
|
if (container) {
|
||||||
|
container.innerHTML = '<div class="error">Failed to load tabs</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render tabs list in the main UI
|
||||||
|
function renderTabsList(tabs, tabOrder, currentTabId) {
|
||||||
|
const container = document.getElementById('tabs-list');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
if (!tabOrder || tabOrder.length === 0) {
|
||||||
|
container.innerHTML = '<div class="muted-text">No tabs available</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = '<div class="tabs-list">';
|
||||||
|
for (const tabId of tabOrder) {
|
||||||
|
const tab = tabs[tabId];
|
||||||
|
if (tab) {
|
||||||
|
const activeClass = tabId === currentTabId ? 'active' : '';
|
||||||
|
const tabName = tab.name || `Tab ${tabId}`;
|
||||||
|
html += `
|
||||||
|
<button class="tab-button ${activeClass}"
|
||||||
|
data-tab-id="${tabId}"
|
||||||
|
title="Click to select, right-click to edit"
|
||||||
|
onclick="selectTab('${tabId}')">
|
||||||
|
${tabName}
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
html += '</div>';
|
||||||
|
container.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render tabs list in modal (like profiles)
|
||||||
|
function renderTabsListModal(tabs, tabOrder, currentTabId) {
|
||||||
|
const container = document.getElementById('tabs-list-modal');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
container.innerHTML = "";
|
||||||
|
let entries = [];
|
||||||
|
|
||||||
|
if (Array.isArray(tabOrder)) {
|
||||||
|
entries = tabOrder.map((tabId) => [tabId, tabs[tabId] || {}]);
|
||||||
|
} else if (tabs && typeof tabs === "object") {
|
||||||
|
entries = Object.entries(tabs).filter(([key]) => {
|
||||||
|
return key !== 'current_tab_id' && key !== 'tabs' && key !== 'tab_order';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entries.length === 0) {
|
||||||
|
const empty = document.createElement("p");
|
||||||
|
empty.className = "muted-text";
|
||||||
|
empty.textContent = "No tabs found.";
|
||||||
|
container.appendChild(empty);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
entries.forEach(([tabId, tab]) => {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "profiles-row";
|
||||||
|
|
||||||
|
const label = document.createElement("span");
|
||||||
|
label.textContent = (tab && tab.name) || tabId;
|
||||||
|
if (String(tabId) === String(currentTabId)) {
|
||||||
|
label.textContent = `✓ ${label.textContent}`;
|
||||||
|
label.style.fontWeight = "bold";
|
||||||
|
label.style.color = "#FFD700";
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyButton = document.createElement("button");
|
||||||
|
applyButton.className = "btn btn-secondary btn-small";
|
||||||
|
applyButton.textContent = "Select";
|
||||||
|
applyButton.addEventListener("click", async () => {
|
||||||
|
await selectTab(tabId);
|
||||||
|
document.getElementById('tabs-modal').classList.remove('active');
|
||||||
|
});
|
||||||
|
|
||||||
|
const editButton = document.createElement("button");
|
||||||
|
editButton.className = "btn btn-secondary btn-small";
|
||||||
|
editButton.textContent = "Edit";
|
||||||
|
editButton.addEventListener("click", () => {
|
||||||
|
openEditTabModal(tabId, tab);
|
||||||
|
});
|
||||||
|
|
||||||
|
const sendPresetsButton = document.createElement("button");
|
||||||
|
sendPresetsButton.className = "btn btn-secondary btn-small";
|
||||||
|
sendPresetsButton.textContent = "Send Presets";
|
||||||
|
sendPresetsButton.addEventListener("click", async () => {
|
||||||
|
await sendTabPresets(tabId);
|
||||||
|
});
|
||||||
|
|
||||||
|
const cloneButton = document.createElement("button");
|
||||||
|
cloneButton.className = "btn btn-secondary btn-small";
|
||||||
|
cloneButton.textContent = "Clone";
|
||||||
|
cloneButton.addEventListener("click", async () => {
|
||||||
|
const baseName = (tab && tab.name) || tabId;
|
||||||
|
const suggested = `${baseName} Copy`;
|
||||||
|
const name = prompt("New tab name:", suggested);
|
||||||
|
if (name === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const trimmed = String(name).trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
alert("Tab name cannot be empty.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/tabs/${tabId}/clone`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Accept": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ name: trimmed }),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({ error: "Failed to clone tab" }));
|
||||||
|
throw new Error(errorData.error || "Failed to clone tab");
|
||||||
|
}
|
||||||
|
const data = await response.json().catch(() => null);
|
||||||
|
let newTabId = null;
|
||||||
|
if (data && typeof data === "object") {
|
||||||
|
if (data.id) {
|
||||||
|
newTabId = String(data.id);
|
||||||
|
} else {
|
||||||
|
const ids = Object.keys(data);
|
||||||
|
if (ids.length > 0) {
|
||||||
|
newTabId = String(ids[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await loadTabsModal();
|
||||||
|
if (newTabId) {
|
||||||
|
await selectTab(newTabId);
|
||||||
|
} else {
|
||||||
|
await loadTabs();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Clone tab failed:", error);
|
||||||
|
alert("Failed to clone tab: " + error.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteButton = document.createElement("button");
|
||||||
|
deleteButton.className = "btn btn-danger btn-small";
|
||||||
|
deleteButton.textContent = "Delete";
|
||||||
|
deleteButton.addEventListener("click", async () => {
|
||||||
|
const confirmed = confirm(`Delete tab "${label.textContent}"?`);
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/tabs/${tabId}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({ error: "Failed to delete tab" }));
|
||||||
|
throw new Error(errorData.error || "Failed to delete tab");
|
||||||
|
}
|
||||||
|
// Clear cookie if deleted tab was current
|
||||||
|
if (tabId === currentTabId) {
|
||||||
|
document.cookie = 'current_tab=; path=/; max-age=0';
|
||||||
|
currentTabId = null;
|
||||||
|
}
|
||||||
|
await loadTabsModal();
|
||||||
|
await loadTabs(); // Reload main tabs list
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Delete tab failed:", error);
|
||||||
|
alert("Failed to delete tab: " + error.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
row.appendChild(label);
|
||||||
|
row.appendChild(applyButton);
|
||||||
|
row.appendChild(editButton);
|
||||||
|
row.appendChild(sendPresetsButton);
|
||||||
|
row.appendChild(cloneButton);
|
||||||
|
row.appendChild(deleteButton);
|
||||||
|
container.appendChild(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load tabs in modal
|
||||||
|
async function loadTabsModal() {
|
||||||
|
const container = document.getElementById('tabs-list-modal');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
container.innerHTML = "";
|
||||||
|
const loading = document.createElement("p");
|
||||||
|
loading.className = "muted-text";
|
||||||
|
loading.textContent = "Loading tabs...";
|
||||||
|
container.appendChild(loading);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch("/tabs", {
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Failed to load tabs");
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
const tabs = data.tabs || data;
|
||||||
|
const currentTabId = getCurrentTabFromCookie() || data.current_tab_id || null;
|
||||||
|
renderTabsListModal(tabs, data.tab_order || [], currentTabId);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Load tabs failed:", error);
|
||||||
|
container.innerHTML = "";
|
||||||
|
const errorMessage = document.createElement("p");
|
||||||
|
errorMessage.className = "muted-text";
|
||||||
|
errorMessage.textContent = "Failed to load tabs.";
|
||||||
|
container.appendChild(errorMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Select a tab
|
||||||
|
async function selectTab(tabId) {
|
||||||
|
// Update active state
|
||||||
|
document.querySelectorAll('.tab-button').forEach(btn => {
|
||||||
|
btn.classList.remove('active');
|
||||||
|
});
|
||||||
|
const btn = document.querySelector(`[data-tab-id="${tabId}"]`);
|
||||||
|
if (btn) {
|
||||||
|
btn.classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set as current tab
|
||||||
|
await setCurrentTab(tabId);
|
||||||
|
// Load tab content
|
||||||
|
loadTabContent(tabId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set current tab in cookie
|
||||||
|
async function setCurrentTab(tabId) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/tabs/${tabId}/set-current`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
if (response.ok) {
|
||||||
|
currentTabId = tabId;
|
||||||
|
// Also set cookie on client side
|
||||||
|
document.cookie = `current_tab=${tabId}; path=/; max-age=31536000`;
|
||||||
|
} else {
|
||||||
|
console.error('Failed to set current tab:', data.error);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error setting current tab:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load tab content
|
||||||
|
async function loadTabContent(tabId) {
|
||||||
|
const container = document.getElementById('tab-content');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/tabs/${tabId}`);
|
||||||
|
const tab = await response.json();
|
||||||
|
|
||||||
|
if (tab.error) {
|
||||||
|
container.innerHTML = `<div class="error">${tab.error}</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render tab content (presets section)
|
||||||
|
const tabName = tab.name || `Tab ${tabId}`;
|
||||||
|
const deviceNames = Array.isArray(tab.names) ? tab.names.join(',') : '';
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="presets-section" data-tab-id="${tabId}" data-device-names="${deviceNames}">
|
||||||
|
<div class="profiles-actions presets-toolbar" style="margin-bottom: 1rem;">
|
||||||
|
<div class="tab-brightness-group">
|
||||||
|
<label for="tab-brightness-slider">Brightness</label>
|
||||||
|
<input type="range" id="tab-brightness-slider" min="0" max="255" value="255">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="presets-list-tab" class="presets-list">
|
||||||
|
<!-- Presets will be loaded here by presets.js -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Wire up per-tab brightness slider to send global brightness via ESPNow.
|
||||||
|
const brightnessSlider = container.querySelector('#tab-brightness-slider');
|
||||||
|
let brightnessSendTimeout = null;
|
||||||
|
if (brightnessSlider) {
|
||||||
|
brightnessSlider.addEventListener('input', (e) => {
|
||||||
|
const val = parseInt(e.target.value, 10) || 0;
|
||||||
|
if (brightnessSendTimeout) {
|
||||||
|
clearTimeout(brightnessSendTimeout);
|
||||||
|
}
|
||||||
|
brightnessSendTimeout = setTimeout(() => {
|
||||||
|
if (typeof window.sendEspnowRaw === 'function') {
|
||||||
|
try {
|
||||||
|
window.sendEspnowRaw({ v: '1', b: val });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to send brightness via ESPNow:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 150);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger presets loading if the function exists
|
||||||
|
if (typeof renderTabPresets === 'function') {
|
||||||
|
renderTabPresets(tabId);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load tab content:', error);
|
||||||
|
container.innerHTML = '<div class="error">Failed to load tab content</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send all presets used by a tab via the /presets/send HTTP endpoint.
|
||||||
|
async function sendTabPresets(tabId) {
|
||||||
|
try {
|
||||||
|
// Load tab data to determine which presets are used
|
||||||
|
const tabResponse = await fetch(`/tabs/${tabId}`, {
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
});
|
||||||
|
if (!tabResponse.ok) {
|
||||||
|
alert('Failed to load tab to send presets.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const tabData = await tabResponse.json();
|
||||||
|
|
||||||
|
// Extract preset IDs from tab (supports grid, flat, and legacy formats)
|
||||||
|
let presetIds = [];
|
||||||
|
if (Array.isArray(tabData.presets_flat)) {
|
||||||
|
presetIds = tabData.presets_flat;
|
||||||
|
} else if (Array.isArray(tabData.presets)) {
|
||||||
|
if (tabData.presets.length && typeof tabData.presets[0] === 'string') {
|
||||||
|
// Flat array of IDs
|
||||||
|
presetIds = tabData.presets;
|
||||||
|
} else if (tabData.presets.length && Array.isArray(tabData.presets[0])) {
|
||||||
|
// 2D grid
|
||||||
|
presetIds = tabData.presets.flat();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
presetIds = (presetIds || []).filter(Boolean);
|
||||||
|
|
||||||
|
if (!presetIds.length) {
|
||||||
|
alert('This tab has no presets to send.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call server-side ESPNow sender with just the IDs; it handles chunking.
|
||||||
|
const payload = { preset_ids: presetIds };
|
||||||
|
if (tabData.default_preset) {
|
||||||
|
payload.default = tabData.default_preset;
|
||||||
|
}
|
||||||
|
const response = await fetch('/presets/send', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok) {
|
||||||
|
const msg = (data && data.error) || 'Failed to send presets.';
|
||||||
|
alert(msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sent = typeof data.presets_sent === 'number' ? data.presets_sent : presetIds.length;
|
||||||
|
const messages = typeof data.messages_sent === 'number' ? data.messages_sent : '?';
|
||||||
|
alert(`Sent ${sent} preset(s) in ${messages} ESPNow message(s).`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to send tab presets:', error);
|
||||||
|
alert('Failed to send tab presets.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send all presets used by all tabs in the current profile via /presets/send.
|
||||||
|
async function sendProfilePresets() {
|
||||||
|
try {
|
||||||
|
// Load current profile to get its tabs
|
||||||
|
const profileRes = await fetch('/profiles/current', {
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
});
|
||||||
|
if (!profileRes.ok) {
|
||||||
|
alert('Failed to load current profile.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const profileData = await profileRes.json();
|
||||||
|
const profile = profileData.profile || {};
|
||||||
|
let tabList = null;
|
||||||
|
if (Array.isArray(profile.tabs)) {
|
||||||
|
tabList = profile.tabs;
|
||||||
|
} else if (profile.tabs) {
|
||||||
|
tabList = [profile.tabs];
|
||||||
|
}
|
||||||
|
if (!tabList || tabList.length === 0) {
|
||||||
|
if (Array.isArray(profile.tab_order)) {
|
||||||
|
tabList = profile.tab_order;
|
||||||
|
} else if (profile.tab_order) {
|
||||||
|
tabList = [profile.tab_order];
|
||||||
|
} else {
|
||||||
|
tabList = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!tabList || tabList.length === 0) {
|
||||||
|
console.warn('sendProfilePresets: no tabs found', {
|
||||||
|
profileData,
|
||||||
|
profile,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tabList.length) {
|
||||||
|
alert('Current profile has no tabs to send presets for.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let totalSent = 0;
|
||||||
|
let totalMessages = 0;
|
||||||
|
let tabsWithPresets = 0;
|
||||||
|
|
||||||
|
for (const tabId of tabList) {
|
||||||
|
try {
|
||||||
|
const tabResp = await fetch(`/tabs/${tabId}`, {
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
});
|
||||||
|
if (!tabResp.ok) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const tabData = await tabResp.json();
|
||||||
|
let presetIds = [];
|
||||||
|
if (Array.isArray(tabData.presets_flat)) {
|
||||||
|
presetIds = tabData.presets_flat;
|
||||||
|
} else if (Array.isArray(tabData.presets)) {
|
||||||
|
if (tabData.presets.length && typeof tabData.presets[0] === 'string') {
|
||||||
|
presetIds = tabData.presets;
|
||||||
|
} else if (tabData.presets.length && Array.isArray(tabData.presets[0])) {
|
||||||
|
presetIds = tabData.presets.flat();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
presetIds = (presetIds || []).filter(Boolean);
|
||||||
|
if (!presetIds.length) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
tabsWithPresets += 1;
|
||||||
|
const payload = { preset_ids: presetIds };
|
||||||
|
if (tabData.default_preset) {
|
||||||
|
payload.default = tabData.default_preset;
|
||||||
|
}
|
||||||
|
const response = await fetch('/presets/send', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok) {
|
||||||
|
const msg = (data && data.error) || `Failed to send presets for tab ${tabId}.`;
|
||||||
|
console.warn(msg);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
totalSent += typeof data.presets_sent === 'number' ? data.presets_sent : presetIds.length;
|
||||||
|
totalMessages += typeof data.messages_sent === 'number' ? data.messages_sent : 0;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to send profile presets for tab:', tabId, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tabsWithPresets) {
|
||||||
|
alert('No presets to send for the current profile.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const messagesLabel = totalMessages ? totalMessages : '?';
|
||||||
|
alert(`Sent ${totalSent} preset(s) across ${tabsWithPresets} tab(s) in ${messagesLabel} ESPNow message(s).`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to send profile presets:', error);
|
||||||
|
alert('Failed to send profile presets.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate the "Add presets to this tab" list: only presets NOT already in the tab, each with a Select button.
|
||||||
|
async function populateEditTabPresetsList(tabId) {
|
||||||
|
const listEl = document.getElementById('edit-tab-presets-list');
|
||||||
|
if (!listEl) return;
|
||||||
|
listEl.innerHTML = '<span class="muted-text">Loading…</span>';
|
||||||
|
try {
|
||||||
|
const tabRes = await fetch(`/tabs/${tabId}`, { headers: { Accept: 'application/json' } });
|
||||||
|
if (!tabRes.ok) {
|
||||||
|
listEl.innerHTML = '<span class="muted-text">Failed to load presets.</span>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const tabData = await tabRes.json();
|
||||||
|
let inTabIds = [];
|
||||||
|
if (Array.isArray(tabData.presets_flat)) {
|
||||||
|
inTabIds = tabData.presets_flat;
|
||||||
|
} else if (Array.isArray(tabData.presets)) {
|
||||||
|
if (tabData.presets.length && typeof tabData.presets[0] === 'string') {
|
||||||
|
inTabIds = tabData.presets;
|
||||||
|
} else if (tabData.presets.length && Array.isArray(tabData.presets[0])) {
|
||||||
|
inTabIds = tabData.presets.flat();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const presetsRes = await fetch('/presets', { headers: { Accept: 'application/json' } });
|
||||||
|
const allPresets = presetsRes.ok ? await presetsRes.json() : {};
|
||||||
|
const allIds = Object.keys(allPresets);
|
||||||
|
const availableToAdd = allIds.filter(id => !inTabIds.includes(id));
|
||||||
|
listEl.innerHTML = '';
|
||||||
|
if (availableToAdd.length === 0) {
|
||||||
|
listEl.innerHTML = '<span class="muted-text">No presets to add. All presets are already in this tab.</span>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const presetId of availableToAdd) {
|
||||||
|
const preset = allPresets[presetId] || {};
|
||||||
|
const name = preset.name || presetId;
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'profiles-row';
|
||||||
|
row.style.display = 'flex';
|
||||||
|
row.style.alignItems = 'center';
|
||||||
|
row.style.justifyContent = 'space-between';
|
||||||
|
row.style.gap = '0.5rem';
|
||||||
|
const label = document.createElement('span');
|
||||||
|
label.textContent = name;
|
||||||
|
const selectBtn = document.createElement('button');
|
||||||
|
selectBtn.type = 'button';
|
||||||
|
selectBtn.className = 'btn btn-primary btn-small';
|
||||||
|
selectBtn.textContent = 'Select';
|
||||||
|
selectBtn.addEventListener('click', async () => {
|
||||||
|
if (typeof window.addPresetToTab === 'function') {
|
||||||
|
await window.addPresetToTab(presetId, tabId);
|
||||||
|
await populateEditTabPresetsList(tabId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
row.appendChild(label);
|
||||||
|
row.appendChild(selectBtn);
|
||||||
|
listEl.appendChild(row);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('populateEditTabPresetsList:', e);
|
||||||
|
listEl.innerHTML = '<span class="muted-text">Failed to load presets.</span>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open edit tab modal
|
||||||
|
function openEditTabModal(tabId, tab) {
|
||||||
|
const modal = document.getElementById('edit-tab-modal');
|
||||||
|
const idInput = document.getElementById('edit-tab-id');
|
||||||
|
const nameInput = document.getElementById('edit-tab-name');
|
||||||
|
const idsInput = document.getElementById('edit-tab-ids');
|
||||||
|
|
||||||
|
if (idInput) idInput.value = tabId;
|
||||||
|
if (nameInput) nameInput.value = tab ? (tab.name || '') : '';
|
||||||
|
if (idsInput) idsInput.value = tab && tab.names ? tab.names.join(', ') : '1';
|
||||||
|
|
||||||
|
if (modal) modal.classList.add('active');
|
||||||
|
populateEditTabPresetsList(tabId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update an existing tab
|
||||||
|
async function updateTab(tabId, name, ids) {
|
||||||
|
try {
|
||||||
|
const names = ids ? ids.split(',').map(id => id.trim()) : ['1'];
|
||||||
|
const response = await fetch(`/tabs/${tabId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: name,
|
||||||
|
names: names
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (response.ok) {
|
||||||
|
// Reload tabs list
|
||||||
|
await loadTabsModal();
|
||||||
|
await loadTabs();
|
||||||
|
// Close modal
|
||||||
|
document.getElementById('edit-tab-modal').classList.remove('active');
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
alert(`Error: ${data.error || 'Failed to update tab'}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update tab:', error);
|
||||||
|
alert('Failed to update tab');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a new tab
|
||||||
|
async function createTab(name, ids) {
|
||||||
|
try {
|
||||||
|
const names = ids ? ids.split(',').map(id => id.trim()) : ['1'];
|
||||||
|
const response = await fetch('/tabs', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: name,
|
||||||
|
names: names
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (response.ok) {
|
||||||
|
// Reload tabs list
|
||||||
|
await loadTabsModal();
|
||||||
|
await loadTabs();
|
||||||
|
// Select the new tab
|
||||||
|
if (data && Object.keys(data).length > 0) {
|
||||||
|
const newTabId = Object.keys(data)[0];
|
||||||
|
await selectTab(newTabId);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
alert(`Error: ${data.error || 'Failed to create tab'}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create tab:', error);
|
||||||
|
alert('Failed to create tab');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize on page load
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
loadTabs();
|
||||||
|
|
||||||
|
// Set up tabs modal
|
||||||
|
const tabsButton = document.getElementById('tabs-btn');
|
||||||
|
const tabsModal = document.getElementById('tabs-modal');
|
||||||
|
const tabsCloseButton = document.getElementById('tabs-close-btn');
|
||||||
|
const newTabNameInput = document.getElementById('new-tab-name');
|
||||||
|
const newTabIdsInput = document.getElementById('new-tab-ids');
|
||||||
|
const createTabButton = document.getElementById('create-tab-btn');
|
||||||
|
|
||||||
|
if (tabsButton && tabsModal) {
|
||||||
|
tabsButton.addEventListener('click', () => {
|
||||||
|
tabsModal.classList.add('active');
|
||||||
|
loadTabsModal();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tabsCloseButton) {
|
||||||
|
tabsCloseButton.addEventListener('click', () => {
|
||||||
|
tabsModal.classList.remove('active');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tabsModal) {
|
||||||
|
tabsModal.addEventListener('click', (event) => {
|
||||||
|
if (event.target === tabsModal) {
|
||||||
|
tabsModal.classList.remove('active');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Right-click on a tab button in the main header bar to edit that tab
|
||||||
|
document.addEventListener('contextmenu', async (event) => {
|
||||||
|
const btn = event.target.closest('.tab-button');
|
||||||
|
if (!btn || !btn.dataset.tabId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
const tabId = btn.dataset.tabId;
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/tabs/${tabId}`);
|
||||||
|
if (response.ok) {
|
||||||
|
const tab = await response.json();
|
||||||
|
openEditTabModal(tabId, tab);
|
||||||
|
} else {
|
||||||
|
alert('Failed to load tab for editing');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load tab:', error);
|
||||||
|
alert('Failed to load tab for editing');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set up create tab
|
||||||
|
const createTabHandler = async () => {
|
||||||
|
if (!newTabNameInput) return;
|
||||||
|
const name = newTabNameInput.value.trim();
|
||||||
|
const ids = (newTabIdsInput && newTabIdsInput.value.trim()) || '1';
|
||||||
|
|
||||||
|
if (name) {
|
||||||
|
await createTab(name, ids);
|
||||||
|
if (newTabNameInput) newTabNameInput.value = '';
|
||||||
|
if (newTabIdsInput) newTabIdsInput.value = '1';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (createTabButton) {
|
||||||
|
createTabButton.addEventListener('click', createTabHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newTabNameInput) {
|
||||||
|
newTabNameInput.addEventListener('keypress', (event) => {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
createTabHandler();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up edit tab form
|
||||||
|
const editTabForm = document.getElementById('edit-tab-form');
|
||||||
|
if (editTabForm) {
|
||||||
|
editTabForm.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const idInput = document.getElementById('edit-tab-id');
|
||||||
|
const nameInput = document.getElementById('edit-tab-name');
|
||||||
|
const idsInput = document.getElementById('edit-tab-ids');
|
||||||
|
|
||||||
|
const tabId = idInput ? idInput.value : null;
|
||||||
|
const name = nameInput ? nameInput.value.trim() : '';
|
||||||
|
const ids = idsInput ? idsInput.value.trim() : '1';
|
||||||
|
|
||||||
|
if (tabId && name) {
|
||||||
|
await updateTab(tabId, name, ids);
|
||||||
|
editTabForm.reset();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close edit modal when clicking outside
|
||||||
|
const editTabModal = document.getElementById('edit-tab-modal');
|
||||||
|
if (editTabModal) {
|
||||||
|
editTabModal.addEventListener('click', (event) => {
|
||||||
|
if (event.target === editTabModal) {
|
||||||
|
editTabModal.classList.remove('active');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Profile-wide "Send Presets" button in header
|
||||||
|
const sendProfilePresetsBtn = document.getElementById('send-profile-presets-btn');
|
||||||
|
if (sendProfilePresetsBtn) {
|
||||||
|
sendProfilePresetsBtn.addEventListener('click', async () => {
|
||||||
|
await sendProfilePresets();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Export for use in other scripts
|
||||||
|
window.tabsManager = {
|
||||||
|
loadTabs,
|
||||||
|
selectTab,
|
||||||
|
createTab,
|
||||||
|
updateTab,
|
||||||
|
openEditTabModal,
|
||||||
|
getCurrentTabId: () => currentTabId
|
||||||
|
};
|
||||||
@@ -1,97 +1,321 @@
|
|||||||
{% args settings, patterns, mac %}
|
<!DOCTYPE html>
|
||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>LED Control</title>
|
<title>LED Controller - Tab Mode</title>
|
||||||
<script src="static/main.js"></script>
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
<link rel="stylesheet" href="static/main.css" />
|
</head>
|
||||||
</head>
|
<body>
|
||||||
<body>
|
<div class="app-container">
|
||||||
<h1>Control LEDs</h1>
|
<header>
|
||||||
<button onclick="selectControls()">Controls</button>
|
<div class="tabs-container">
|
||||||
<button onclick="selectSettings()">Settings</button>
|
<div id="tabs-list">
|
||||||
|
Loading tabs...
|
||||||
<!-- Main LED Controls -->
|
</div>
|
||||||
<div id="controls">
|
|
||||||
<div id="pattern_buttons">
|
|
||||||
{% for p in patterns %}
|
|
||||||
<button class="pattern_button" value="{{p}}">{{p}}</button>
|
|
||||||
{% endfor %}
|
|
||||||
|
|
||||||
<!-- Pattern buttons will be inserted here -->
|
|
||||||
</div>
|
</div>
|
||||||
<form id="delay_form" method="post" action="/delay">
|
<div class="header-actions">
|
||||||
<label for="delay">Delay:</label>
|
<button class="btn btn-secondary" id="tabs-btn">Tabs</button>
|
||||||
<input
|
<button class="btn btn-secondary" id="color-palette-btn">Color Palette</button>
|
||||||
type="range"
|
<button class="btn btn-secondary" id="presets-btn">Presets</button>
|
||||||
id="delay"
|
<button class="btn btn-secondary" id="send-profile-presets-btn">Send Presets</button>
|
||||||
name="delay"
|
<button class="btn btn-secondary" id="patterns-btn">Patterns</button>
|
||||||
min="1"
|
<button class="btn btn-secondary" id="profiles-btn">Profiles</button>
|
||||||
max="1000"
|
<button class="btn btn-secondary" id="settings-btn">Settings</button>
|
||||||
value="{{settings['delay']}}"
|
<button class="btn btn-secondary" id="help-btn">Help</button>
|
||||||
step="10"
|
</div>
|
||||||
/>
|
<div class="header-menu-mobile">
|
||||||
</form>
|
<button class="btn btn-secondary" id="main-menu-btn">Menu</button>
|
||||||
<form id="brightness_form" method="post" action="/brightness">
|
<div id="main-menu-dropdown" class="main-menu-dropdown">
|
||||||
<label for="brightness">Brightness:</label>
|
<button type="button" data-target="tabs-btn">Tabs</button>
|
||||||
<input
|
<button type="button" data-target="color-palette-btn">Color Palette</button>
|
||||||
type="range"
|
<button type="button" data-target="presets-btn">Presets</button>
|
||||||
id="brightness"
|
<button type="button" data-target="send-profile-presets-btn">Send Presets</button>
|
||||||
name="brightness"
|
<button type="button" data-target="patterns-btn">Patterns</button>
|
||||||
min="0"
|
<button type="button" data-target="profiles-btn">Profiles</button>
|
||||||
max="100"
|
<button type="button" data-target="settings-btn">Settings</button>
|
||||||
value="{{settings['brightness']}}"
|
<button type="button" data-target="help-btn">Help</button>
|
||||||
step="1"
|
</div>
|
||||||
/>
|
</div>
|
||||||
</form>
|
</header>
|
||||||
<form id="color_form" method="post" action="/color">
|
|
||||||
<input
|
<div class="main-content">
|
||||||
type="color"
|
<div id="tab-content" class="tab-content">
|
||||||
id="color"
|
<div class="tab-content-placeholder">
|
||||||
name="color"
|
Select a tab to get started
|
||||||
value="{{settings['color1']}}"
|
</div>
|
||||||
/>
|
</div>
|
||||||
</form>
|
</div>
|
||||||
<form id="color2_form" method="post" action="/color2">
|
</div>
|
||||||
<input
|
|
||||||
type="color"
|
<!-- Tabs Modal -->
|
||||||
id="color2"
|
<div id="tabs-modal" class="modal">
|
||||||
name="color2"
|
<div class="modal-content">
|
||||||
value="{{settings['color2']}}"
|
<h2>Tabs</h2>
|
||||||
/>
|
<div class="profiles-actions">
|
||||||
|
<input type="text" id="new-tab-name" placeholder="Tab name">
|
||||||
|
<input type="text" id="new-tab-ids" placeholder="Device IDs (1,2,3)" value="1">
|
||||||
|
<button class="btn btn-primary" id="create-tab-btn">Create</button>
|
||||||
|
</div>
|
||||||
|
<div id="tabs-list-modal" class="profiles-list"></div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="btn btn-secondary" id="tabs-close-btn">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Edit Tab Modal -->
|
||||||
|
<div id="edit-tab-modal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<h2>Edit Tab</h2>
|
||||||
|
<form id="edit-tab-form">
|
||||||
|
<input type="hidden" id="edit-tab-id">
|
||||||
|
<div class="modal-actions" style="margin-bottom: 1rem;">
|
||||||
|
<button type="submit" class="btn btn-primary">Save</button>
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="document.getElementById('edit-tab-modal').classList.remove('active')">Close</button>
|
||||||
|
</div>
|
||||||
|
<label>Tab Name:</label>
|
||||||
|
<input type="text" id="edit-tab-name" placeholder="Enter tab name" required>
|
||||||
|
<label>Device IDs (comma-separated):</label>
|
||||||
|
<input type="text" id="edit-tab-ids" placeholder="1,2,3" required>
|
||||||
|
<label style="margin-top: 1rem;">Add presets to this tab</label>
|
||||||
|
<div id="edit-tab-presets-list" class="profiles-list" style="max-height: 200px; overflow-y: auto; margin-bottom: 1rem;"></div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Settings Menu for num_leds, Wi-Fi SSID, and Password -->
|
<!-- Profiles Modal -->
|
||||||
|
<div id="profiles-modal" class="modal">
|
||||||
<div id="settings_menu" style="display: none">
|
<div class="modal-content">
|
||||||
<h2>Settings</h2>
|
<h2>Profiles</h2>
|
||||||
|
<div class="profiles-actions">
|
||||||
<form id="name_form" method="post" action="/name">
|
<input type="text" id="new-profile-name" placeholder="Profile name">
|
||||||
<label for="name">Name:</label>
|
<button class="btn btn-primary" id="create-profile-btn">Create</button>
|
||||||
<input
|
</div>
|
||||||
type="text"
|
<div id="profiles-list" class="profiles-list"></div>
|
||||||
id="name"
|
<div class="modal-actions">
|
||||||
name="num_leds"
|
<button class="btn btn-secondary" id="profiles-close-btn">Close</button>
|
||||||
value="{{settings['name']}}"
|
</div>
|
||||||
/>
|
|
||||||
<input type="submit" value="Update Name" />
|
|
||||||
</form>
|
|
||||||
<!-- Separate form for submitting num_leds -->
|
|
||||||
<form id="num_leds_form" method="post" action="/num_leds">
|
|
||||||
<label for="num_leds">Number of LEDs:</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="num_leds"
|
|
||||||
name="num_leds"
|
|
||||||
value="{{settings['num_leds']}}"
|
|
||||||
/>
|
|
||||||
<input type="submit" value="Update Number of LEDs" />
|
|
||||||
</form>
|
|
||||||
<p>Mac address: {{mac}}</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div id="connection-status"></div>
|
</div>
|
||||||
</body>
|
|
||||||
|
<!-- Presets Modal -->
|
||||||
|
<div id="presets-modal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<h2>Presets</h2>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="btn btn-primary" id="preset-add-btn">Add</button>
|
||||||
|
</div>
|
||||||
|
<div id="presets-list" class="profiles-list"></div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="btn btn-secondary" id="presets-close-btn">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Preset Editor Modal -->
|
||||||
|
<div id="preset-editor-modal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<h2>Preset</h2>
|
||||||
|
<div class="profiles-actions">
|
||||||
|
<input type="text" id="preset-name-input" placeholder="Preset name">
|
||||||
|
<select id="preset-pattern-input">
|
||||||
|
<option value="">Pattern</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<div class="profiles-actions">
|
||||||
|
<div class="preset-editor-field">
|
||||||
|
<label for="preset-brightness-input">Brightness (0–255)</label>
|
||||||
|
<input type="number" id="preset-brightness-input" placeholder="Brightness" min="0" max="255" value="0">
|
||||||
|
</div>
|
||||||
|
<div class="preset-editor-field">
|
||||||
|
<label for="preset-delay-input">Delay (ms)</label>
|
||||||
|
<input type="number" id="preset-delay-input" placeholder="Delay" min="0" max="10000" value="0">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="n-params-grid">
|
||||||
|
<div class="n-param-group">
|
||||||
|
<label for="preset-n1-input" id="preset-n1-label">n1:</label>
|
||||||
|
<input type="number" id="preset-n1-input" min="0" max="255" value="0" class="n-input">
|
||||||
|
</div>
|
||||||
|
<div class="n-param-group">
|
||||||
|
<label for="preset-n2-input" id="preset-n2-label">n2:</label>
|
||||||
|
<input type="number" id="preset-n2-input" min="0" max="255" value="0" class="n-input">
|
||||||
|
</div>
|
||||||
|
<div class="n-param-group">
|
||||||
|
<label for="preset-n3-input" id="preset-n3-label">n3:</label>
|
||||||
|
<input type="number" id="preset-n3-input" min="0" max="255" value="0" class="n-input">
|
||||||
|
</div>
|
||||||
|
<div class="n-param-group">
|
||||||
|
<label for="preset-n4-input" id="preset-n4-label">n4:</label>
|
||||||
|
<input type="number" id="preset-n4-input" min="0" max="255" value="0" class="n-input">
|
||||||
|
</div>
|
||||||
|
<div class="n-param-group">
|
||||||
|
<label for="preset-n5-input" id="preset-n5-label">n5:</label>
|
||||||
|
<input type="number" id="preset-n5-input" min="0" max="255" value="0" class="n-input">
|
||||||
|
</div>
|
||||||
|
<div class="n-param-group">
|
||||||
|
<label for="preset-n6-input" id="preset-n6-label">n6:</label>
|
||||||
|
<input type="number" id="preset-n6-input" min="0" max="255" value="0" class="n-input">
|
||||||
|
</div>
|
||||||
|
<div class="n-param-group">
|
||||||
|
<label for="preset-n7-input" id="preset-n7-label">n7:</label>
|
||||||
|
<input type="number" id="preset-n7-input" min="0" max="255" value="0" class="n-input">
|
||||||
|
</div>
|
||||||
|
<div class="n-param-group">
|
||||||
|
<label for="preset-n8-input" id="preset-n8-label">n8:</label>
|
||||||
|
<input type="number" id="preset-n8-input" min="0" max="255" value="0" class="n-input">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="btn btn-secondary" id="preset-send-btn">Try</button>
|
||||||
|
<button class="btn btn-secondary" id="preset-default-btn">Default</button>
|
||||||
|
<button class="btn btn-primary" id="preset-save-btn">Save & Send</button>
|
||||||
|
<button class="btn btn-danger" id="preset-remove-from-tab-btn">Remove from Tab</button>
|
||||||
|
<button class="btn btn-secondary" id="preset-clear-btn">Clear</button>
|
||||||
|
<button class="btn btn-secondary" id="preset-editor-close-btn">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Patterns Modal -->
|
||||||
|
<div id="patterns-modal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<h2>Patterns</h2>
|
||||||
|
<div id="patterns-list" class="profiles-list"></div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="btn btn-secondary" id="patterns-close-btn">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Color Palette Modal -->
|
||||||
|
<div id="color-palette-modal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<h2>Color Palette</h2>
|
||||||
|
<p class="muted-text">Profile: <span id="palette-current-profile-name">None</span></p>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Help Modal -->
|
||||||
|
<div id="help-modal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<h2>Help</h2>
|
||||||
|
<p class="muted-text">How to use the LED controller UI.</p>
|
||||||
|
|
||||||
|
<h3>Tabs & devices</h3>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Select tab</strong>: left-click a tab button in the top bar.</li>
|
||||||
|
<li><strong>Edit tab</strong>: right-click a tab button, or click <strong>Edit</strong> in the Tabs modal.</li>
|
||||||
|
<li><strong>Send all presets</strong>: open the <strong>Tabs</strong> menu and click <strong>Send Presets</strong> next to the tab to push every preset used in that tab to all devices.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>Presets, profiles & colors</h3>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Presets</strong>: use the <strong>Presets</strong> button in the header to create and manage reusable presets.</li>
|
||||||
|
<li><strong>Profiles</strong>: use <strong>Profiles</strong> to save and recall groups of settings.</li>
|
||||||
|
<li><strong>Color Palette</strong>: use <strong>Color Palette</strong> to build a reusable set of colors you can pull into presets.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="btn btn-secondary" id="help-close-btn">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Settings Modal -->
|
||||||
|
<div id="settings-modal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<h2>Device Settings</h2>
|
||||||
|
<p class="muted-text">Configure WiFi Access Point and device settings.</p>
|
||||||
|
|
||||||
|
<div id="settings-message" class="message"></div>
|
||||||
|
|
||||||
|
<!-- Device Name -->
|
||||||
|
<div class="settings-section">
|
||||||
|
<h3>Device</h3>
|
||||||
|
<form id="device-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="device-name-input">Device Name</label>
|
||||||
|
<input type="text" id="device-name-input" name="device_name" placeholder="e.g. led-controller" required>
|
||||||
|
<small>This name may be used for mDNS (e.g. <code>name.local</code>) and UI display.</small>
|
||||||
|
</div>
|
||||||
|
<div class="btn-group">
|
||||||
|
<button type="submit" class="btn btn-primary btn-full">Save Name</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- WiFi Access Point Settings -->
|
||||||
|
<div class="settings-section ap-settings-section">
|
||||||
|
<h3>WiFi Access Point</h3>
|
||||||
|
|
||||||
|
<div id="ap-status" class="status-info">
|
||||||
|
<h4>AP Status</h4>
|
||||||
|
<p>Loading...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="ap-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="ap-ssid">AP SSID (Network Name)</label>
|
||||||
|
<input type="text" id="ap-ssid" name="ssid" placeholder="Enter AP name" required>
|
||||||
|
<small>The name of the WiFi access point this device creates</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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)">
|
||||||
|
<small>Leave empty for open network (min 8 characters if set)</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="ap-channel">Channel (1-11)</label>
|
||||||
|
<input type="number" id="ap-channel" name="channel" min="1" max="11" placeholder="Auto">
|
||||||
|
<small>WiFi channel (1-11 for 2.4GHz). Leave empty for auto.</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="btn-group">
|
||||||
|
<button type="submit" class="btn btn-primary btn-full">Configure AP</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="btn btn-secondary" id="settings-close-btn">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Styles moved to /static/style.css -->
|
||||||
|
<script src="/static/tabs.js"></script>
|
||||||
|
<script src="/static/help.js"></script>
|
||||||
|
<script src="/static/color_palette.js"></script>
|
||||||
|
<script src="/static/profiles.js"></script>
|
||||||
|
<script src="/static/tab_palette.js"></script>
|
||||||
|
<script src="/static/patterns.js"></script>
|
||||||
|
<script src="/static/presets.js"></script>
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
309
src/templates/settings.html
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>LED Controller - Settings</title>
|
||||||
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
|
<style>
|
||||||
|
.settings-container {
|
||||||
|
padding: 2rem;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
overflow-y: auto;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-header {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-header h1 {
|
||||||
|
font-size: 2rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-header p {
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-section {
|
||||||
|
background-color: #1a1a1a;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
border: 1px solid #4a4a4a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-section h2 {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: #fff;
|
||||||
|
border-bottom: 2px solid #4a4a4a;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
color: #ccc;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input[type="text"],
|
||||||
|
.form-group input[type="password"],
|
||||||
|
.form-group input[type="number"],
|
||||||
|
.form-group select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background-color: #2e2e2e;
|
||||||
|
border: 1px solid #4a4a4a;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: white;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input:focus,
|
||||||
|
.form-group select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #5a5a5a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group small {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
color: #888;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-info {
|
||||||
|
background-color: #2e2e2e;
|
||||||
|
border: 1px solid #4a4a4a;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-info h3 {
|
||||||
|
font-size: 1rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-info p {
|
||||||
|
color: #aaa;
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-connected {
|
||||||
|
color: #4caf50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-disconnected {
|
||||||
|
color: #f44336;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-full {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link {
|
||||||
|
display: inline-block;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: #aaa;
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link:hover {
|
||||||
|
background-color: #2e2e2e;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.success {
|
||||||
|
background-color: #1b5e20;
|
||||||
|
color: #4caf50;
|
||||||
|
border: 1px solid #4caf50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.error {
|
||||||
|
background-color: #5e1b1b;
|
||||||
|
color: #f44336;
|
||||||
|
border: 1px solid #f44336;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.show {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="app-container">
|
||||||
|
<div class="settings-container">
|
||||||
|
<a href="/" class="back-link">← Back to Dashboard</a>
|
||||||
|
|
||||||
|
<div class="settings-header">
|
||||||
|
<h1>Device Settings</h1>
|
||||||
|
<p>Configure WiFi Access Point settings</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="message" class="message"></div>
|
||||||
|
|
||||||
|
<!-- WiFi Access Point Settings -->
|
||||||
|
<div class="settings-section">
|
||||||
|
<h2>WiFi Access Point Settings</h2>
|
||||||
|
|
||||||
|
<div id="ap-status" class="status-info">
|
||||||
|
<h3>AP Status</h3>
|
||||||
|
<p>Loading...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="ap-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="ap-ssid">AP SSID (Network Name)</label>
|
||||||
|
<input type="text" id="ap-ssid" name="ssid" placeholder="Enter AP name" required>
|
||||||
|
<small>The name of the WiFi access point this device creates</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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)">
|
||||||
|
<small>Leave empty for open network (min 8 characters if set)</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="ap-channel">Channel (1-11)</label>
|
||||||
|
<input type="number" id="ap-channel" name="channel" min="1" max="11" placeholder="Auto">
|
||||||
|
<small>WiFi channel (1-11 for 2.4GHz). Leave empty for auto.</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="btn-group">
|
||||||
|
<button type="submit" class="btn btn-primary btn-full">Configure AP</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Show message helper
|
||||||
|
function showMessage(text, type = 'success') {
|
||||||
|
const messageEl = document.getElementById('message');
|
||||||
|
messageEl.textContent = text;
|
||||||
|
messageEl.className = `message ${type} show`;
|
||||||
|
setTimeout(() => {
|
||||||
|
messageEl.classList.remove('show');
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load AP status and config
|
||||||
|
async function loadAPStatus() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/settings/wifi/ap');
|
||||||
|
const config = await response.json();
|
||||||
|
|
||||||
|
const statusEl = document.getElementById('ap-status');
|
||||||
|
if (config.active) {
|
||||||
|
statusEl.innerHTML = `
|
||||||
|
<h3>AP Status: <span class="status-connected">Active</span></h3>
|
||||||
|
<p><strong>SSID:</strong> ${config.ssid || 'N/A'}</p>
|
||||||
|
<p><strong>Channel:</strong> ${config.channel || 'Auto'}</p>
|
||||||
|
<p><strong>IP Address:</strong> ${config.ip || 'N/A'}</p>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
statusEl.innerHTML = `
|
||||||
|
<h3>AP Status: <span class="status-disconnected">Inactive</span></h3>
|
||||||
|
<p>Access Point is not currently active</p>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load saved values
|
||||||
|
if (config.saved_ssid) document.getElementById('ap-ssid').value = config.saved_ssid;
|
||||||
|
if (config.saved_channel) document.getElementById('ap-channel').value = config.saved_channel;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading AP status:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AP form submission
|
||||||
|
document.getElementById('ap-form').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const formData = {
|
||||||
|
ssid: document.getElementById('ap-ssid').value,
|
||||||
|
password: document.getElementById('ap-password').value,
|
||||||
|
channel: document.getElementById('ap-channel').value || null
|
||||||
|
};
|
||||||
|
|
||||||
|
// Validate password length if provided
|
||||||
|
if (formData.password && formData.password.length > 0 && formData.password.length < 8) {
|
||||||
|
showMessage('AP password must be at least 8 characters', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert channel to number if provided
|
||||||
|
if (formData.channel) {
|
||||||
|
formData.channel = parseInt(formData.channel);
|
||||||
|
if (formData.channel < 1 || formData.channel > 11) {
|
||||||
|
showMessage('Channel must be between 1 and 11', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/settings/wifi/ap', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(formData)
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
showMessage('Access Point configured successfully!', 'success');
|
||||||
|
setTimeout(loadAPStatus, 1000);
|
||||||
|
} else {
|
||||||
|
showMessage(`Error: ${result.error || 'Failed to configure AP'}`, 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showMessage(`Error: ${error.message}`, 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load all data on page load
|
||||||
|
loadAPStatus();
|
||||||
|
|
||||||
|
// Refresh status every 10 seconds
|
||||||
|
setInterval(loadAPStatus, 10000);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
80
src/util/README.md
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
# ESPNow Message Builder
|
||||||
|
|
||||||
|
This utility module provides functions to build ESPNow messages according to the LED Driver API specification.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Basic Message Building
|
||||||
|
|
||||||
|
```python
|
||||||
|
from util.espnow_message import build_message, build_preset_dict, build_select_dict
|
||||||
|
|
||||||
|
# Build a message with presets and select
|
||||||
|
presets = {
|
||||||
|
"red_blink": build_preset_dict({
|
||||||
|
"pattern": "blink",
|
||||||
|
"colors": ["#FF0000"],
|
||||||
|
"delay": 200,
|
||||||
|
"brightness": 255,
|
||||||
|
"auto": True
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
select = build_select_dict({
|
||||||
|
"device1": "red_blink"
|
||||||
|
})
|
||||||
|
|
||||||
|
message = build_message(presets=presets, select=select)
|
||||||
|
# Result: {"v": "1", "presets": {...}, "select": {...}}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Building Select Messages with Step Synchronization
|
||||||
|
|
||||||
|
```python
|
||||||
|
from util.espnow_message import build_message, build_select_dict
|
||||||
|
|
||||||
|
# Select with step for synchronization
|
||||||
|
select = build_select_dict(
|
||||||
|
{"device1": "rainbow_preset", "device2": "rainbow_preset"},
|
||||||
|
step_mapping={"device1": 10, "device2": 10}
|
||||||
|
)
|
||||||
|
|
||||||
|
message = build_message(select=select)
|
||||||
|
# Result: {"v": "1", "select": {"device1": ["rainbow_preset", 10], "device2": ["rainbow_preset", 10]}}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Converting Presets
|
||||||
|
|
||||||
|
```python
|
||||||
|
from util.espnow_message import build_preset_dict, build_presets_dict
|
||||||
|
|
||||||
|
# Single preset
|
||||||
|
preset = build_preset_dict({
|
||||||
|
"name": "my_preset",
|
||||||
|
"pattern": "rainbow",
|
||||||
|
"colors": ["#FF0000", "#00FF00"], # Can be hex strings or RGB tuples
|
||||||
|
"delay": 100,
|
||||||
|
"brightness": 127,
|
||||||
|
"auto": False,
|
||||||
|
"n1": 2
|
||||||
|
})
|
||||||
|
|
||||||
|
# Multiple presets
|
||||||
|
presets_data = {
|
||||||
|
"preset1": {"pattern": "on", "colors": ["#FF0000"]},
|
||||||
|
"preset2": {"pattern": "blink", "colors": ["#00FF00"]}
|
||||||
|
}
|
||||||
|
presets = build_presets_dict(presets_data)
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Specification
|
||||||
|
|
||||||
|
See `docs/API.md` for the complete ESPNow API specification.
|
||||||
|
|
||||||
|
## Key Features
|
||||||
|
|
||||||
|
- **Version Field**: All messages include `"v": "1"` for version tracking
|
||||||
|
- **Preset Format**: Presets use hex color strings (`#RRGGBB`), not RGB tuples
|
||||||
|
- **Select Format**: Select values are always lists: `["preset_name"]` or `["preset_name", step]`
|
||||||
|
- **Color Conversion**: Automatically converts RGB tuples to hex strings
|
||||||
|
- **Default Values**: Provides sensible defaults for missing fields
|
||||||
274
src/util/espnow_message.py
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
"""
|
||||||
|
ESPNow message builder utility for LED driver communication.
|
||||||
|
|
||||||
|
This module provides utilities to build ESPNow messages according to the API specification.
|
||||||
|
ESPNow has a 250-byte payload limit; messages larger than that must be split into multiple
|
||||||
|
frames.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
# ESPNow payload limit (bytes). Messages larger than this must be split.
|
||||||
|
ESPNOW_MAX_PAYLOAD_BYTES = 240
|
||||||
|
|
||||||
|
|
||||||
|
def build_message(presets=None, select=None, save=False, default=None):
|
||||||
|
"""
|
||||||
|
Build an ESPNow message according to the API specification.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
presets: Dictionary mapping preset names to preset objects, or None
|
||||||
|
select: Dictionary mapping device names to select lists, or None
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON string ready to send via ESPNow
|
||||||
|
|
||||||
|
Example:
|
||||||
|
message = build_message(
|
||||||
|
presets={
|
||||||
|
"red_blink": {
|
||||||
|
"pattern": "blink",
|
||||||
|
"colors": ["#FF0000"],
|
||||||
|
"delay": 200,
|
||||||
|
"brightness": 255,
|
||||||
|
"auto": True
|
||||||
|
}
|
||||||
|
},
|
||||||
|
select={
|
||||||
|
"device1": ["red_blink"]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
message = {
|
||||||
|
"v": "1"
|
||||||
|
}
|
||||||
|
|
||||||
|
if presets:
|
||||||
|
message["presets"] = presets
|
||||||
|
# When sending presets, optionally include a save flag so the
|
||||||
|
# led-driver can persist them.
|
||||||
|
if save:
|
||||||
|
message["save"] = True
|
||||||
|
|
||||||
|
if select:
|
||||||
|
message["select"] = select
|
||||||
|
|
||||||
|
if default is not None:
|
||||||
|
message["default"] = default
|
||||||
|
|
||||||
|
return json.dumps(message)
|
||||||
|
|
||||||
|
|
||||||
|
def split_espnow_message(msg_dict, max_bytes=None):
|
||||||
|
"""
|
||||||
|
Split a message dict into one or more JSON strings each within ESPNow payload limit.
|
||||||
|
If the message fits in max_bytes, returns a single-element list. Otherwise splits
|
||||||
|
"select" and/or "presets" into multiple messages (other keys like v, b, default, save
|
||||||
|
are included only in the first message).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
msg_dict: Full message as a dict (e.g. from json.loads).
|
||||||
|
max_bytes: Max payload size in bytes (default ESPNOW_MAX_PAYLOAD_BYTES).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of JSON strings, each <= max_bytes, to send in order.
|
||||||
|
"""
|
||||||
|
if max_bytes is None:
|
||||||
|
max_bytes = ESPNOW_MAX_PAYLOAD_BYTES
|
||||||
|
|
||||||
|
single = json.dumps(msg_dict)
|
||||||
|
if len(single) <= max_bytes:
|
||||||
|
return [single]
|
||||||
|
|
||||||
|
# Keys to attach only to the first message we emit
|
||||||
|
first_only = {k: msg_dict[k] for k in ("b", "default", "save") if k in msg_dict}
|
||||||
|
out = []
|
||||||
|
|
||||||
|
def emit(chunk_dict, is_first):
|
||||||
|
m = {"v": msg_dict.get("v", "1")}
|
||||||
|
if is_first and first_only:
|
||||||
|
m.update(first_only)
|
||||||
|
m.update(chunk_dict)
|
||||||
|
s = json.dumps(m)
|
||||||
|
if len(s) > max_bytes:
|
||||||
|
raise ValueError(f"Chunk still too large ({len(s)} > {max_bytes})")
|
||||||
|
out.append(s)
|
||||||
|
|
||||||
|
def chunk_dict(key, items_dict):
|
||||||
|
if not items_dict:
|
||||||
|
return
|
||||||
|
items = list(items_dict.items())
|
||||||
|
i = 0
|
||||||
|
first = True
|
||||||
|
while i < len(items):
|
||||||
|
chunk = {}
|
||||||
|
while i < len(items):
|
||||||
|
k, v = items[i]
|
||||||
|
trial = dict(chunk)
|
||||||
|
trial[k] = v
|
||||||
|
trial_msg = {"v": msg_dict.get("v", "1"), key: trial}
|
||||||
|
if first_only and first:
|
||||||
|
trial_msg.update(first_only)
|
||||||
|
if len(json.dumps(trial_msg)) <= max_bytes:
|
||||||
|
chunk[k] = v
|
||||||
|
i += 1
|
||||||
|
else:
|
||||||
|
if not chunk:
|
||||||
|
# Single entry too large; send as-is and hope receiver accepts
|
||||||
|
chunk[k] = v
|
||||||
|
i += 1
|
||||||
|
break
|
||||||
|
if chunk:
|
||||||
|
emit({key: chunk}, first)
|
||||||
|
first = False
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
|
||||||
|
if "select" in msg_dict:
|
||||||
|
chunk_dict("select", msg_dict["select"])
|
||||||
|
if "presets" in msg_dict:
|
||||||
|
chunk_dict("presets", msg_dict["presets"])
|
||||||
|
|
||||||
|
if not out:
|
||||||
|
# Fallback: emit one message even if over limit (receiver may reject)
|
||||||
|
out = [single]
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def build_select_message(device_name, preset_name, step=None):
|
||||||
|
"""
|
||||||
|
Build a select message for a single device.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
device_name: Name of the device
|
||||||
|
preset_name: Name of the preset to select
|
||||||
|
step: Optional step value for synchronization
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with select field ready to use in build_message
|
||||||
|
|
||||||
|
Example:
|
||||||
|
select = build_select_message("device1", "rainbow_preset", step=10)
|
||||||
|
message = build_message(select=select)
|
||||||
|
"""
|
||||||
|
select_list = [preset_name]
|
||||||
|
if step is not None:
|
||||||
|
select_list.append(step)
|
||||||
|
|
||||||
|
return {device_name: select_list}
|
||||||
|
|
||||||
|
|
||||||
|
def build_preset_dict(preset_data):
|
||||||
|
"""
|
||||||
|
Convert preset data to API-compliant format.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
preset_data: Dictionary with preset fields (may include name, pattern, colors, etc.)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with preset in API-compliant format (without name field)
|
||||||
|
|
||||||
|
Example:
|
||||||
|
preset = build_preset_dict({
|
||||||
|
"name": "red_blink",
|
||||||
|
"pattern": "blink",
|
||||||
|
"colors": ["#FF0000"],
|
||||||
|
"delay": 200,
|
||||||
|
"brightness": 255,
|
||||||
|
"auto": True,
|
||||||
|
"n1": 0,
|
||||||
|
"n2": 0,
|
||||||
|
"n3": 0,
|
||||||
|
"n4": 0,
|
||||||
|
"n5": 0,
|
||||||
|
"n6": 0
|
||||||
|
})
|
||||||
|
"""
|
||||||
|
# Ensure colors are in hex format
|
||||||
|
colors = preset_data.get("colors", preset_data.get("c", ["#FFFFFF"]))
|
||||||
|
if colors:
|
||||||
|
# Convert RGB tuples to hex strings if needed
|
||||||
|
if isinstance(colors[0], list) and len(colors[0]) == 3:
|
||||||
|
# RGB tuple format [r, g, b]
|
||||||
|
colors = [f"#{r:02x}{g:02x}{b:02x}" for r, g, b in colors]
|
||||||
|
elif not isinstance(colors[0], str):
|
||||||
|
# Handle other formats - convert to hex
|
||||||
|
colors = ["#FFFFFF"]
|
||||||
|
# Ensure all colors start with #
|
||||||
|
colors = [c if c.startswith("#") else f"#{c}" for c in colors]
|
||||||
|
else:
|
||||||
|
colors = ["#FFFFFF"]
|
||||||
|
|
||||||
|
# Build payload using the short keys expected by led-driver
|
||||||
|
preset = {
|
||||||
|
"p": preset_data.get("pattern", preset_data.get("p", "off")),
|
||||||
|
"c": colors,
|
||||||
|
"d": preset_data.get("delay", preset_data.get("d", 100)),
|
||||||
|
"b": preset_data.get("brightness", preset_data.get("b", preset_data.get("br", 127))),
|
||||||
|
"a": preset_data.get("auto", preset_data.get("a", True)),
|
||||||
|
"n1": preset_data.get("n1", 0),
|
||||||
|
"n2": preset_data.get("n2", 0),
|
||||||
|
"n3": preset_data.get("n3", 0),
|
||||||
|
"n4": preset_data.get("n4", 0),
|
||||||
|
"n5": preset_data.get("n5", 0),
|
||||||
|
"n6": preset_data.get("n6", 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
return preset
|
||||||
|
|
||||||
|
|
||||||
|
def build_presets_dict(presets_data):
|
||||||
|
"""
|
||||||
|
Convert multiple presets to API-compliant format.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
presets_data: Dictionary mapping preset names to preset data
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary mapping preset names to API-compliant preset objects
|
||||||
|
|
||||||
|
Example:
|
||||||
|
presets = build_presets_dict({
|
||||||
|
"red_blink": {
|
||||||
|
"pattern": "blink",
|
||||||
|
"colors": ["#FF0000"],
|
||||||
|
"delay": 200
|
||||||
|
},
|
||||||
|
"blue_pulse": {
|
||||||
|
"pattern": "pulse",
|
||||||
|
"colors": ["#0000FF"],
|
||||||
|
"delay": 100
|
||||||
|
}
|
||||||
|
})
|
||||||
|
"""
|
||||||
|
result = {}
|
||||||
|
for preset_name, preset_data in presets_data.items():
|
||||||
|
result[preset_name] = build_preset_dict(preset_data)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def build_select_dict(device_preset_mapping, step_mapping=None):
|
||||||
|
"""
|
||||||
|
Build a select dictionary mapping device names to select lists.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
device_preset_mapping: Dictionary mapping device names to preset names
|
||||||
|
step_mapping: Optional dictionary mapping device names to step values
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with select field ready to use in build_message
|
||||||
|
|
||||||
|
Example:
|
||||||
|
select = build_select_dict(
|
||||||
|
{"device1": "rainbow_preset", "device2": "pulse_preset"},
|
||||||
|
step_mapping={"device1": 10}
|
||||||
|
)
|
||||||
|
message = build_message(select=select)
|
||||||
|
"""
|
||||||
|
select = {}
|
||||||
|
for device_name, preset_name in device_preset_mapping.items():
|
||||||
|
select_list = [preset_name]
|
||||||
|
if step_mapping and device_name in step_mapping:
|
||||||
|
select_list.append(step_mapping[device_name])
|
||||||
|
select[device_name] = select_list
|
||||||
|
return select
|
||||||
42
src/util/wifi.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import network
|
||||||
|
|
||||||
|
|
||||||
|
def ap(ssid, password, channel=None):
|
||||||
|
ap_if = network.WLAN(network.AP_IF)
|
||||||
|
ap_mac = ap_if.config('mac')
|
||||||
|
print(ssid)
|
||||||
|
ap_if.active(True)
|
||||||
|
if channel is not None:
|
||||||
|
ap_if.config(essid=ssid, password=password, channel=channel)
|
||||||
|
else:
|
||||||
|
ap_if.config(essid=ssid, password=password)
|
||||||
|
ap_if.active(False)
|
||||||
|
ap_if.active(True)
|
||||||
|
print(ap_if.ifconfig())
|
||||||
|
|
||||||
|
def get_mac():
|
||||||
|
ap_if = network.WLAN(network.AP_IF)
|
||||||
|
return ap_if.config('mac')
|
||||||
|
|
||||||
|
|
||||||
|
def get_ap_config():
|
||||||
|
"""Get current AP configuration."""
|
||||||
|
try:
|
||||||
|
ap_if = network.WLAN(network.AP_IF)
|
||||||
|
if ap_if.active():
|
||||||
|
config = ap_if.ifconfig()
|
||||||
|
return {
|
||||||
|
'ssid': ap_if.config('essid'),
|
||||||
|
'channel': ap_if.config('channel'),
|
||||||
|
'ip': config[0] if config else None,
|
||||||
|
'active': True
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
'ssid': None,
|
||||||
|
'channel': None,
|
||||||
|
'ip': None,
|
||||||
|
'active': False
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error getting AP config: {e}")
|
||||||
|
return None
|
||||||
62
src/web.py
@@ -1,62 +0,0 @@
|
|||||||
from microdot import Microdot, send_file, Response
|
|
||||||
from microdot.utemplate import Template
|
|
||||||
from microdot.websocket import with_websocket
|
|
||||||
import machine
|
|
||||||
from settings import set_settings
|
|
||||||
import wifi
|
|
||||||
import json
|
|
||||||
import aioespnow
|
|
||||||
|
|
||||||
def web(settings, patterns):
|
|
||||||
app = Microdot()
|
|
||||||
Response.default_content_type = 'text/html'
|
|
||||||
e = aioespnow.AIOESPNow() # Returns AIOESPNow enhanced with async support
|
|
||||||
e.active(True)
|
|
||||||
peer = bytes.fromhex("e4b323c5411d")
|
|
||||||
peer = b'\xbb\xbb\xbb\xbb\xbb\xbb'
|
|
||||||
e.add_peer(peer)
|
|
||||||
print(peer)
|
|
||||||
|
|
||||||
@app.route('/')
|
|
||||||
async def index_hnadler(request):
|
|
||||||
mac = wifi.get_mac().hex()
|
|
||||||
return Template('/index.html').render(settings=settings, patterns=patterns.patterns.keys(), mac=mac)
|
|
||||||
|
|
||||||
@app.route("/static/<path:path>")
|
|
||||||
def static_handler(request, path):
|
|
||||||
if '..' in path:
|
|
||||||
# Directory traversal is not allowed
|
|
||||||
return 'Not found', 404
|
|
||||||
return send_file('static/' + path)
|
|
||||||
|
|
||||||
@app.post("/settings")
|
|
||||||
def settings_handler(request):
|
|
||||||
# Keep the POST handler for compatibility or alternative usage if needed
|
|
||||||
# For WebSocket updates, the /ws handler is now primary
|
|
||||||
return set_settings(request.body.decode('utf-8'), settings, patterns)
|
|
||||||
|
|
||||||
@app.route("/ws")
|
|
||||||
@with_websocket
|
|
||||||
async def ws(request, ws):
|
|
||||||
while True:
|
|
||||||
data = await ws.receive()
|
|
||||||
if data:
|
|
||||||
# Process the received data
|
|
||||||
await send(data)
|
|
||||||
_, status_code = set_settings(data, settings, patterns)
|
|
||||||
#await ws.send(status_code)
|
|
||||||
else:
|
|
||||||
break
|
|
||||||
|
|
||||||
async def send(data):
|
|
||||||
|
|
||||||
msg = {"delay": 1000}
|
|
||||||
try:
|
|
||||||
if not await e.asend(peer, data):
|
|
||||||
print("Heartbeat: peer not responding:", peer)
|
|
||||||
else:
|
|
||||||
print("Heartbeat: ping", peer)
|
|
||||||
except:
|
|
||||||
return
|
|
||||||
|
|
||||||
return app
|
|
||||||
38
src/wifi.py
@@ -1,38 +0,0 @@
|
|||||||
import network
|
|
||||||
from time import sleep
|
|
||||||
|
|
||||||
def connect(ssid, password, ip, gateway):
|
|
||||||
if ssid is None or password is None:
|
|
||||||
print("Missing ssid or password")
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
sta_if = network.WLAN(network.STA_IF)
|
|
||||||
if ip is not None and gateway is not None:
|
|
||||||
sta_if.ifconfig((ip, '255.255.255.0', gateway, '1.1.1.1'))
|
|
||||||
if not sta_if.isconnected():
|
|
||||||
print('connecting to network...')
|
|
||||||
sta_if.active(True)
|
|
||||||
sta_if.connect(ssid, password)
|
|
||||||
sleep(0.1)
|
|
||||||
if sta_if.isconnected():
|
|
||||||
return sta_if.ifconfig()
|
|
||||||
return None
|
|
||||||
return sta_if.ifconfig()
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Failed to connect to wifi {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def ap(ssid, password):
|
|
||||||
ap_if = network.WLAN(network.AP_IF)
|
|
||||||
ap_mac = ap_if.config('mac')
|
|
||||||
print(ssid)
|
|
||||||
ap_if.active(True)
|
|
||||||
ap_if.config(essid=ssid, password=password)
|
|
||||||
ap_if.active(False)
|
|
||||||
ap_if.active(True)
|
|
||||||
print(ap_if.ifconfig())
|
|
||||||
|
|
||||||
def get_mac():
|
|
||||||
ap_if = network.WLAN(network.AP_IF)
|
|
||||||
return ap_if.config('mac')
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<title>RGB Slider Tabs</title>
|
|
||||||
<link rel="stylesheet" href="styles.css" />
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="tabs"></div>
|
|
||||||
<div class="tab-content"></div>
|
|
||||||
|
|
||||||
<script type="module" src="main.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
import "./rgb-slider.js";
|
|
||||||
|
|
||||||
const ws = new WebSocket("ws://localhost:8000/ws");
|
|
||||||
|
|
||||||
ws.onopen = () => {
|
|
||||||
console.log("WebSocket connection established");
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onclose = () => {
|
|
||||||
console.log("WebSocket connection closed");
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onerror = (error) => {
|
|
||||||
console.error("WebSocket error:", error);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Number of sliders (tabs) you want to create
|
|
||||||
const numTabs = 3;
|
|
||||||
|
|
||||||
// Select the container for tabs and content
|
|
||||||
const tabsContainer = document.querySelector(".tabs");
|
|
||||||
const tabContentContainer = document.querySelector(".tab-content");
|
|
||||||
|
|
||||||
// Create tabs dynamically
|
|
||||||
for (let i = 1; i <= numTabs; i++) {
|
|
||||||
// Create the tab button
|
|
||||||
const tabButton = document.createElement("button");
|
|
||||||
tabButton.classList.add("tab");
|
|
||||||
tabButton.id = `tab${i}`;
|
|
||||||
tabButton.textContent = `Tab ${i}`;
|
|
||||||
|
|
||||||
// Add the tab button to the container
|
|
||||||
tabsContainer.appendChild(tabButton);
|
|
||||||
|
|
||||||
// Create the corresponding tab content (RGB slider)
|
|
||||||
const tabContent = document.createElement("div");
|
|
||||||
tabContent.classList.add("tab-pane");
|
|
||||||
tabContent.id = `content${i}`;
|
|
||||||
const slider = document.createElement("rgb-slider");
|
|
||||||
slider.id = i;
|
|
||||||
tabContent.appendChild(slider);
|
|
||||||
|
|
||||||
// Add the tab content to the container
|
|
||||||
tabContentContainer.appendChild(tabContent);
|
|
||||||
|
|
||||||
// Listen for color change on each RGB slider
|
|
||||||
slider.addEventListener("color-change", (e) => {
|
|
||||||
const { r, g, b } = e.detail;
|
|
||||||
console.log(`Color changed in tab ${i}:`, e.detail);
|
|
||||||
// Send RGB data to WebSocket server
|
|
||||||
if (ws.readyState === WebSocket.OPEN) {
|
|
||||||
const colorData = { r, g, b };
|
|
||||||
ws.send(JSON.stringify(colorData));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Function to switch tabs
|
|
||||||
function switchTab(tabId) {
|
|
||||||
const tabs = document.querySelectorAll(".tab");
|
|
||||||
const tabContents = document.querySelectorAll(".tab-pane");
|
|
||||||
|
|
||||||
tabs.forEach((tab) => tab.classList.remove("active"));
|
|
||||||
tabContents.forEach((content) => content.classList.remove("active"));
|
|
||||||
|
|
||||||
// Activate the clicked tab and corresponding content
|
|
||||||
document.getElementById(tabId).classList.add("active");
|
|
||||||
document
|
|
||||||
.getElementById("content" + tabId.replace("tab", ""))
|
|
||||||
.classList.add("active");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add event listeners to tabs
|
|
||||||
tabsContainer.addEventListener("click", (e) => {
|
|
||||||
if (e.target.classList.contains("tab")) {
|
|
||||||
switchTab(e.target.id);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Initially set the first tab as active
|
|
||||||
switchTab("tab1");
|
|
||||||
@@ -1,195 +0,0 @@
|
|||||||
// rgb-slider.js
|
|
||||||
|
|
||||||
export class RGBSlider extends HTMLElement {
|
|
||||||
constructor() {
|
|
||||||
super();
|
|
||||||
const shadow = this.attachShadow({ mode: "open" });
|
|
||||||
|
|
||||||
shadow.innerHTML = `
|
|
||||||
<style>
|
|
||||||
.container {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
padding: 1em;
|
|
||||||
border: 1px solid #ccc;
|
|
||||||
border-radius: 8px;
|
|
||||||
width: 50%;
|
|
||||||
|
|
||||||
font-family: sans-serif;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview {
|
|
||||||
width: 50%;
|
|
||||||
height: 60px;
|
|
||||||
border-radius: 6px;
|
|
||||||
border: 1px solid #000;
|
|
||||||
background-color: rgb(0, 0, 0);
|
|
||||||
margin-bottom: 1em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sliders {
|
|
||||||
display: flex;
|
|
||||||
gap: 50px;
|
|
||||||
justify-content: center;
|
|
||||||
margin-bottom: 1em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.slider-group {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column-reverse;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.slider-group input[type="range"] {
|
|
||||||
writing-mode: vertical-lr;
|
|
||||||
direction: rtl;
|
|
||||||
|
|
||||||
width: 10px;
|
|
||||||
height: 120px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.slider-group label {
|
|
||||||
margin-top: 8px;
|
|
||||||
font-size: 0.8em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rgb-inputs {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rgb-inputs input {
|
|
||||||
width: 6ch;
|
|
||||||
padding: 2px;
|
|
||||||
font-family: monospace;
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rgb-inputs label {
|
|
||||||
font-size: 0.8em;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rgb-input-group {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Mobile styles */
|
|
||||||
@media (max-width: 600px) {
|
|
||||||
.preview {
|
|
||||||
height: 80px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.slider-group input[type="range"] {
|
|
||||||
height: 180px;
|
|
||||||
width: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rgb-inputs input {
|
|
||||||
font-size: 1em;
|
|
||||||
padding: 4px;
|
|
||||||
width: 7ch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.slider-group label,
|
|
||||||
.rgb-inputs label {
|
|
||||||
font-size: 1em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.container {
|
|
||||||
padding: 1.5em;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
|
|
||||||
<div class="container">
|
|
||||||
<div class="preview" id="preview"></div>
|
|
||||||
|
|
||||||
<div class="sliders">
|
|
||||||
<div class="slider-group">
|
|
||||||
<input type="range" min="0" max="255" value="0" id="r">
|
|
||||||
<label>R</label>
|
|
||||||
</div>
|
|
||||||
<div class="slider-group">
|
|
||||||
<input type="range" min="0" max="255" value="0" id="g">
|
|
||||||
<label>G</label>
|
|
||||||
</div>
|
|
||||||
<div class="slider-group">
|
|
||||||
<input type="range" min="0" max="255" value="0" id="b">
|
|
||||||
<label>B</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="rgb-inputs">
|
|
||||||
<div class="rgb-input-group">
|
|
||||||
<label for="rInput">R</label>
|
|
||||||
<input type="number" min="0" max="255" id="rInput" value="0">
|
|
||||||
</div>
|
|
||||||
<div class="rgb-input-group">
|
|
||||||
<label for="gInput">G</label>
|
|
||||||
<input type="number" min="0" max="255" id="gInput" value="0">
|
|
||||||
</div>
|
|
||||||
<div class="rgb-input-group">
|
|
||||||
<label for="bInput">B</label>
|
|
||||||
<input type="number" min="0" max="255" id="bInput" value="0">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
const get = (id) => shadow.querySelector(id);
|
|
||||||
this.r = get("#r");
|
|
||||||
this.g = get("#g");
|
|
||||||
this.b = get("#b");
|
|
||||||
this.rInput = get("#rInput");
|
|
||||||
this.gInput = get("#gInput");
|
|
||||||
this.bInput = get("#bInput");
|
|
||||||
this.preview = get("#preview");
|
|
||||||
|
|
||||||
const updateColor = (r, g, b) => {
|
|
||||||
this.preview.style.backgroundColor = `rgb(${r}, ${g}, ${b})`;
|
|
||||||
this.rInput.value = r;
|
|
||||||
this.gInput.value = g;
|
|
||||||
this.bInput.value = b;
|
|
||||||
this.dispatchEvent(
|
|
||||||
new CustomEvent("color-change", {
|
|
||||||
detail: { r, g, b },
|
|
||||||
bubbles: true,
|
|
||||||
composed: true,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const syncFromSliders = () => {
|
|
||||||
const r = +this.r.value;
|
|
||||||
const g = +this.g.value;
|
|
||||||
const b = +this.b.value;
|
|
||||||
updateColor(r, g, b);
|
|
||||||
};
|
|
||||||
|
|
||||||
const syncFromInputs = () => {
|
|
||||||
const r = Math.min(255, Math.max(0, +this.rInput.value));
|
|
||||||
const g = Math.min(255, Math.max(0, +this.gInput.value));
|
|
||||||
const b = Math.min(255, Math.max(0, +this.bInput.value));
|
|
||||||
this.r.value = r;
|
|
||||||
this.g.value = g;
|
|
||||||
this.b.value = b;
|
|
||||||
updateColor(r, g, b);
|
|
||||||
};
|
|
||||||
|
|
||||||
this.r.addEventListener("input", syncFromSliders);
|
|
||||||
this.g.addEventListener("input", syncFromSliders);
|
|
||||||
this.b.addEventListener("input", syncFromSliders);
|
|
||||||
|
|
||||||
this.rInput.addEventListener("change", syncFromInputs);
|
|
||||||
this.gInput.addEventListener("change", syncFromInputs);
|
|
||||||
this.bInput.addEventListener("change", syncFromInputs);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
customElements.define("rgb-slider", RGBSlider);
|
|
||||||
79
tests/README.md
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
# Tests
|
||||||
|
|
||||||
|
This directory contains tests for the LED Controller project.
|
||||||
|
|
||||||
|
## Directory Structure
|
||||||
|
|
||||||
|
- `test_endpoints.py` - HTTP endpoint tests that mimic web browser requests (runs against 192.168.4.1)
|
||||||
|
- `test_ws.py` - WebSocket tests
|
||||||
|
- `test_p2p.py` - ESP-NOW P2P tests
|
||||||
|
- `models/` - Model unit tests
|
||||||
|
- `web.py` - Local development web server
|
||||||
|
|
||||||
|
## Running Tests
|
||||||
|
|
||||||
|
### Browser Tests (Real Browser Automation)
|
||||||
|
|
||||||
|
Tests the web interface in an actual browser using Selenium:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python tests/test_browser.py
|
||||||
|
```
|
||||||
|
|
||||||
|
These tests:
|
||||||
|
- Open a real Chrome browser
|
||||||
|
- Navigate to the device at 192.168.4.1
|
||||||
|
- Interact with UI elements (buttons, forms, modals)
|
||||||
|
- Test complete user workflows
|
||||||
|
- Verify visual elements and interactions
|
||||||
|
|
||||||
|
**Requirements:**
|
||||||
|
```bash
|
||||||
|
pip install selenium
|
||||||
|
# Also need ChromeDriver installed and in PATH
|
||||||
|
# Download from: https://chromedriver.chromium.org/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Endpoint Tests (Browser-like HTTP)
|
||||||
|
|
||||||
|
Tests HTTP endpoints by making requests to the device at 192.168.4.1:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python tests/test_endpoints.py
|
||||||
|
```
|
||||||
|
|
||||||
|
These tests:
|
||||||
|
- Mimic web browser requests with proper headers
|
||||||
|
- Handle cookies for session management
|
||||||
|
- Test all CRUD operations (GET, POST, PUT, DELETE)
|
||||||
|
- Verify responses and status codes
|
||||||
|
|
||||||
|
**Requirements:**
|
||||||
|
```bash
|
||||||
|
pip install requests
|
||||||
|
```
|
||||||
|
|
||||||
|
### WebSocket Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python tests/test_ws.py
|
||||||
|
```
|
||||||
|
|
||||||
|
**Requirements:**
|
||||||
|
```bash
|
||||||
|
pip install websockets
|
||||||
|
```
|
||||||
|
|
||||||
|
### Model Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python tests/models/run_all.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### Local Development Server
|
||||||
|
|
||||||
|
Run the local development server (port 5000):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python tests/web.py
|
||||||
|
```
|
||||||