Compare commits
91 Commits
2fa02086c9
...
pi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09a87b79d2 | ||
|
|
ec39df00fc | ||
|
|
43d494bcb9 | ||
|
|
fed312a397 | ||
| 63235c7822 | |||
| 5badf17719 | |||
| 4597573ac5 | |||
| 1550122ced | |||
| b7c45fd72c | |||
| 9479d0d292 | |||
| 3698385af4 | |||
| ef968ebe39 | |||
| a5432db99a | |||
| 764d918d5b | |||
| edadb40cb6 | |||
| 9323719a85 | |||
| 91de705647 | |||
| 3ee7b74152 | |||
| 98bbdcbb3d | |||
| a2abd3e833 | |||
| 550217c443 | |||
| 2d2032e8b9 | |||
| 81bf4dded5 | |||
| a75e27e3d2 | |||
| 13538c39a6 | |||
| 7b724e9ce1 | |||
| aaca5435e9 | |||
| b64dacc1c3 | |||
| 8689bdb6ef | |||
| c178e87966 | |||
| dfe7ae50d2 | |||
| 8e87559af6 | |||
| aa3546e9ac | |||
| b56af23cbf | |||
| ac9fca8d4b | |||
| 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 | |||
| 8b6bbdeb56 | |||
| 09bc09cca3 | |||
| e57feda131 | |||
| 3242aa464b | |||
| 72b7ba39ef | |||
| c2a0cfaef4 | |||
| 4c3337a232 | |||
| 825ae1f637 | |||
| 14a70cb024 | |||
| 425511d41f | |||
| 3e5239f3c6 |
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}
|
||||
26
.cursor/rules/commit.mdc
Normal file
@@ -0,0 +1,26 @@
|
||||
---
|
||||
description: Git commit messages and how to split work into commits
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Commits
|
||||
|
||||
When preparing commits (especially when the user asks to commit):
|
||||
|
||||
1. **Prefer multiple commits** over one large commit when changes span distinct concerns (e.g. UI vs docs vs API). One logical unit per commit.
|
||||
2. **Message format:** `type(scope): short imperative subject` (lowercase subject after the colon; no trailing period).
|
||||
- **Types:** `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`, `perf` (use what fits).
|
||||
- **Scope:** optional but encouraged — e.g. `ui`, `api`, `profiles`, `presets`, `esp32`.
|
||||
3. **Subject line:** ~50 characters or less; describe *what* changed, not the ticket number alone.
|
||||
4. **Body:** only when needed (breaking change, non-obvious rationale, or multiple bullets). Otherwise subject is enough.
|
||||
|
||||
**Examples**
|
||||
|
||||
- `feat(ui): gate profile delete to edit mode`
|
||||
- `docs: document run vs edit in API`
|
||||
- `fix(api): resolve preset delete route argument clash`
|
||||
|
||||
**Do not**
|
||||
|
||||
- Squash unrelated fixes and doc tweaks into one commit unless the user explicitly wants a single commit.
|
||||
- Use vague messages like `update`, `fixes`, or `wip`.
|
||||
10
.cursor/rules/spelling.mdc
Normal file
@@ -0,0 +1,10 @@
|
||||
---
|
||||
description: British spelling for user-facing text; technical identifiers stay as-is
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Spelling: colour
|
||||
|
||||
- **User-facing strings** (Help modal, button labels, README prose, `docs/`, error messages shown in the UI): use **British English** — **colour**, **favour**, **behaviour**, etc., unless quoting existing product names.
|
||||
- **Do not rename** existing code for spelling: **identifiers**, file names, URL paths, JSON keys, CSS properties (`color`), HTML attributes (`type="color"`), and API field names stay as they are (`color`, `colors`, `palette`, etc.) so nothing breaks.
|
||||
- **New** UI copy and docs should follow **colour** in prose; new code symbols may still use `color` when matching surrounding APIs or conventions.
|
||||
30
.gitignore
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
# 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
|
||||
docs/.help-print.html
|
||||
settings.json
|
||||
*.log
|
||||
*.db
|
||||
*.sqlite
|
||||
6
.gitmodules
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
[submodule "led-driver"]
|
||||
path = led-driver
|
||||
url = git@git.technical.kiwi:technicalkiwi/led-driver.git
|
||||
[submodule "led-tool"]
|
||||
path = led-tool
|
||||
url = git@git.technical.kiwi:technicalkiwi/led-tool.git
|
||||
15
Pipfile
@@ -7,8 +7,23 @@ name = "pypi"
|
||||
mpremote = "*"
|
||||
pyserial = "*"
|
||||
esptool = "*"
|
||||
pyjwt = "*"
|
||||
watchfiles = "*"
|
||||
requests = "*"
|
||||
selenium = "*"
|
||||
adafruit-ampy = "*"
|
||||
microdot = "*"
|
||||
|
||||
[dev-packages]
|
||||
pytest = "*"
|
||||
|
||||
[requires]
|
||||
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"
|
||||
run = "sh -c 'cd src && python main.py'"
|
||||
dev = "watchfiles \"sh -c 'cd src && python main.py'\" src"
|
||||
help-pdf = "sh scripts/build_help_pdf.sh"
|
||||
|
||||
958
Pipfile.lock
generated
Normal file
@@ -0,0 +1,958 @@
|
||||
{
|
||||
"_meta": {
|
||||
"hash": {
|
||||
"sha256": "6cec0fe6dec67c9177363a558131f333153b6caa47e1ddeca303cb0d19954cf8"
|
||||
},
|
||||
"pipfile-spec": 6,
|
||||
"requires": {
|
||||
"python_version": "3.12"
|
||||
},
|
||||
"sources": [
|
||||
{
|
||||
"name": "pypi",
|
||||
"url": "https://pypi.org/simple",
|
||||
"verify_ssl": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"default": {
|
||||
"adafruit-ampy": {
|
||||
"hashes": [
|
||||
"sha256:4a74812226e53c17d01eb828633424bc4f4fe76b9499a7b35eba6fc2532635b7",
|
||||
"sha256:f4cba36f564096f2aafd173f7fbabb845365cc3bb3f41c37541edf98b58d3976"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==1.1.0"
|
||||
},
|
||||
"anyio": {
|
||||
"hashes": [
|
||||
"sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708",
|
||||
"sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc"
|
||||
],
|
||||
"markers": "python_version >= '3.10'",
|
||||
"version": "==4.13.0"
|
||||
},
|
||||
"attrs": {
|
||||
"hashes": [
|
||||
"sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309",
|
||||
"sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32"
|
||||
],
|
||||
"markers": "python_version >= '3.9'",
|
||||
"version": "==26.1.0"
|
||||
},
|
||||
"bitarray": {
|
||||
"hashes": [
|
||||
"sha256:004d518fa410e6da43386d20e07b576a41eb417ac67abf9f30fa75e125697199",
|
||||
"sha256:014df8a9430276862392ac5d471697de042367996c49f32d0008585d2c60755a",
|
||||
"sha256:01c5f0dc080b0ebb432f7a68ee1e88a76bd34f6d89c9568fcec65fb16ed71f0e",
|
||||
"sha256:025d133bf4ca8cf75f904eeb8ea946228d7c043231866143f31946a6f4dd0bf3",
|
||||
"sha256:0ca70ccf789446a6dfde40b482ec21d28067172cd1f8efd50d5548159fccad9e",
|
||||
"sha256:0df69d26f21a9d2f1b20266f6737fa43f08aa5015c99900fb69f255fbe4dabb4",
|
||||
"sha256:0f8069a807a3e6e3c361ce302ece4bf1c3b49962c1726d1d56587e8f48682861",
|
||||
"sha256:15a2eff91f54d2b1f573cca8ca6fb58763ce8fea80e7899ab028f3987ef71cd5",
|
||||
"sha256:15e8d0597cc6e8496de6f4dea2a6880c57e1251502a7072f5631108a1aa28521",
|
||||
"sha256:165052a0e61c880f7093808a0c524ce1b3555bfa114c0dfb5c809cd07918a60d",
|
||||
"sha256:178c5a4c7fdfb5cd79e372ae7f675390e670f3732e5bc68d327e01a5b3ff8d55",
|
||||
"sha256:18214bac86341f1cc413772e66447d6cca10981e2880b70ecaf4e826c04f95e9",
|
||||
"sha256:1a54d7e7999735faacdcbe8128e30207abc2caf9f9fd7102d180b32f1b78bfce",
|
||||
"sha256:1a926fa554870642607fd10e66ee25b75fdd9a7ca4bbffa93d424e4ae2bf734a",
|
||||
"sha256:1c8f2a5d8006db5a555e06f9437e76bf52537d3dfd130cb8ae2b30866aca32c9",
|
||||
"sha256:21ca6a47bf20db9e7ad74ca04b3d479e4d76109b68333eb23535553d2705339e",
|
||||
"sha256:22c540ed20167d3dbb1e2d868ca935180247d620c40eace90efa774504a40e3b",
|
||||
"sha256:239578587b9c29469ab61149dda40a2fe714a6a4eca0f8ff9ea9439ec4b7bc30",
|
||||
"sha256:25b9cff6c9856bc396232e2f609ea0c5ec1a8a24c500cee4cca96ba8a3cd50b6",
|
||||
"sha256:26714898eb0d847aac8af94c4441c9cb50387847d0fe6b9fc4217c086cd68b80",
|
||||
"sha256:28a85b056c0eb7f5d864c0ceef07034117e8ebfca756f50648c71950a568ba11",
|
||||
"sha256:2a3d1b05ffdd3e95687942ae7b13c63689f85d3f15c39b33329e3cb9ce6c015f",
|
||||
"sha256:2c3bb96b6026643ce24677650889b09073f60b9860a71765f843c99f9ab38b25",
|
||||
"sha256:2fa23fdb3beab313950bbb49674e8a161e61449332d3997089fe3944953f1b77",
|
||||
"sha256:2fe8c54b15a9cd4f93bc2aaceab354ec65af93370aa1496ba2f9c537a4855ee0",
|
||||
"sha256:30989a2451b693c3f9359d91098a744992b5431a0be4858f1fdf0ec76b457125",
|
||||
"sha256:31a4ad2b730128e273f1c22300da3e3631f125703e4fee0ac44d385abfb15671",
|
||||
"sha256:337c8cd46a4c6568d367ed676cbf2d7de16f890bb31dbb54c44c1d6bb6d4a1de",
|
||||
"sha256:33af25c4ff7723363cb8404dfc2eefeab4110b654f6c98d26aba8a08c745d860",
|
||||
"sha256:3ea52df96566457735314794422274bd1962066bfb609e7eea9113d70cf04ffe",
|
||||
"sha256:3eae38daffd77c9621ae80c16932eea3fb3a4af141fb7cc724d4ad93eff9210d",
|
||||
"sha256:41b53711f89008ba2de62e4c2d2260a8b357072fd4f18e1351b28955db2719dc",
|
||||
"sha256:451f9958850ea98440d542278368c8d1e1ea821e2494b204570ba34a340759df",
|
||||
"sha256:46cf239856b87fe1c86dfbb3d459d840a8b1649e7922b1e0bfb6b6464692644a",
|
||||
"sha256:4779f356083c62e29b4198d290b7b17a39a69702d150678b7efff0fdddf494a8",
|
||||
"sha256:4902f4ecd5fcb6a5f482d7b0ae1c16c21f26fc5279b3b6127363d13ad8e7a9d9",
|
||||
"sha256:4d73d4948dcc5591d880db8933004e01f1dd2296df9de815354d53469beb26fe",
|
||||
"sha256:4d9984017314da772f5f7460add7a0301a4ffc06c72c2998bb16c300a6253607",
|
||||
"sha256:4f298daaaea58d45e245a132d6d2bdfb6f856da50dc03d75ebb761439fb626cf",
|
||||
"sha256:50ddbe3a7b4b6ab96812f5a4d570f401a2cdb95642fd04c062f98939610bbeee",
|
||||
"sha256:5338a313f998e1be7267191b7caaae82563b4a2b42b393561055412a34042caa",
|
||||
"sha256:5591daf81313096909d973fb2612fccd87528fdfdd39f6478bdce54543178954",
|
||||
"sha256:56896ceeffe25946c4010320629e2d858ca763cd8ded273c81672a5edbcb1e0a",
|
||||
"sha256:58a01ea34057463f7a98a4d6ff40160f65f945e924fec08a5b39e327e372875d",
|
||||
"sha256:5bfac7f236ba1a4d402644bdce47fb9db02a7cf3214a1f637d3a88390f9e5428",
|
||||
"sha256:5c5a8a83df95e51f7a7c2b083eaea134cbed39fc42c6aeb2e764ddb7ccccd43e",
|
||||
"sha256:5f2fb10518f6b365f5b720e43a529c3b2324ca02932f609631a44edb347d8d54",
|
||||
"sha256:64af877116edf051375b45f0bda648143176a017b13803ec7b3a3111dc05f4c5",
|
||||
"sha256:6d70fa9c6d2e955bde8cd327ffc11f2cc34bc21944e5571a46ca501e7eadef24",
|
||||
"sha256:6d79f659965290af60d6acc8e2716341865fe74609a7ede2a33c2f86ad893b8f",
|
||||
"sha256:720963fee259291a88348ae9735d9deb5d334e84a016244f61c89f5a49aa400a",
|
||||
"sha256:75a3b6e9c695a6570ea488db75b84bb592ff70a944957efa1c655867c575018b",
|
||||
"sha256:792462abfeeca6cc8c6c1e6d27e14319682f0182f6b0ba37befe911af794db70",
|
||||
"sha256:79ec4498a545733ecace48d780d22407411b07403a2e08b9a4d7596c0b97ebd7",
|
||||
"sha256:7f14d6b303e55bd7d19b28309ef8014370e84a3806c5e452e078e7df7344d97a",
|
||||
"sha256:7f65bd5d4cdb396295b6aa07f84ca659ac65c5c68b53956a6d95219e304b0ada",
|
||||
"sha256:81c6b4a6c1af800d52a6fa32389ef8f4281583f4f99dc1a40f2bb47667281541",
|
||||
"sha256:82a07de83dce09b4fa1bccbdc8bde8f188b131666af0dc9048ba0a0e448d8a3b",
|
||||
"sha256:847c7f61964225fc489fe1d49eda7e0e0d253e98862c012cecf845f9ad45cdf4",
|
||||
"sha256:84b52b2cf77bb7f703d16c4007b021078dbbe6cf8ffb57abe81a7bacfc175ef2",
|
||||
"sha256:86685fa04067f7175f9718489ae755f6acde03593a1a9ca89305554af40e14fd",
|
||||
"sha256:8a9c962c64a4c08def58b9799333e33af94ec53038cf151d36edacdb41f81646",
|
||||
"sha256:8cbd4bfc933b33b85c43ef4c1f4d5e3e9d91975ea6368acf5fbac02bac06ea89",
|
||||
"sha256:8ffe660e963ae711cb9e2b8d8461c9b1ad6167823837fc17d59d5e539fb898fa",
|
||||
"sha256:94652da1a4ca7cfb69c15dd6986b205e0bd9c63a05029c3b48b4201085f527bd",
|
||||
"sha256:969fd67de8c42affdb47b38b80f1eaa79ac0ef17d65407cdd931db1675315af1",
|
||||
"sha256:9858dcbc23ba7eaadcd319786b982278a1a2b2020720b19db43e309579ff76fb",
|
||||
"sha256:99d25aff3745c54e61ab340b98400c52ebec04290a62078155e0d7eb30380220",
|
||||
"sha256:99f55e14e7c56f4fafe1343480c32b110ef03836c21ff7c48bae7add6818f77c",
|
||||
"sha256:9d35d8f8a1c9ed4e2b08187b513f8a3c71958600129db3aa26d85ea3abfd1310",
|
||||
"sha256:a2ba92f59e30ce915e9e79af37649432e3a212ddddf416d4d686b1b4825bcdb2",
|
||||
"sha256:a2cb35a6efaa0e3623d8272471371a12c7e07b51a33e5efce9b58f655d864b4e",
|
||||
"sha256:a358277122456666a8b2a0b9aa04f1b89d34e8aa41d08a6557d693e6abb6667c",
|
||||
"sha256:a60da2f9efbed355edb35a1fb6829148676786c829fad708bb6bb47211b3593a",
|
||||
"sha256:aa7dec53c25f1949513457ef8b0ea1fb40e76c672cc4d2daa8ad3c8d6b73491a",
|
||||
"sha256:b1572ee0eb1967e71787af636bb7d1eb9c6735d5337762c450650e7f51844594",
|
||||
"sha256:b4f10d3f304be7183fac79bf2cd997f82e16aa9a9f37343d76c026c6e435a8a8",
|
||||
"sha256:bbbbfbb7d039b20d289ce56b1beb46138d65769d04af50c199c6ac4cb6054d52",
|
||||
"sha256:c394a3f055b49f92626f83c1a0b6d6cd2c628f1ccd72481c3e3c6aa4695f3b20",
|
||||
"sha256:c396358023b876cff547ce87f4e8ff8a2280598873a137e8cc69e115262260b8",
|
||||
"sha256:c5ba07e58fd98c9782201e79eb8dd4225733d212a5a3700f9a84d329bd0463a6",
|
||||
"sha256:c764fb167411d5afaef88138542a4bfa28bd5e5ded5e8e42df87cef965efd6e9",
|
||||
"sha256:cbba763d99de0255a3e4938f25a8579930ac8aa089233cb2fb2ed7d04d4aff02",
|
||||
"sha256:cbd1660fb48827381ce3a621a4fdc237959e1cd4e98b098952a8f624a0726425",
|
||||
"sha256:cd761d158f67e288fd0ebe00c3b158095ce80a4bc7c32b60c7121224003ba70d",
|
||||
"sha256:cdfbb27f2c46bb5bbdcee147530cbc5ca8ab858d7693924e88e30ada21b2c5e2",
|
||||
"sha256:d2dbe8a3baf2d842e342e8acb06ae3844765d38df67687c144cdeb71f1bcb5d7",
|
||||
"sha256:d5c931ec1c03111718cabf85f6012bb2815fa0ce578175567fa8d6f2cc15d3b4",
|
||||
"sha256:df6d7bf3e15b7e6e202a16ff4948a51759354016026deb04ab9b5acbbe35e096",
|
||||
"sha256:dfbe2aa45b273f49e715c5345d94874cb65a28482bf231af408891c260601b8d",
|
||||
"sha256:e12769d3adcc419e65860de946df8d2ed274932177ac1cdb05186e498aaa9149",
|
||||
"sha256:e5aed4754895942ae15ffa48c52d181e1c1463236fda68d2dba29c03aa61786b",
|
||||
"sha256:e645b4c365d6f1f9e0799380ad6395268f3c3b898244a650aaeb8d9d27b74c35",
|
||||
"sha256:ed3493a369fe849cce98542d7405c88030b355e4d2e113887cb7ecc86c205773",
|
||||
"sha256:f08342dc8d19214faa7ef99574dea6c37a2790d6d04a9793ef8fa76c188dc08d",
|
||||
"sha256:f0a55cf02d2cdd739b40ce10c09bbdd520e141217696add7a48b56e67bdfdfe6",
|
||||
"sha256:f0ce9d9e07c75da8027c62b4c9f45771d1d8aae7dc9ad7fb606c6a5aedbe9741",
|
||||
"sha256:f1f723e260c35e1c7c57a09d3a6ebe681bd56c83e1208ae3ce1869b7c0d10d4f",
|
||||
"sha256:f2fcbe9b3a5996b417e030aa33a562e7e20dfc86271e53d7e841fc5df16268b8",
|
||||
"sha256:f3fd8df63c41ff6a676d031956aebf68ebbc687b47c507da25501eb22eec341f",
|
||||
"sha256:f8d3417db5e14a6789073b21ae44439a755289477901901bae378a57b905e148",
|
||||
"sha256:fbf05678c2ae0064fb1b8de7e9e8f0fc30621b73c8477786dd0fb3868044a8c8",
|
||||
"sha256:fc98ff43abad61f00515ad9a06213b7716699146e46eabd256cdfe7cb522bd97",
|
||||
"sha256:ff1863f037dad765ef5963efc2e37d399ac023e192a6f2bb394e2377d023cefe"
|
||||
],
|
||||
"version": "==3.8.0"
|
||||
},
|
||||
"bitstring": {
|
||||
"hashes": [
|
||||
"sha256:e682ac522bb63e041d16cbc9d0ca86a4f00194db16d0847c7efe066f836b2e37",
|
||||
"sha256:feac49524fcf3ef27e6081e86f02b10d2adf6c3773bf22fbe0e7eea9534bc737"
|
||||
],
|
||||
"markers": "python_version >= '3.8'",
|
||||
"version": "==4.4.0"
|
||||
},
|
||||
"certifi": {
|
||||
"hashes": [
|
||||
"sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa",
|
||||
"sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7"
|
||||
],
|
||||
"markers": "python_version >= '3.7'",
|
||||
"version": "==2026.2.25"
|
||||
},
|
||||
"cffi": {
|
||||
"hashes": [
|
||||
"sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb",
|
||||
"sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b",
|
||||
"sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f",
|
||||
"sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9",
|
||||
"sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44",
|
||||
"sha256:0f6084a0ea23d05d20c3edcda20c3d006f9b6f3fefeac38f59262e10cef47ee2",
|
||||
"sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c",
|
||||
"sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75",
|
||||
"sha256:1cd13c99ce269b3ed80b417dcd591415d3372bcac067009b6e0f59c7d4015e65",
|
||||
"sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e",
|
||||
"sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a",
|
||||
"sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e",
|
||||
"sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25",
|
||||
"sha256:2081580ebb843f759b9f617314a24ed5738c51d2aee65d31e02f6f7a2b97707a",
|
||||
"sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe",
|
||||
"sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b",
|
||||
"sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91",
|
||||
"sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592",
|
||||
"sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187",
|
||||
"sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c",
|
||||
"sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1",
|
||||
"sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94",
|
||||
"sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba",
|
||||
"sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb",
|
||||
"sha256:3f4d46d8b35698056ec29bca21546e1551a205058ae1a181d871e278b0b28165",
|
||||
"sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529",
|
||||
"sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca",
|
||||
"sha256:4647afc2f90d1ddd33441e5b0e85b16b12ddec4fca55f0d9671fef036ecca27c",
|
||||
"sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6",
|
||||
"sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c",
|
||||
"sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0",
|
||||
"sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743",
|
||||
"sha256:61d028e90346df14fedc3d1e5441df818d095f3b87d286825dfcbd6459b7ef63",
|
||||
"sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5",
|
||||
"sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5",
|
||||
"sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4",
|
||||
"sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d",
|
||||
"sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b",
|
||||
"sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93",
|
||||
"sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205",
|
||||
"sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27",
|
||||
"sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512",
|
||||
"sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d",
|
||||
"sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c",
|
||||
"sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037",
|
||||
"sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26",
|
||||
"sha256:89472c9762729b5ae1ad974b777416bfda4ac5642423fa93bd57a09204712322",
|
||||
"sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb",
|
||||
"sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c",
|
||||
"sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8",
|
||||
"sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4",
|
||||
"sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414",
|
||||
"sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9",
|
||||
"sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664",
|
||||
"sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9",
|
||||
"sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775",
|
||||
"sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739",
|
||||
"sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc",
|
||||
"sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062",
|
||||
"sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe",
|
||||
"sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9",
|
||||
"sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92",
|
||||
"sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5",
|
||||
"sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13",
|
||||
"sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d",
|
||||
"sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26",
|
||||
"sha256:cb527a79772e5ef98fb1d700678fe031e353e765d1ca2d409c92263c6d43e09f",
|
||||
"sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495",
|
||||
"sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b",
|
||||
"sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6",
|
||||
"sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c",
|
||||
"sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef",
|
||||
"sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5",
|
||||
"sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18",
|
||||
"sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad",
|
||||
"sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3",
|
||||
"sha256:de8dad4425a6ca6e4e5e297b27b5c824ecc7581910bf9aee86cb6835e6812aa7",
|
||||
"sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5",
|
||||
"sha256:e6e73b9e02893c764e7e8d5bb5ce277f1a009cd5243f8228f75f842bf937c534",
|
||||
"sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49",
|
||||
"sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2",
|
||||
"sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5",
|
||||
"sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453",
|
||||
"sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf"
|
||||
],
|
||||
"markers": "python_full_version >= '3.9' and platform_python_implementation != 'PyPy'",
|
||||
"version": "==2.0.0"
|
||||
},
|
||||
"charset-normalizer": {
|
||||
"hashes": [
|
||||
"sha256:06a7e86163334edfc5d20fe104db92fcd666e5a5df0977cb5680a506fe26cc8e",
|
||||
"sha256:0c173ce3a681f309f31b87125fecec7a5d1347261ea11ebbb856fa6006b23c8c",
|
||||
"sha256:0e28d62a8fc7a1fa411c43bd65e346f3bce9716dc51b897fbe930c5987b402d5",
|
||||
"sha256:0e901eb1049fdb80f5bd11ed5ea1e498ec423102f7a9b9e4645d5b8204ff2815",
|
||||
"sha256:11afb56037cbc4b1555a34dd69151e8e069bee82e613a73bef6e714ce733585f",
|
||||
"sha256:150b8ce8e830eb7ccb029ec9ca36022f756986aaaa7956aad6d9ec90089338c0",
|
||||
"sha256:172985e4ff804a7ad08eebec0a1640ece87ba5041d565fff23c8f99c1f389484",
|
||||
"sha256:197c1a244a274bb016dd8b79204850144ef77fe81c5b797dc389327adb552407",
|
||||
"sha256:1ae6b62897110aa7c79ea2f5dd38d1abca6db663687c0b1ad9aed6f6bae3d9d6",
|
||||
"sha256:1cf0a70018692f85172348fe06d3a4b63f94ecb055e13a00c644d368eb82e5b8",
|
||||
"sha256:1ed80ff870ca6de33f4d953fda4d55654b9a2b340ff39ab32fa3adbcd718f264",
|
||||
"sha256:22c6f0c2fbc31e76c3b8a86fba1a56eda6166e238c29cdd3d14befdb4a4e4815",
|
||||
"sha256:231d4da14bcd9301310faf492051bee27df11f2bc7549bc0bb41fef11b82daa2",
|
||||
"sha256:259695e2ccc253feb2a016303543d691825e920917e31f894ca1a687982b1de4",
|
||||
"sha256:2a24157fa36980478dd1770b585c0f30d19e18f4fb0c47c13aa568f871718579",
|
||||
"sha256:2b1a63e8224e401cafe7739f77efd3f9e7f5f2026bda4aead8e59afab537784f",
|
||||
"sha256:2bd9d128ef93637a5d7a6af25363cf5dec3fa21cf80e68055aad627f280e8afa",
|
||||
"sha256:2e1d8ca8611099001949d1cdfaefc510cf0f212484fe7c565f735b68c78c3c95",
|
||||
"sha256:2ef7fedc7a6ecbe99969cd09632516738a97eeb8bd7258bf8a0f23114c057dab",
|
||||
"sha256:2f7fdd9b6e6c529d6a2501a2d36b240109e78a8ceaef5687cfcfa2bbe671d297",
|
||||
"sha256:30f445ae60aad5e1f8bdbb3108e39f6fbc09f4ea16c815c66578878325f8f15a",
|
||||
"sha256:31215157227939b4fb3d740cd23fe27be0439afef67b785a1eb78a3ae69cba9e",
|
||||
"sha256:34315ff4fc374b285ad7f4a0bf7dcbfe769e1b104230d40f49f700d4ab6bbd84",
|
||||
"sha256:3516bbb8d42169de9e61b8520cbeeeb716f12f4ecfe3fd30a9919aa16c806ca8",
|
||||
"sha256:3778fd7d7cd04ae8f54651f4a7a0bd6e39a0cf20f801720a4c21d80e9b7ad6b0",
|
||||
"sha256:39f5068d35621da2881271e5c3205125cc456f54e9030d3f723288c873a71bf9",
|
||||
"sha256:404a1e552cf5b675a87f0651f8b79f5f1e6fd100ee88dc612f89aa16abd4486f",
|
||||
"sha256:419a9d91bd238052642a51938af8ac05da5b3343becde08d5cdeab9046df9ee1",
|
||||
"sha256:423fb7e748a08f854a08a222b983f4df1912b1daedce51a72bd24fe8f26a1843",
|
||||
"sha256:4482481cb0572180b6fd976a4d5c72a30263e98564da68b86ec91f0fe35e8565",
|
||||
"sha256:461598cd852bfa5a61b09cae2b1c02e2efcd166ee5516e243d540ac24bfa68a7",
|
||||
"sha256:47955475ac79cc504ef2704b192364e51d0d473ad452caedd0002605f780101c",
|
||||
"sha256:48696db7f18afb80a068821504296eb0787d9ce239b91ca15059d1d3eaacf13b",
|
||||
"sha256:4be9f4830ba8741527693848403e2c457c16e499100963ec711b1c6f2049b7c7",
|
||||
"sha256:4d1d02209e06550bdaef34af58e041ad71b88e624f5d825519da3a3308e22687",
|
||||
"sha256:4f41da960b196ea355357285ad1316a00099f22d0929fe168343b99b254729c9",
|
||||
"sha256:517ad0e93394ac532745129ceabdf2696b609ec9f87863d337140317ebce1c14",
|
||||
"sha256:51fb3c322c81d20567019778cb5a4a6f2dc1c200b886bc0d636238e364848c89",
|
||||
"sha256:5273b9f0b5835ff0350c0828faea623c68bfa65b792720c453e22b25cc72930f",
|
||||
"sha256:530d548084c4a9f7a16ed4a294d459b4f229db50df689bfe92027452452943a0",
|
||||
"sha256:530e8cebeea0d76bdcf93357aa5e41336f48c3dc709ac52da2bb167c5b8271d9",
|
||||
"sha256:54fae94be3d75f3e573c9a1b5402dc593de19377013c9a0e4285e3d402dd3a2a",
|
||||
"sha256:572d7c822caf521f0525ba1bce1a622a0b85cf47ffbdae6c9c19e3b5ac3c4389",
|
||||
"sha256:58c948d0d086229efc484fe2f30c2d382c86720f55cd9bc33591774348ad44e0",
|
||||
"sha256:5d11595abf8dd942a77883a39d81433739b287b6aa71620f15164f8096221b30",
|
||||
"sha256:5f8ddd609f9e1af8c7bd6e2aca279c931aefecd148a14402d4e368f3171769fd",
|
||||
"sha256:5feb91325bbceade6afab43eb3b508c63ee53579fe896c77137ded51c6b6958e",
|
||||
"sha256:60c74963d8350241a79cb8feea80e54d518f72c26db618862a8f53e5023deaf9",
|
||||
"sha256:613f19aa6e082cf96e17e3ffd89383343d0d589abda756b7764cf78361fd41dc",
|
||||
"sha256:659a1e1b500fac8f2779dd9e1570464e012f43e580371470b45277a27baa7532",
|
||||
"sha256:695f5c2823691a25f17bc5d5ffe79fa90972cc34b002ac6c843bb8a1720e950d",
|
||||
"sha256:69dd852c2f0ad631b8b60cfbe25a28c0058a894de5abb566619c205ce0550eae",
|
||||
"sha256:6cceb5473417d28edd20c6c984ab6fee6c6267d38d906823ebfe20b03d607dc2",
|
||||
"sha256:71be7e0e01753a89cf024abf7ecb6bca2c81738ead80d43004d9b5e3f1244e64",
|
||||
"sha256:74119174722c4349af9708993118581686f343adc1c8c9c007d59be90d077f3f",
|
||||
"sha256:74a2e659c7ecbc73562e2a15e05039f1e22c75b7c7618b4b574a3ea9118d1557",
|
||||
"sha256:7504e9b7dc05f99a9bbb4525c67a2c155073b44d720470a148b34166a69c054e",
|
||||
"sha256:79090741d842f564b1b2827c0b82d846405b744d31e84f18d7a7b41c20e473ff",
|
||||
"sha256:7a6967aaf043bceabab5412ed6bd6bd26603dae84d5cb75bf8d9a74a4959d398",
|
||||
"sha256:7bda6eebafd42133efdca535b04ccb338ab29467b3f7bf79569883676fc628db",
|
||||
"sha256:7edbed096e4a4798710ed6bc75dcaa2a21b68b6c356553ac4823c3658d53743a",
|
||||
"sha256:7f9019c9cb613f084481bd6a100b12e1547cf2efe362d873c2e31e4035a6fa43",
|
||||
"sha256:802168e03fba8bbc5ce0d866d589e4b1ca751d06edee69f7f3a19c5a9fe6b597",
|
||||
"sha256:80d0a5615143c0b3225e5e3ef22c8d5d51f3f72ce0ea6fb84c943546c7b25b6c",
|
||||
"sha256:82060f995ab5003a2d6e0f4ad29065b7672b6593c8c63559beefe5b443242c3e",
|
||||
"sha256:836ab36280f21fc1a03c99cd05c6b7af70d2697e374c7af0b61ed271401a72a2",
|
||||
"sha256:8761ac29b6c81574724322a554605608a9960769ea83d2c73e396f3df896ad54",
|
||||
"sha256:87725cfb1a4f1f8c2fc9890ae2f42094120f4b44db9360be5d99a4c6b0e03a9e",
|
||||
"sha256:899d28f422116b08be5118ef350c292b36fc15ec2daeb9ea987c89281c7bb5c4",
|
||||
"sha256:8bc5f0687d796c05b1e28ab0d38a50e6309906ee09375dd3aff6a9c09dd6e8f4",
|
||||
"sha256:8bea55c4eef25b0b19a0337dc4e3f9a15b00d569c77211fa8cde38684f234fb7",
|
||||
"sha256:8e5a94886bedca0f9b78fecd6afb6629142fd2605aa70a125d49f4edc6037ee6",
|
||||
"sha256:90ca27cd8da8118b18a52d5f547859cc1f8354a00cd1e8e5120df3e30d6279e5",
|
||||
"sha256:92734d4d8d187a354a556626c221cd1a892a4e0802ccb2af432a1d85ec012194",
|
||||
"sha256:947cf925bc916d90adba35a64c82aace04fa39b46b52d4630ece166655905a69",
|
||||
"sha256:95b52c68d64c1878818687a473a10547b3292e82b6f6fe483808fb1468e2f52f",
|
||||
"sha256:97d0235baafca5f2b09cf332cc275f021e694e8362c6bb9c96fc9a0eb74fc316",
|
||||
"sha256:9ca4c0b502ab399ef89248a2c84c54954f77a070f28e546a85e91da627d1301e",
|
||||
"sha256:9cc4fc6c196d6a8b76629a70ddfcd4635a6898756e2d9cac5565cf0654605d73",
|
||||
"sha256:9cc6e6d9e571d2f863fa77700701dae73ed5f78881efc8b3f9a4398772ff53e8",
|
||||
"sha256:a056d1ad2633548ca18ffa2f85c202cfb48b68615129143915b8dc72a806a923",
|
||||
"sha256:a26611d9987b230566f24a0a125f17fe0de6a6aff9f25c9f564aaa2721a5fb88",
|
||||
"sha256:a4474d924a47185a06411e0064b803c68be044be2d60e50e8bddcc2649957c1f",
|
||||
"sha256:a4ea868bc28109052790eb2b52a9ab33f3aa7adc02f96673526ff47419490e21",
|
||||
"sha256:a9e68c9d88823b274cf1e72f28cb5dc89c990edf430b0bfd3e2fb0785bfeabf4",
|
||||
"sha256:aa9cccf4a44b9b62d8ba8b4dd06c649ba683e4bf04eea606d2e94cfc2d6ff4d6",
|
||||
"sha256:ab30e5e3e706e3063bc6de96b118688cb10396b70bb9864a430f67df98c61ecc",
|
||||
"sha256:ac2393c73378fea4e52aa56285a3d64be50f1a12395afef9cce47772f60334c2",
|
||||
"sha256:ad8faf8df23f0378c6d527d8b0b15ea4a2e23c89376877c598c4870d1b2c7866",
|
||||
"sha256:b35b200d6a71b9839a46b9b7fff66b6638bb52fc9658aa58796b0326595d3021",
|
||||
"sha256:b3694e3f87f8ac7ce279d4355645b3c878d24d1424581b46282f24b92f5a4ae2",
|
||||
"sha256:b4ff1d35e8c5bd078be89349b6f3a845128e685e751b6ea1169cf2160b344c4d",
|
||||
"sha256:bbc8c8650c6e51041ad1be191742b8b421d05bbd3410f43fa2a00c8db87678e8",
|
||||
"sha256:bc72863f4d9aba2e8fd9085e63548a324ba706d2ea2c83b260da08a59b9482de",
|
||||
"sha256:bf625105bb9eef28a56a943fec8c8a98aeb80e7d7db99bd3c388137e6eb2d237",
|
||||
"sha256:c2274ca724536f173122f36c98ce188fd24ce3dad886ec2b7af859518ce008a4",
|
||||
"sha256:c45a03a4c69820a399f1dda9e1d8fbf3562eda46e7720458180302021b08f778",
|
||||
"sha256:c8ae56368f8cc97c7e40a7ee18e1cedaf8e780cd8bc5ed5ac8b81f238614facb",
|
||||
"sha256:c907cdc8109f6c619e6254212e794d6548373cc40e1ec75e6e3823d9135d29cc",
|
||||
"sha256:ca0276464d148c72defa8bb4390cce01b4a0e425f3b50d1435aa6d7a18107602",
|
||||
"sha256:cd5e2801c89992ed8c0a3f0293ae83c159a60d9a5d685005383ef4caca77f2c4",
|
||||
"sha256:d08ec48f0a1c48d75d0356cea971921848fb620fdeba805b28f937e90691209f",
|
||||
"sha256:d1a2ee9c1499fc8f86f4521f27a973c914b211ffa87322f4ee33bb35392da2c5",
|
||||
"sha256:d5f5d1e9def3405f60e3ca8232d56f35c98fb7bf581efcc60051ebf53cb8b611",
|
||||
"sha256:d60377dce4511655582e300dc1e5a5f24ba0cb229005a1d5c8d0cb72bb758ab8",
|
||||
"sha256:d73beaac5e90173ac3deb9928a74763a6d230f494e4bfb422c217a0ad8e629bf",
|
||||
"sha256:d7de2637729c67d67cf87614b566626057e95c303bc0a55ffe391f5205e7003d",
|
||||
"sha256:dad6e0f2e481fffdcf776d10ebee25e0ef89f16d691f1e5dee4b586375fdc64b",
|
||||
"sha256:dda86aba335c902b6149a02a55b38e96287157e609200811837678214ba2b1db",
|
||||
"sha256:df01808ee470038c3f8dc4f48620df7225c49c2d6639e38f96e6d6ac6e6f7b0e",
|
||||
"sha256:e1f6e2f00a6b8edb562826e4632e26d063ac10307e80f7461f7de3ad8ef3f077",
|
||||
"sha256:e25369dc110d58ddf29b949377a93e0716d72a24f62bad72b2b39f155949c1fd",
|
||||
"sha256:e3c701e954abf6fc03a49f7c579cc80c2c6cc52525340ca3186c41d3f33482ef",
|
||||
"sha256:e5bcc1a1ae744e0bb59641171ae53743760130600da8db48cbb6e4918e186e4e",
|
||||
"sha256:e68c14b04827dd76dcbd1aeea9e604e3e4b78322d8faf2f8132c7138efa340a8",
|
||||
"sha256:e8aeb10fcbe92767f0fa69ad5a72deca50d0dca07fbde97848997d778a50c9fe",
|
||||
"sha256:e985a16ff513596f217cee86c21371b8cd011c0f6f056d0920aa2d926c544058",
|
||||
"sha256:ecbbd45615a6885fe3240eb9db73b9e62518b611850fdf8ab08bd56de7ad2b17",
|
||||
"sha256:ee4ec14bc1680d6b0afab9aea2ef27e26d2024f18b24a2d7155a52b60da7e833",
|
||||
"sha256:ef5960d965e67165d75b7c7ffc60a83ec5abfc5c11b764ec13ea54fbef8b4421",
|
||||
"sha256:f0cdaecd4c953bfae0b6bb64910aaaca5a424ad9c72d85cb88417bb9814f7550",
|
||||
"sha256:f1ce721c8a7dfec21fcbdfe04e8f68174183cf4e8188e0645e92aa23985c57ff",
|
||||
"sha256:f50498891691e0864dc3da965f340fada0771f6142a378083dc4608f4ea513e2",
|
||||
"sha256:f5ea69428fa1b49573eef0cc44a1d43bebd45ad0c611eb7d7eac760c7ae771bc",
|
||||
"sha256:f61aa92e4aad0be58eb6eb4e0c21acf32cf8065f4b2cae5665da756c4ceef982",
|
||||
"sha256:f6e4333fb15c83f7d1482a76d45a0818897b3d33f00efd215528ff7c51b8e35d",
|
||||
"sha256:f820f24b09e3e779fe84c3c456cb4108a7aa639b0d1f02c28046e11bfcd088ed",
|
||||
"sha256:f98059e4fcd3e3e4e2d632b7cf81c2faae96c43c60b569e9c621468082f1d104",
|
||||
"sha256:fcce033e4021347d80ed9c66dcf1e7b1546319834b74445f561d2e2221de5659"
|
||||
],
|
||||
"markers": "python_version >= '3.7'",
|
||||
"version": "==3.4.6"
|
||||
},
|
||||
"click": {
|
||||
"hashes": [
|
||||
"sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a",
|
||||
"sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6"
|
||||
],
|
||||
"markers": "python_version >= '3.10'",
|
||||
"version": "==8.3.1"
|
||||
},
|
||||
"cryptography": {
|
||||
"hashes": [
|
||||
"sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72",
|
||||
"sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235",
|
||||
"sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9",
|
||||
"sha256:2ae6971afd6246710480e3f15824ed3029a60fc16991db250034efd0b9fb4356",
|
||||
"sha256:2b7a67c9cd56372f3249b39699f2ad479f6991e62ea15800973b956f4b73e257",
|
||||
"sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad",
|
||||
"sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4",
|
||||
"sha256:3b4995dc971c9fb83c25aa44cf45f02ba86f71ee600d81091c2f0cbae116b06c",
|
||||
"sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614",
|
||||
"sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed",
|
||||
"sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31",
|
||||
"sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229",
|
||||
"sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0",
|
||||
"sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731",
|
||||
"sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b",
|
||||
"sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4",
|
||||
"sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4",
|
||||
"sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263",
|
||||
"sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595",
|
||||
"sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1",
|
||||
"sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678",
|
||||
"sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48",
|
||||
"sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76",
|
||||
"sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0",
|
||||
"sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18",
|
||||
"sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d",
|
||||
"sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d",
|
||||
"sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1",
|
||||
"sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981",
|
||||
"sha256:8456928655f856c6e1533ff59d5be76578a7157224dbd9ce6872f25055ab9ab7",
|
||||
"sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82",
|
||||
"sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2",
|
||||
"sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4",
|
||||
"sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663",
|
||||
"sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c",
|
||||
"sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d",
|
||||
"sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a",
|
||||
"sha256:bc84e875994c3b445871ea7181d424588171efec3e185dced958dad9e001950a",
|
||||
"sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d",
|
||||
"sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b",
|
||||
"sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a",
|
||||
"sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826",
|
||||
"sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee",
|
||||
"sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9",
|
||||
"sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648",
|
||||
"sha256:d861ee9e76ace6cf36a6a89b959ec08e7bc2493ee39d07ffe5acb23ef46d27da",
|
||||
"sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2",
|
||||
"sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2",
|
||||
"sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87"
|
||||
],
|
||||
"markers": "python_version >= '3.8' and python_full_version not in '3.9.0, 3.9.1'",
|
||||
"version": "==46.0.5"
|
||||
},
|
||||
"esptool": {
|
||||
"hashes": [
|
||||
"sha256:9c355b7d6331cc92979cc710ae5c41f59830d1ea29ec24c467c6005a092c06d6"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==5.2.0"
|
||||
},
|
||||
"h11": {
|
||||
"hashes": [
|
||||
"sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1",
|
||||
"sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"
|
||||
],
|
||||
"markers": "python_version >= '3.8'",
|
||||
"version": "==0.16.0"
|
||||
},
|
||||
"idna": {
|
||||
"hashes": [
|
||||
"sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea",
|
||||
"sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902"
|
||||
],
|
||||
"markers": "python_version >= '3.8'",
|
||||
"version": "==3.11"
|
||||
},
|
||||
"intelhex": {
|
||||
"hashes": [
|
||||
"sha256:87cc5225657524ec6361354be928adfd56bcf2a3dcc646c40f8f094c39c07db4",
|
||||
"sha256:892b7361a719f4945237da8ccf754e9513db32f5628852785aea108dcd250093"
|
||||
],
|
||||
"version": "==2.3.0"
|
||||
},
|
||||
"markdown-it-py": {
|
||||
"hashes": [
|
||||
"sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147",
|
||||
"sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3"
|
||||
],
|
||||
"markers": "python_version >= '3.10'",
|
||||
"version": "==4.0.0"
|
||||
},
|
||||
"mdurl": {
|
||||
"hashes": [
|
||||
"sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8",
|
||||
"sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"
|
||||
],
|
||||
"markers": "python_version >= '3.7'",
|
||||
"version": "==0.1.2"
|
||||
},
|
||||
"microdot": {
|
||||
"hashes": [
|
||||
"sha256:363e3ebfc80b7e0415779848c9332e4e7fb7bd365ee54d3620abffe42ed82946",
|
||||
"sha256:abfb82ca31cc430174e4761cc7356adc4bff00ea758d437c2b258883dc63f464"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==2.6.0"
|
||||
},
|
||||
"mpremote": {
|
||||
"hashes": [
|
||||
"sha256:11d134c69b21b487dae3d03eed54c8ccbf84c916c8732a3e069a97cae47be3d4",
|
||||
"sha256:6bb75774648091dad6833af4f86c5bf6505f8d7aec211380f9e6996c01d23cb5"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==1.27.0"
|
||||
},
|
||||
"outcome": {
|
||||
"hashes": [
|
||||
"sha256:9dcf02e65f2971b80047b377468e72a268e15c0af3cf1238e6ff14f7f91143b8",
|
||||
"sha256:e771c5ce06d1415e356078d3bdd68523f284b4ce5419828922b6871e65eda82b"
|
||||
],
|
||||
"markers": "python_version >= '3.7'",
|
||||
"version": "==1.3.0.post0"
|
||||
},
|
||||
"platformdirs": {
|
||||
"hashes": [
|
||||
"sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934",
|
||||
"sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868"
|
||||
],
|
||||
"markers": "python_version >= '3.10'",
|
||||
"version": "==4.9.4"
|
||||
},
|
||||
"pycparser": {
|
||||
"hashes": [
|
||||
"sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29",
|
||||
"sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992"
|
||||
],
|
||||
"markers": "implementation_name != 'PyPy'",
|
||||
"version": "==3.0"
|
||||
},
|
||||
"pygments": {
|
||||
"hashes": [
|
||||
"sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887",
|
||||
"sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"
|
||||
],
|
||||
"markers": "python_version >= '3.8'",
|
||||
"version": "==2.19.2"
|
||||
},
|
||||
"pyjwt": {
|
||||
"hashes": [
|
||||
"sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c",
|
||||
"sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==2.12.1"
|
||||
},
|
||||
"pyserial": {
|
||||
"hashes": [
|
||||
"sha256:3c77e014170dfffbd816e6ffc205e9842efb10be9f58ec16d3e8675b4925cddb",
|
||||
"sha256:c4451db6ba391ca6ca299fb3ec7bae67a5c55dde170964c7a14ceefec02f2cf0"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==3.5"
|
||||
},
|
||||
"pysocks": {
|
||||
"hashes": [
|
||||
"sha256:08e69f092cc6dbe92a0fdd16eeb9b9ffbc13cadfe5ca4c7bd92ffb078b293299",
|
||||
"sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5",
|
||||
"sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0"
|
||||
],
|
||||
"version": "==1.7.1"
|
||||
},
|
||||
"python-dotenv": {
|
||||
"hashes": [
|
||||
"sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a",
|
||||
"sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3"
|
||||
],
|
||||
"markers": "python_version >= '3.10'",
|
||||
"version": "==1.2.2"
|
||||
},
|
||||
"pyyaml": {
|
||||
"hashes": [
|
||||
"sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c",
|
||||
"sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a",
|
||||
"sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3",
|
||||
"sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956",
|
||||
"sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6",
|
||||
"sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c",
|
||||
"sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65",
|
||||
"sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a",
|
||||
"sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0",
|
||||
"sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b",
|
||||
"sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1",
|
||||
"sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6",
|
||||
"sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7",
|
||||
"sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e",
|
||||
"sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007",
|
||||
"sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310",
|
||||
"sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4",
|
||||
"sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9",
|
||||
"sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295",
|
||||
"sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea",
|
||||
"sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0",
|
||||
"sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e",
|
||||
"sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac",
|
||||
"sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9",
|
||||
"sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7",
|
||||
"sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35",
|
||||
"sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb",
|
||||
"sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b",
|
||||
"sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69",
|
||||
"sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5",
|
||||
"sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b",
|
||||
"sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c",
|
||||
"sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369",
|
||||
"sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd",
|
||||
"sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824",
|
||||
"sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198",
|
||||
"sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065",
|
||||
"sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c",
|
||||
"sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c",
|
||||
"sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764",
|
||||
"sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196",
|
||||
"sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b",
|
||||
"sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00",
|
||||
"sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac",
|
||||
"sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8",
|
||||
"sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e",
|
||||
"sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28",
|
||||
"sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3",
|
||||
"sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5",
|
||||
"sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4",
|
||||
"sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b",
|
||||
"sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf",
|
||||
"sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5",
|
||||
"sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702",
|
||||
"sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8",
|
||||
"sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788",
|
||||
"sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da",
|
||||
"sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d",
|
||||
"sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc",
|
||||
"sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c",
|
||||
"sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba",
|
||||
"sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f",
|
||||
"sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917",
|
||||
"sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5",
|
||||
"sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26",
|
||||
"sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f",
|
||||
"sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b",
|
||||
"sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be",
|
||||
"sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c",
|
||||
"sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3",
|
||||
"sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6",
|
||||
"sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926",
|
||||
"sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0"
|
||||
],
|
||||
"markers": "python_version >= '3.8'",
|
||||
"version": "==6.0.3"
|
||||
},
|
||||
"reedsolo": {
|
||||
"hashes": [
|
||||
"sha256:2b6a3e402a1ee3e1eea3f932f81e6c0b7bbc615588074dca1dbbcdeb055002bd",
|
||||
"sha256:c1359f02742751afe0f1c0de9f0772cc113835aa2855d2db420ea24393c87732"
|
||||
],
|
||||
"version": "==1.7.0"
|
||||
},
|
||||
"requests": {
|
||||
"hashes": [
|
||||
"sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6",
|
||||
"sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==2.32.5"
|
||||
},
|
||||
"rich": {
|
||||
"hashes": [
|
||||
"sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d",
|
||||
"sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b"
|
||||
],
|
||||
"markers": "python_full_version >= '3.8.0'",
|
||||
"version": "==14.3.3"
|
||||
},
|
||||
"rich-click": {
|
||||
"hashes": [
|
||||
"sha256:022997c1e30731995bdbc8ec2f82819340d42543237f033a003c7b1f843fc5dc",
|
||||
"sha256:2f99120fca78f536e07b114d3b60333bc4bb2a0969053b1250869bcdc1b5351b"
|
||||
],
|
||||
"markers": "python_version >= '3.8'",
|
||||
"version": "==1.9.7"
|
||||
},
|
||||
"selenium": {
|
||||
"hashes": [
|
||||
"sha256:003e971f805231ad63e671783a2b91a299355d10cefb9de964c36ff3819115aa",
|
||||
"sha256:b8ccde8d2e7642221ca64af184a92c19eee6accf2e27f20f30472f5efae18eb1"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==4.41.0"
|
||||
},
|
||||
"sniffio": {
|
||||
"hashes": [
|
||||
"sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2",
|
||||
"sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"
|
||||
],
|
||||
"markers": "python_version >= '3.7'",
|
||||
"version": "==1.3.1"
|
||||
},
|
||||
"sortedcontainers": {
|
||||
"hashes": [
|
||||
"sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88",
|
||||
"sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0"
|
||||
],
|
||||
"version": "==2.4.0"
|
||||
},
|
||||
"tibs": {
|
||||
"hashes": [
|
||||
"sha256:01ea5258bdf942d21560dc07d532082cd04f07cfef65fedd58ae84f7d0d2562a",
|
||||
"sha256:0a7ce857ef05c59dc61abadc31c4b9b1e3c62f9e5fb29217988c308936aea71e",
|
||||
"sha256:130bc68ff500fc8185677df7a97350b5d5339e6ba7e325bc3031337f6424ede7",
|
||||
"sha256:173dfbecb2309edd9771f453580c88cf251e775613461566b23dbd756b3d54cb",
|
||||
"sha256:1906729038b85c3b4c040aa28a456d85bc976d0c5007177350eb73374ffa0fd0",
|
||||
"sha256:1b56583db148e5094d781c3d746815dbcbb6378c6f813c8ce291efd4ab21da8b",
|
||||
"sha256:1d5521cc6768bfa6282a0c591ba06b079ab91b5c7d5696925ad2abac59779a54",
|
||||
"sha256:1f95d5db62960205a1e9eba73ce67dc14e7366ae080cd4e5b6f005ebd90faf02",
|
||||
"sha256:29480bf03e3372a5f9cc59ea0541f76f8efd696d4f0d214715e94247c342a037",
|
||||
"sha256:2a618de62004d9217d2d2ab0f7f9bbdd098c12642dc01f07b3fb00f0b5f3131a",
|
||||
"sha256:42725200f1b02687ed6e6a1c01e0ec150dc829d21d901ffc74cc0ac4d821f57f",
|
||||
"sha256:477608f9b87e24a22ab6d50b81da04a5cb59bfa49598ff7ec5165035a18fb392",
|
||||
"sha256:4b7510235379368b7523f624d46e0680f3706e3a3965877a6583cdcb598b8bac",
|
||||
"sha256:501728d096e10d9a165aa526743d47418a6bbfd7b084fa47ecb22be7641d3edb",
|
||||
"sha256:63255749f937c5e6fedcc7d54e7bd359aef711017e6855f373b0510a14ee2215",
|
||||
"sha256:6a9feed5931b881809a950eca0e01e757113e2383a2af06a3e6982f110c869e2",
|
||||
"sha256:76746f01b3db9dbd802f5e615f11f68df7a29ecef521b082dca53f3fa7d0084f",
|
||||
"sha256:77103a9f1af72ac4cf5006828d0fb21578d19ce55fd990e9a1c8e46fd549561f",
|
||||
"sha256:7d6592ed93c6748acd39df484c1ee24d40ee247c2a20ca38ba03363506fd24f3",
|
||||
"sha256:847709c108800ad6a45efaf9a040628278956938a4897f7427a2587013dc3b98",
|
||||
"sha256:859f05315ffb307d3474c505d694f3a547f00730a024c982f5f60316a5505b3c",
|
||||
"sha256:a61d36155f8ab8642e1b6744e13822f72050fc7ec4f86ec6965295afa04949e2",
|
||||
"sha256:a883ca13a922a66b2c1326a9c188123a574741a72510a4bf52fd6f97db191e44",
|
||||
"sha256:ac0aa2aae38f7325c91c261ce1d18f769c4c7033c98d6ea3ea5534585cf16452",
|
||||
"sha256:ace018a057459e3dccd06a4aae1c5c8cd57e352b263dcef534ae39bf3e03b5cf",
|
||||
"sha256:ad61df93b50f875b277ab736c5d37b6bce56f9abce489a22f4e02d9daa2966e3",
|
||||
"sha256:b9535dc7b7484904a58b51bd8e64da7efbf1d8466ff7e84ed1d78f4ddc561c99",
|
||||
"sha256:d4f3ff613d486650816bc5516760c0382a2cc0ca8aeddd8914d011bc3b81d9a2",
|
||||
"sha256:e13b9c7ff2604b0146772025e1ac6f85c8c625bf6ac73736ff671eaf357dda41",
|
||||
"sha256:f5eea45851c960628a2bd29847765d55e19a687c5374456ad2c8cf6410eb1efa",
|
||||
"sha256:f70bd250769381c73110d6f24feaf8b6fcd44f680b3cb28a20ea06db3d04fb6f"
|
||||
],
|
||||
"markers": "python_version >= '3.8'",
|
||||
"version": "==0.5.7"
|
||||
},
|
||||
"trio": {
|
||||
"hashes": [
|
||||
"sha256:3bd5d87f781d9b0192d592aef28691f8951d6c2e41b7e1da4c25cde6c180ae9b",
|
||||
"sha256:a29b92b73f09d4b48ed249acd91073281a7f1063f09caba5dc70465b5c7aa970"
|
||||
],
|
||||
"markers": "python_version >= '3.10'",
|
||||
"version": "==0.33.0"
|
||||
},
|
||||
"trio-websocket": {
|
||||
"hashes": [
|
||||
"sha256:22c72c436f3d1e264d0910a3951934798dcc5b00ae56fc4ee079d46c7cf20fae",
|
||||
"sha256:df605665f1db533f4a386c94525870851096a223adcb97f72a07e8b4beba45b6"
|
||||
],
|
||||
"markers": "python_version >= '3.8'",
|
||||
"version": "==0.12.2"
|
||||
},
|
||||
"typing-extensions": {
|
||||
"hashes": [
|
||||
"sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466",
|
||||
"sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"
|
||||
],
|
||||
"markers": "python_version >= '3.9'",
|
||||
"version": "==4.15.0"
|
||||
},
|
||||
"urllib3": {
|
||||
"hashes": [
|
||||
"sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed",
|
||||
"sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4"
|
||||
],
|
||||
"markers": "python_version >= '3.9'",
|
||||
"version": "==2.6.3"
|
||||
},
|
||||
"watchfiles": {
|
||||
"hashes": [
|
||||
"sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c",
|
||||
"sha256:03fa0f5237118a0c5e496185cafa92878568b652a2e9a9382a5151b1a0380a43",
|
||||
"sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510",
|
||||
"sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0",
|
||||
"sha256:08af70fd77eee58549cd69c25055dc344f918d992ff626068242259f98d598a2",
|
||||
"sha256:0b495de0bb386df6a12b18335a0285dda90260f51bdb505503c02bcd1ce27a8b",
|
||||
"sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18",
|
||||
"sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219",
|
||||
"sha256:17ef139237dfced9da49fb7f2232c86ca9421f666d78c264c7ffca6601d154c3",
|
||||
"sha256:1a0bb430adb19ef49389e1ad368450193a90038b5b752f4ac089ec6942c4dff4",
|
||||
"sha256:1db5d7ae38ff20153d542460752ff397fcf5c96090c1230803713cf3147a6803",
|
||||
"sha256:28475ddbde92df1874b6c5c8aaeb24ad5be47a11f87cde5a28ef3835932e3e94",
|
||||
"sha256:2edc3553362b1c38d9f06242416a5d8e9fe235c204a4072e988ce2e5bb1f69f6",
|
||||
"sha256:30f7da3fb3f2844259cba4720c3fc7138eb0f7b659c38f3bfa65084c7fc7abce",
|
||||
"sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099",
|
||||
"sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae",
|
||||
"sha256:35c53bd62a0b885bf653ebf6b700d1bf05debb78ad9292cf2a942b23513dc4c4",
|
||||
"sha256:36193ed342f5b9842edd3532729a2ad55c4160ffcfa3700e0d54be496b70dd43",
|
||||
"sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd",
|
||||
"sha256:399600947b170270e80134ac854e21b3ccdefa11a9529a3decc1327088180f10",
|
||||
"sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374",
|
||||
"sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051",
|
||||
"sha256:3bc570d6c01c206c46deb6e935a260be44f186a2f05179f52f7fcd2be086a94d",
|
||||
"sha256:3dbd8cbadd46984f802f6d479b7e3afa86c42d13e8f0f322d669d79722c8ec34",
|
||||
"sha256:3e6f39af2eab0118338902798b5aa6664f46ff66bc0280de76fca67a7f262a49",
|
||||
"sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7",
|
||||
"sha256:3f6d37644155fb5beca5378feb8c1708d5783145f2a0f1c4d5a061a210254844",
|
||||
"sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77",
|
||||
"sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b",
|
||||
"sha256:421e29339983e1bebc281fab40d812742268ad057db4aee8c4d2bce0af43b741",
|
||||
"sha256:4b943d3668d61cfa528eb949577479d3b077fd25fb83c641235437bc0b5bc60e",
|
||||
"sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33",
|
||||
"sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42",
|
||||
"sha256:544364b2b51a9b0c7000a4b4b02f90e9423d97fbbf7e06689236443ebcad81ab",
|
||||
"sha256:5524298e3827105b61951a29c3512deb9578586abf3a7c5da4a8069df247cccc",
|
||||
"sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5",
|
||||
"sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da",
|
||||
"sha256:57ca5281a8b5e27593cb7d82c2ac927ad88a96ed406aa446f6344e4328208e9e",
|
||||
"sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05",
|
||||
"sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a",
|
||||
"sha256:5f3f58818dc0b07f7d9aa7fe9eb1037aecb9700e63e1f6acfed13e9fef648f5d",
|
||||
"sha256:5fac835b4ab3c6487b5dbad78c4b3724e26bcc468e886f8ba8cc4306f68f6701",
|
||||
"sha256:620bae625f4cb18427b1bb1a2d9426dc0dd5a5ba74c7c2cdb9de405f7b129863",
|
||||
"sha256:672b8adf25b1a0d35c96b5888b7b18699d27d4194bac8beeae75be4b7a3fc9b2",
|
||||
"sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101",
|
||||
"sha256:6c3631058c37e4a0ec440bf583bc53cdbd13e5661bb6f465bc1d88ee9a0a4d02",
|
||||
"sha256:6c9c9262f454d1c4d8aaa7050121eb4f3aea197360553699520767daebf2180b",
|
||||
"sha256:6e43d39a741e972bab5d8100b5cdacf69db64e34eb19b6e9af162bccf63c5cc6",
|
||||
"sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb",
|
||||
"sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620",
|
||||
"sha256:74472234c8370669850e1c312490f6026d132ca2d396abfad8830b4f1c096957",
|
||||
"sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6",
|
||||
"sha256:77a13aea58bc2b90173bc69f2a90de8e282648939a00a602e1dc4ee23e26b66d",
|
||||
"sha256:79ff6c6eadf2e3fc0d7786331362e6ef1e51125892c75f1004bd6b52155fb956",
|
||||
"sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef",
|
||||
"sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261",
|
||||
"sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02",
|
||||
"sha256:8526e8f916bb5b9a0a777c8317c23ce65de259422bba5b31325a6fa6029d33af",
|
||||
"sha256:859e43a1951717cc8de7f4c77674a6d389b106361585951d9e69572823f311d9",
|
||||
"sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21",
|
||||
"sha256:89eef07eee5e9d1fda06e38822ad167a044153457e6fd997f8a858ab7564a336",
|
||||
"sha256:8c89f9f2f740a6b7dcc753140dd5e1ab9215966f7a3530d0c0705c83b401bd7d",
|
||||
"sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c",
|
||||
"sha256:8ca65483439f9c791897f7db49202301deb6e15fe9f8fe2fed555bf986d10c31",
|
||||
"sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81",
|
||||
"sha256:91d4c9a823a8c987cce8fa2690923b069966dabb196dd8d137ea2cede885fde9",
|
||||
"sha256:9bb9f66367023ae783551042d31b1d7fd422e8289eedd91f26754a66f44d5cff",
|
||||
"sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2",
|
||||
"sha256:a36d8efe0f290835fd0f33da35042a1bb5dc0e83cbc092dcf69bce442579e88e",
|
||||
"sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc",
|
||||
"sha256:a625815d4a2bdca61953dbba5a39d60164451ef34c88d751f6c368c3ea73d404",
|
||||
"sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01",
|
||||
"sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18",
|
||||
"sha256:acb08650863767cbc58bca4813b92df4d6c648459dcaa3d4155681962b2aa2d3",
|
||||
"sha256:aebfd0861a83e6c3d1110b78ad54704486555246e542be3e2bb94195eabb2606",
|
||||
"sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04",
|
||||
"sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3",
|
||||
"sha256:b2cd9e04277e756a2e2d2543d65d1e2166d6fd4c9b183f8808634fda23f17b14",
|
||||
"sha256:b9c4702f29ca48e023ffd9b7ff6b822acdf47cb1ff44cb490a3f1d5ec8987e9c",
|
||||
"sha256:bbe1ef33d45bc71cf21364df962af171f96ecaeca06bd9e3d0b583efb12aec82",
|
||||
"sha256:bd404be08018c37350f0d6e34676bd1e2889990117a2b90070b3007f172d0610",
|
||||
"sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0",
|
||||
"sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150",
|
||||
"sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5",
|
||||
"sha256:c1f5210f1b8fc91ead1283c6fd89f70e76fb07283ec738056cf34d51e9c1d62c",
|
||||
"sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a",
|
||||
"sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b",
|
||||
"sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d",
|
||||
"sha256:c882d69f6903ef6092bedfb7be973d9319940d56b8427ab9187d1ecd73438a70",
|
||||
"sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70",
|
||||
"sha256:cdab464fee731e0884c35ae3588514a9bcf718d0e2c82169c1c4a85cc19c3c7f",
|
||||
"sha256:ce19e06cbda693e9e7686358af9cd6f5d61312ab8b00488bc36f5aabbaf77e24",
|
||||
"sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e",
|
||||
"sha256:cf57a27fb986c6243d2ee78392c503826056ffe0287e8794503b10fb51b881be",
|
||||
"sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5",
|
||||
"sha256:d6ff426a7cb54f310d51bfe83fe9f2bbe40d540c741dc974ebc30e6aa238f52e",
|
||||
"sha256:d7e7067c98040d646982daa1f37a33d3544138ea155536c2e0e63e07ff8a7e0f",
|
||||
"sha256:db476ab59b6765134de1d4fe96a1a9c96ddf091683599be0f26147ea1b2e4b88",
|
||||
"sha256:dcc5c24523771db3a294c77d94771abcfcb82a0e0ee8efd910c37c59ec1b31bb",
|
||||
"sha256:de6da501c883f58ad50db3a32ad397b09ad29865b5f26f64c24d3e3281685849",
|
||||
"sha256:e84087b432b6ac94778de547e08611266f1f8ffad28c0ee4c82e028b0fc5966d",
|
||||
"sha256:eef58232d32daf2ac67f42dea51a2c80f0d03379075d44a587051e63cc2e368c",
|
||||
"sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44",
|
||||
"sha256:f0ab1c1af0cb38e3f598244c17919fb1a84d1629cc08355b0074b6d7f53138ac",
|
||||
"sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428",
|
||||
"sha256:f537afb3276d12814082a2e9b242bdcf416c2e8fd9f799a737990a1dbe906e5b",
|
||||
"sha256:f57b396167a2565a4e8b5e56a5a1c537571733992b226f4f1197d79e94cf0ae5",
|
||||
"sha256:f8979280bdafff686ba5e4d8f97840f929a87ed9cdf133cbbd42f7766774d2aa",
|
||||
"sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==1.1.1"
|
||||
},
|
||||
"websocket-client": {
|
||||
"hashes": [
|
||||
"sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98",
|
||||
"sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef"
|
||||
],
|
||||
"markers": "python_version >= '3.9'",
|
||||
"version": "==1.9.0"
|
||||
},
|
||||
"wsproto": {
|
||||
"hashes": [
|
||||
"sha256:61eea322cdf56e8cc904bd3ad7573359a242ba65688716b0710a5eb12beab584",
|
||||
"sha256:b86885dcf294e15204919950f666e06ffc6c7c114ca900b060d6e16293528294"
|
||||
],
|
||||
"markers": "python_version >= '3.10'",
|
||||
"version": "==1.3.2"
|
||||
}
|
||||
},
|
||||
"develop": {
|
||||
"iniconfig": {
|
||||
"hashes": [
|
||||
"sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730",
|
||||
"sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"
|
||||
],
|
||||
"markers": "python_version >= '3.10'",
|
||||
"version": "==2.3.0"
|
||||
},
|
||||
"packaging": {
|
||||
"hashes": [
|
||||
"sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4",
|
||||
"sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529"
|
||||
],
|
||||
"markers": "python_version >= '3.8'",
|
||||
"version": "==26.0"
|
||||
},
|
||||
"pluggy": {
|
||||
"hashes": [
|
||||
"sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3",
|
||||
"sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"
|
||||
],
|
||||
"markers": "python_version >= '3.9'",
|
||||
"version": "==1.6.0"
|
||||
},
|
||||
"pygments": {
|
||||
"hashes": [
|
||||
"sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887",
|
||||
"sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"
|
||||
],
|
||||
"markers": "python_version >= '3.8'",
|
||||
"version": "==2.19.2"
|
||||
},
|
||||
"pytest": {
|
||||
"hashes": [
|
||||
"sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b",
|
||||
"sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==9.0.2"
|
||||
}
|
||||
}
|
||||
}
|
||||
35
README.md
@@ -1,2 +1,37 @@
|
||||
# led-controller
|
||||
|
||||
LED controller web app for managing profiles, tabs, presets, and colour palettes, and sending commands to LED devices over the serial -> ESP-NOW bridge.
|
||||
|
||||
## Run
|
||||
|
||||
- One-time setup for port 80 without root: `sudo scripts/setup-port80.sh`
|
||||
- Start app: `pipenv run run`
|
||||
- Dev watcher (auto-restart on `src/` changes): `pipenv run dev`
|
||||
- Regenerate **`docs/help.pdf`** from **`docs/help.md`**: `pipenv run help-pdf` (requires **pandoc** and **chromium** on the host)
|
||||
|
||||
## UI modes
|
||||
|
||||
- **Run mode**: focused control view. Select tabs/presets and apply profiles. Editing actions are hidden.
|
||||
- **Edit mode**: management view. Shows Tabs, Presets, Patterns, Colour Palette, and Send Presets controls, plus per-tile preset edit/remove and drag-reorder.
|
||||
|
||||
## Profiles
|
||||
|
||||
- Applying a profile updates session scope and refreshes the active tab content.
|
||||
- In **Run mode**, Profiles supports apply-only behavior (no create/clone/delete).
|
||||
- In **Edit mode**, Profiles supports create/clone/delete.
|
||||
- Creating a profile always creates a populated `default` tab (starter presets).
|
||||
- Optional **DJ tab** seeding creates:
|
||||
- `dj` tab bound to device name `dj`
|
||||
- starter DJ presets (rainbow, single colour, transition)
|
||||
|
||||
## Preset colours and palette linking
|
||||
|
||||
- In preset editor, selecting a colour picker value auto-adds it when the picker closes.
|
||||
- Use **From Palette** to add a palette-linked preset colour.
|
||||
- Linked colours are stored as palette references and shown with a `P` badge.
|
||||
- When profile palette colours change, linked preset colours update across that profile.
|
||||
|
||||
## API docs
|
||||
|
||||
- Main API reference: `docs/API.md`
|
||||
|
||||
|
||||
1
db/device.json
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
1
db/group.json
Normal file
@@ -0,0 +1 @@
|
||||
{"1": {"name": "Main Group", "devices": ["1", "2", "3"]}, "2": {"name": "Accent Group", "devices": ["4", "5"]}}
|
||||
1
db/palette.json
Normal file
@@ -0,0 +1 @@
|
||||
{"1": ["#FF0000", "#00FF00", "#0000FF", "#FFFF00", "#FF00FF", "#00FFFF", "#FFFFFF", "#000000"], "2": [], "3": [], "4": [], "5": [], "6": [], "7": ["#FF0000", "#00FF00", "#0000FF", "#FFFF00", "#FF00FF", "#00FFFF", "#FFFFFF", "#000000"], "8": [], "9": [], "10": [], "11": [], "12": ["#890b0b", "#0b8935"]}
|
||||
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
|
||||
}
|
||||
}
|
||||
1
db/preset.json
Normal file
@@ -0,0 +1 @@
|
||||
{"1": {"name": "on", "pattern": "on", "colors": ["#FFFFFF"], "brightness": 255, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "2": {"name": "off", "pattern": "off", "colors": [], "brightness": 0, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "3": {"name": "rainbow", "pattern": "rainbow", "colors": [], "brightness": 255, "delay": 100, "auto": true, "n1": 2, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "4": {"name": "transition", "pattern": "transition", "colors": ["#FF0000", "#00FF00", "#0000FF", "#FFFFFF", "#0000FF", "#FFFF00"], "brightness": 255, "delay": 5000, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null, 6, 2, 3]}, "5": {"name": "chase", "pattern": "chase", "colors": ["#FF0000", "#0000FF"], "brightness": 255, "delay": 200, "auto": true, "n1": 5, "n2": 5, "n3": 1, "n4": 1, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "6": {"name": "pulse", "pattern": "pulse", "colors": ["#00FF00"], "brightness": 255, "delay": 500, "auto": true, "n1": 1000, "n2": 500, "n3": 1000, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "7": {"name": "circle", "pattern": "circle", "colors": ["#FFA500", "#800080"], "brightness": 255, "delay": 200, "auto": true, "n1": 2, "n2": 10, "n3": 2, "n4": 5, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "8": {"name": "blink", "pattern": "blink", "colors": ["#FF0000", "#00FF00", "#0000FF", "#FFFF00"], "brightness": 255, "delay": 1000, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "9": {"name": "warm white", "pattern": "on", "colors": ["#FFF5E6"], "brightness": 200, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "10": {"name": "cool white", "pattern": "on", "colors": ["#E6F2FF"], "brightness": 200, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "11": {"name": "red", "pattern": "on", "colors": ["#FF0000"], "brightness": 255, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "12": {"name": "blue", "pattern": "on", "colors": ["#0000FF"], "brightness": 255, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "13": {"name": "rainbow slow", "pattern": "rainbow", "colors": [], "brightness": 255, "delay": 150, "auto": true, "n1": 1, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "14": {"name": "pulse slow", "pattern": "pulse", "colors": ["#FF6600"], "brightness": 255, "delay": 800, "auto": true, "n1": 2000, "n2": 1000, "n3": 2000, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "15": {"name": "blink red green", "pattern": "blink", "colors": ["#FF0000", "#00FF00"], "brightness": 255, "delay": 500, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "30": {"name": "rainbow slow", "pattern": "rainbow", "colors": [], "brightness": 255, "delay": 150, "auto": true, "n1": 1, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "2"}, "31": {"name": "DJ Rainbow", "pattern": "rainbow", "colors": [], "brightness": 220, "delay": 60, "n1": 12, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "2"}, "32": {"name": "DJ Single Color", "pattern": "on", "colors": ["#ff00ff"], "brightness": 220, "delay": 100, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "2"}, "33": {"name": "DJ Transition", "pattern": "transition", "colors": ["#ff0000", "#00ff00", "#0000ff"], "brightness": 220, "delay": 250, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "2"}, "34": {"name": "DJ Rainbow", "pattern": "rainbow", "colors": [], "brightness": 220, "delay": 60, "n1": 12, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "2"}, "35": {"name": "DJ Single Color", "pattern": "on", "colors": ["#ff00ff"], "brightness": 220, "delay": 100, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "2"}, "36": {"name": "DJ Transition", "pattern": "transition", "colors": ["#ff0000", "#00ff00", "#0000ff"], "brightness": 220, "delay": 250, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "2"}, "37": {"name": "tranistion2", "pattern": "transition", "colors": ["#FF0000", "#FFFF00", "#FF00FF"], "brightness": 128, "delay": 1000, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [0, 3, 4]}}
|
||||
1
db/profile.json
Normal file
@@ -0,0 +1 @@
|
||||
{"1": {"name": "default", "type": "tabs", "tabs": ["1", "8"], "scenes": [], "palette_id": "1"}, "2": {"name": "test", "type": "tabs", "tabs": ["6", "7"], "scenes": [], "palette_id": "12"}}
|
||||
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"
|
||||
]
|
||||
}
|
||||
}
|
||||
1
db/sequence.json
Normal file
@@ -0,0 +1 @@
|
||||
{"1": {"group_name": "Main Group", "presets": ["1", "2"], "sequence_duration": 3000, "sequence_transition": 500, "sequence_loop": true, "sequence_repeat_count": 0, "sequence_active": false, "sequence_index": 0, "sequence_start_time": 0}, "2": {"group_name": "Accent Group", "presets": ["2", "3"], "sequence_duration": 2000, "sequence_transition": 300, "sequence_loop": true, "sequence_repeat_count": 0, "sequence_active": false, "sequence_index": 0, "sequence_start_time": 0}}
|
||||
1
db/tab.json
Normal file
@@ -0,0 +1 @@
|
||||
{"1": {"name": "default", "names": ["1", "2", "3", "4", "5", "6", "7", "8", "0", "a"], "presets": [["4", "2", "7"], ["15", "3", "14"], ["5", "8", "10"], ["11", "9", "12"], ["1", "13", "37"]], "presets_flat": ["4", "2", "7", "15", "3", "14", "5", "8", "10", "11", "9", "12", "1", "13", "37"], "default_preset": "15"}, "2": {"name": "default", "names": ["1", "2", "3", "4", "5", "6", "7", "8", "0", "a"], "presets": [["16", "17", "18"], ["19", "20", "21"], ["22", "23", "24"], ["25", "26", "27"], ["28", "29", "30"]], "presets_flat": ["16", "17", "18", "19", "20", "21", "22", "23", "24", "25", "26", "27", "28", "29", "30"]}, "3": {"name": "default", "names": ["1"], "presets": [], "default_preset": null}, "4": {"name": "default", "names": ["1"], "presets": [], "default_preset": null}, "5": {"name": "dj", "names": ["dj"], "presets": [["31", "32", "33"]], "default_preset": "31", "presets_flat": ["31", "32", "33"]}, "6": {"name": "default", "names": ["1"], "presets": [], "default_preset": null}, "7": {"name": "dj", "names": ["dj"], "presets": [["34", "35", "36"]], "default_preset": "34", "presets_flat": ["34", "35", "36"]}, "8": {"name": "test", "names": ["11"], "presets": [["1", "2", "3"], ["4", "5"]], "default_preset": "1", "presets_flat": ["1", "2", "3", "4", "5"]}}
|
||||
50
dev.py
@@ -6,28 +6,48 @@ import sys
|
||||
|
||||
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:]:
|
||||
print(cmd)
|
||||
match cmd:
|
||||
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":
|
||||
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":
|
||||
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":
|
||||
with serial.Serial(port, baudrate=115200) as ser:
|
||||
ser.write(b'\x03\x03\x04')
|
||||
if port:
|
||||
with serial.Serial(port, baudrate=115200) as ser:
|
||||
ser.write(b'\x03\x03\x04')
|
||||
else:
|
||||
print("Error: Port required for 'reset' command")
|
||||
case "follow":
|
||||
with serial.Serial(port, baudrate=115200) as ser:
|
||||
while True:
|
||||
if ser.in_waiting > 0: # Check if there is data in the buffer
|
||||
data = ser.readline().decode('utf-8').strip() # Read and decode the data
|
||||
print(data)
|
||||
|
||||
|
||||
|
||||
if port:
|
||||
with serial.Serial(port, baudrate=115200) as ser:
|
||||
while True:
|
||||
if ser.in_waiting > 0: # Check if there is data in the buffer
|
||||
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")
|
||||
|
||||
318
docs/API.md
Normal file
@@ -0,0 +1,318 @@
|
||||
# LED Controller API
|
||||
|
||||
This document covers:
|
||||
|
||||
1. **HTTP and WebSocket** exposed by the Raspberry Pi app (`src/main.py`) — profiles, presets, transport send, and related resources.
|
||||
2. **LED driver JSON** — the compact message format sent over the serial→ESP-NOW bridge to devices (same logical API as ESP-NOW payloads).
|
||||
|
||||
Default listen address: `0.0.0.0`. Port defaults to **80**; override with the `PORT` environment variable (see `pipenv run run`).
|
||||
|
||||
All JSON APIs use `Content-Type: application/json` for bodies and responses unless noted.
|
||||
|
||||
---
|
||||
|
||||
## UI behavior notes
|
||||
|
||||
The main UI has two modes controlled by the mode toggle:
|
||||
|
||||
- **Run mode**: optimized for operation (tab/preset selection and profile apply).
|
||||
- **Edit mode**: shows editing/management controls (tabs, presets, patterns, colour palette, send presets, and profile management actions).
|
||||
|
||||
Profiles are available in both modes, but behavior differs:
|
||||
|
||||
- **Run mode**: profile **apply** only.
|
||||
- **Edit mode**: profile **create/clone/delete/apply**.
|
||||
|
||||
`POST /presets/send` is wired to the **Send Presets** UI action, which is exposed in Edit mode.
|
||||
|
||||
---
|
||||
|
||||
## Session and scoping
|
||||
|
||||
Several routes use **`@with_session`**: the server stores a **current profile** in the session (cookie). Endpoints that scope data to “the current profile” (notably **`/presets`**) only return or mutate presets whose `profile_id` matches that session value.
|
||||
|
||||
Profiles are selected with **`POST /profiles/<id>/apply`**, which sets `current_profile` in the session.
|
||||
|
||||
---
|
||||
|
||||
## Static pages and assets
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/` | Main UI (`templates/index.html`) |
|
||||
| GET | `/settings` | Settings page (`templates/settings.html`) |
|
||||
| GET | `/favicon.ico` | Empty response (204) |
|
||||
| GET | `/static/<path>` | Static files under `src/static/` |
|
||||
|
||||
---
|
||||
|
||||
## WebSocket: `/ws`
|
||||
|
||||
Connect to **`ws://<host>:<port>/ws`**.
|
||||
|
||||
- Send **JSON**: the object is forwarded to the transport (serial bridge → ESP-NOW) as JSON. Optional key **`to`**: 12-character hex MAC address; if present it is removed from the object and the payload is sent to that peer; otherwise the default destination is used.
|
||||
- Send **non-JSON text**: forwarded as raw bytes with the default address.
|
||||
- On send failure, the server may reply with `{"error": "Send failed"}`.
|
||||
|
||||
---
|
||||
|
||||
## HTTP API by resource
|
||||
|
||||
Below, `<id>` values are string identifiers used by the JSON stores (numeric strings in practice).
|
||||
|
||||
### Settings — `/settings`
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/settings` | Full settings object (from `settings.json` / `Settings` model). |
|
||||
| PUT | `/settings/settings` | Merge keys into settings and save. Returns `{"message": "Settings updated successfully"}`. |
|
||||
| GET | `/settings/wifi/ap` | Saved Wi‑Fi AP fields: `saved_ssid`, `saved_password`, `saved_channel`, `active` (Pi: `active` is always false). |
|
||||
| POST | `/settings/wifi/ap` | Body: `ssid` (required), `password`, `channel` (1–11). Persists AP-related settings. |
|
||||
| GET | `/settings/page` | Serves `templates/settings.html` (same page as `GET /settings` from the root app, for convenience). |
|
||||
|
||||
### Profiles — `/profiles`
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/profiles` | `{"profiles": {...}, "current_profile_id": "<id>"}`. Ensures a default current profile when possible. |
|
||||
| GET | `/profiles/current` | `{"id": "...", "profile": {...}}` |
|
||||
| GET | `/profiles/<id>` | Single profile. If `<id>` is `current`, same as `/profiles/current`. |
|
||||
| POST | `/profiles` | Create profile. Body may include `name` and other fields. Optional `seed_dj_tab` (request-only) seeds a DJ tab + presets. New profiles always get a populated `default` tab. Returns `{ "<id>": { ... } }` with status 201. |
|
||||
| POST | `/profiles/<id>/apply` | Sets session current profile to `<id>`. |
|
||||
| POST | `/profiles/<id>/clone` | Clone profile (tabs, palettes, presets). Body may include `name`. |
|
||||
| PUT | `/profiles/current` | Update the current profile (from session). |
|
||||
| PUT | `/profiles/<id>` | Update profile by id. |
|
||||
| DELETE | `/profiles/<id>` | Delete profile. |
|
||||
|
||||
### Presets — `/presets`
|
||||
|
||||
Scoped to **current profile** in session (see above).
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/presets` | Map of preset id → preset object for the current profile only. |
|
||||
| GET | `/presets/<id>` | One preset, 404 if missing or wrong profile. |
|
||||
| POST | `/presets` | Create preset; server assigns id and sets `profile_id`. Body fields stored on the preset. Returns `{ "<id>": { ... } }`, 201. |
|
||||
| PUT | `/presets/<id>` | Update preset (must belong to current profile). |
|
||||
| DELETE | `/presets/<id>` | Delete preset. |
|
||||
| POST | `/presets/send` | Push presets to the LED driver over the configured transport (see below). |
|
||||
|
||||
**`POST /presets/send` body:**
|
||||
|
||||
```json
|
||||
{
|
||||
"preset_ids": ["1", "2"],
|
||||
"save": true,
|
||||
"default": "1",
|
||||
"destination_mac": "aabbccddeeff"
|
||||
}
|
||||
```
|
||||
|
||||
- **`preset_ids`** (or **`ids`**): non-empty list of preset ids to include.
|
||||
- **`save`**: if true, the outgoing message includes `"save": true` so the driver may persist presets (default true).
|
||||
- **`default`**: optional preset id string; forwarded as top-level `"default"` in the driver message (startup selection on device).
|
||||
- **`destination_mac`** (or **`to`**): optional 12-character hex MAC for unicast; omitted uses the transport default (e.g. broadcast).
|
||||
|
||||
Response on success includes `presets_sent`, `messages_sent` (chunking splits payloads so each JSON string stays ≤ 240 bytes).
|
||||
|
||||
Stored preset records can include:
|
||||
|
||||
- `colors`: resolved hex colours for editor/display.
|
||||
- `palette_refs`: optional array of palette indexes parallel to `colors`. If a slot contains an integer index, the colour is linked to the current profile palette at that index.
|
||||
|
||||
### Tabs — `/tabs`
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/tabs` | `tabs`, `tab_order`, `current_tab_id`, `profile_id` for the session-backed profile. |
|
||||
| GET | `/tabs/current` | Current tab from cookie/session. |
|
||||
| POST | `/tabs` | Create tab; optional JSON `name`, `names`, `presets`; can append to current profile’s tab list. |
|
||||
| GET | `/tabs/<id>` | Tab JSON. |
|
||||
| PUT | `/tabs/<id>` | Update tab. |
|
||||
| DELETE | `/tabs/<id>` | Delete tab; can delete `current` to remove the active tab; updates profile tab list. |
|
||||
| POST | `/tabs/<id>/set-current` | Sets `current_tab` cookie. |
|
||||
| POST | `/tabs/<id>/clone` | Clone tab into current profile. |
|
||||
|
||||
### Palettes — `/palettes`
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/palettes` | Map of id → colour list. |
|
||||
| GET | `/palettes/<id>` | `{"colors": [...], "id": "<id>"}` |
|
||||
| POST | `/palettes` | Body may include `colors`. Returns palette object with `id`, 201. |
|
||||
| PUT | `/palettes/<id>` | Update colours (`name` ignored). |
|
||||
| DELETE | `/palettes/<id>` | Delete palette. |
|
||||
|
||||
### Groups — `/groups`
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/groups` | All groups. |
|
||||
| GET | `/groups/<id>` | One group. |
|
||||
| POST | `/groups` | Create; optional `name` and fields. |
|
||||
| PUT | `/groups/<id>` | Update. |
|
||||
| DELETE | `/groups/<id>` | Delete. |
|
||||
|
||||
### Scenes — `/scenes`
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/scenes` | All scenes. |
|
||||
| GET | `/scenes/<id>` | One scene. |
|
||||
| POST | `/scenes` | Create (body JSON stored on scene). |
|
||||
| PUT | `/scenes/<id>` | Update. |
|
||||
| DELETE | `/scenes/<id>` | Delete. |
|
||||
|
||||
### Sequences — `/sequences`
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/sequences` | All sequences. |
|
||||
| GET | `/sequences/<id>` | One sequence. |
|
||||
| POST | `/sequences` | Create; may use `group_name`, `presets` in body. |
|
||||
| PUT | `/sequences/<id>` | Update. |
|
||||
| DELETE | `/sequences/<id>` | Delete. |
|
||||
|
||||
### Patterns — `/patterns`
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/patterns/definitions` | Contents of `pattern.json` (pattern metadata for the UI). |
|
||||
| GET | `/patterns` | All pattern records. |
|
||||
| GET | `/patterns/<id>` | One pattern. |
|
||||
| POST | `/patterns` | Create (`name`, optional `data`). |
|
||||
| PUT | `/patterns/<id>` | Update. |
|
||||
| DELETE | `/patterns/<id>` | Delete. |
|
||||
|
||||
---
|
||||
|
||||
## LED driver message format (transport / ESP-NOW)
|
||||
|
||||
Messages are JSON objects. The Pi **`build_message()`** helper (`src/util/espnow_message.py`) produces the same shape sent over serial and forwarded by the ESP32 bridge.
|
||||
|
||||
### Top-level fields
|
||||
|
||||
```json
|
||||
{
|
||||
"v": "1",
|
||||
"presets": { },
|
||||
"select": { },
|
||||
"save": true,
|
||||
"default": "preset_id",
|
||||
"b": 255
|
||||
}
|
||||
```
|
||||
|
||||
- **`v`** (required): Must be `"1"` or the driver ignores the message.
|
||||
- **`presets`**: Map of **preset id** (string) → preset object (see below). Optional **`name`** field on each value is accepted for display; the driver keys presets by map key.
|
||||
- **`select`**: Map of **device name** (as in device settings) → `[ "preset_id" ]` or `[ "preset_id", step ]`.
|
||||
- **`save`**: If present (e.g. true), the driver may persist presets to flash after applying.
|
||||
- **`default`**: Preset id string to use as startup default on the device.
|
||||
- **`b`**: Optional **global** brightness 0–255 (driver applies this in addition to per-preset brightness).
|
||||
|
||||
### Preset object (wire / driver keys)
|
||||
|
||||
On the wire, presets use **short keys** (saves space in the ≤240-byte chunks):
|
||||
|
||||
| Key | Meaning | Notes |
|
||||
|-----|---------|--------|
|
||||
| `p` | Pattern id | `off`, `on`, `blink`, `rainbow`, `pulse`, `transition`, `chase`, `circle` |
|
||||
| `c` | Colours | Array of `"#RRGGBB"` hex strings; converted to RGB on device |
|
||||
| `d` | Delay ms | Default 100 |
|
||||
| `b` | Preset brightness | 0–255; combined with global `b` on the device |
|
||||
| `a` | Auto | `true`: run continuously; `false`: one step/cycle per “beat” |
|
||||
| `n1`–`n6` | Pattern parameters | See below |
|
||||
|
||||
The HTTP app’s **`POST /presets/send`** path builds this from stored presets via **`build_preset_dict()`** (long names like `pattern` / `colors` in the DB are translated to `p` / `c` / …).
|
||||
|
||||
### Pattern-specific parameters (`n1`–`n6`)
|
||||
|
||||
#### Rainbow
|
||||
- **`n1`**: Step increment on the colour wheel per update (default 1).
|
||||
|
||||
#### Pulse
|
||||
- **`n1`**: Attack (fade in) ms
|
||||
- **`n2`**: Hold ms
|
||||
- **`n3`**: Decay (fade out) ms
|
||||
- **`d`**: Off time between pulses ms
|
||||
|
||||
#### Transition
|
||||
- **`d`**: Transition duration ms
|
||||
|
||||
#### Chase
|
||||
- **`n1`**: LEDs with first colour
|
||||
- **`n2`**: LEDs with second colour
|
||||
- **`n3`**: Movement on even steps (may be negative)
|
||||
- **`n4`**: Movement on odd steps (may be negative)
|
||||
|
||||
#### Circle
|
||||
- **`n1`**: Head speed (LEDs/s)
|
||||
- **`n2`**: Max length
|
||||
- **`n3`**: Tail speed (LEDs/s)
|
||||
- **`n4`**: Min length
|
||||
|
||||
### Select messages
|
||||
|
||||
```json
|
||||
{
|
||||
"select": {
|
||||
"device_name": ["preset_id"],
|
||||
"other_device": ["preset_id", 10]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- One element: select preset; step behavior follows driver rules (reset on `off`, etc.).
|
||||
- Two elements: explicit **step** for sync.
|
||||
|
||||
### Beat and sync behavior
|
||||
|
||||
- Sending **`select`** again with the **same** preset name acts as a **beat** (advances manual patterns / restarts generators per driver logic).
|
||||
- Choosing **`off`** resets step as a sync point; then selecting a pattern aligns step 0 across devices unless a step is passed explicitly.
|
||||
|
||||
### Example (compact preset map)
|
||||
|
||||
```json
|
||||
{
|
||||
"v": "1",
|
||||
"save": true,
|
||||
"presets": {
|
||||
"1": {
|
||||
"name": "Red blink",
|
||||
"p": "blink",
|
||||
"c": ["#FF0000"],
|
||||
"d": 200,
|
||||
"b": 255,
|
||||
"a": true,
|
||||
"n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0
|
||||
}
|
||||
},
|
||||
"select": {
|
||||
"living-room": ["1"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Processing summary (driver)
|
||||
|
||||
1. Reject if `v != "1"`.
|
||||
2. Apply optional top-level **`b`** (global brightness).
|
||||
3. For each entry in **`presets`**, normalize colours and upsert preset by id.
|
||||
4. If this device’s **`name`** appears in **`select`**, run selection (optional step).
|
||||
5. If **`default`** is set, store startup preset id.
|
||||
6. If **`save`** is set, persist presets.
|
||||
|
||||
---
|
||||
|
||||
## Error handling (HTTP)
|
||||
|
||||
Controllers typically return JSON with an **`error`** string and 4xx/5xx status codes. Invalid JSON bodies often yield `{"error": "Invalid JSON"}`.
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- **Human-readable preset fields** (`pattern`, `colors`, `delay`, …) are fine in the **web app / database**; the **send path** converts them to **`p` / `c` / `d`** for the driver.
|
||||
- For a copy of the older long-key reference, see **`led-driver/docs/API.md`** in this repo (conceptually the same behavior; wire format prefers short keys).
|
||||
1846
docs/SPECIFICATION.md
Normal file
112
docs/help.md
Normal file
@@ -0,0 +1,112 @@
|
||||
# LED controller — user guide
|
||||
|
||||
This page describes the **main web UI** served from the Raspberry Pi app: profiles, tabs, presets, colour palettes, and sending commands to LED devices over the serial → ESP-NOW bridge.
|
||||
|
||||
For HTTP routes and the wire format the driver expects, see **[API.md](API.md)**. For running the app locally, see the project **README**.
|
||||
|
||||
Figures below are **schematic** (layout and ideas), not pixel-perfect screenshots.
|
||||
|
||||
---
|
||||
|
||||
## Run mode and Edit mode
|
||||
|
||||
The header has a mode toggle (desktop and mobile menu). The **label on the button is the mode you switch to** when you press it.
|
||||
|
||||

|
||||
|
||||
*The active tab is highlighted. Extra management buttons appear only in Edit mode.*
|
||||
|
||||
| Mode | Purpose |
|
||||
|------|--------|
|
||||
| **Run mode** | Day-to-day control: choose a tab, tap presets, apply profiles. Management buttons are hidden. |
|
||||
| **Edit mode** | Full setup: tabs, presets, patterns, colour palette, **Send Presets**, profile create/clone/delete, preset reordering, and per-tile **Edit** on the strip. |
|
||||
|
||||
**Profiles** is available in both modes: in Run mode you can only **apply** a profile; in Edit mode you can also **create**, **clone**, and **delete** profiles.
|
||||
|
||||
---
|
||||
|
||||
## Tabs
|
||||
|
||||
- **Select a tab**: click its button in the top bar. The main area shows that tab’s preset strip and controls.
|
||||
- **Edit mode — open tab settings**: **right-click** a tab button to change its name, **device IDs** (comma-separated), and which presets appear on the tab. Device identifiers are matched to each device’s **name** when the app builds `select` messages for the driver.
|
||||
- **Tabs modal** (Edit mode): create new tabs from the header **Tabs** button. New tabs need a name and device ID list (defaults to `1` if you leave a simple placeholder).
|
||||
- **Brightness slider** (per tab): adjusts **global** brightness sent to devices (`b` in the driver message), with a short debounce so small drags do not flood the link.
|
||||
|
||||
---
|
||||
|
||||
## Presets on the tab strip
|
||||
|
||||
- **Run and Edit mode**: click the **main part** of a preset tile to **select** that preset on all devices assigned to the current tab (same logical action as a `select` in the driver API).
|
||||
- **Edit mode only**:
|
||||
- **Edit** beside a tile opens the preset editor for that preset, scoped to the current tab (so you can **Remove from tab** without deleting the preset from the profile).
|
||||
- **Drag and drop** tiles to reorder them; order is saved for that tab.
|
||||
|
||||

|
||||
|
||||
*The slider controls global brightness for the tab’s devices. Click the coloured area of a tile to select that preset.*
|
||||
|
||||
The **Presets** header button (Edit mode) opens a **profile-wide** list: **Add** new presets, **Edit**, **Send** (push definition over the transport), and **Delete** (removes the preset from the profile entirely).
|
||||
|
||||
---
|
||||
|
||||
## Preset editor
|
||||
|
||||
- **Pattern**: chosen from the dropdown; optional **n1–n8** fields depend on the pattern (see **Pattern-specific parameters** in [API.md](API.md)).
|
||||
- **Colours**: choosing a value in the colour picker **adds** a swatch when the picker closes. Swatches can be **reordered** by dragging. Changing a swatch with the picker **clears** palette linkage for that slot.
|
||||
- **From Palette**: inserts a colour **linked** to the current profile’s palette. Linked slots show a **P** badge; if you change that palette entry later, presets using it update.
|
||||
- **Brightness (0–255)** and **Delay (ms)**: stored on the preset and sent with the compact preset payload.
|
||||
- **Try**: sends the current form values to devices on the **current tab**, then selects that preset — **without** `save` on the device (good for auditioning).
|
||||
- **Default**: updates the tab’s **default preset** and sends a **default** hint for those devices; it does not force the same live selection behaviour as clicking a tile.
|
||||
- **Save & Send**: writes the preset to the server, then pushes definitions with **save** so devices may persist them. It does **not** auto-select the preset on devices (use the strip or **Try** if you want that).
|
||||
- **Remove from tab** (when you opened the editor from a tab): removes the preset from **this tab’s list only**; the preset remains in the profile for other tabs.
|
||||
|
||||

|
||||
|
||||
*Try previews without persisting on the device; **Save & Send** stores the preset and pushes definitions with save.*
|
||||
|
||||
---
|
||||
|
||||
## Profiles
|
||||
|
||||
- **Apply**: sets the **current profile** in your session. Tabs and presets you see are scoped to that profile.
|
||||
- **Edit mode — Create**: new profiles always get a populated **default** tab. Optionally tick **DJ tab** to also create a `dj` tab (device name `dj`) with starter DJ-oriented presets.
|
||||
- **Clone** / **Delete**: available in Edit mode from the profile list.
|
||||
|
||||
---
|
||||
|
||||
## Send Presets (Edit mode)
|
||||
|
||||
**Send Presets** walks **every tab** in the **current profile**, collects each tab’s preset IDs, and calls **`POST /presets/send`** per tab (including each tab’s **default** preset when set). Use this to bulk-push definitions to hardware after editing, without clicking **Send** on every preset individually.
|
||||
|
||||
---
|
||||
|
||||
## Patterns
|
||||
|
||||
The **Patterns** dialog (Edit mode) is a **read-only reference**: pattern names and typical **delay** ranges from the pattern definitions. It does not change device behaviour by itself; patterns are chosen inside the preset editor.
|
||||
|
||||
---
|
||||
|
||||
## Colour palette
|
||||
|
||||
**Colour Palette** (Edit mode) edits the **current profile’s** palette swatches. Those colours are reused by **From Palette** in the preset editor and stay in sync while the **P** link remains.
|
||||
|
||||

|
||||
|
||||
*Add or change swatches here; linked preset colours update automatically.*
|
||||
|
||||
---
|
||||
|
||||
## Mobile layout
|
||||
|
||||
On narrow screens, use **Menu** to reach the same actions as the desktop header (Profiles, Tabs, Presets, Help, mode toggle, etc.).
|
||||
|
||||

|
||||
|
||||
*Preset tiles behave the same once a tab is selected.*
|
||||
|
||||
---
|
||||
|
||||
## Further reading
|
||||
|
||||
- **[API.md](API.md)** — REST routes, session scoping, WebSocket `/ws`, and LED driver JSON (`presets`, `select`, `save`, `default`, pattern keys).
|
||||
- **README** — `pipenv run run`, port 80 setup, and high-level behaviour.
|
||||
BIN
docs/help.pdf
Normal file
14
docs/images/help/colour-palette.svg
Normal file
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 480 140" width="480" height="140">
|
||||
<title>Colour Palette modal (concept)</title>
|
||||
<rect width="480" height="140" fill="#2a2a2a" stroke="#555" stroke-width="1" rx="6"/>
|
||||
<text x="20" y="28" fill="#fff" font-family="sans-serif" font-size="15" font-weight="600">Colour Palette</text>
|
||||
<text x="20" y="48" fill="#888" font-family="sans-serif" font-size="10">Profile: current profile name</text>
|
||||
<rect x="20" y="58" width="44" height="44" rx="4" fill="#e53935" stroke="#333"/>
|
||||
<rect x="72" y="58" width="44" height="44" rx="4" fill="#fdd835" stroke="#333"/>
|
||||
<rect x="124" y="58" width="44" height="44" rx="4" fill="#43a047" stroke="#333"/>
|
||||
<rect x="176" y="58" width="44" height="44" rx="4" fill="#1e88e5" stroke="#333"/>
|
||||
<rect x="228" y="58" width="44" height="44" rx="4" fill="#8e24aa" stroke="#333"/>
|
||||
<rect x="280" y="70" width="36" height="28" rx="3" fill="#1a1a1a" stroke="#666"/>
|
||||
<text x="288" y="88" fill="#ccc" font-family="sans-serif" font-size="10">+</text>
|
||||
<text x="20" y="122" fill="#aaa" font-family="sans-serif" font-size="10">Swatches belong to the profile; preset editor uses them via From Palette.</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
24
docs/images/help/header-toolbar.svg
Normal file
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 820 108" width="820" height="108">
|
||||
<title>Header: tab buttons and action bar</title>
|
||||
<rect width="820" height="108" fill="#1a1a1a"/>
|
||||
<rect x="0" y="106" width="820" height="2" fill="#4a4a4a"/>
|
||||
<text x="16" y="28" fill="#888" font-family="sans-serif" font-size="11">Tabs</text>
|
||||
<rect x="16" y="40" width="72" height="36" rx="4" fill="#333" stroke="#666" stroke-width="1"/>
|
||||
<text x="34" y="63" fill="#ccc" font-family="sans-serif" font-size="13">default</text>
|
||||
<rect x="96" y="40" width="88" height="36" rx="4" fill="#2a4a7a" stroke="#5a8fd4" stroke-width="2"/>
|
||||
<text x="108" y="63" fill="#fff" font-family="sans-serif" font-size="13">lounge</text>
|
||||
<rect x="192" y="40" width="56" height="36" rx="4" fill="#333" stroke="#666" stroke-width="1"/>
|
||||
<text x="204" y="63" fill="#ccc" font-family="sans-serif" font-size="13">dj</text>
|
||||
<text x="380" y="28" fill="#888" font-family="sans-serif" font-size="11">Actions (Edit mode)</text>
|
||||
<rect x="380" y="40" width="72" height="30" rx="3" fill="#3d3d3d" stroke="#555" stroke-width="1"/>
|
||||
<text x="396" y="59" fill="#e0e0e0" font-family="sans-serif" font-size="11">Profiles</text>
|
||||
<rect x="458" y="40" width="52" height="30" rx="3" fill="#3d3d3d" stroke="#555" stroke-width="1"/>
|
||||
<text x="470" y="59" fill="#e0e0e0" font-family="sans-serif" font-size="11">Tabs</text>
|
||||
<rect x="516" y="40" width="64" height="30" rx="3" fill="#3d3d3d" stroke="#555" stroke-width="1"/>
|
||||
<text x="524" y="59" fill="#e0e0e0" font-family="sans-serif" font-size="11">Presets</text>
|
||||
<rect x="586" y="40" width="78" height="30" rx="3" fill="#3d3d3d" stroke="#555" stroke-width="1"/>
|
||||
<text x="598" y="59" fill="#e0e0e0" font-family="sans-serif" font-size="11">Patterns</text>
|
||||
<rect x="670" y="40" width="134" height="30" rx="3" fill="#4a4a6a" stroke="#7a7aaf" stroke-width="1"/>
|
||||
<text x="688" y="59" fill="#e0e0e0" font-family="sans-serif" font-size="11">Run mode</text>
|
||||
<text x="16" y="98" fill="#aaa" font-family="sans-serif" font-size="10">Active tab highlighted. Mode button shows the mode you switch to next.</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
26
docs/images/help/mobile-menu.svg
Normal file
@@ -0,0 +1,26 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 340" width="300" height="340" role="img" aria-labelledby="t">
|
||||
<title id="t">Narrow screen: Menu aggregates header actions</title>
|
||||
<rect width="300" height="340" fill="#2e2e2e"/>
|
||||
<rect x="0" y="0" width="300" height="52" fill="#1a1a1a"/>
|
||||
<rect x="12" y="12" width="56" height="28" rx="4" fill="#3d3d3d" stroke="#555" stroke-width="1"/>
|
||||
<text x="22" y="30" fill="#eee" font-family="DejaVu Sans, Liberation Sans, Arial, sans-serif" font-size="12">Menu <20></text>
|
||||
<rect x="76" y="14" width="52" height="24" rx="4" fill="#333" stroke="#666" stroke-width="1"/>
|
||||
<text x="86" y="30" fill="#ccc" font-family="DejaVu Sans, Liberation Sans, Arial, sans-serif" font-size="11">tab</text>
|
||||
<rect x="136" y="14" width="52" height="24" rx="4" fill="#2a4a7a" stroke="#5a8fd4" stroke-width="1"/>
|
||||
<text x="142" y="30" fill="#fff" font-family="DejaVu Sans, Liberation Sans, Arial, sans-serif" font-size="11">tab</text>
|
||||
<rect x="12" y="60" width="276" height="168" rx="4" fill="#252525" stroke="#4a4a4a" stroke-width="1"/>
|
||||
<text x="24" y="84" fill="#888" font-family="DejaVu Sans, Liberation Sans, Arial, sans-serif" font-size="10">Dropdown (same actions as desktop header)</text>
|
||||
<g font-family="DejaVu Sans, Liberation Sans, Arial, sans-serif" font-size="12" fill="#e8e8e8">
|
||||
<text x="24" y="108">Run mode</text>
|
||||
<text x="24" y="132">Profiles</text>
|
||||
<text x="24" y="156">Tabs</text>
|
||||
<text x="24" y="180">Presets</text>
|
||||
<text x="24" y="204">Help</text>
|
||||
</g>
|
||||
<rect x="12" y="240" width="276" height="80" rx="6" fill="#222" stroke="#444" stroke-width="1"/>
|
||||
<text x="24" y="268" fill="#aaa" font-family="DejaVu Sans, Liberation Sans, Arial, sans-serif" font-size="10">Content area presets as on desktop</text>
|
||||
<rect x="24" y="278" width="80" height="32" rx="4" fill="#333" stroke="#555"/>
|
||||
<text x="36" y="298" fill="#ddd" font-family="DejaVu Sans, Liberation Sans, Arial, sans-serif" font-size="11">preset</text>
|
||||
<rect x="112" y="278" width="80" height="32" rx="4" fill="#333" stroke="#555"/>
|
||||
<text x="124" y="298" fill="#ddd" font-family="DejaVu Sans, Liberation Sans, Arial, sans-serif" font-size="11">preset</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
31
docs/images/help/preset-editor.svg
Normal file
@@ -0,0 +1,31 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 520 400" width="520" height="400">
|
||||
<title>Preset editor modal (simplified)</title>
|
||||
<rect width="520" height="400" fill="#1e1e1e"/>
|
||||
<rect x="40" y="28" width="440" height="344" rx="8" fill="#2a2a2a" stroke="#555" stroke-width="1"/>
|
||||
<text x="60" y="58" fill="#fff" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="17" font-weight="600">Preset</text>
|
||||
<text x="60" y="86" fill="#aaa" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="11">Name</text>
|
||||
<rect x="60" y="92" width="200" height="28" rx="3" fill="#1a1a1a" stroke="#444" stroke-width="1"/>
|
||||
<text x="72" y="111" fill="#ddd" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="12">evening glow</text>
|
||||
<text x="280" y="86" fill="#aaa" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="11">Pattern</text>
|
||||
<rect x="280" y="92" width="160" height="28" rx="3" fill="#1a1a1a" stroke="#444" stroke-width="1"/>
|
||||
<text x="292" y="111" fill="#ddd" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="12">pulse</text>
|
||||
<text x="60" y="148" fill="#aaa" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="11">Colours</text>
|
||||
<rect x="60" y="156" width="48" height="48" rx="4" fill="#7e57c2" stroke="#333" stroke-width="1"/>
|
||||
<circle cx="66" cy="162" r="8" fill="#3f51b5" stroke="#fff" stroke-width="1"/>
|
||||
<text x="63" y="166" fill="#fff" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="7" font-weight="700">P</text>
|
||||
<rect x="116" y="156" width="48" height="48" rx="4" fill="#26a69a" stroke="#333" stroke-width="1"/>
|
||||
<text x="176" y="184" fill="#888" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="10">P = palette-linked</text>
|
||||
<text x="60" y="232" fill="#aaa" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="11">Brightness, delay, n1-n8</text>
|
||||
<rect x="60" y="238" width="120" height="24" rx="3" fill="#1a1a1a" stroke="#444" stroke-width="1"/>
|
||||
<text x="68" y="254" fill="#888" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="11">0-255</text>
|
||||
<text x="60" y="290" fill="#aaa" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="10">Actions</text>
|
||||
<rect x="60" y="298" width="44" height="26" rx="3" fill="#444" stroke="#666" stroke-width="1"/>
|
||||
<text x="72" y="315" fill="#eee" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="11">Try</text>
|
||||
<rect x="112" y="298" width="56" height="26" rx="3" fill="#444" stroke="#666" stroke-width="1"/>
|
||||
<text x="120" y="315" fill="#eee" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="11">Default</text>
|
||||
<rect x="176" y="298" width="88" height="26" rx="3" fill="#3d5a80" stroke="#5a7ab8" stroke-width="1"/>
|
||||
<text x="188" y="315" fill="#fff" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="11">Save+Send</text>
|
||||
<rect x="272" y="298" width="48" height="26" rx="3" fill="#444" stroke="#666" stroke-width="1"/>
|
||||
<text x="284" y="315" fill="#eee" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="11">Close</text>
|
||||
<text x="60" y="352" fill="#888" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="9">Try: preview without device save. Save+Send: store and push with save.</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.3 KiB |
35
docs/images/help/tab-preset-strip.svg
Normal file
@@ -0,0 +1,35 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 220" width="800" height="220">
|
||||
<title>Main area: brightness and preset tiles</title>
|
||||
<defs>
|
||||
<linearGradient id="rg1" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" style="stop-color:#ffd54f"/><stop offset="100%" style="stop-color:#fff8e1"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="rg2" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" style="stop-color:#e53935"/><stop offset="33%" style="stop-color:#fdd835"/>
|
||||
<stop offset="66%" style="stop-color:#43a047"/><stop offset="100%" style="stop-color:#1e88e5"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="rg3" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" style="stop-color:#00897b"/><stop offset="100%" style="stop-color:#4db6ac"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="800" height="220" fill="#2e2e2e"/>
|
||||
<text x="20" y="32" fill="#fff" font-family="sans-serif" font-size="16" font-weight="600">lounge</text>
|
||||
<text x="20" y="56" fill="#aaa" font-family="sans-serif" font-size="11">Brightness (global)</text>
|
||||
<rect x="20" y="64" width="320" height="8" rx="4" fill="#444"/>
|
||||
<rect x="20" y="64" width="200" height="8" rx="4" fill="#6a9ee2"/>
|
||||
<circle cx="220" cy="68" r="10" fill="#ccc" stroke="#333" stroke-width="1"/>
|
||||
<text x="360" y="74" fill="#888" font-family="sans-serif" font-size="11">drag to adjust</text>
|
||||
<text x="20" y="108" fill="#aaa" font-family="sans-serif" font-size="11">Click tile body to select on tab devices</text>
|
||||
<rect x="20" y="118" width="200" height="72" rx="6" fill="#252525" stroke="#555" stroke-width="1"/>
|
||||
<rect x="28" y="128" width="184" height="36" rx="4" fill="url(#rg1)"/>
|
||||
<text x="32" y="180" fill="#eee" font-family="sans-serif" font-size="13" font-weight="600">warm white</text>
|
||||
<rect x="232" y="118" width="200" height="72" rx="6" fill="#252525" stroke="#6a9ee2" stroke-width="2"/>
|
||||
<rect x="240" y="128" width="184" height="36" rx="4" fill="url(#rg2)"/>
|
||||
<text x="244" y="180" fill="#eee" font-family="sans-serif" font-size="13" font-weight="600">rainbow</text>
|
||||
<rect x="444" y="118" width="200" height="72" rx="6" fill="#252525" stroke="#555" stroke-width="1"/>
|
||||
<rect x="452" y="128" width="184" height="36" rx="4" fill="url(#rg3)"/>
|
||||
<text x="456" y="180" fill="#eee" font-family="sans-serif" font-size="13" font-weight="600">chase</text>
|
||||
<rect x="656" y="130" width="56" height="48" rx="4" fill="#3d3d3d" stroke="#555" stroke-width="1"/>
|
||||
<text x="670" y="158" fill="#ddd" font-family="sans-serif" font-size="11">Edit</text>
|
||||
<text x="656" y="198" fill="#888" font-family="sans-serif" font-size="10">Edit mode: drag tiles to reorder</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
239
docs/mockups/COLOR_PICKER_README.md
Normal file
@@ -0,0 +1,239 @@
|
||||
# Custom Colour Picker Component
|
||||
|
||||
A cross-platform, cross-browser colour 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 Colour Model** - Uses Hue, Saturation, Brightness for intuitive colour 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 colour 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 colour in hex format (default: '#FF0000')
|
||||
- `onColorChange` (function) - Callback when colour changes (receives hex colour 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 Colour 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 Colour 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 colour picker uses CSS classes that can be customized:
|
||||
|
||||
- `.color-picker-container` - Main container
|
||||
- `.color-picker-preview` - Colour preview button
|
||||
- `.color-picker-panel` - Dropdown panel
|
||||
- `.color-picker-main` - Main colour 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+
|
||||
|
||||
## Colour Format
|
||||
|
||||
The colour picker uses **hex colour 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 colour picker is integrated into:
|
||||
- `dashboard.html` - Colour selection for patterns
|
||||
- `presets.html` - Colour selection when creating/editing presets
|
||||
|
||||
### Example: Getting Colours 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 colour 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 colour 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>
|
||||
|
||||
23
docs/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
|
||||
}
|
||||
112
esp32/benchmark_peers.py
Normal file
@@ -0,0 +1,112 @@
|
||||
# Benchmark: LRU eviction vs add-then-remove-after-use on ESP32.
|
||||
# Run on device: mpremote run esp32/benchmark_peers.py
|
||||
# (add/del_peer are timed; send() may fail if no peer is listening - timing still valid)
|
||||
import espnow
|
||||
import network
|
||||
import time
|
||||
|
||||
BROADCAST = b"\xff\xff\xff\xff\xff\xff"
|
||||
MAX_PEERS = 20
|
||||
ITERATIONS = 50
|
||||
PAYLOAD = b"x" * 32 # small payload
|
||||
|
||||
network.WLAN(network.STA_IF).active(True)
|
||||
esp = espnow.ESPNow()
|
||||
esp.active(True)
|
||||
esp.add_peer(BROADCAST)
|
||||
|
||||
# Build 19 dummy MACs so we have 20 peers total (broadcast + 19).
|
||||
def mac(i):
|
||||
return bytes([0, 0, 0, 0, 0, i])
|
||||
peers_list = [mac(i) for i in range(1, 20)]
|
||||
for p in peers_list:
|
||||
esp.add_peer(p)
|
||||
|
||||
# One "new" MAC we'll add/remove.
|
||||
new_mac = bytes([0, 0, 0, 0, 0, 99])
|
||||
|
||||
def bench_lru():
|
||||
"""LRU: ensure_peer (evict oldest + add new), send, update last_used."""
|
||||
last_used = {BROADCAST: time.ticks_ms()}
|
||||
for p in peers_list:
|
||||
last_used[p] = time.ticks_ms()
|
||||
# Pre-remove one so we have 19; ensure_peer(new) will add 20th.
|
||||
esp.del_peer(peers_list[-1])
|
||||
last_used.pop(peers_list[-1], None)
|
||||
# Now 19 peers. Each iteration: ensure_peer(new) -> add_peer(new), send, update.
|
||||
# Next iter: ensure_peer(new) -> already there, just send. So we need to force
|
||||
# eviction each time: use a different "new" each time so we always evict+add.
|
||||
t0 = time.ticks_us()
|
||||
for i in range(ITERATIONS):
|
||||
addr = bytes([0, 0, 0, 0, 0, 50 + (i % 30)]) # 30 different "new" MACs
|
||||
peers = esp.get_peers()
|
||||
peer_macs = [p[0] for p in peers]
|
||||
if addr not in peer_macs:
|
||||
if len(peer_macs) >= MAX_PEERS:
|
||||
oldest_mac = None
|
||||
oldest_ts = time.ticks_ms()
|
||||
for m in peer_macs:
|
||||
if m == BROADCAST:
|
||||
continue
|
||||
ts = last_used.get(m, 0)
|
||||
if ts <= oldest_ts:
|
||||
oldest_ts = ts
|
||||
oldest_mac = m
|
||||
if oldest_mac is not None:
|
||||
esp.del_peer(oldest_mac)
|
||||
last_used.pop(oldest_mac, None)
|
||||
esp.add_peer(addr)
|
||||
esp.send(addr, PAYLOAD)
|
||||
last_used[addr] = time.ticks_ms()
|
||||
t1 = time.ticks_us()
|
||||
return time.ticks_diff(t1, t0)
|
||||
|
||||
def bench_add_then_remove():
|
||||
"""Add peer, send, del_peer (remove after use). At 20 we must del one first."""
|
||||
# Start full: 20 peers. To add new we del any one, add new, send, del new.
|
||||
victim = peers_list[0]
|
||||
t0 = time.ticks_us()
|
||||
for i in range(ITERATIONS):
|
||||
esp.del_peer(victim) # make room
|
||||
esp.add_peer(new_mac)
|
||||
esp.send(new_mac, PAYLOAD)
|
||||
esp.del_peer(new_mac)
|
||||
esp.add_peer(victim) # put victim back so we're at 20 again
|
||||
t1 = time.ticks_us()
|
||||
return time.ticks_diff(t1, t0)
|
||||
|
||||
def bench_send_existing():
|
||||
"""Baseline: send to existing peer only (no add/del)."""
|
||||
t0 = time.ticks_us()
|
||||
for _ in range(ITERATIONS):
|
||||
esp.send(peers_list[0], PAYLOAD)
|
||||
t1 = time.ticks_us()
|
||||
return time.ticks_diff(t1, t0)
|
||||
|
||||
print("ESP-NOW peer benchmark ({} iterations)".format(ITERATIONS))
|
||||
print()
|
||||
|
||||
# Baseline: send to existing peer
|
||||
try:
|
||||
us = bench_send_existing()
|
||||
print("Send to existing peer only: {:>8} us total {:>7.1f} us/iter".format(us, us / ITERATIONS))
|
||||
except Exception as e:
|
||||
print("Send existing failed:", e)
|
||||
print()
|
||||
|
||||
# LRU: evict oldest then add new, send
|
||||
try:
|
||||
us = bench_lru()
|
||||
print("LRU (evict oldest + add + send): {:>8} us total {:>7.1f} us/iter".format(us, us / ITERATIONS))
|
||||
except Exception as e:
|
||||
print("LRU failed:", e)
|
||||
print()
|
||||
|
||||
# Add then remove after use
|
||||
try:
|
||||
us = bench_add_then_remove()
|
||||
print("Add then remove after use: {:>8} us total {:>7.1f} us/iter".format(us, us / ITERATIONS))
|
||||
except Exception as e:
|
||||
print("Add-then-remove failed:", e)
|
||||
print()
|
||||
print("Done.")
|
||||
72
esp32/main.py
Normal file
@@ -0,0 +1,72 @@
|
||||
# Serial-to-ESP-NOW bridge: receives from Pi on UART, forwards to ESP-NOW peers.
|
||||
# Wire format: first 6 bytes = destination MAC, rest = payload. Address is always 6 bytes.
|
||||
from machine import Pin, UART
|
||||
import espnow
|
||||
import network
|
||||
import time
|
||||
|
||||
UART_BAUD = 912000
|
||||
BROADCAST = b"\xff\xff\xff\xff\xff\xff"
|
||||
MAX_PEERS = 20
|
||||
# Match led-driver / controller default settings wifi_channel (1–11)
|
||||
WIFI_CHANNEL = 6
|
||||
|
||||
sta = network.WLAN(network.STA_IF)
|
||||
sta.active(True)
|
||||
sta.config(pm=network.WLAN.PM_NONE, channel=WIFI_CHANNEL)
|
||||
print("WiFi STA channel:", sta.config("channel"), "(WIFI_CHANNEL=%s)" % WIFI_CHANNEL)
|
||||
|
||||
esp = espnow.ESPNow()
|
||||
esp.active(True)
|
||||
esp.add_peer(BROADCAST)
|
||||
|
||||
uart = UART(1, UART_BAUD, tx=Pin(21), rx=Pin(6))
|
||||
|
||||
# Track last send time per peer for LRU eviction (remove oldest when at limit).
|
||||
last_used = {BROADCAST: time.ticks_ms()}
|
||||
|
||||
|
||||
# ESP_ERR_ESPNOW_EXIST: peer already registered (ignore when adding).
|
||||
ESP_ERR_ESPNOW_EXIST = -12395
|
||||
|
||||
|
||||
def ensure_peer(addr):
|
||||
"""Ensure addr is in the peer list. When at 20 peers, remove the oldest-used (LRU)."""
|
||||
peers = esp.get_peers()
|
||||
peer_macs = [p[0] for p in peers]
|
||||
if addr in peer_macs:
|
||||
return
|
||||
if len(peer_macs) >= MAX_PEERS:
|
||||
# Remove the peer we used least recently (oldest).
|
||||
oldest_mac = None
|
||||
oldest_ts = time.ticks_ms()
|
||||
for mac in peer_macs:
|
||||
if mac == BROADCAST:
|
||||
continue
|
||||
ts = last_used.get(mac, 0)
|
||||
if ts <= oldest_ts:
|
||||
oldest_ts = ts
|
||||
oldest_mac = mac
|
||||
if oldest_mac is not None:
|
||||
esp.del_peer(oldest_mac)
|
||||
last_used.pop(oldest_mac, None)
|
||||
try:
|
||||
esp.add_peer(addr)
|
||||
except OSError as e:
|
||||
if e.args[0] != ESP_ERR_ESPNOW_EXIST:
|
||||
raise
|
||||
|
||||
|
||||
print("Starting ESP32 main.py")
|
||||
|
||||
while True:
|
||||
if uart.any():
|
||||
data = uart.read()
|
||||
if not data or len(data) < 6:
|
||||
continue
|
||||
print(f"Received data: {data}")
|
||||
addr = data[:6]
|
||||
payload = data[6:]
|
||||
ensure_peer(addr)
|
||||
esp.send(addr, payload)
|
||||
last_used[addr] = time.ticks_ms()
|
||||
1
led-driver
Submodule
1
led-tool
Submodule
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
|
||||
4
pytest.ini
Normal file
@@ -0,0 +1,4 @@
|
||||
[pytest]
|
||||
testpaths = tests
|
||||
python_files = test_endpoints_pytest.py
|
||||
|
||||
19
scripts/build_help_pdf.sh
Executable file
@@ -0,0 +1,19 @@
|
||||
#!/usr/bin/env sh
|
||||
# Build docs/help.pdf from docs/help.md.
|
||||
# Requires: pandoc, chromium (headless print-to-PDF).
|
||||
set -eu
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
# HTML next to docs/help.md so relative image paths (e.g. images/help/*.svg) resolve.
|
||||
HTML="$ROOT/docs/.help-print.html"
|
||||
trap 'rm -f "$HTML"' EXIT
|
||||
|
||||
pandoc "$ROOT/docs/help.md" -s \
|
||||
--css="$ROOT/scripts/help-pdf.css" \
|
||||
--metadata title="LED controller — user guide" \
|
||||
-o "$HTML"
|
||||
|
||||
chromium --headless --no-sandbox --disable-gpu \
|
||||
--print-to-pdf="$ROOT/docs/help.pdf" \
|
||||
"file://${HTML}"
|
||||
|
||||
echo "Wrote $ROOT/docs/help.pdf ($(wc -c < "$ROOT/docs/help.pdf") bytes)"
|
||||
4
scripts/cp-esp32-main.sh
Normal file
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env bash
|
||||
# Copy esp32/main.py to the connected ESP32 as /main.py (single line, no wrap).
|
||||
cd "$(dirname "$0")/.."
|
||||
pipenv run mpremote fs cp esp32/main.py :/main.py
|
||||
96
scripts/help-pdf.css
Normal file
@@ -0,0 +1,96 @@
|
||||
/* Print stylesheet for docs/help.md → PDF (Chromium headless) */
|
||||
@page {
|
||||
margin: 18mm;
|
||||
size: A4;
|
||||
}
|
||||
html {
|
||||
font-size: 11pt;
|
||||
line-height: 1.4;
|
||||
}
|
||||
body {
|
||||
font-family: "DejaVu Sans", "Liberation Sans", Helvetica, Arial, sans-serif;
|
||||
color: #222;
|
||||
max-width: 100%;
|
||||
}
|
||||
h1 {
|
||||
font-size: 1.45rem;
|
||||
border-bottom: 2px solid #333;
|
||||
padding-bottom: 0.25em;
|
||||
margin-top: 0;
|
||||
}
|
||||
h2 {
|
||||
font-size: 1.15rem;
|
||||
margin-top: 1.25em;
|
||||
page-break-after: avoid;
|
||||
}
|
||||
h3 {
|
||||
font-size: 1.05rem;
|
||||
margin-top: 1em;
|
||||
page-break-after: avoid;
|
||||
}
|
||||
code {
|
||||
font-family: "DejaVu Sans Mono", "Liberation Mono", Consolas, monospace;
|
||||
font-size: 0.92em;
|
||||
background: #f3f3f3;
|
||||
padding: 0.1em 0.35em;
|
||||
border-radius: 3px;
|
||||
}
|
||||
pre {
|
||||
font-family: "DejaVu Sans Mono", "Liberation Mono", Consolas, monospace;
|
||||
font-size: 0.88em;
|
||||
background: #f5f5f5;
|
||||
border: 1px solid #ddd;
|
||||
padding: 0.65em 0.85em;
|
||||
overflow-x: auto;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin: 0.75em 0;
|
||||
font-size: 0.95em;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
th, td {
|
||||
border: 1px solid #bbb;
|
||||
padding: 6px 8px;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
th {
|
||||
background: #eee;
|
||||
}
|
||||
a {
|
||||
color: #1a5276;
|
||||
text-decoration: none;
|
||||
}
|
||||
hr {
|
||||
border: none;
|
||||
border-top: 1px solid #ccc;
|
||||
margin: 1.25em 0;
|
||||
}
|
||||
ul, ol {
|
||||
padding-left: 1.35em;
|
||||
}
|
||||
li {
|
||||
margin: 0.2em 0;
|
||||
}
|
||||
|
||||
/* Images in docs/help.md */
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
page-break-inside: avoid;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
}
|
||||
p.help-figure-caption {
|
||||
font-size: 0.9em;
|
||||
color: #555;
|
||||
margin: 0.35em 0 1em 0;
|
||||
line-height: 1.35;
|
||||
}
|
||||
20
scripts/install-boot-service.sh
Executable file
@@ -0,0 +1,20 @@
|
||||
#!/usr/bin/env bash
|
||||
# Install systemd service so LED controller starts at boot.
|
||||
# Run once: sudo scripts/install-boot-service.sh
|
||||
set -e
|
||||
cd "$(dirname "$0")/.."
|
||||
REPO="$(pwd)"
|
||||
SERVICE_NAME="led-controller.service"
|
||||
UNIT_PATH="/etc/systemd/system/$SERVICE_NAME"
|
||||
if [ ! -f "scripts/led-controller.service" ]; then
|
||||
echo "Run this script from the repo root."
|
||||
exit 1
|
||||
fi
|
||||
chmod +x scripts/start.sh
|
||||
sudo cp "scripts/led-controller.service" "$UNIT_PATH"
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable "$SERVICE_NAME"
|
||||
echo "Installed and enabled $SERVICE_NAME"
|
||||
echo "Start now: sudo systemctl start $SERVICE_NAME"
|
||||
echo "Status: sudo systemctl status $SERVICE_NAME"
|
||||
echo "Logs: journalctl -u $SERVICE_NAME -f"
|
||||
17
scripts/led-controller.service
Normal file
@@ -0,0 +1,17 @@
|
||||
[Unit]
|
||||
Description=LED Controller web server
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=pi
|
||||
WorkingDirectory=/home/pi/led-controller
|
||||
Environment=PORT=80
|
||||
Environment=PATH=/home/pi/.local/bin:/usr/local/bin:/usr/bin:/bin
|
||||
ExecStart=/bin/bash /home/pi/led-controller/scripts/start.sh
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
35
scripts/setup-port80.sh
Executable file
@@ -0,0 +1,35 @@
|
||||
#!/usr/bin/env bash
|
||||
# Allow the app to bind to port 80 without root.
|
||||
# Run once: sudo scripts/setup-port80.sh (from repo root)
|
||||
# Or: scripts/setup-port80.sh (will prompt for sudo only for setcap)
|
||||
|
||||
set -e
|
||||
cd "$(dirname "$0")/.."
|
||||
REPO_ROOT="$(pwd)"
|
||||
# If run under sudo, use the invoking user's pipenv so the venv is found
|
||||
if [ -n "$SUDO_USER" ]; then
|
||||
VENV="$(sudo -u "$SUDO_USER" bash -c "cd '$REPO_ROOT' && pipenv --venv" 2>/dev/null)" || true
|
||||
else
|
||||
VENV="$(pipenv --venv 2>/dev/null)" || true
|
||||
fi
|
||||
if [ -z "$VENV" ]; then
|
||||
echo "Run 'pipenv install' first, then run this script again."
|
||||
exit 1
|
||||
fi
|
||||
PYTHON="${VENV}/bin/python3"
|
||||
if [ ! -f "$PYTHON" ]; then
|
||||
PYTHON="${VENV}/bin/python"
|
||||
fi
|
||||
if [ ! -f "$PYTHON" ]; then
|
||||
echo "Python not found in venv: $VENV"
|
||||
exit 1
|
||||
fi
|
||||
# Use the real binary (setcap can fail on symlinks or some filesystems)
|
||||
REAL_PYTHON="$(readlink -f "$PYTHON" 2>/dev/null)" || REAL_PYTHON="$PYTHON"
|
||||
if sudo setcap 'cap_net_bind_service=+ep' "$REAL_PYTHON" 2>/dev/null; then
|
||||
echo "OK: port 80 enabled for $REAL_PYTHON"
|
||||
echo "Start the app with: pipenv run run"
|
||||
else
|
||||
echo "setcap failed on $REAL_PYTHON"
|
||||
exit 1
|
||||
fi
|
||||
5
scripts/start.sh
Executable file
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
# Start the LED controller web server (port 80 by default).
|
||||
cd "$(dirname "$0")/.."
|
||||
export PORT="${PORT:-80}"
|
||||
pipenv run run
|
||||
33
scripts/test-port80.sh
Executable file
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env bash
|
||||
# Test the app on port 80. Run after: sudo scripts/setup-port80.sh
|
||||
# Usage: ./scripts/test-port80.sh
|
||||
|
||||
set -e
|
||||
cd "$(dirname "$0")/.."
|
||||
APP_URL="${APP_URL:-http://127.0.0.1:80}"
|
||||
|
||||
echo "Starting app on port 80 in background..."
|
||||
pipenv run run &
|
||||
PID=$!
|
||||
trap "kill $PID 2>/dev/null; exit" EXIT
|
||||
|
||||
echo "Waiting for server to start..."
|
||||
for i in 1 2 3 4 5 6 7 8 9 10; do
|
||||
if curl -s -o /dev/null -w "%{http_code}" "$APP_URL/" 2>/dev/null | grep -q 200; then
|
||||
echo "Server is up."
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
echo "Requesting $APP_URL/ ..."
|
||||
CODE=$(curl -s -o /dev/null -w "%{http_code}" "$APP_URL/")
|
||||
if [ "$CODE" = "200" ]; then
|
||||
echo "OK: GET / returned HTTP $CODE"
|
||||
curl -s "$APP_URL/" | head -5
|
||||
echo "..."
|
||||
exit 0
|
||||
else
|
||||
echo "FAIL: GET / returned HTTP $CODE (expected 200)"
|
||||
exit 1
|
||||
fi
|
||||
21
src/boot.py
@@ -1,19 +1,6 @@
|
||||
import wifi
|
||||
import time
|
||||
# Boot script (ESP only; no-op on Pi)
|
||||
import settings # noqa: F401
|
||||
from settings import Settings
|
||||
|
||||
print(wifi.ap('qwerty'))
|
||||
|
||||
|
||||
settings = Settings()
|
||||
ssid = settings.get('wifi', {}).get('ssid', None)
|
||||
password = settings.get('wifi', {}).get('password', None)
|
||||
ip = settings.get('wifi', {}).get('ip', None)
|
||||
gateway = settings.get('wifi', {}).get('gateway', None)
|
||||
|
||||
for i in range(10):
|
||||
config = wifi.connect(ssid, password, ip, gateway)
|
||||
if config:
|
||||
print(config)
|
||||
break
|
||||
time.sleep(0.1)
|
||||
s = Settings()
|
||||
# AP setup was here when running on ESP; Pi uses system networking.
|
||||
|
||||
1
src/controllers/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Controllers package
|
||||
68
src/controllers/device.py
Normal file
@@ -0,0 +1,68 @@
|
||||
from microdot import Microdot
|
||||
from models.device import Device
|
||||
import json
|
||||
|
||||
controller = Microdot()
|
||||
devices = Device()
|
||||
|
||||
|
||||
@controller.get("")
|
||||
async def list_devices(request):
|
||||
"""List all devices."""
|
||||
devices_data = {}
|
||||
for dev_id in devices.list():
|
||||
d = devices.read(dev_id)
|
||||
if d:
|
||||
devices_data[dev_id] = d
|
||||
return json.dumps(devices_data), 200, {"Content-Type": "application/json"}
|
||||
|
||||
|
||||
@controller.get("/<id>")
|
||||
async def get_device(request, id):
|
||||
"""Get a device by ID."""
|
||||
dev = devices.read(id)
|
||||
if dev:
|
||||
return json.dumps(dev), 200, {"Content-Type": "application/json"}
|
||||
return json.dumps({"error": "Device not found"}), 404
|
||||
|
||||
|
||||
@controller.post("")
|
||||
async def create_device(request):
|
||||
"""Create a new device."""
|
||||
try:
|
||||
data = request.json or {}
|
||||
name = data.get("name", "").strip()
|
||||
address = data.get("address")
|
||||
default_pattern = data.get("default_pattern")
|
||||
tabs = data.get("tabs")
|
||||
if isinstance(tabs, list):
|
||||
tabs = [str(t) for t in tabs]
|
||||
else:
|
||||
tabs = []
|
||||
dev_id = devices.create(name=name, address=address, default_pattern=default_pattern, tabs=tabs)
|
||||
dev = devices.read(dev_id)
|
||||
return json.dumps({dev_id: dev}), 201, {"Content-Type": "application/json"}
|
||||
except Exception as e:
|
||||
return json.dumps({"error": str(e)}), 400
|
||||
|
||||
|
||||
@controller.put("/<id>")
|
||||
async def update_device(request, id):
|
||||
"""Update a device."""
|
||||
try:
|
||||
data = request.json or {}
|
||||
if "tabs" in data and isinstance(data["tabs"], list):
|
||||
data["tabs"] = [str(t) for t in data["tabs"]]
|
||||
if devices.update(id, data):
|
||||
return json.dumps(devices.read(id)), 200, {"Content-Type": "application/json"}
|
||||
return json.dumps({"error": "Device not found"}), 404
|
||||
except Exception as e:
|
||||
return json.dumps({"error": str(e)}), 400
|
||||
|
||||
|
||||
@controller.delete("/<id>")
|
||||
async def delete_device(request, id):
|
||||
"""Delete a device."""
|
||||
if devices.delete(id):
|
||||
return json.dumps({"message": "Device deleted successfully"}), 200
|
||||
return json.dumps({"error": "Device not found"}), 404
|
||||
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
|
||||
58
src/controllers/palette.py
Normal file
@@ -0,0 +1,58 @@
|
||||
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."""
|
||||
if str(id) in palettes:
|
||||
palette = palettes.read(id)
|
||||
return json.dumps({"colors": palette or [], "id": str(id)}), 200, {'Content-Type': 'application/json'}
|
||||
return json.dumps({"error": "Palette not found"}), 404
|
||||
|
||||
@controller.post('')
|
||||
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)
|
||||
created_colors = palettes.read(palette_id) or []
|
||||
return json.dumps({"id": str(palette_id), "colors": created_colors}), 201, {'Content-Type': 'application/json'}
|
||||
except Exception as e:
|
||||
return json.dumps({"error": str(e)}), 400
|
||||
|
||||
@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):
|
||||
colors = palettes.read(id) or []
|
||||
return json.dumps({"id": str(id), "colors": colors}), 200, {'Content-Type': 'application/json'}
|
||||
return json.dumps({"error": "Palette not found"}), 404
|
||||
except Exception as e:
|
||||
return json.dumps({"error": str(e)}), 400
|
||||
|
||||
@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
|
||||
89
src/controllers/pattern.py
Normal file
@@ -0,0 +1,89 @@
|
||||
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:
|
||||
payload = request.json or {}
|
||||
name = payload.get("name", "")
|
||||
pattern_data = payload.get("data", {})
|
||||
|
||||
# IMPORTANT:
|
||||
# `patterns.create()` stores `pattern_data` as the underlying dict value.
|
||||
# If we then call `patterns.update(pattern_id, payload)` with the full
|
||||
# request object, it may assign `payload["data"]` back onto that same
|
||||
# dict object, creating a circular reference (json.dumps fails).
|
||||
pattern_id = patterns.create(name, pattern_data)
|
||||
|
||||
# Only merge "extra" metadata fields (anything except name/data).
|
||||
extra = dict(payload)
|
||||
extra.pop("name", None)
|
||||
extra.pop("data", None)
|
||||
if extra:
|
||||
patterns.update(pattern_id, extra)
|
||||
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
|
||||
223
src/controllers/preset.py
Normal file
@@ -0,0 +1,223 @@
|
||||
from microdot import Microdot
|
||||
from microdot.session import with_session
|
||||
from models.preset import Preset
|
||||
from models.profile import Profile
|
||||
from models.transport import get_current_sender
|
||||
from util.espnow_message import build_message, build_preset_dict
|
||||
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('/<preset_id>')
|
||||
@with_session
|
||||
async def get_preset(request, session, preset_id):
|
||||
"""Get a specific preset by ID (current profile only)."""
|
||||
preset = presets.read(preset_id)
|
||||
current_profile_id = get_current_profile_id(session)
|
||||
if preset and str(preset.get("profile_id")) == str(current_profile_id):
|
||||
return json.dumps(preset), 200, {'Content-Type': 'application/json'}
|
||||
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('/<preset_id>')
|
||||
@with_session
|
||||
async def update_preset(request, session, preset_id):
|
||||
"""Update an existing preset (current profile only)."""
|
||||
try:
|
||||
preset = presets.read(preset_id)
|
||||
current_profile_id = get_current_profile_id(session)
|
||||
if not preset or str(preset.get("profile_id")) != str(current_profile_id):
|
||||
return json.dumps({"error": "Preset not found"}), 404
|
||||
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(preset_id, data):
|
||||
return json.dumps(presets.read(preset_id)), 200, {'Content-Type': 'application/json'}
|
||||
return json.dumps({"error": "Preset not found"}), 404
|
||||
except Exception as e:
|
||||
return json.dumps({"error": str(e)}), 400
|
||||
|
||||
@controller.delete('/<preset_id>')
|
||||
@with_session
|
||||
async def delete_preset(request, *args, **kwargs):
|
||||
"""Delete a preset (current profile only)."""
|
||||
# Be tolerant of wrapper/arg-order variations.
|
||||
session = None
|
||||
preset_id = None
|
||||
if len(args) > 0:
|
||||
session = args[0]
|
||||
if len(args) > 1:
|
||||
preset_id = args[1]
|
||||
if 'session' in kwargs and kwargs.get('session') is not None:
|
||||
session = kwargs.get('session')
|
||||
if 'preset_id' in kwargs and kwargs.get('preset_id') is not None:
|
||||
preset_id = kwargs.get('preset_id')
|
||||
if 'id' in kwargs and kwargs.get('id') is not None and preset_id is None:
|
||||
preset_id = kwargs.get('id')
|
||||
if preset_id is None:
|
||||
return json.dumps({"error": "Preset ID is required"}), 400
|
||||
preset = presets.read(preset_id)
|
||||
current_profile_id = get_current_profile_id(session)
|
||||
if not preset or str(preset.get("profile_id")) != str(current_profile_id):
|
||||
return json.dumps({"error": "Preset not found"}), 404
|
||||
if presets.delete(preset_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 to the LED driver (via serial transport).
|
||||
|
||||
Body JSON:
|
||||
{"preset_ids": ["1", "2", ...]} or {"ids": ["1", "2", ...]}
|
||||
|
||||
The controller looks up each preset, converts to API format, chunks into
|
||||
<= 240-byte messages, and sends them over the configured transport.
|
||||
"""
|
||||
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')
|
||||
# Optional 12-char hex MAC to send to one device; omit for default (e.g. broadcast).
|
||||
destination_mac = data.get('destination_mac') or data.get('to')
|
||||
|
||||
# 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
|
||||
|
||||
sender = get_current_sender()
|
||||
if not sender:
|
||||
return json.dumps({"error": "Transport not configured"}), 503, {'Content-Type': 'application/json'}
|
||||
|
||||
async def send_chunk(chunk_presets, is_last):
|
||||
# Save/default should only be sent with the final presets chunk.
|
||||
msg = build_message(
|
||||
presets=chunk_presets,
|
||||
save=save_flag and is_last,
|
||||
default=default_id if is_last else None,
|
||||
)
|
||||
await sender.send(msg, addr=destination_mac)
|
||||
|
||||
MAX_BYTES = 240
|
||||
send_delay_s = 0.1
|
||||
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, False)
|
||||
except Exception:
|
||||
return json.dumps({"error": "Send failed"}), 503, {'Content-Type': 'application/json'}
|
||||
await asyncio.sleep(send_delay_s)
|
||||
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, True)
|
||||
except Exception:
|
||||
return json.dumps({"error": "Send failed"}), 503, {'Content-Type': 'application/json'}
|
||||
await asyncio.sleep(send_delay_s)
|
||||
messages_sent += 1
|
||||
|
||||
return json.dumps({
|
||||
"message": "Presets sent",
|
||||
"presets_sent": total_presets,
|
||||
"messages_sent": messages_sent
|
||||
}), 200, {'Content-Type': 'application/json'}
|
||||
|
||||
348
src/controllers/profile.py
Normal file
@@ -0,0 +1,348 @@
|
||||
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 = dict(request.json or {})
|
||||
name = data.get("name", "")
|
||||
seed_raw = data.get("seed_dj_tab", False)
|
||||
if isinstance(seed_raw, str):
|
||||
seed_dj_tab = seed_raw.strip().lower() in ("1", "true", "yes", "on")
|
||||
else:
|
||||
seed_dj_tab = bool(seed_raw)
|
||||
# Request-only flag: do not persist on profile records.
|
||||
data.pop("seed_dj_tab", None)
|
||||
profile_id = profiles.create(name)
|
||||
# Avoid persisting request-only fields.
|
||||
data.pop("name", None)
|
||||
if data:
|
||||
profiles.update(profile_id, data)
|
||||
|
||||
# New profiles always start with a default tab pre-populated with starter presets.
|
||||
default_preset_ids = []
|
||||
default_preset_defs = [
|
||||
{
|
||||
"name": "on",
|
||||
"pattern": "on",
|
||||
"colors": ["#FFFFFF"],
|
||||
"brightness": 255,
|
||||
"delay": 100,
|
||||
"auto": True,
|
||||
},
|
||||
{
|
||||
"name": "off",
|
||||
"pattern": "off",
|
||||
"colors": [],
|
||||
"brightness": 0,
|
||||
"delay": 100,
|
||||
"auto": True,
|
||||
},
|
||||
{
|
||||
"name": "rainbow",
|
||||
"pattern": "rainbow",
|
||||
"colors": [],
|
||||
"brightness": 255,
|
||||
"delay": 100,
|
||||
"auto": True,
|
||||
"n1": 2,
|
||||
},
|
||||
{
|
||||
"name": "transition",
|
||||
"pattern": "transition",
|
||||
"colors": ["#FF0000", "#00FF00", "#0000FF"],
|
||||
"brightness": 255,
|
||||
"delay": 500,
|
||||
"auto": True,
|
||||
},
|
||||
]
|
||||
|
||||
for preset_data in default_preset_defs:
|
||||
pid = presets.create(profile_id)
|
||||
presets.update(pid, preset_data)
|
||||
default_preset_ids.append(str(pid))
|
||||
|
||||
default_tab_id = tabs.create(name="default", names=["1"], presets=[default_preset_ids])
|
||||
tabs.update(default_tab_id, {
|
||||
"presets_flat": default_preset_ids,
|
||||
"default_preset": default_preset_ids[0] if default_preset_ids else None,
|
||||
})
|
||||
|
||||
profile = profiles.read(profile_id) or {}
|
||||
profile_tabs = profile.get("tabs", []) if isinstance(profile.get("tabs", []), list) else []
|
||||
profile_tabs.append(str(default_tab_id))
|
||||
|
||||
if seed_dj_tab:
|
||||
# Seed a DJ-focused tab with three starter presets.
|
||||
seeded_preset_ids = []
|
||||
preset_defs = [
|
||||
{
|
||||
"name": "DJ Rainbow",
|
||||
"pattern": "rainbow",
|
||||
"colors": [],
|
||||
"brightness": 220,
|
||||
"delay": 60,
|
||||
"n1": 12,
|
||||
},
|
||||
{
|
||||
"name": "DJ Single Color",
|
||||
"pattern": "on",
|
||||
"colors": ["#ff00ff"],
|
||||
"brightness": 220,
|
||||
"delay": 100,
|
||||
},
|
||||
{
|
||||
"name": "DJ Transition",
|
||||
"pattern": "transition",
|
||||
"colors": ["#ff0000", "#00ff00", "#0000ff"],
|
||||
"brightness": 220,
|
||||
"delay": 250,
|
||||
},
|
||||
]
|
||||
|
||||
for preset_data in preset_defs:
|
||||
pid = presets.create(profile_id)
|
||||
presets.update(pid, preset_data)
|
||||
seeded_preset_ids.append(str(pid))
|
||||
|
||||
dj_tab_id = tabs.create(name="dj", names=["dj"], presets=[seeded_preset_ids])
|
||||
tabs.update(dj_tab_id, {
|
||||
"presets_flat": seeded_preset_ids,
|
||||
"default_preset": seeded_preset_ids[0] if seeded_preset_ids else None,
|
||||
})
|
||||
|
||||
profile_tabs.append(str(dj_tab_id))
|
||||
|
||||
profiles.update(profile_id, {"tabs": profile_tabs})
|
||||
|
||||
profile_data = profiles.read(profile_id)
|
||||
return json.dumps({profile_id: profile_data}), 201, {'Content-Type': 'application/json'}
|
||||
except Exception as e:
|
||||
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
|
||||
87
src/controllers/settings.py
Normal file
@@ -0,0 +1,87 @@
|
||||
from microdot import Microdot, send_file
|
||||
from settings import Settings
|
||||
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 saved AP configuration (Pi: no in-device AP)."""
|
||||
config = {
|
||||
'saved_ssid': settings.get('wifi_ap_ssid'),
|
||||
'saved_password': settings.get('wifi_ap_password'),
|
||||
'saved_channel': settings.get('wifi_ap_channel'),
|
||||
'active': False,
|
||||
}
|
||||
return json.dumps(config), 200, {'Content-Type': 'application/json'}
|
||||
|
||||
@controller.post('/wifi/ap')
|
||||
async def configure_ap(request):
|
||||
"""Save AP configuration to settings (Pi: no in-device AP)."""
|
||||
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
|
||||
|
||||
settings['wifi_ap_ssid'] = ssid
|
||||
settings['wifi_ap_password'] = password
|
||||
if channel is not None:
|
||||
settings['wifi_ap_channel'] = channel
|
||||
settings.save()
|
||||
|
||||
return json.dumps({
|
||||
"message": "AP settings saved",
|
||||
"ssid": ssid,
|
||||
"channel": channel
|
||||
}), 200, {'Content-Type': 'application/json'}
|
||||
except Exception as e:
|
||||
return json.dumps({"error": str(e)}), 500
|
||||
|
||||
def _validate_wifi_channel(value):
|
||||
"""Return int 1–11 or raise ValueError."""
|
||||
ch = int(value)
|
||||
if ch < 1 or ch > 11:
|
||||
raise ValueError("wifi_channel must be between 1 and 11")
|
||||
return ch
|
||||
|
||||
|
||||
@controller.put('/settings')
|
||||
async def update_settings(request):
|
||||
"""Update general settings."""
|
||||
try:
|
||||
data = request.json
|
||||
for key, value in data.items():
|
||||
if key == 'wifi_channel' and value is not None:
|
||||
settings[key] = _validate_wifi_channel(value)
|
||||
else:
|
||||
settings[key] = value
|
||||
settings.save()
|
||||
return json.dumps({"message": "Settings updated successfully"}), 200, {'Content-Type': 'application/json'}
|
||||
except ValueError as e:
|
||||
return json.dumps({"error": str(e)}), 400
|
||||
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
|
||||
152
src/main.py
@@ -1,55 +1,127 @@
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
from microdot import Microdot, send_file
|
||||
from microdot.websocket import with_websocket
|
||||
from microdot.session import Session
|
||||
from settings import Settings
|
||||
from web import web
|
||||
from patterns import Patterns
|
||||
import gc
|
||||
import utime
|
||||
import machine
|
||||
import ntptime
|
||||
import time
|
||||
import wifi
|
||||
|
||||
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.transport import get_sender, set_sender
|
||||
|
||||
|
||||
async def main():
|
||||
|
||||
|
||||
|
||||
async def main(port=80):
|
||||
settings = Settings()
|
||||
|
||||
patterns = Patterns(4, settings["num_leds"], selected=settings["selected_pattern"])
|
||||
patterns.set_color1(tuple(int(settings["color1"][i:i+2], 16) for i in (1, 5, 3)))
|
||||
patterns.set_color2(tuple(int(settings["color2"][i:i+2], 16) for i in (1, 5, 3)))
|
||||
patterns.set_brightness(int(settings["brightness"]))
|
||||
patterns.set_delay(int(settings["delay"]))
|
||||
|
||||
w = web(settings, patterns)
|
||||
print(settings)
|
||||
# start the server in a bacakground task
|
||||
print("Starting")
|
||||
server = asyncio.create_task(w.start_server(host="0.0.0.0", port=80))
|
||||
wdt = machine.WDT(timeout=10000)
|
||||
wdt.feed()
|
||||
|
||||
async def tick():
|
||||
# Initialize transport (serial to ESP32 bridge)
|
||||
sender = get_sender(settings)
|
||||
set_sender(sender)
|
||||
|
||||
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
|
||||
# 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 (cwd is src/ when run via pipenv run run)
|
||||
@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:
|
||||
patterns.tick()
|
||||
await asyncio.sleep_ms(1)
|
||||
data = await ws.receive()
|
||||
print(data)
|
||||
if data:
|
||||
try:
|
||||
parsed = json.loads(data)
|
||||
print("WS received JSON:", parsed)
|
||||
# Optional "to": 12-char hex MAC; rest is payload (sent with that address).
|
||||
addr = parsed.pop("to", None)
|
||||
payload = json.dumps(parsed) if parsed else data
|
||||
await sender.send(payload, addr=addr)
|
||||
except json.JSONDecodeError:
|
||||
# Not JSON: send raw with default address
|
||||
try:
|
||||
await sender.send(data)
|
||||
except Exception:
|
||||
try:
|
||||
await ws.send(json.dumps({"error": "Send failed"}))
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
try:
|
||||
await ws.send(json.dumps({"error": "Send failed"}))
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
break
|
||||
|
||||
asyncio.create_task(tick())
|
||||
|
||||
first = True
|
||||
|
||||
server = asyncio.create_task(app.start_server(host="0.0.0.0", port=port))
|
||||
|
||||
while True:
|
||||
#print(time.localtime())
|
||||
|
||||
# gc.collect()
|
||||
for i in range(60):
|
||||
wdt.feed()
|
||||
await asyncio.sleep_ms(500)
|
||||
|
||||
await asyncio.sleep(30)
|
||||
# cleanup before ending the application
|
||||
await server
|
||||
|
||||
asyncio.run(main())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import os
|
||||
port = int(os.environ.get("PORT", 80))
|
||||
asyncio.run(main(port=port))
|
||||
|
||||
1
src/models/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Models package
|
||||
54
src/models/device.py
Normal file
@@ -0,0 +1,54 @@
|
||||
from models.model import Model
|
||||
|
||||
|
||||
def _normalize_address(addr):
|
||||
"""Normalize 6-byte ESP32 address to 12-char lowercase hex (no colons)."""
|
||||
if addr is None:
|
||||
return None
|
||||
s = str(addr).strip().lower().replace(":", "").replace("-", "")
|
||||
if len(s) == 12 and all(c in "0123456789abcdef" for c in s):
|
||||
return s
|
||||
return None
|
||||
|
||||
|
||||
class Device(Model):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
def create(self, name="", address=None, default_pattern=None, tabs=None):
|
||||
next_id = self.get_next_id()
|
||||
addr = _normalize_address(address)
|
||||
self[next_id] = {
|
||||
"name": name,
|
||||
"address": addr,
|
||||
"default_pattern": default_pattern if default_pattern else None,
|
||||
"tabs": list(tabs) if tabs else [],
|
||||
}
|
||||
self.save()
|
||||
return next_id
|
||||
|
||||
def read(self, id):
|
||||
id_str = str(id)
|
||||
return self.get(id_str, None)
|
||||
|
||||
def update(self, id, data):
|
||||
id_str = str(id)
|
||||
if id_str not in self:
|
||||
return False
|
||||
if "address" in data and data["address"] is not None:
|
||||
data = dict(data)
|
||||
data["address"] = _normalize_address(data["address"])
|
||||
self[id_str].update(data)
|
||||
self.save()
|
||||
return True
|
||||
|
||||
def delete(self, id):
|
||||
id_str = str(id)
|
||||
if id_str not in self:
|
||||
return False
|
||||
self.pop(id_str)
|
||||
self.save()
|
||||
return True
|
||||
|
||||
def list(self):
|
||||
return list(self.keys())
|
||||
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())
|
||||
113
src/models/model.py
Normal file
@@ -0,0 +1,113 @@
|
||||
import json
|
||||
import os
|
||||
import traceback
|
||||
|
||||
# DB directory: project root / db (writable without root)
|
||||
def _db_dir():
|
||||
try:
|
||||
# src/models/model.py -> project root
|
||||
base = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
return os.path.join(base, "db")
|
||||
except Exception:
|
||||
return "db"
|
||||
|
||||
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
|
||||
|
||||
db_dir = _db_dir()
|
||||
try:
|
||||
os.makedirs(db_dir, exist_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
self.class_name = self.__class__.__name__
|
||||
self.file = os.path.join(db_dir, f"{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:
|
||||
db_dir = os.path.dirname(self.file)
|
||||
try:
|
||||
os.makedirs(db_dir, exist_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
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}")
|
||||
traceback.print_exception(type(e), e, e.__traceback__)
|
||||
|
||||
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())
|
||||
12
src/models/serial.py
Normal file
@@ -0,0 +1,12 @@
|
||||
class Serial:
|
||||
def __init__(self, port, baudrate):
|
||||
self.port = port
|
||||
self.baudrate = baudrate
|
||||
self.uart = UART(1, baudrate, tx=Pin(21), rx=Pin(6))
|
||||
|
||||
def send(self, data):
|
||||
self.uart.write(data)
|
||||
|
||||
def receive(self):
|
||||
return self.uart.read()
|
||||
|
||||
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())
|
||||
66
src/models/transport.py
Normal file
@@ -0,0 +1,66 @@
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
|
||||
# Default: broadcast (6 bytes). Pi always sends 6-byte address + payload to ESP32.
|
||||
BROADCAST_MAC = bytes.fromhex("ffffffffffff")
|
||||
|
||||
|
||||
def _encode_payload(data):
|
||||
if isinstance(data, str):
|
||||
return data.encode()
|
||||
if isinstance(data, dict):
|
||||
return json.dumps(data).encode()
|
||||
return data
|
||||
|
||||
|
||||
def _parse_mac(addr):
|
||||
"""Convert 12-char hex string or 6-byte bytes to 6-byte MAC."""
|
||||
if addr is None or addr == b"":
|
||||
return BROADCAST_MAC
|
||||
if isinstance(addr, bytes) and len(addr) == 6:
|
||||
return addr
|
||||
if isinstance(addr, str) and len(addr) == 12:
|
||||
return bytes.fromhex(addr)
|
||||
return BROADCAST_MAC
|
||||
|
||||
|
||||
async def _to_thread(func, *args):
|
||||
to_thread = getattr(asyncio, "to_thread", None)
|
||||
if to_thread:
|
||||
return await to_thread(func, *args)
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(None, func, *args)
|
||||
|
||||
|
||||
class SerialSender:
|
||||
def __init__(self, port, baudrate, default_addr=None):
|
||||
import serial
|
||||
|
||||
self._serial = serial.Serial(port, baudrate=baudrate, timeout=1)
|
||||
self._default_addr = _parse_mac(default_addr)
|
||||
|
||||
async def send(self, data, addr=None):
|
||||
mac = _parse_mac(addr) if addr is not None else self._default_addr
|
||||
payload = _encode_payload(data)
|
||||
await _to_thread(self._serial.write, mac + payload)
|
||||
return True
|
||||
|
||||
|
||||
_current_sender = None
|
||||
|
||||
|
||||
def set_sender(sender):
|
||||
global _current_sender
|
||||
_current_sender = sender
|
||||
|
||||
|
||||
def get_current_sender():
|
||||
return _current_sender
|
||||
|
||||
|
||||
def get_sender(settings):
|
||||
port = settings.get("serial_port", "/dev/ttyS0")
|
||||
baudrate = settings.get("serial_baudrate", 912000)
|
||||
default_addr = settings.get("serial_destination_mac", "ffffffffffff")
|
||||
return SerialSender(port, baudrate, default_addr=default_addr)
|
||||
0
src/profile.py
Normal file
@@ -1,20 +1,53 @@
|
||||
import json
|
||||
import os
|
||||
import binascii
|
||||
|
||||
|
||||
def _settings_path():
|
||||
"""Path to settings.json in project root (writable without root)."""
|
||||
try:
|
||||
base = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
return os.path.join(base, "settings.json")
|
||||
except Exception:
|
||||
return "settings.json"
|
||||
|
||||
|
||||
class Settings(dict):
|
||||
SETTINGS_FILE = "/settings.json"
|
||||
SETTINGS_FILE = None # Set in __init__ from _settings_path()
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
if Settings.SETTINGS_FILE is None:
|
||||
Settings.SETTINGS_FILE = _settings_path()
|
||||
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):
|
||||
self["num_leds"] = 50
|
||||
self["selected_pattern"] = "blink"
|
||||
self["color1"] = "#000f00"
|
||||
self["color2"] = "#0f0000"
|
||||
self["delay"] = 100
|
||||
self["brightness"] = 100
|
||||
self["wifi"] = {"ssid": "", "password": ""}
|
||||
"""Set default settings if they don't exist."""
|
||||
if 'session_secret_key' not in self:
|
||||
self['session_secret_key'] = self.generate_secret_key()
|
||||
# Save immediately when generating a new key
|
||||
self.save()
|
||||
# ESP-NOW STA channel (2.4 GHz) for LED drivers / bridge alignment; 1–11
|
||||
if 'wifi_channel' not in self:
|
||||
self['wifi_channel'] = 6
|
||||
|
||||
def save(self):
|
||||
try:
|
||||
@@ -26,28 +59,19 @@ class Settings(dict):
|
||||
print(f"Error saving settings: {e}")
|
||||
|
||||
def load(self):
|
||||
loaded_from_file = False
|
||||
try:
|
||||
with open(self.SETTINGS_FILE, 'r') as file:
|
||||
loaded_settings = json.load(file)
|
||||
self.update(loaded_settings)
|
||||
loaded_from_file = True
|
||||
print("Settings loaded successfully.")
|
||||
except Exception as e:
|
||||
print(f"Error loading settings")
|
||||
self.clear()
|
||||
finally:
|
||||
# Ensure defaults are set even if file exists but is missing keys
|
||||
self.set_defaults()
|
||||
|
||||
# Example usage
|
||||
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)
|
||||
|
||||
# Run the example
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
# Only save if file didn't exist or was invalid
|
||||
if not loaded_from_file:
|
||||
self.save()
|
||||
|
||||
1734
src/static/app.js
Normal file
194
src/static/color_palette.js
Normal file
@@ -0,0 +1,194 @@
|
||||
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 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 (paletteNewColor) {
|
||||
const addSelectedColor = async () => {
|
||||
const color = paletteNewColor.value;
|
||||
if (!color) {
|
||||
return;
|
||||
}
|
||||
if (currentPalette.includes(color)) {
|
||||
alert('Color already in palette.');
|
||||
return;
|
||||
}
|
||||
await savePalette([...currentPalette, color]);
|
||||
};
|
||||
// Add when the picker closes (user confirms selection).
|
||||
paletteNewColor.addEventListener('change', addSelectedColor);
|
||||
}
|
||||
});
|
||||
251
src/static/devices.js
Normal file
@@ -0,0 +1,251 @@
|
||||
// Device management: list, create, edit, delete (name and 6-byte address)
|
||||
|
||||
const HEX_BOX_COUNT = 12;
|
||||
|
||||
function makeHexAddressBoxes(container) {
|
||||
if (!container || container.querySelector('.hex-addr-box')) return;
|
||||
container.innerHTML = '';
|
||||
for (let i = 0; i < HEX_BOX_COUNT; i++) {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.className = 'hex-addr-box';
|
||||
input.maxLength = 1;
|
||||
input.autocomplete = 'off';
|
||||
input.setAttribute('data-index', i);
|
||||
input.setAttribute('inputmode', 'numeric');
|
||||
input.setAttribute('aria-label', `Hex digit ${i + 1}`);
|
||||
input.addEventListener('input', (e) => {
|
||||
const v = e.target.value.replace(/[^0-9a-fA-F]/g, '');
|
||||
e.target.value = v;
|
||||
if (v && e.target.nextElementSibling && e.target.nextElementSibling.classList.contains('hex-addr-box')) {
|
||||
e.target.nextElementSibling.focus();
|
||||
}
|
||||
});
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Backspace' && !e.target.value && e.target.previousElementSibling) {
|
||||
e.target.previousElementSibling.focus();
|
||||
}
|
||||
});
|
||||
input.addEventListener('paste', (e) => {
|
||||
e.preventDefault();
|
||||
const pasted = (e.clipboardData.getData('text') || '').replace(/[^0-9a-fA-F]/g, '').slice(0, HEX_BOX_COUNT);
|
||||
const boxes = container.querySelectorAll('.hex-addr-box');
|
||||
for (let j = 0; j < pasted.length && j < boxes.length; j++) {
|
||||
boxes[j].value = pasted[j];
|
||||
}
|
||||
if (pasted.length > 0) {
|
||||
const nextIdx = Math.min(pasted.length, boxes.length - 1);
|
||||
boxes[nextIdx].focus();
|
||||
}
|
||||
});
|
||||
container.appendChild(input);
|
||||
}
|
||||
}
|
||||
|
||||
function getAddressFromBoxes(container) {
|
||||
if (!container) return '';
|
||||
const boxes = container.querySelectorAll('.hex-addr-box');
|
||||
return Array.from(boxes).map((b) => b.value).join('').toLowerCase();
|
||||
}
|
||||
|
||||
function setAddressToBoxes(container, addrStr) {
|
||||
if (!container) return;
|
||||
const s = (addrStr || '').replace(/[^0-9a-fA-F]/g, '').slice(0, HEX_BOX_COUNT);
|
||||
const boxes = container.querySelectorAll('.hex-addr-box');
|
||||
boxes.forEach((b, i) => {
|
||||
b.value = s[i] || '';
|
||||
});
|
||||
}
|
||||
|
||||
async function loadDevicesModal() {
|
||||
const container = document.getElementById('devices-list-modal');
|
||||
if (!container) return;
|
||||
container.innerHTML = '<span class="muted-text">Loading...</span>';
|
||||
try {
|
||||
const response = await fetch('/devices', { headers: { Accept: 'application/json' } });
|
||||
if (!response.ok) throw new Error('Failed to load devices');
|
||||
const devices = await response.json();
|
||||
renderDevicesList(devices || {});
|
||||
} catch (e) {
|
||||
console.error('loadDevicesModal:', e);
|
||||
container.innerHTML = '<span class="muted-text">Failed to load devices.</span>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderDevicesList(devices) {
|
||||
const container = document.getElementById('devices-list-modal');
|
||||
if (!container) return;
|
||||
container.innerHTML = '';
|
||||
const ids = Object.keys(devices).filter((k) => devices[k] && typeof devices[k] === 'object');
|
||||
if (ids.length === 0) {
|
||||
const p = document.createElement('p');
|
||||
p.className = 'muted-text';
|
||||
p.textContent = 'No devices. Create one above.';
|
||||
container.appendChild(p);
|
||||
return;
|
||||
}
|
||||
ids.forEach((devId) => {
|
||||
const dev = devices[devId];
|
||||
const row = document.createElement('div');
|
||||
row.className = 'profiles-row';
|
||||
row.style.display = 'flex';
|
||||
row.style.alignItems = 'center';
|
||||
row.style.gap = '0.5rem';
|
||||
row.style.flexWrap = 'wrap';
|
||||
|
||||
const label = document.createElement('span');
|
||||
label.textContent = (dev && dev.name) || devId;
|
||||
label.style.flex = '1';
|
||||
label.style.minWidth = '100px';
|
||||
|
||||
const meta = document.createElement('span');
|
||||
meta.className = 'muted-text';
|
||||
meta.style.fontSize = '0.85em';
|
||||
const addr = (dev && dev.address) ? dev.address : '—';
|
||||
meta.textContent = `Address: ${addr}`;
|
||||
|
||||
const editBtn = document.createElement('button');
|
||||
editBtn.className = 'btn btn-secondary btn-small';
|
||||
editBtn.textContent = 'Edit';
|
||||
editBtn.addEventListener('click', () => openEditDeviceModal(devId, dev));
|
||||
|
||||
const deleteBtn = document.createElement('button');
|
||||
deleteBtn.className = 'btn btn-secondary btn-small';
|
||||
deleteBtn.textContent = 'Delete';
|
||||
deleteBtn.addEventListener('click', async () => {
|
||||
if (!confirm(`Delete device "${(dev && dev.name) || devId}"?`)) return;
|
||||
try {
|
||||
const res = await fetch(`/devices/${devId}`, { method: 'DELETE' });
|
||||
if (res.ok) await loadDevicesModal();
|
||||
else {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
alert(data.error || 'Delete failed');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert('Delete failed');
|
||||
}
|
||||
});
|
||||
|
||||
row.appendChild(label);
|
||||
row.appendChild(meta);
|
||||
row.appendChild(editBtn);
|
||||
row.appendChild(deleteBtn);
|
||||
container.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
function openEditDeviceModal(devId, dev) {
|
||||
const modal = document.getElementById('edit-device-modal');
|
||||
const idInput = document.getElementById('edit-device-id');
|
||||
const nameInput = document.getElementById('edit-device-name');
|
||||
const addressBoxes = document.getElementById('edit-device-address-boxes');
|
||||
if (!modal || !idInput) return;
|
||||
idInput.value = devId;
|
||||
if (nameInput) nameInput.value = (dev && dev.name) || '';
|
||||
setAddressToBoxes(addressBoxes, (dev && dev.address) || '');
|
||||
modal.classList.add('active');
|
||||
}
|
||||
|
||||
async function createDevice(name, address) {
|
||||
try {
|
||||
const res = await fetch('/devices', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, address: address || null }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (res.ok) {
|
||||
await loadDevicesModal();
|
||||
return true;
|
||||
}
|
||||
alert(data.error || 'Failed to create device');
|
||||
return false;
|
||||
} catch (e) {
|
||||
console.error('createDevice:', e);
|
||||
alert('Failed to create device');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateDevice(devId, name, address) {
|
||||
try {
|
||||
const res = await fetch(`/devices/${devId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, address: address || null }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (res.ok) {
|
||||
await loadDevicesModal();
|
||||
return true;
|
||||
}
|
||||
alert(data.error || 'Failed to update device');
|
||||
return false;
|
||||
} catch (e) {
|
||||
console.error('updateDevice:', e);
|
||||
alert('Failed to update device');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
makeHexAddressBoxes(document.getElementById('new-device-address-boxes'));
|
||||
makeHexAddressBoxes(document.getElementById('edit-device-address-boxes'));
|
||||
|
||||
const devicesBtn = document.getElementById('devices-btn');
|
||||
const devicesModal = document.getElementById('devices-modal');
|
||||
const devicesCloseBtn = document.getElementById('devices-close-btn');
|
||||
const newName = document.getElementById('new-device-name');
|
||||
const createBtn = document.getElementById('create-device-btn');
|
||||
const editForm = document.getElementById('edit-device-form');
|
||||
const editCloseBtn = document.getElementById('edit-device-close-btn');
|
||||
const editDeviceModal = document.getElementById('edit-device-modal');
|
||||
|
||||
if (devicesBtn && devicesModal) {
|
||||
devicesBtn.addEventListener('click', () => {
|
||||
devicesModal.classList.add('active');
|
||||
loadDevicesModal();
|
||||
});
|
||||
}
|
||||
if (devicesCloseBtn) {
|
||||
devicesCloseBtn.addEventListener('click', () => devicesModal && devicesModal.classList.remove('active'));
|
||||
}
|
||||
const newAddressBoxes = document.getElementById('new-device-address-boxes');
|
||||
const doCreate = async () => {
|
||||
const name = (newName && newName.value.trim()) || '';
|
||||
if (!name) {
|
||||
alert('Device name is required.');
|
||||
return;
|
||||
}
|
||||
const address = newAddressBoxes ? getAddressFromBoxes(newAddressBoxes) : '';
|
||||
const ok = await createDevice(name, address);
|
||||
if (ok && newName) {
|
||||
newName.value = '';
|
||||
setAddressToBoxes(newAddressBoxes, '');
|
||||
}
|
||||
};
|
||||
if (createBtn) createBtn.addEventListener('click', doCreate);
|
||||
if (newName) newName.addEventListener('keypress', (e) => { if (e.key === 'Enter') doCreate(); });
|
||||
|
||||
if (editForm) {
|
||||
editForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const idInput = document.getElementById('edit-device-id');
|
||||
const nameInput = document.getElementById('edit-device-name');
|
||||
const addressBoxes = document.getElementById('edit-device-address-boxes');
|
||||
const devId = idInput && idInput.value;
|
||||
if (!devId) return;
|
||||
const address = addressBoxes ? getAddressFromBoxes(addressBoxes) : '';
|
||||
const ok = await updateDevice(
|
||||
devId,
|
||||
nameInput ? nameInput.value.trim() : '',
|
||||
address
|
||||
);
|
||||
if (ok) editDeviceModal.classList.remove('active');
|
||||
});
|
||||
}
|
||||
if (editCloseBtn) {
|
||||
editCloseBtn.addEventListener('click', () => editDeviceModal && editDeviceModal.classList.remove('active'));
|
||||
}
|
||||
});
|
||||
197
src/static/help.js
Normal file
@@ -0,0 +1,197 @@
|
||||
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');
|
||||
});
|
||||
}
|
||||
|
||||
// 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');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 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';
|
||||
}
|
||||
const chInput = document.getElementById('wifi-channel-input');
|
||||
if (chInput && data && typeof data === 'object') {
|
||||
const ch = data.wifi_channel;
|
||||
chInput.value =
|
||||
ch !== undefined && ch !== null && ch !== '' ? String(ch) : '6';
|
||||
}
|
||||
} 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');
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
const chRaw = document.getElementById('wifi-channel-input')
|
||||
? document.getElementById('wifi-channel-input').value
|
||||
: '6';
|
||||
const wifiChannel = parseInt(chRaw, 10);
|
||||
if (Number.isNaN(wifiChannel) || wifiChannel < 1 || wifiChannel > 11) {
|
||||
showSettingsMessage('WiFi channel must be between 1 and 11', 'error');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await fetch('/settings/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
device_name: deviceName,
|
||||
wifi_channel: wifiChannel,
|
||||
}),
|
||||
});
|
||||
const result = await response.json();
|
||||
if (response.ok) {
|
||||
showSettingsMessage(
|
||||
'Device settings saved. They will apply on next restart where relevant.',
|
||||
'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,75 +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 .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);
|
||||
}
|
||||
}
|
||||
@@ -1,156 +1,81 @@
|
||||
let delayTimeout;
|
||||
let brightnessTimeout;
|
||||
let colorTimeout;
|
||||
let color2Timeout;
|
||||
let socket;
|
||||
import "./rgb-slider.js";
|
||||
|
||||
const host = window.location.host;
|
||||
const ws = new WebSocket("ws://localhost:8000/ws");
|
||||
|
||||
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) // Convert data to JSON string
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error during POST request:', error);
|
||||
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));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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(); // Assuming you are expecting JSON response
|
||||
} catch (error) {
|
||||
console.error('Error during GET request:', error);
|
||||
}
|
||||
// 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");
|
||||
}
|
||||
|
||||
async function updateColor(event) {
|
||||
event.preventDefault();
|
||||
clearTimeout(colorTimeout);
|
||||
colorTimeout = setTimeout(async function() {
|
||||
const color = document.getElementById('color').value;
|
||||
await post("/color", { color }); // Send as JSON
|
||||
}, 500);
|
||||
}
|
||||
|
||||
async function updateColor2(event) {
|
||||
event.preventDefault();
|
||||
clearTimeout(color2Timeout);
|
||||
color2Timeout = setTimeout(async function() {
|
||||
const color = document.getElementById('color2').value;
|
||||
await post("/color2", { color }); // Send as JSON
|
||||
}, 500);
|
||||
}
|
||||
|
||||
async function updatePattern(pattern) {
|
||||
await post("/pattern", { pattern }); // Send as JSON
|
||||
//socket.send(JSON.stringify({"selected_pattern":pattern}))
|
||||
|
||||
}
|
||||
|
||||
async function updateBrightness(event) {
|
||||
event.preventDefault();
|
||||
clearTimeout(brightnessTimeout);
|
||||
brightnessTimeout = setTimeout(async function() {
|
||||
const brightness = document.getElementById('brightness').value;
|
||||
//await post('/brightness', { brightness }); // Send as JSON
|
||||
}, 500);
|
||||
}
|
||||
|
||||
async function updateDelay(event) {
|
||||
event.preventDefault();
|
||||
clearTimeout(delayTimeout);
|
||||
delayTimeout = setTimeout(async function() {
|
||||
const delay = document.getElementById('delay').value;
|
||||
await post('/delay', { delay }); // Send as JSON
|
||||
}, 500);
|
||||
}
|
||||
|
||||
async function updateNumLeds(event) {
|
||||
event.preventDefault();
|
||||
const numLeds = document.getElementById('num_leds').value;
|
||||
await post('/num_leds', { num_leds: numLeds }); // Send as JSON
|
||||
}
|
||||
|
||||
async function updateWifi(event) {
|
||||
event.preventDefault();
|
||||
const ssid = document.getElementById('ssid').value;
|
||||
const password = document.getElementById('password').value;
|
||||
const ip = document.getElementById('ip').value;
|
||||
const gateway = document.getElementById('gateway').value;
|
||||
|
||||
const wifiSettings = { ssid, password, ip, gateway }; // Create JSON object
|
||||
console.log(wifiSettings);
|
||||
const response = await post('/wifi_settings', wifiSettings); // Send as JSON
|
||||
if (response === 500) {
|
||||
alert("Failed to connect to Wi-Fi");
|
||||
}
|
||||
}
|
||||
|
||||
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'; // Use 'button' instead of 'submit'
|
||||
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() {
|
||||
document.getElementById('color').addEventListener('input', updateColor);
|
||||
document.getElementById('color2').addEventListener('input', updateColor2);
|
||||
document.getElementById('delay').addEventListener('input', updateDelay);
|
||||
document.getElementById('brightness').addEventListener('input', updateBrightness);
|
||||
document.getElementById('num_leds_form').addEventListener('submit', updateNumLeds);
|
||||
document.getElementById('wifi_form').addEventListener('submit', updateWifi);
|
||||
document.getElementById('delay').addEventListener('touchend', updateDelay);
|
||||
document.getElementById('brightness').addEventListener('touchend', updateBrightness);
|
||||
|
||||
document.querySelectorAll(".pattern_button").forEach(button => {
|
||||
console.log(button.value);
|
||||
button.addEventListener('click', async event => {
|
||||
event.preventDefault();
|
||||
await updatePattern(button.value);
|
||||
});
|
||||
});
|
||||
|
||||
socket = new WebSocket(`ws://${host}/settings`)
|
||||
|
||||
|
||||
|
||||
// Add event listeners to tabs
|
||||
tabsContainer.addEventListener("click", (e) => {
|
||||
if (e.target.classList.contains("tab")) {
|
||||
switchTab(e.target.id);
|
||||
}
|
||||
});
|
||||
|
||||
// Function to toggle the display of the settings menu
|
||||
function selectSettings() {
|
||||
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';
|
||||
}
|
||||
// Initially set the first tab as active
|
||||
switchTab("tab1");
|
||||
|
||||
81
src/static/patterns.js
Normal file
@@ -0,0 +1,81 @@
|
||||
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);
|
||||
}
|
||||
|
||||
});
|
||||