Compare commits
8 Commits
preset
...
3bb75d49de
| Author | SHA1 | Date | |
|---|---|---|---|
| 3bb75d49de | |||
| 3d77cb448a | |||
| 49383c0003 | |||
| 7d821b9c1c | |||
| 9b7e387ea6 | |||
| b4f0d1891e | |||
| 0da30b6d6b | |||
| 6cbb728d9a |
12
.cursor/rules/pattern-workflow.mdc
Normal file
12
.cursor/rules/pattern-workflow.mdc
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
---
|
||||||
|
description: Require test pattern, pattern metadata, and test preset for new patterns
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# Pattern workflow requirements
|
||||||
|
|
||||||
|
1. When creating a new pattern under `led-driver/src/patterns/`, also add/update a corresponding test file in `led-driver/tests/patterns/`.
|
||||||
|
|
||||||
|
2. When adding a new pattern, ensure led-controller has `db/pattern.json`; if it does not exist, create it. Add the new pattern metadata and parameter mappings there.
|
||||||
|
|
||||||
|
3. When adding a new pattern, add at least one test preset entry in `db/preset.json` in led-controller that uses the new pattern.
|
||||||
3
.gitmodules
vendored
3
.gitmodules
vendored
@@ -4,3 +4,6 @@
|
|||||||
[submodule "led-tool"]
|
[submodule "led-tool"]
|
||||||
path = led-tool
|
path = led-tool
|
||||||
url = git@git.technical.kiwi:technicalkiwi/led-tool.git
|
url = git@git.technical.kiwi:technicalkiwi/led-tool.git
|
||||||
|
[submodule "led-simulator"]
|
||||||
|
path = led-simulator
|
||||||
|
url = git@git.technical.kiwi:technicalkiwi/led-simulator.git
|
||||||
|
|||||||
@@ -1,92 +1 @@
|
|||||||
{
|
{"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}, "colour_cycle": {"n1": "Step Rate", "min_delay": 10, "max_delay": 10000, "max_colors": 10}, "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}, "flicker": {"n1": "Min brightness", "min_delay": 10, "max_delay": 10000, "max_colors": 10}, "flame": {"n1": "Min brightness", "n2": "Breath period (ms)", "n3": "Spark gap min (ms, 0=default 10\u201330 s, -1=off)", "n4": "Spark gap max (ms)", "min_delay": 10, "max_delay": 10000, "max_colors": 10}, "twinkle": {"n1": "Twinkle activity (1\u2013255, higher = more changes)", "n2": "Density (0\u2013255, higher = more of the strip lit)", "n3": "Min adjacent LEDs per twinkle (same as max for fixed length)", "n4": "Max adjacent LEDs per twinkle (same as min for fixed length)", "min_delay": 10, "max_delay": 10000, "max_colors": 10}, "radiate": {"n1": "Node spacing (LEDs)", "n2": "Out time (ms)", "n3": "In time (ms)", "min_delay": 10, "max_delay": 10000, "max_colors": 2}, "meteor_rain": {"n1": "Tail length", "n2": "Speed (LEDs per frame)", "n3": "Fade amount (1-255)", "min_delay": 10, "max_delay": 10000, "max_colors": 10}, "scanner": {"n1": "Eye width", "n2": "End pause (frames)", "min_delay": 10, "max_delay": 10000, "max_colors": 10}, "gradient_scroll": {"n1": "Scroll step rate", "min_delay": 10, "max_delay": 10000, "max_colors": 10}, "comet_dual": {"n1": "Tail length", "n2": "Speed", "n3": "Gap", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "sparkle_trail": {"n1": "Spark density", "n2": "Decay", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "wave": {"n1": "Wavelength", "n2": "Amplitude", "n3": "Drift speed", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "plasma": {"n1": "Scale", "n2": "Speed", "n3": "Contrast", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "segment_chase": {"n1": "Segment size", "n2": "Phase step", "n3": "Segment phase offset", "n4": "Gap per segment", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "bar_graph": {"n1": "Level percent", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "breathing_dual": {"n1": "Phase offset", "n2": "Ease", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "strobe_burst": {"n1": "Burst count", "n2": "Burst gap", "n3": "Cooldown", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "rain_drops": {"n1": "Drop rate", "n2": "Ripple width", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "fireflies": {"n1": "Count", "n2": "Twinkle speed", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "clock_sweep": {"n1": "Hand width", "n2": "Marker interval", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "marquee": {"n1": "On length", "n2": "Off length", "n3": "Step", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "aurora": {"n1": "Band count", "n2": "Shimmer", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "snowfall": {"n1": "Flake density", "n2": "Fall speed", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "heartbeat": {"n1": "Pulse 1 ms", "n2": "Pulse 2 ms", "n3": "Pause ms", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "orbit": {"n1": "Orbit count", "n2": "Base speed", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "palette_morph": {"n1": "Morph ms", "n2": "Warp rate", "n3": "Turbulence", "max_colors": 10, "min_delay": 10, "max_delay": 10000}}
|
||||||
"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
|
|
||||||
},
|
|
||||||
"colour_cycle": {
|
|
||||||
"n1": "Step Rate",
|
|
||||||
"min_delay": 10,
|
|
||||||
"max_delay": 10000,
|
|
||||||
"max_colors": 10
|
|
||||||
},
|
|
||||||
"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
|
|
||||||
},
|
|
||||||
"flicker": {
|
|
||||||
"n1": "Min brightness",
|
|
||||||
"min_delay": 10,
|
|
||||||
"max_delay": 10000,
|
|
||||||
"max_colors": 10
|
|
||||||
},
|
|
||||||
"flame": {
|
|
||||||
"n1": "Min brightness",
|
|
||||||
"n2": "Breath period (ms)",
|
|
||||||
"n3": "Spark gap min (ms, 0=default 10–30 s, -1=off)",
|
|
||||||
"n4": "Spark gap max (ms)",
|
|
||||||
"min_delay": 10,
|
|
||||||
"max_delay": 10000,
|
|
||||||
"max_colors": 10
|
|
||||||
},
|
|
||||||
"twinkle": {
|
|
||||||
"n1": "Twinkle activity (1–255, higher = more changes)",
|
|
||||||
"n2": "Density (0–255, higher = more of the strip lit)",
|
|
||||||
"n3": "Min adjacent LEDs per twinkle (same as max for fixed length)",
|
|
||||||
"n4": "Max adjacent LEDs per twinkle (same as min for fixed length)",
|
|
||||||
"min_delay": 10,
|
|
||||||
"max_delay": 10000,
|
|
||||||
"max_colors": 10
|
|
||||||
},
|
|
||||||
"radiate": {
|
|
||||||
"n1": "Node spacing (LEDs)",
|
|
||||||
"n2": "Out time (ms)",
|
|
||||||
"n3": "In time (ms)",
|
|
||||||
"min_delay": 10,
|
|
||||||
"max_delay": 10000,
|
|
||||||
"max_colors": 2
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because one or more lines are too long
BIN
db/presets/1.bin
Normal file
BIN
db/presets/1.bin
Normal file
Binary file not shown.
3
db/presets/10.bin
Normal file
3
db/presets/10.bin
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
PRST1xœ%ÎÁ
|
||||||
|
Â0Ð_‘ñšCSµJîæ'D$¶«
|
||||||
|
ÄÝ’¦ˆˆÿntOovæ²opxz‘´zޱ¦P
|
||||||
2
db/presets/11.bin
Normal file
2
db/presets/11.bin
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
PRST1xњ%ОAВ …б»<·,J5\Е4
|
||||||
|
К $84SX4Ж»‹eхеНlюШЅ B
|
||||||
1
db/presets/12.bin
Normal file
1
db/presets/12.bin
Normal file
@@ -0,0 +1 @@
|
|||||||
|
PRST1xœ%ÎA л|·, ŠÐK˜ÆP;*
|
||||||
2
db/presets/13.bin
Normal file
2
db/presets/13.bin
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
PRST1xœEÎÁ
|
||||||
|
Â0Ð_‘9ç`«Qɯˆ”Ô®ˆ»e“RDüwsðô˜™Ë¼ÁñIИx”uS²¬p˜c¤ü¬»J-ç‹Ã¨éþ¨LÅrï½ÃD9¾:¿uˆK„ª9pg¥Ñ#ØÂ»Æ¾á‡Æ±qú1«ÜR¦!Mö¡Ãç<0B><>1
|
||||||
2
db/presets/14.bin
Normal file
2
db/presets/14.bin
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
PRST1xœ=ÎÝ
|
||||||
|
!†á[‰¯StK[¼€½‰ˆ°v*ÁTü!"º÷Ü¤Žžá<C5BE>9˜¼¹4bu™VÙ…¢)…’ÿåVÎÁ…”¡÷XO“RœãÀpJöz+žr[R2ÌäÌzäœÁÔ KªÄàE;àKõ´èÓæß¶Ð²£:»Îø%¦p±ŽŽvn? ¼?<3F>¨2ú
|
||||||
BIN
db/presets/15.bin
Normal file
BIN
db/presets/15.bin
Normal file
Binary file not shown.
BIN
db/presets/2.bin
Normal file
BIN
db/presets/2.bin
Normal file
Binary file not shown.
2
db/presets/3.bin
Normal file
2
db/presets/3.bin
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
PRST1xœUÎÁ
|
||||||
|
Â0ЙsM5Uò+"²µ«â¦lSDÄwiNž³3‡ý@èɈPJ2–fª•Uþn×’‹.ˆ§³Ã¨éþ¨Â‹å>‡‰3½}×9ÐZbÕ•ÄÛÀè‘]cß<08>¡qh7f-·”ù’&ûÁãûF9/.
|
||||||
2
db/presets/30.bin
Normal file
2
db/presets/30.bin
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
PRST1xœEÎÁ
|
||||||
|
Â0Ð_‘9ç`«Qɯˆ”Ô®ˆ»e“RDüwsðô˜™Ë¼ÁñIИx”uS²¬p˜c¤ü¬»J-ç‹Ã¨éþ¨LÅrï½ÃD9¾:¿uˆK„ª9pg¥Ñ#ØÂ»Æ¾á‡Æ±qú1«ÜR¦!Mö¡Çç<0B>“1
|
||||||
BIN
db/presets/31.bin
Normal file
BIN
db/presets/31.bin
Normal file
Binary file not shown.
2
db/presets/32.bin
Normal file
2
db/presets/32.bin
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
PRST1xœ%ͽÂ0àW©Ž5C~•&VÆ
|
||||||
|
¡@<40>)uª4K…xwR<}ç»Á° —ks<DjÎ)¦…É•B™ë–¸ž¯µža;l¼×Ú{Üž9ïÂ4×ÁÐStl«kævÅ[a'ì…ƒpN¦œ|ˆô}ýmðý‡-‰
|
||||||
1
db/presets/33.bin
Normal file
1
db/presets/33.bin
Normal file
@@ -0,0 +1 @@
|
|||||||
|
PRST1xœMÎ1!†á¿b¾[=5ÌNÎnÆô@I°\€Åÿ»Å.²<oÚ¼Aîéa±?,ŽÅQ<C385>-f‚ÂìZó…xÓþÇ·œr©°'!h~<´î-Õg…k‰÷G#_ùØ0ùä^Ü#7-a;FX ka6ÂVØý˜K1ùKœø_Ÿ/ÐM4y
|
||||||
BIN
db/presets/34.bin
Normal file
BIN
db/presets/34.bin
Normal file
Binary file not shown.
2
db/presets/35.bin
Normal file
2
db/presets/35.bin
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
PRST1xœ%ͽÂ0àW©Ž5C~•&VÆ
|
||||||
|
¡@<40>)uª4K…xwR<}ç»Á° —ks<DjÎ)¦…É•B™ë–¸ž¯µža;l¼×Ú{Üž9ïÂ4×ÁÐStl«kævÅ[a'ì…ƒpN¦œ|ˆô}ýmðý‡-‰
|
||||||
1
db/presets/36.bin
Normal file
1
db/presets/36.bin
Normal file
@@ -0,0 +1 @@
|
|||||||
|
PRST1xœMÎ1!†á¿b¾[=5ÌNÎnÆô@I°\€Åÿ»Å.²<oÚ¼Aîéa±?,ŽÅQ<C385>-f‚ÂìZó…xÓþÇ·œr©°'!h~<´î-Õg…k‰÷G#_ùØ0ùä^Ü#7-a;FX ka6ÂVØý˜K1ùKœø_Ÿ/ÐM4y
|
||||||
BIN
db/presets/37.bin
Normal file
BIN
db/presets/37.bin
Normal file
Binary file not shown.
BIN
db/presets/38.bin
Normal file
BIN
db/presets/38.bin
Normal file
Binary file not shown.
3
db/presets/39.bin
Normal file
3
db/presets/39.bin
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
PRST1xœUÎÁ‚0„áw¯=¤jú*†<>
|
||||||
|
[m\[²”ƒ1¾»…ž<}ÉÌåÿ ºÁÂsŸ$P˜]Î$ño'Y`¯88ÒÚ{ô
|
||||||
|
7 ÷GŽ´”£5Fa"voX£Üšl–•bÛè2ÆvãXé*¦rªœ+—<>Y’LC˜JM³·1•ºAÈo5qeî¿?ªð9±
|
||||||
BIN
db/presets/4.bin
Normal file
BIN
db/presets/4.bin
Normal file
Binary file not shown.
4
db/presets/40.bin
Normal file
4
db/presets/40.bin
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
PRST1xśMÎÁ‚0„áwŻ=$ű*†<>
|
||||||
|
[%Y[RÚ1ľ»…^<}ÉĚĺ˙Ŕ™7<E284A2>`ĺPa51rpËäŇ
|
||||||
|
tÇĹÚ©×<1A>Â#,ĎWtĽĺŁŞ{…™Ĺě V+<2B>=(†Ä
|
||||||
|
®5m¶ŐťÎŻk@×B[č
|
||||||
2
db/presets/41.bin
Normal file
2
db/presets/41.bin
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
PRST1xśmŹÁ‚0†ßĄ\wČ`ŮMQ^Â2ĄčâÜČ1ĆřînĚ‹‰—~í—?Mű#ďüC™›F 0IďŃ™w¶ÚşÄ˛š7Ľm<C4BD>ËĺMęveýuUąo<v[şć:'§.Wop
|
||||||
|
ƨĺDN)ąx» <09><H¤)B2r"˘Śá@–Ć*ˇNŕ+&gGĄ±WC8<_ßĐéŽńpłhMţ”îýŹ!I°
|
||||||
2
db/presets/42.bin
Normal file
2
db/presets/42.bin
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
PRST1xњUЋ;В0птТєp>°WAQґђ5X2Nд8BЬ;©hv¤·SМЃ_BдЙq(,њ’Др·Эg?ЗtEЕЅЦЦжТZіf
|
||||||
|
·иПdНJcЊВ$ћЯ “ЮТJq…PѓЪј…t)ПР‚є]ЁАињњw,q¶ОЛи¦\Wп^rнЕ–є°yЇКѕ?Эh>Ў
|
||||||
BIN
db/presets/43.bin
Normal file
BIN
db/presets/43.bin
Normal file
Binary file not shown.
2
db/presets/44.bin
Normal file
2
db/presets/44.bin
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
PRST1xœEÎM
|
||||||
|
Â0à«Ès›Eÿ¢’ôE$¶£â¤$Ó…ˆww0góÁ{o1o°„ŠìÊì™)Ã`õ"”Y‹6§˜r<CB9C>›°ÇFgƒk÷‡0-:k
|
||||||
3
db/presets/45.bin
Normal file
3
db/presets/45.bin
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
PRST1xœ=ŽA‚0E¯B>Û.
|
||||||
|
*š€KC*ŒØ¤¶¤Æxw<1B>Í{™7‹y!ØÁ€)s5';9
|
||||||
|
\å1Eï¡°XfJA~mø·1ú˜2ÌußkÙÕZo^ls\®ÉÍw”å¸mµÂDÞ>a:Q»r„á´’Bh¤Z)aW°/8tÇ‚ÓKŠ7çip“üÙàý)<¡
|
||||||
3
db/presets/46.bin
Normal file
3
db/presets/46.bin
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
PRST1xś-ÎÁ‚0Đ_!õ‡Šdo˝ô'Ś!Ş’”–”ĺ`Ś˙î<˝ÍĚö<>čfű•‹!Íž‹qs
|
||||||
|
‹cö9J·Çý?RHy]QZkŚÖ’•Zc-n
|
||||||
|
÷<=_ý*“Zk…Ń÷µrşŤ<13>óćbę„T
|
||||||
2
db/presets/47.bin
Normal file
2
db/presets/47.bin
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
PRST1x<EFBFBD>5־A‚0…ב«<D791>ַ¶@Dׂ- —0ֶT©<54>X[2ֶxwG׳ש&»˜‚yXh°M\₪<>׀<EFBFBD><D780>‚ֹ8…<>0[
|
||||||
|
’ור/חט#%ט=ֺ¾†q”·r\…¹כ<C2B9>ƒMע¥©*…ֹzף„מd5Gh¦ֵ*„Zz+6b-1l ¿´™m¦ֻל2ֺLסגה"7ֹy5<79>־ד:G
|
||||||
2
db/presets/48.bin
Normal file
2
db/presets/48.bin
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
PRST1xœ-ÎÁ Ð_1ã•ÔZŽúÆ´«’ 4°Õã¿»Š§7;sÙ¢»,˜
|
||||||
|
/îNP˜3å(í¿8¥<38>r<EFBFBD>Ýa©õ¶ìŽÙ_®©ÈÐh0RpOØN¢›9ÁržI!XÓˆ<C393>ØËW„ö{+]eSéL9<4C>} ƒåƒ÷ªù0¿
|
||||||
2
db/presets/49.bin
Normal file
2
db/presets/49.bin
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
PRST1x<EFBFBD>=ЮA
|
||||||
|
Т0аЋШw<D0A8>EZ5JаK<14>б<EFBFBD>ZH<5A><48>L"онС<D0BD>Ћ7ќџѓFЄ<46>с!\e<>е<>`<60>I<EFBFBD>KдќнRHЅТ<D085>и<0E>ЕЮсlp-ѓу)<29>ЋНЕzС;=i<>/ee<65>иiІє:Sv<53>=МютЁсЧЦщG.щ>ОЬ<D09E>Овсѓ,<2C>1И
|
||||||
BIN
db/presets/5.bin
Normal file
BIN
db/presets/5.bin
Normal file
Binary file not shown.
2
db/presets/50.bin
Normal file
2
db/presets/50.bin
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
PRST1xœ5ÎA‚0Ы<C390>϶‹‚ˆ¦è%Œ!F <20>–´ÃÂïîhu6o2ÿ/æ ïV‚Sâ"Ѹ’碟\"(lŽ™¢—ø—tÿ¤Kˆ æ‚ÒZ-#·ò£µ¸*Üâ<Nì)I¥ÖZa Å=`ZYÝΆãN
|
||||||
|
¾‚i„¦0RðMæ˜i3§ÌùËÃ}^¨›ùÂë
|
||||||
BIN
db/presets/51.bin
Normal file
BIN
db/presets/51.bin
Normal file
Binary file not shown.
BIN
db/presets/52.bin
Normal file
BIN
db/presets/52.bin
Normal file
Binary file not shown.
2
db/presets/53.bin
Normal file
2
db/presets/53.bin
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
PRST1xœ5Î=Â0†á«Tk†þQ<C3BE>À%*T%Ô@¥’TŽ; ÄÝIáå±ôzðÞ¾å¨ET Ž·JT,V•ŧšÃð·0‰ ‡Ë>¸8™OõS¨ËÒ`äÙ¾A]Zíª¤²²<C2B2>¯@M¢ÎÉ7 v;÷-hã˜é2§Ìyg‘pŸf¦1ýTáû^
|
||||||
|
7˜
|
||||||
3
db/presets/54.bin
Normal file
3
db/presets/54.bin
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
PRST1x<EFBFBD>5ΞΝ
|
||||||
|
Β0ΰW)γ5‡ώhΉϊ"%ΪU5)›νAΔww5xϊ–™9μΑ=BI
|
||||||
|
v>Η%Α`q"ΔA»o<ώγK<CEB3>#'Ψ#6‡²ο†'ƒ3ϋΫ]%-κ²4<C2B2>hvOΨVO·J„^Ι T°MΦ<C2AD><CEA6>ΐκ"l3»L›ΩgΊΗ«<CE97>iτ“ώSαύ<01><>5%
|
||||||
4
db/presets/55.bin
Normal file
4
db/presets/55.bin
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
PRST1xœMαÂ0Ð_A×5CZ ´™Q~!¨‘BR%î€ÿŽE¦gÝÝà7¢{˜
|
||||||
|
ofŸiž
|
||||||
|
ÇL9JõŸÞRH¹ÀœÐX{Ô½–¬µµ£ÆYášýýÁ‘ŠL:&
|
||||||
|
îÓËéVN0œWRˆdB3[Ä]e_é+‡ÊðcÉiö<69>.~’¿Z|¾¡ 61
|
||||||
1
db/presets/56.bin
Normal file
1
db/presets/56.bin
Normal file
@@ -0,0 +1 @@
|
|||||||
|
PRST1xœ5ŽAƒ E¯b¾[¨U+WiŒ¡2¶¦`š¦éÝ’nxÌ›Y¼Œ|ùPÌÚÎ<C39A>¿ˆ60l2r&.?ýýlµuâ‚Rõ|àCt%Wuß5®n½Ýƒ!OjÎiùN¹ÜN¦‚¨¢35DÑ@¤é”Ñft}ÆùÀæì²jšVÓª#TSL<53>-)ËìZ³ôŒßQ•AÓ
|
||||||
1
db/presets/57.bin
Normal file
1
db/presets/57.bin
Normal file
@@ -0,0 +1 @@
|
|||||||
|
PRST1xњEО1В0Р« ПљЎiЎ ЂK „5)MЪФвоXНЂ—gщяБD72В‹lF—зВѓЙ‰pЋьoчR^@glOлаbpЛющ’И‹mУЬФлкЉ$ђдВС‚:ҐХљТЃ¬Іi/о+}еP9®L9=|а«ф‹пжg2д
|
||||||
2
db/presets/58.bin
Normal file
2
db/presets/58.bin
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
PRST1xœ=ÎÍ
|
||||||
|
Â0àW‘é5‡ô?ìM"} ‰vÕBMJ’D|wSž¾afû†5O!rˆ;³zç
|
||||||
3
db/presets/59.bin
Normal file
3
db/presets/59.bin
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
PRST1x°Mна
|
||||||
|
б0ЮW▒вз╘SzTЯ%D╓╨L╣m├┬ЬНfКе\╬ДOЫ ╦'а┌)С"┤ЬЙ°ВP3╔ ⌡©П}LЖ└Й8≈dуNЖр²╝╘©?8P√⌠Zk┘√╪{ц6р╨▒#,╖▒┌≥Жb
|
||||||
|
k└%Л4╜
|
||||||
2
db/presets/6.bin
Normal file
2
db/presets/6.bin
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
PRST1xœMÎK
|
||||||
|
Â0…á½§ÜT£’tR$Ú«âMÉc âÞm<C39E>ˆ£þ39Oˆ»3,¦2Car¥p’¿rŽ!¦{ÀЍï‰0(œ’¿ÞŠpž‡Î…‘ƒ{À"WK„-©²‚hXMK•î;Ëú—6°¦±mìûSŠøèÇù’Æë
|
||||||
4
db/presets/60.bin
Normal file
4
db/presets/60.bin
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
PRST1xœMÎA‚0Ы˜ï¶‹RÉ€KcŠŒBR[Òc¼»l\½Éÿùɼáí“ANr˜ÙFÙ
|
||||||
|
V+ÂÑçê?½b
|
||||||
|
8ö½éj<EFBFBD>‹Â—Ç,žS.ŒÖ
|
||||||
|
;ûµù´›<04>Ä<EFBFBD>|ªL½uŨ)_ƒ
|
||||||
BIN
db/presets/61.bin
Normal file
BIN
db/presets/61.bin
Normal file
Binary file not shown.
3
db/presets/62.bin
Normal file
3
db/presets/62.bin
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
PRST1xœ5ŽA‚0E¯B>Û.
|
||||||
|
*š€KCªŒBRÚ¦c¼»ÅÙ¼7óÿb>ðv"0Í\D눙Š)¤8@!ZÙ’—xOºò.¤æŠ²mµŒÜJW϶:n
|
||||||
|
÷4¾ö4K¹ÖZ¡'gß0<C39F>¨]8ÀpZHÁW0ÕVðõÞô˜ÇŒSF“qθlˆ)<GGÝØË«¾?ð¹<
|
||||||
3
db/presets/7.bin
Normal file
3
db/presets/7.bin
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
PRST1xœMŽ1Â0Eïò»fp
|
||||||
|
<EFBFBD>(K/<2F>
|
||||||
|
<EFBFBD>H!©Òt@ˆ»cÈÂô¾Ÿ¿%¿<>üƒá0†2F†Âìkå’þÕ˜c.ÜÝ0‘¸Î‘%œ.%Üî5ñ"•Þ…‰£J&RðkÍpµ¬¬<C2AC>´HA§e•6mÜÂÉQ2p_¹kØ7Øæ’¯!ò9Lò–Æû¼Ã1ó
|
||||||
BIN
db/presets/8.bin
Normal file
BIN
db/presets/8.bin
Normal file
Binary file not shown.
2
db/presets/9.bin
Normal file
2
db/presets/9.bin
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
PRST1xœ%ÎK
|
||||||
|
Ã0Ы”éÖ‹$ýâ«”ÜFnŽ›PJï^ÇÖæI£Í|Áf&hlFæÃ6¹HPXLŒ$œãÀù|d…~àhË WxŠ{O‘iÍ<69>®iFòæÝî»I1@GI¤À-tޏ«œ*çÊ¥rÜ*÷Â"Á:Oƒs<>¶´ò”{
|
||||||
@@ -1 +1 @@
|
|||||||
{"1": {"name": "default", "names": ["led-188b0e1560a8", "led-f0f5bdfb9d30"], "presets": [["4", "2", "7"], ["3", "14", "5"], ["8", "9", "1"], ["6", "38", "42"], ["39", "40", "41"]], "presets_flat": ["4", "2", "7", "3", "14", "5", "8", "9", "1", "6", "38", "42", "39", "40", "41"], "default_preset": "4"}, "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": ["led-188b0e1560a8"], "presets": [["1", "2", "3"], ["4", "5"]], "default_preset": "1", "presets_flat": ["1", "2", "3", "4", "5"]}}
|
{"1": {"name": "default", "names": ["led-188b0e1560a8", "led-f0f5bdfb9d30"], "presets": [["4", "2", "7"], ["3", "14", "5"], ["8", "9", "1"], ["6", "38", "42"], ["39", "40", "41"], ["43", "44", "45"], ["46", "47", "48"], ["49", "50", "51"], ["52", "53", "54"], ["55", "56", "57"], ["58", "59", "60"], ["61", "62"]], "presets_flat": ["4", "2", "7", "3", "14", "5", "8", "9", "1", "6", "38", "42", "39", "40", "41", "43", "44", "45", "46", "47", "48", "49", "50", "51", "52", "53", "54", "55", "56", "57", "58", "59", "60", "61", "62"], "default_preset": "41"}, "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, "presets_flat": []}, "7": {"name": "dj", "names": ["dj"], "presets": [["34", "35", "36"]], "default_preset": "34", "presets_flat": ["34", "35", "36"]}, "8": {"name": "test", "names": ["led-188b0e1560a8"], "presets": [["1", "2", "3"], ["4", "5"]], "default_preset": "1", "presets_flat": ["1", "2", "3", "4", "5"]}}
|
||||||
7
espnow-sender/README.md
Normal file
7
espnow-sender/README.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# espnow-sender
|
||||||
|
|
||||||
|
Minimal MicroPython project for receiving JSON over Microdot WebSocket.
|
||||||
|
|
||||||
|
- WebSocket endpoint: `/ws`
|
||||||
|
- Entry point: `main.py`
|
||||||
|
- Message template: `msg.json`
|
||||||
120
espnow-sender/main.py
Normal file
120
espnow-sender/main.py
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
|
||||||
|
from microdot import Microdot
|
||||||
|
from microdot.websocket import WebSocketError, with_websocket
|
||||||
|
|
||||||
|
import espnow
|
||||||
|
import network
|
||||||
|
from util import format_mac, parse_mac
|
||||||
|
|
||||||
|
|
||||||
|
app = Microdot()
|
||||||
|
_esp = None
|
||||||
|
_known_peers = set()
|
||||||
|
_ws_clients = set()
|
||||||
|
|
||||||
|
|
||||||
|
def _init_espnow():
|
||||||
|
global _esp
|
||||||
|
sta = network.WLAN(network.STA_IF)
|
||||||
|
sta.active(True)
|
||||||
|
_esp = espnow.ESPNow()
|
||||||
|
_esp.active(True)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_envelope(obj):
|
||||||
|
if obj.get("v") != "1":
|
||||||
|
raise ValueError("message.v must be '1'")
|
||||||
|
devices = obj["devices"]
|
||||||
|
for address in devices.keys():
|
||||||
|
parse_mac(address)
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
def _send_espnow(address, payload):
|
||||||
|
if _esp is None:
|
||||||
|
raise ValueError("espnow is not initialized")
|
||||||
|
mac = parse_mac(address)
|
||||||
|
msg = json.dumps(payload, separators=(",", ":")).encode("utf-8")
|
||||||
|
if mac not in _known_peers:
|
||||||
|
_esp.add_peer(mac)
|
||||||
|
_known_peers.add(mac)
|
||||||
|
_esp.send(mac, msg)
|
||||||
|
return mac, len(msg)
|
||||||
|
|
||||||
|
|
||||||
|
async def _broadcast_ws(obj):
|
||||||
|
text = json.dumps(obj)
|
||||||
|
dead = []
|
||||||
|
for client in list(_ws_clients):
|
||||||
|
try:
|
||||||
|
await client.send(text)
|
||||||
|
except Exception:
|
||||||
|
dead.append(client)
|
||||||
|
for client in dead:
|
||||||
|
_ws_clients.discard(client)
|
||||||
|
|
||||||
|
|
||||||
|
async def _espnow_receive_loop():
|
||||||
|
while True:
|
||||||
|
host, msg = _esp.recv(0)
|
||||||
|
if not host:
|
||||||
|
await asyncio.sleep(0.01)
|
||||||
|
continue
|
||||||
|
await _broadcast_ws(
|
||||||
|
{
|
||||||
|
"from": format_mac(host),
|
||||||
|
"payload": msg.decode("utf-8"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/ws")
|
||||||
|
@with_websocket
|
||||||
|
async def ws(request, ws):
|
||||||
|
_ws_clients.add(ws)
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
raw = await ws.receive()
|
||||||
|
except WebSocketError:
|
||||||
|
break
|
||||||
|
|
||||||
|
if not raw:
|
||||||
|
break
|
||||||
|
|
||||||
|
try:
|
||||||
|
parsed = json.loads(raw)
|
||||||
|
env = _validate_envelope(parsed)
|
||||||
|
sent = []
|
||||||
|
for address, payload in env["devices"].items():
|
||||||
|
mac, payload_size = _send_espnow(address, payload)
|
||||||
|
sent.append(
|
||||||
|
{
|
||||||
|
"address": format_mac(mac),
|
||||||
|
"bytes": payload_size,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except (ValueError, TypeError) as e:
|
||||||
|
await ws.send(json.dumps({"ok": False, "error": str(e)}))
|
||||||
|
continue
|
||||||
|
|
||||||
|
await ws.send(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"ok": True,
|
||||||
|
"sent": sent,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
_ws_clients.discard(ws)
|
||||||
|
|
||||||
|
|
||||||
|
async def main(port=80):
|
||||||
|
_init_espnow()
|
||||||
|
asyncio.create_task(_espnow_receive_loop())
|
||||||
|
await app.start_server(host="0.0.0.0", port=port)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main(port=80))
|
||||||
24
espnow-sender/msg.json
Normal file
24
espnow-sender/msg.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"v": "1",
|
||||||
|
"devices": {
|
||||||
|
"ff:ff:ff:ff:ff:ff": {
|
||||||
|
"presets": {
|
||||||
|
"preset_id": {
|
||||||
|
"pattern": "on",
|
||||||
|
"colors": ["#FF0000"],
|
||||||
|
"delay": 100,
|
||||||
|
"brightness": 255,
|
||||||
|
"auto": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"select": {
|
||||||
|
"preset": "preset_id",
|
||||||
|
"step": 0
|
||||||
|
},
|
||||||
|
"save": true,
|
||||||
|
"default": "preset_id",
|
||||||
|
"b": 255
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
12
espnow-sender/util.py
Normal file
12
espnow-sender/util.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
def parse_mac(value):
|
||||||
|
raw = value.strip().lower().replace(":", "").replace("-", "")
|
||||||
|
if len(raw) != 12:
|
||||||
|
raise ValueError("address must be 12 hex chars or aa:bb:cc:dd:ee:ff")
|
||||||
|
try:
|
||||||
|
return bytes.fromhex(raw)
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError("address contains non-hex characters")
|
||||||
|
|
||||||
|
|
||||||
|
def format_mac(mac_bytes):
|
||||||
|
return ":".join("{:02x}".format(b) for b in mac_bytes)
|
||||||
Submodule led-driver updated: 428ed8b884...3ee89ce3b4
1
led-simulator
Submodule
1
led-simulator
Submodule
Submodule led-simulator added at 7ce56b64df
2
led-tool
2
led-tool
Submodule led-tool updated: 713cd6e9a1...2f3db9272b
123
led_bar_vertical_stand.scad
Normal file
123
led_bar_vertical_stand.scad
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
// Parametric LED bar vertical stand socket
|
||||||
|
// For a bar nominally 14 x 17 mm, 2 m long.
|
||||||
|
// This part is intended to be screwed to an MDF base.
|
||||||
|
|
||||||
|
// -------------------------
|
||||||
|
// User parameters
|
||||||
|
// -------------------------
|
||||||
|
bar_w = 14; // Bar width (mm)
|
||||||
|
bar_d = 17; // Bar depth (mm)
|
||||||
|
clearance = 0.4; // Total clearance added to each axis (mm)
|
||||||
|
|
||||||
|
socket_height = 36; // Height of printed socket body (mm)
|
||||||
|
wall = 3.2; // Socket wall thickness (mm)
|
||||||
|
base_thickness = 5; // Printed bottom plate thickness (mm)
|
||||||
|
|
||||||
|
// USB cable/connector side opening
|
||||||
|
usb_notch_enable = true;
|
||||||
|
usb_notch_w = 11;
|
||||||
|
usb_notch_h = 9;
|
||||||
|
usb_notch_from_bottom = 6;
|
||||||
|
usb_notch_side = "right"; // "right" or "left"
|
||||||
|
|
||||||
|
// Mounting ears for MDF screws
|
||||||
|
ear_enable = true;
|
||||||
|
ear_len = 16;
|
||||||
|
ear_w = 16;
|
||||||
|
ear_thickness = base_thickness;
|
||||||
|
screw_hole_d = 4.2; // M4 clearance. Use 3.4 for M3.
|
||||||
|
screw_hole_edge = 5.5; // Hole center offset from ear outer corner
|
||||||
|
|
||||||
|
// Optional clamp lip at top to reduce wobble
|
||||||
|
top_lip_enable = true;
|
||||||
|
top_lip_depth = 2.0; // Intrudes into opening on each side
|
||||||
|
top_lip_height = 3.0;
|
||||||
|
|
||||||
|
$fn = 48;
|
||||||
|
|
||||||
|
// -------------------------
|
||||||
|
// Derived
|
||||||
|
// -------------------------
|
||||||
|
inner_w = bar_w + clearance;
|
||||||
|
inner_d = bar_d + clearance;
|
||||||
|
|
||||||
|
outer_w = inner_w + wall * 2;
|
||||||
|
outer_d = inner_d + wall * 2;
|
||||||
|
outer_h = socket_height;
|
||||||
|
|
||||||
|
module screw_hole() {
|
||||||
|
cylinder(h = ear_thickness + 0.2, d = screw_hole_d);
|
||||||
|
}
|
||||||
|
|
||||||
|
module mounting_ear(sign_y = 1) {
|
||||||
|
translate([outer_w / 2, sign_y * (outer_d / 2), 0])
|
||||||
|
cube([ear_len, ear_w, ear_thickness], center = false);
|
||||||
|
}
|
||||||
|
|
||||||
|
module top_lip() {
|
||||||
|
if (top_lip_enable) {
|
||||||
|
// Front and back lips at the top of the socket.
|
||||||
|
translate([wall, wall, outer_h - top_lip_height])
|
||||||
|
cube([top_lip_depth, inner_d, top_lip_height]);
|
||||||
|
|
||||||
|
translate([outer_w - wall - top_lip_depth, wall, outer_h - top_lip_height])
|
||||||
|
cube([top_lip_depth, inner_d, top_lip_height]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
difference() {
|
||||||
|
union() {
|
||||||
|
// Main body
|
||||||
|
cube([outer_w, outer_d, outer_h], center = false);
|
||||||
|
|
||||||
|
// Base plate under socket for stiffness
|
||||||
|
translate([0, 0, -base_thickness])
|
||||||
|
cube([outer_w, outer_d, base_thickness], center = false);
|
||||||
|
|
||||||
|
// Mounting ears
|
||||||
|
if (ear_enable) {
|
||||||
|
translate([0, 0, -ear_thickness]) {
|
||||||
|
mounting_ear(1);
|
||||||
|
mounting_ear(-1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
top_lip();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main bar cavity
|
||||||
|
translate([wall, wall, 0])
|
||||||
|
cube([inner_w, inner_d, outer_h + 0.2], center = false);
|
||||||
|
|
||||||
|
// USB side notch
|
||||||
|
if (usb_notch_enable) {
|
||||||
|
if (usb_notch_side == "right") {
|
||||||
|
translate([outer_w - wall - 0.1, (outer_d - usb_notch_w) / 2, usb_notch_from_bottom])
|
||||||
|
cube([wall + 0.3, usb_notch_w, usb_notch_h], center = false);
|
||||||
|
} else {
|
||||||
|
translate([-0.2, (outer_d - usb_notch_w) / 2, usb_notch_from_bottom])
|
||||||
|
cube([wall + 0.3, usb_notch_w, usb_notch_h], center = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Screw holes in ears
|
||||||
|
if (ear_enable) {
|
||||||
|
// Upper ear hole
|
||||||
|
translate([
|
||||||
|
outer_w / 2 + ear_len - screw_hole_edge,
|
||||||
|
outer_d / 2 + ear_w - screw_hole_edge,
|
||||||
|
-ear_thickness - 0.05
|
||||||
|
]) screw_hole();
|
||||||
|
|
||||||
|
// Lower ear hole
|
||||||
|
translate([
|
||||||
|
outer_w / 2 + ear_len - screw_hole_edge,
|
||||||
|
-outer_d / 2 + screw_hole_edge,
|
||||||
|
-ear_thickness - 0.05
|
||||||
|
]) screw_hole();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Print orientation helper:
|
||||||
|
// Keep the base/ears on the bed.
|
||||||
|
// If fit is tight, increase clearance to 0.5 or 0.6.
|
||||||
16
scripts/dev-run.sh
Normal file
16
scripts/dev-run.sh
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
PORT="${PORT:-80}"
|
||||||
|
|
||||||
|
# On watchfiles restarts the previous process can linger briefly.
|
||||||
|
# Proactively terminate any listener on the target port before boot.
|
||||||
|
pids="$(ss -ltnp "sport = :$PORT" 2>/dev/null | sed -n 's/.*pid=\([0-9]\+\).*/\1/p' | sort -u)"
|
||||||
|
if [ -n "${pids}" ]; then
|
||||||
|
kill -TERM ${pids} 2>/dev/null || true
|
||||||
|
sleep 0.3
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "$ROOT_DIR/src"
|
||||||
|
exec python main.py
|
||||||
@@ -4,6 +4,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const patternsCloseButton = document.getElementById('patterns-close-btn');
|
const patternsCloseButton = document.getElementById('patterns-close-btn');
|
||||||
const patternsList = document.getElementById('patterns-list');
|
const patternsList = document.getElementById('patterns-list');
|
||||||
const patternAddButton = document.getElementById('pattern-add-btn');
|
const patternAddButton = document.getElementById('pattern-add-btn');
|
||||||
|
const patternSendAllButton = document.getElementById('pattern-send-all-btn');
|
||||||
const patternEditorModal = document.getElementById('pattern-editor-modal');
|
const patternEditorModal = document.getElementById('pattern-editor-modal');
|
||||||
const patternEditorCloseButton = document.getElementById('pattern-editor-close-btn');
|
const patternEditorCloseButton = document.getElementById('pattern-editor-close-btn');
|
||||||
const patternCreateBtn = document.getElementById('pattern-create-btn');
|
const patternCreateBtn = document.getElementById('pattern-create-btn');
|
||||||
@@ -24,6 +25,71 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const coercePresetInt = (v, def = 0) => {
|
||||||
|
if (typeof v === 'number' && Number.isFinite(v)) {
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
const t = parseInt(String(v), 10);
|
||||||
|
return Number.isFinite(t) ? t : def;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCurrentProfileId = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/profiles/current', { headers: { Accept: 'application/json' } });
|
||||||
|
if (!response.ok) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
return data && (data.id || (data.profile && data.profile.id)) ? String(data.id || data.profile.id) : null;
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const filterPresetsForCurrentProfile = async (presetsObj) => {
|
||||||
|
const scoped = presetsObj && typeof presetsObj === 'object' ? presetsObj : {};
|
||||||
|
const currentProfileId = await getCurrentProfileId();
|
||||||
|
if (!currentProfileId) {
|
||||||
|
return scoped;
|
||||||
|
}
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(scoped).filter(([, preset]) => {
|
||||||
|
if (!preset || typeof preset !== 'object') return false;
|
||||||
|
if (!('profile_id' in preset)) return true;
|
||||||
|
return String(preset.profile_id) === String(currentProfileId);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const tabDeviceNamesFromSection = (section) => {
|
||||||
|
if (typeof window.parseTabDeviceNames === 'function') {
|
||||||
|
return window.parseTabDeviceNames(section);
|
||||||
|
}
|
||||||
|
const namesAttr = section && section.getAttribute('data-device-names');
|
||||||
|
return namesAttr
|
||||||
|
? namesAttr.split(',').map((n) => n.trim()).filter((n) => n.length > 0)
|
||||||
|
: [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const postDriverSequence = async (sequence, targetMacs, delayS = 0.05) => {
|
||||||
|
const body = {
|
||||||
|
sequence,
|
||||||
|
targets: Array.isArray(targetMacs) && targetMacs.length ? [...new Set(targetMacs)] : undefined,
|
||||||
|
delay_s: delayS,
|
||||||
|
};
|
||||||
|
const res = await fetch('/presets/push', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||||
|
credentials: 'same-origin',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
throw new Error((err && err.error) || res.statusText || 'Send failed');
|
||||||
|
}
|
||||||
|
return res.json().catch(() => ({}));
|
||||||
|
};
|
||||||
|
|
||||||
const nReadableStringFromMeta = (meta, key) => {
|
const nReadableStringFromMeta = (meta, key) => {
|
||||||
if (!meta || typeof meta !== 'object') {
|
if (!meta || typeof meta !== 'object') {
|
||||||
return '';
|
return '';
|
||||||
@@ -424,4 +490,93 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
patternsCloseButton.addEventListener('click', closeModal);
|
patternsCloseButton.addEventListener('click', closeModal);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (patternSendAllButton) {
|
||||||
|
patternSendAllButton.addEventListener('click', async () => {
|
||||||
|
const section = document.querySelector('.presets-section[data-zone-id]');
|
||||||
|
const zoneId = section ? section.dataset.zoneId : null;
|
||||||
|
if (!zoneId) {
|
||||||
|
alert('Could not determine current zone.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const deviceNames = tabDeviceNamesFromSection(section);
|
||||||
|
if (!deviceNames.length) {
|
||||||
|
alert('No devices found in the current zone.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const [zoneRes, presetsRes] = await Promise.all([
|
||||||
|
fetch(`/zones/${zoneId}`, { headers: { Accept: 'application/json' } }),
|
||||||
|
fetch('/presets', { headers: { Accept: 'application/json' } }),
|
||||||
|
]);
|
||||||
|
if (!zoneRes.ok || !presetsRes.ok) {
|
||||||
|
throw new Error('Failed to load zone presets');
|
||||||
|
}
|
||||||
|
const zoneData = await zoneRes.json();
|
||||||
|
const allPresetsRaw = await presetsRes.json();
|
||||||
|
const allPresets = await filterPresetsForCurrentProfile(allPresetsRaw);
|
||||||
|
const zonePresetIds = Array.isArray(zoneData.presets_flat)
|
||||||
|
? zoneData.presets_flat.map((id) => String(id))
|
||||||
|
: [];
|
||||||
|
if (!zonePresetIds.length) {
|
||||||
|
alert('No presets found in this zone.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const wirePresets = {};
|
||||||
|
zonePresetIds.forEach((presetId) => {
|
||||||
|
const preset = allPresets[presetId];
|
||||||
|
if (!preset) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const colors = Array.isArray(preset.colors) && preset.colors.length
|
||||||
|
? preset.colors
|
||||||
|
: ['#FFFFFF'];
|
||||||
|
wirePresets[presetId] = {
|
||||||
|
pattern: preset.pattern || 'off',
|
||||||
|
colors,
|
||||||
|
delay: typeof preset.delay === 'number' ? preset.delay : 100,
|
||||||
|
brightness: typeof preset.brightness === 'number'
|
||||||
|
? preset.brightness
|
||||||
|
: (typeof preset.br === 'number' ? preset.br : 127),
|
||||||
|
auto: typeof preset.auto === 'boolean' ? preset.auto : true,
|
||||||
|
n1: coercePresetInt(preset.n1),
|
||||||
|
n2: coercePresetInt(preset.n2),
|
||||||
|
n3: coercePresetInt(preset.n3),
|
||||||
|
n4: coercePresetInt(preset.n4),
|
||||||
|
n5: coercePresetInt(preset.n5),
|
||||||
|
n6: coercePresetInt(preset.n6),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
if (!Object.keys(wirePresets).length) {
|
||||||
|
alert('No matching presets found to send.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const select = {};
|
||||||
|
deviceNames.forEach((name) => {
|
||||||
|
if (name) {
|
||||||
|
select[name] = zonePresetIds.slice();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const targetMacs =
|
||||||
|
typeof window.tabsManager !== 'undefined' &&
|
||||||
|
typeof window.tabsManager.resolveTabDeviceMacs === 'function'
|
||||||
|
? await window.tabsManager.resolveTabDeviceMacs(deviceNames)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const sequence = [
|
||||||
|
{ v: '1', clear_presets: true, save: true },
|
||||||
|
{ v: '1', presets: wirePresets, save: true },
|
||||||
|
];
|
||||||
|
if (Object.keys(select).length) {
|
||||||
|
sequence.push({ v: '1', select });
|
||||||
|
}
|
||||||
|
await postDriverSequence(sequence, targetMacs, 0.05);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Send all patterns failed:', error);
|
||||||
|
alert('Failed to send all patterns.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -175,39 +175,6 @@ async function postDriverSequence(sequence, targetMacs, delayS) {
|
|||||||
return res.json().catch(() => ({}));
|
return res.json().catch(() => ({}));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send a select message for a preset to all devices on the current zone (ESP-NOW or Wi-Fi).
|
|
||||||
const sendSelectForCurrentTabDevices = async (presetId, sectionEl) => {
|
|
||||||
const section = sectionEl || document.querySelector('.presets-section[data-zone-id]');
|
|
||||||
if (!section || !presetId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const deviceNames = tabDeviceNamesFromSection(section);
|
|
||||||
|
|
||||||
if (!deviceNames.length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const select = {};
|
|
||||||
deviceNames.forEach((name) => {
|
|
||||||
if (name) {
|
|
||||||
select[name] = [presetId];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const targetMacs =
|
|
||||||
typeof window.tabsManager !== 'undefined' &&
|
|
||||||
typeof window.tabsManager.resolveTabDeviceMacs === 'function'
|
|
||||||
? await window.tabsManager.resolveTabDeviceMacs(deviceNames)
|
|
||||||
: [];
|
|
||||||
|
|
||||||
try {
|
|
||||||
await postDriverSequence([{ v: '1', select }], targetMacs);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('sendSelectForCurrentTabDevices:', err);
|
|
||||||
alert('Failed to send preset selection to devices.');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
const presetsButton = document.getElementById('presets-btn');
|
const presetsButton = document.getElementById('presets-btn');
|
||||||
const presetsModal = document.getElementById('presets-modal');
|
const presetsModal = document.getElementById('presets-modal');
|
||||||
@@ -1332,7 +1299,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
// Work out the preset ID: for existing presets use currentEditId, otherwise fall back to name
|
// Work out the preset ID: for existing presets use currentEditId, otherwise fall back to name
|
||||||
const presetId = currentEditId || payload.name;
|
const presetId = currentEditId || payload.name;
|
||||||
// Try sends preset first, then select; never persist on device.
|
// Try sends preset first, then select; never persist on device.
|
||||||
await sendPresetViaEspNow(presetId, payload, deviceNames, false, false);
|
await sendPresetViaEspNow(presetId, payload, deviceNames, false, false, '2');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1346,8 +1313,9 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const section = document.querySelector('.presets-section[data-zone-id]');
|
const section = document.querySelector('.presets-section[data-zone-id]');
|
||||||
const deviceNames = tabDeviceNamesFromSection(section);
|
const deviceNames = tabDeviceNamesFromSection(section);
|
||||||
const presetId = currentEditId || payload.name;
|
const presetId = currentEditId || payload.name;
|
||||||
|
await sendPresetViaEspNow(presetId, payload, deviceNames, true, true, '1');
|
||||||
await updateTabDefaultPreset(presetId);
|
await updateTabDefaultPreset(presetId);
|
||||||
await sendDefaultPreset(presetId, deviceNames);
|
await sendDefaultPreset('1', deviceNames);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1379,7 +1347,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
throw new Error('Failed to save preset');
|
throw new Error('Failed to save preset');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Same device targeting as Try: zone tab supplies names → /presets/push gets targets + select.
|
// Same device targeting as Try: zone tab supplies names and selection without persistence.
|
||||||
const section = document.querySelector('.presets-section[data-zone-id]');
|
const section = document.querySelector('.presets-section[data-zone-id]');
|
||||||
const deviceNames = tabDeviceNamesFromSection(section);
|
const deviceNames = tabDeviceNamesFromSection(section);
|
||||||
|
|
||||||
@@ -1388,18 +1356,18 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
if (saved && typeof saved === 'object') {
|
if (saved && typeof saved === 'object') {
|
||||||
if (currentEditId) {
|
if (currentEditId) {
|
||||||
// PUT returns the preset object directly; use the existing ID
|
// PUT returns the preset object directly; use the existing ID
|
||||||
await sendPresetViaEspNow(currentEditId, saved, deviceNames, true, false);
|
await sendPresetViaEspNow(currentEditId, saved, deviceNames, false, false, '2');
|
||||||
} else {
|
} else {
|
||||||
// POST returns { id: preset }
|
// POST returns { id: preset }
|
||||||
const entries = Object.entries(saved);
|
const entries = Object.entries(saved);
|
||||||
if (entries.length > 0) {
|
if (entries.length > 0) {
|
||||||
const [newId, presetData] = entries[0];
|
const [newId, presetData] = entries[0];
|
||||||
await sendPresetViaEspNow(newId, presetData, deviceNames, true, false);
|
await sendPresetViaEspNow(newId, presetData, deviceNames, false, false, '2');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Fallback: send what we just built
|
// Fallback: send what we just built
|
||||||
await sendPresetViaEspNow(currentEditId || payload.name, payload, deviceNames, true, false);
|
await sendPresetViaEspNow(currentEditId || payload.name, payload, deviceNames, false, false, '2');
|
||||||
}
|
}
|
||||||
|
|
||||||
await loadPresets();
|
await loadPresets();
|
||||||
@@ -1454,7 +1422,14 @@ const coercePresetInt = (v, def = 0) => {
|
|||||||
// 1) preset payload (optionally with save)
|
// 1) preset payload (optionally with save)
|
||||||
// 2) optional select for device names (never with save)
|
// 2) optional select for device names (never with save)
|
||||||
// saveToDevice defaults to true.
|
// saveToDevice defaults to true.
|
||||||
const sendPresetViaEspNow = async (presetId, preset, deviceNames, saveToDevice = true, setDefault = false) => {
|
const sendPresetViaEspNow = async (
|
||||||
|
presetId,
|
||||||
|
preset,
|
||||||
|
deviceNames,
|
||||||
|
saveToDevice = true,
|
||||||
|
setDefault = false,
|
||||||
|
devicePresetId = null,
|
||||||
|
) => {
|
||||||
try {
|
try {
|
||||||
const baseColors = Array.isArray(preset.colors) && preset.colors.length
|
const baseColors = Array.isArray(preset.colors) && preset.colors.length
|
||||||
? preset.colors
|
? preset.colors
|
||||||
@@ -1462,10 +1437,11 @@ const sendPresetViaEspNow = async (presetId, preset, deviceNames, saveToDevice =
|
|||||||
const paletteColors = await getCurrentProfilePaletteColors();
|
const paletteColors = await getCurrentProfilePaletteColors();
|
||||||
const colors = resolveColorsWithPaletteRefs(baseColors, preset.palette_refs, paletteColors);
|
const colors = resolveColorsWithPaletteRefs(baseColors, preset.palette_refs, paletteColors);
|
||||||
|
|
||||||
|
const wirePresetId = devicePresetId != null ? String(devicePresetId) : String(presetId);
|
||||||
const presetMessage = {
|
const presetMessage = {
|
||||||
v: '1',
|
v: '1',
|
||||||
presets: {
|
presets: {
|
||||||
[presetId]: {
|
[wirePresetId]: {
|
||||||
pattern: preset.pattern || 'off',
|
pattern: preset.pattern || 'off',
|
||||||
colors,
|
colors,
|
||||||
delay: typeof preset.delay === 'number' ? preset.delay : 100,
|
delay: typeof preset.delay === 'number' ? preset.delay : 100,
|
||||||
@@ -1486,7 +1462,7 @@ const sendPresetViaEspNow = async (presetId, preset, deviceNames, saveToDevice =
|
|||||||
presetMessage.save = true;
|
presetMessage.save = true;
|
||||||
}
|
}
|
||||||
if (setDefault) {
|
if (setDefault) {
|
||||||
presetMessage.default = presetId;
|
presetMessage.default = wirePresetId;
|
||||||
}
|
}
|
||||||
|
|
||||||
const names = Array.isArray(deviceNames) ? deviceNames : [];
|
const names = Array.isArray(deviceNames) ? deviceNames : [];
|
||||||
@@ -1502,7 +1478,7 @@ const sendPresetViaEspNow = async (presetId, preset, deviceNames, saveToDevice =
|
|||||||
const select = {};
|
const select = {};
|
||||||
names.forEach((name) => {
|
names.forEach((name) => {
|
||||||
if (name) {
|
if (name) {
|
||||||
select[name] = [presetId];
|
select[name] = [wirePresetId];
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if (Object.keys(select).length > 0) {
|
if (Object.keys(select).length > 0) {
|
||||||
@@ -1879,7 +1855,8 @@ const createPresetButton = (presetId, preset, zoneId, isSelected = false) => {
|
|||||||
button.classList.add('active');
|
button.classList.add('active');
|
||||||
selectedPresets[zoneId] = presetId;
|
selectedPresets[zoneId] = presetId;
|
||||||
const section = row.closest('.presets-section');
|
const section = row.closest('.presets-section');
|
||||||
sendSelectForCurrentTabDevices(presetId, section).catch((err) => {
|
const deviceNames = tabDeviceNamesFromSection(section);
|
||||||
|
sendPresetViaEspNow(presetId, preset, deviceNames, false, false, '2').catch((err) => {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -149,6 +149,40 @@ header h1 {
|
|||||||
background-color: #333;
|
background-color: #333;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.menu-brightness-control {
|
||||||
|
padding: 0.45rem 0.75rem 0.55rem;
|
||||||
|
border-bottom: 1px solid #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-brightness-control label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: #bdbdbd;
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-brightness-control input[type="range"] {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-brightness-control {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
min-width: 13rem;
|
||||||
|
padding: 0.2rem 0.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-brightness-control label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #bdbdbd;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-brightness-control input[type="range"] {
|
||||||
|
width: 8.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* Header/menu actions that should only appear in Edit mode */
|
/* Header/menu actions that should only appear in Edit mode */
|
||||||
body.preset-ui-run .edit-mode-only {
|
body.preset-ui-run .edit-mode-only {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
@@ -248,7 +282,8 @@ body.preset-ui-run .edit-mode-only {
|
|||||||
display: block;
|
display: block;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
padding: 0.5rem 1rem 1rem;
|
padding: 0.5rem 1rem calc(1rem + env(safe-area-inset-bottom, 0px) + 3.5rem);
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.presets-toolbar {
|
.presets-toolbar {
|
||||||
@@ -528,6 +563,12 @@ body.preset-ui-run .edit-mode-only {
|
|||||||
row-gap: 0.3rem;
|
row-gap: 0.3rem;
|
||||||
align-content: start;
|
align-content: start;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
padding-bottom: calc(env(safe-area-inset-bottom, 0px) + 5.5rem);
|
||||||
|
scroll-padding-bottom: calc(env(safe-area-inset-bottom, 0px) + 5.5rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
#presets-list-zone > :last-child {
|
||||||
|
margin-bottom: calc(env(safe-area-inset-bottom, 0px) + 2.5rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Settings modal layout */
|
/* Settings modal layout */
|
||||||
@@ -949,7 +990,7 @@ body.preset-ui-run .edit-mode-only {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Mobile-friendly layout */
|
/* Mobile-friendly layout */
|
||||||
@media (max-width: 800px) {
|
@media (max-width: 1000px) {
|
||||||
header {
|
header {
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1001,6 +1042,9 @@ body.preset-ui-run .edit-mode-only {
|
|||||||
min-width: 280px;
|
min-width: 280px;
|
||||||
max-width: 95vw;
|
max-width: 95vw;
|
||||||
padding: 1.25rem;
|
padding: 1.25rem;
|
||||||
|
max-height: calc(100dvh - 1rem);
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-bottom: calc(1.25rem + env(safe-area-inset-bottom, 0px));
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-row {
|
.form-row {
|
||||||
@@ -1018,6 +1062,10 @@ body.preset-ui-run .edit-mode-only {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background-color: rgba(0,0,0,0.7);
|
background-color: rgba(0,0,0,0.7);
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 1rem;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
overscroll-behavior: contain;
|
||||||
}
|
}
|
||||||
.modal.active {
|
.modal.active {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1030,6 +1078,20 @@ body.preset-ui-run .edit-mode-only {
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
min-width: 400px;
|
min-width: 400px;
|
||||||
max-width: 600px;
|
max-width: 600px;
|
||||||
|
max-height: calc(100dvh - 2rem);
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-bottom: calc(2rem + env(safe-area-inset-bottom, 0px));
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Real-phone viewport fallback for browsers with unstable 100dvh behavior. */
|
||||||
|
@supports (-webkit-touch-callout: none) {
|
||||||
|
.modal {
|
||||||
|
min-height: -webkit-fill-available;
|
||||||
|
}
|
||||||
|
.modal-content {
|
||||||
|
max-height: calc(-webkit-fill-available - 2rem);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.modal-content label {
|
.modal-content label {
|
||||||
display: block;
|
display: block;
|
||||||
@@ -1200,9 +1262,11 @@ body.preset-ui-run .edit-mode-only {
|
|||||||
min-height: 80px;
|
min-height: 80px;
|
||||||
}
|
}
|
||||||
/* Presets list: 3 columns and vertical scroll (defined above); mobile same */
|
/* Presets list: 3 columns and vertical scroll (defined above); mobile same */
|
||||||
@media (max-width: 800px) {
|
@media (max-width: 1000px) {
|
||||||
#presets-list-zone {
|
#presets-list-zone {
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
padding-bottom: calc(env(safe-area-inset-bottom, 0px) + 7rem);
|
||||||
|
scroll-padding-bottom: calc(env(safe-area-inset-bottom, 0px) + 7rem);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/* Help modal readability */
|
/* Help modal readability */
|
||||||
|
|||||||
@@ -1,5 +1,47 @@
|
|||||||
// Zone management JavaScript
|
// Zone management JavaScript
|
||||||
let currentZoneId = null;
|
let currentZoneId = null;
|
||||||
|
let brightnessSendTimeout = null;
|
||||||
|
|
||||||
|
function sendZoneBrightness(value) {
|
||||||
|
const val = Math.max(0, Math.min(255, parseInt(value, 10) || 0));
|
||||||
|
const headerSlider = document.getElementById('header-brightness-slider');
|
||||||
|
const menuSlider = document.getElementById('menu-brightness-slider');
|
||||||
|
if (headerSlider && String(headerSlider.value) !== String(val)) {
|
||||||
|
headerSlider.value = String(val);
|
||||||
|
}
|
||||||
|
if (menuSlider && String(menuSlider.value) !== String(val)) {
|
||||||
|
menuSlider.value = String(val);
|
||||||
|
}
|
||||||
|
if (brightnessSendTimeout) {
|
||||||
|
clearTimeout(brightnessSendTimeout);
|
||||||
|
}
|
||||||
|
brightnessSendTimeout = setTimeout(() => {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const section = document.querySelector('.presets-section[data-zone-id]');
|
||||||
|
const names = typeof window.parseTabDeviceNames === 'function'
|
||||||
|
? window.parseTabDeviceNames(section)
|
||||||
|
: [];
|
||||||
|
const targetMacs =
|
||||||
|
names.length > 0 &&
|
||||||
|
typeof window.tabsManager !== 'undefined' &&
|
||||||
|
typeof window.tabsManager.resolveTabDeviceMacs === 'function'
|
||||||
|
? await window.tabsManager.resolveTabDeviceMacs(names)
|
||||||
|
: [];
|
||||||
|
if (typeof window.postDriverSequence === 'function') {
|
||||||
|
await window.postDriverSequence([{ v: '1', b: val, save: true }], targetMacs, 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Fallback to raw websocket sender if presets.js helper isn't available yet.
|
||||||
|
if (typeof window.sendEspnowRaw === 'function') {
|
||||||
|
window.sendEspnowRaw({ v: '1', b: val, save: true });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to send brightness via driver sequence:', err);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, 150);
|
||||||
|
}
|
||||||
|
|
||||||
const isEditModeActive = () => {
|
const isEditModeActive = () => {
|
||||||
const toggle = document.querySelector('.ui-mode-toggle');
|
const toggle = document.querySelector('.ui-mode-toggle');
|
||||||
@@ -468,37 +510,17 @@ async function loadZoneContent(zoneId) {
|
|||||||
const legacyAttr = legacyOk ? ` data-device-names="${escapeHtmlAttr(names.join(","))}"` : "";
|
const legacyAttr = legacyOk ? ` data-device-names="${escapeHtmlAttr(names.join(","))}"` : "";
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="presets-section" data-zone-id="${zoneId}" data-device-names-json="${namesJsonAttr}"${legacyAttr}>
|
<div class="presets-section" data-zone-id="${zoneId}" data-device-names-json="${namesJsonAttr}"${legacyAttr}>
|
||||||
<div class="profiles-actions presets-toolbar" style="margin-bottom: 1rem;">
|
|
||||||
<div class="zone-brightness-group">
|
|
||||||
<label for="zone-brightness-slider">Brightness</label>
|
|
||||||
<input type="range" id="zone-brightness-slider" min="0" max="255" value="255">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="presets-list-zone" class="presets-list">
|
<div id="presets-list-zone" class="presets-list">
|
||||||
<!-- Presets will be loaded here by presets.js -->
|
<!-- Presets will be loaded here by presets.js -->
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Wire up per-zone brightness slider to send global brightness via ESPNow.
|
// Keep header and menu brightness controls in sync.
|
||||||
const brightnessSlider = container.querySelector('#zone-brightness-slider');
|
const brightnessSlider = document.getElementById('header-brightness-slider');
|
||||||
let brightnessSendTimeout = null;
|
const menuBrightnessSlider = document.getElementById('menu-brightness-slider');
|
||||||
if (brightnessSlider) {
|
if (menuBrightnessSlider && brightnessSlider) {
|
||||||
brightnessSlider.addEventListener('input', (e) => {
|
menuBrightnessSlider.value = brightnessSlider.value;
|
||||||
const val = parseInt(e.target.value, 10) || 0;
|
|
||||||
if (brightnessSendTimeout) {
|
|
||||||
clearTimeout(brightnessSendTimeout);
|
|
||||||
}
|
|
||||||
brightnessSendTimeout = setTimeout(() => {
|
|
||||||
if (typeof window.sendEspnowRaw === 'function') {
|
|
||||||
try {
|
|
||||||
window.sendEspnowRaw({ v: '1', b: val, save: true });
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to send brightness via ESPNow:', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, 150);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Trigger presets loading if the function exists
|
// Trigger presets loading if the function exists
|
||||||
@@ -967,6 +989,21 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const menuBrightnessSlider = document.getElementById('menu-brightness-slider');
|
||||||
|
if (menuBrightnessSlider) {
|
||||||
|
menuBrightnessSlider.addEventListener('input', (e) => {
|
||||||
|
sendZoneBrightness(e.target.value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const headerBrightnessSlider = document.getElementById('header-brightness-slider');
|
||||||
|
if (headerBrightnessSlider) {
|
||||||
|
headerBrightnessSlider.addEventListener('input', (e) => {
|
||||||
|
sendZoneBrightness(e.target.value);
|
||||||
|
});
|
||||||
|
// Initial sync so both controls start aligned.
|
||||||
|
sendZoneBrightness(headerBrightnessSlider.value);
|
||||||
|
}
|
||||||
|
|
||||||
// When run/edit mode toggles, refresh tabs UI so edit actions show/hide immediately.
|
// When run/edit mode toggles, refresh tabs UI so edit actions show/hide immediately.
|
||||||
document.querySelectorAll('.ui-mode-toggle').forEach((btn) => {
|
document.querySelectorAll('.ui-mode-toggle').forEach((btn) => {
|
||||||
btn.addEventListener('click', async () => {
|
btn.addEventListener('click', async () => {
|
||||||
|
|||||||
@@ -15,6 +15,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
|
<div class="header-brightness-control">
|
||||||
|
<label for="header-brightness-slider">Brightness</label>
|
||||||
|
<input type="range" id="header-brightness-slider" min="0" max="255" value="255">
|
||||||
|
</div>
|
||||||
<button class="btn btn-secondary" id="profiles-btn">Profiles</button>
|
<button class="btn btn-secondary" id="profiles-btn">Profiles</button>
|
||||||
<button class="btn btn-secondary edit-mode-only" id="devices-btn">Devices</button>
|
<button class="btn btn-secondary edit-mode-only" id="devices-btn">Devices</button>
|
||||||
<button class="btn btn-secondary edit-mode-only" id="zones-btn">Zones</button>
|
<button class="btn btn-secondary edit-mode-only" id="zones-btn">Zones</button>
|
||||||
@@ -30,6 +34,10 @@
|
|||||||
<button class="btn btn-secondary" id="main-menu-btn">Menu</button>
|
<button class="btn btn-secondary" id="main-menu-btn">Menu</button>
|
||||||
<div id="main-menu-dropdown" class="main-menu-dropdown">
|
<div id="main-menu-dropdown" class="main-menu-dropdown">
|
||||||
<button type="button" class="ui-mode-toggle" id="ui-mode-toggle-mobile" aria-pressed="false">Edit mode</button>
|
<button type="button" class="ui-mode-toggle" id="ui-mode-toggle-mobile" aria-pressed="false">Edit mode</button>
|
||||||
|
<div class="menu-brightness-control">
|
||||||
|
<label for="menu-brightness-slider">Brightness</label>
|
||||||
|
<input type="range" id="menu-brightness-slider" min="0" max="255" value="255">
|
||||||
|
</div>
|
||||||
<button type="button" data-target="profiles-btn">Profiles</button>
|
<button type="button" data-target="profiles-btn">Profiles</button>
|
||||||
<button type="button" class="edit-mode-only" data-target="devices-btn">Devices</button>
|
<button type="button" class="edit-mode-only" data-target="devices-btn">Devices</button>
|
||||||
<button type="button" class="edit-mode-only" data-target="zones-btn">Tabs</button>
|
<button type="button" class="edit-mode-only" data-target="zones-btn">Tabs</button>
|
||||||
@@ -245,6 +253,7 @@
|
|||||||
<h2>Patterns</h2>
|
<h2>Patterns</h2>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button type="button" class="btn btn-primary" id="pattern-add-btn">Add</button>
|
<button type="button" class="btn btn-primary" id="pattern-add-btn">Add</button>
|
||||||
|
<button type="button" class="btn btn-secondary" id="pattern-send-all-btn">Send All Patterns</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="patterns-list" class="profiles-list"></div>
|
<div id="patterns-list" class="profiles-list"></div>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
|
|||||||
508
src/util/binary_envelope.py
Normal file
508
src/util/binary_envelope.py
Normal file
@@ -0,0 +1,508 @@
|
|||||||
|
"""
|
||||||
|
Compact binary controller → led-driver messages (ESP-NOW friendly).
|
||||||
|
|
||||||
|
Header (5 bytes), same for v1 (legacy) and v2 (native binary):
|
||||||
|
|
||||||
|
0: version — 1 = legacy (JSON text blobs); 2 = native binary blobs
|
||||||
|
1: brightness — 0–127 scales to device 0–255; 128–255 = leave unchanged
|
||||||
|
2: byte length of presets section (0–255)
|
||||||
|
3: byte length of select section
|
||||||
|
4: byte length of default section
|
||||||
|
|
||||||
|
v2 presets blob (no JSON):
|
||||||
|
u8 preset_count
|
||||||
|
each preset:
|
||||||
|
u8 name_len; name utf-8
|
||||||
|
u8 pattern_len; pattern utf-8 (``p``)
|
||||||
|
u8 color_count; color_count × (u8 r, u8 g, u8 b)
|
||||||
|
u16 delay_le (``d``)
|
||||||
|
u8 preset_brightness (``b``)
|
||||||
|
u8 auto (0/1) (``a``)
|
||||||
|
i16 n1..n6 little-endian (``n1``–``n6``)
|
||||||
|
|
||||||
|
v2 select blob:
|
||||||
|
u8 entry_count
|
||||||
|
each:
|
||||||
|
u8 device_len; device utf-8
|
||||||
|
u8 preset_name_len; preset name utf-8
|
||||||
|
u8 has_step (0/1); optional u16 step_le
|
||||||
|
|
||||||
|
v2 default blob:
|
||||||
|
u8 default_name_len; name utf-8
|
||||||
|
u8 target_count
|
||||||
|
each: u8 len; target name utf-8
|
||||||
|
|
||||||
|
Legacy v1: sections are UTF-8 JSON text (see ``parse_binary_envelope_v1``).
|
||||||
|
|
||||||
|
Keep ``5 + lp + ls + ld`` ≤ 245 for a single ESP-NOW frame body.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import struct
|
||||||
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
BINARY_ENVELOPE_VERSION_1 = 1
|
||||||
|
BINARY_ENVELOPE_VERSION_2 = 2
|
||||||
|
HEADER_LEN = 5
|
||||||
|
|
||||||
|
|
||||||
|
def brightness_wire_from_0_255(value: int) -> int:
|
||||||
|
"""Map device brightness 0–255 to wire 0–127."""
|
||||||
|
v = max(0, min(255, int(value)))
|
||||||
|
return (v * 127 + 127) // 255
|
||||||
|
|
||||||
|
|
||||||
|
def brightness_0_255_from_wire(wire: int) -> int:
|
||||||
|
"""Map wire 0–127 to device brightness 0–255."""
|
||||||
|
w = max(0, min(127, int(wire)))
|
||||||
|
return min(255, (w * 255) // 127)
|
||||||
|
|
||||||
|
|
||||||
|
def _clamp_i16(x: int) -> int:
|
||||||
|
x = int(x)
|
||||||
|
return max(-32768, min(32767, x))
|
||||||
|
|
||||||
|
|
||||||
|
def _colors_to_rgb_list(colors: Any) -> List[Tuple[int, int, int]]:
|
||||||
|
out: List[Tuple[int, int, int]] = []
|
||||||
|
if not colors:
|
||||||
|
return out
|
||||||
|
for c in colors:
|
||||||
|
if isinstance(c, str):
|
||||||
|
h = c.strip().lstrip("#")
|
||||||
|
if len(h) >= 6:
|
||||||
|
out.append(
|
||||||
|
(int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16))
|
||||||
|
)
|
||||||
|
elif isinstance(c, (list, tuple)) and len(c) >= 3:
|
||||||
|
out.append((int(c[0]), int(c[1]), int(c[2])))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _pack_preset_dict(name: str, preset: Dict[str, Any]) -> bytes:
|
||||||
|
pname = name.encode("utf-8")
|
||||||
|
if len(pname) > 250:
|
||||||
|
raise ValueError("preset name too long")
|
||||||
|
pattern = str(preset.get("p") or preset.get("pattern", "off")).encode("utf-8")
|
||||||
|
if len(pattern) > 250:
|
||||||
|
raise ValueError("pattern string too long")
|
||||||
|
rgbs = _colors_to_rgb_list(preset.get("c") or preset.get("colors") or [])
|
||||||
|
if len(rgbs) > 255:
|
||||||
|
raise ValueError("too many colours")
|
||||||
|
delay = max(0, min(65535, int(preset.get("d") or preset.get("delay", 100))))
|
||||||
|
br = max(0, min(255, int(preset.get("b") or preset.get("brightness", 127))))
|
||||||
|
auto = 1 if preset.get("a", preset.get("auto", True)) else 0
|
||||||
|
parts = [
|
||||||
|
bytes([len(pname)]),
|
||||||
|
pname,
|
||||||
|
bytes([len(pattern)]),
|
||||||
|
pattern,
|
||||||
|
bytes([len(rgbs)]),
|
||||||
|
]
|
||||||
|
for r, g, b in rgbs:
|
||||||
|
parts.append(bytes([r & 255, g & 255, b & 255]))
|
||||||
|
n1 = _clamp_i16(preset.get("n1", 0))
|
||||||
|
n2 = _clamp_i16(preset.get("n2", 0))
|
||||||
|
n3 = _clamp_i16(preset.get("n3", 0))
|
||||||
|
n4 = _clamp_i16(preset.get("n4", 0))
|
||||||
|
n5 = _clamp_i16(preset.get("n5", 0))
|
||||||
|
n6 = _clamp_i16(preset.get("n6", 0))
|
||||||
|
parts.append(
|
||||||
|
struct.pack(
|
||||||
|
"<HBBhhhhhh",
|
||||||
|
delay,
|
||||||
|
br,
|
||||||
|
auto,
|
||||||
|
n1,
|
||||||
|
n2,
|
||||||
|
n3,
|
||||||
|
n4,
|
||||||
|
n5,
|
||||||
|
n6,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return b"".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _pack_presets_blob(presets: Dict[str, Any]) -> bytes:
|
||||||
|
items = [(k, v) for k, v in presets.items() if isinstance(v, dict)]
|
||||||
|
out = [bytes([len(items)])]
|
||||||
|
for name, pdata in items:
|
||||||
|
out.append(_pack_preset_dict(str(name), pdata))
|
||||||
|
return b"".join(out)
|
||||||
|
|
||||||
|
|
||||||
|
def _pack_select_blob(select: Dict[str, Any]) -> bytes:
|
||||||
|
out = [bytes([len(select)])]
|
||||||
|
for device, sel in select.items():
|
||||||
|
dev_b = str(device).encode("utf-8")
|
||||||
|
if len(dev_b) > 250:
|
||||||
|
raise ValueError("device name too long")
|
||||||
|
if isinstance(sel, (list, tuple)) and sel:
|
||||||
|
pn = str(sel[0]).encode("utf-8")
|
||||||
|
step = sel[1] if len(sel) > 1 else None
|
||||||
|
else:
|
||||||
|
pn = str(sel).encode("utf-8")
|
||||||
|
step = None
|
||||||
|
if len(pn) > 250:
|
||||||
|
raise ValueError("preset name too long")
|
||||||
|
if step is None:
|
||||||
|
out.append(
|
||||||
|
bytes([len(dev_b)])
|
||||||
|
+ dev_b
|
||||||
|
+ bytes([len(pn)])
|
||||||
|
+ pn
|
||||||
|
+ bytes([0])
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
s = int(step)
|
||||||
|
if s < 0 or s > 65535:
|
||||||
|
raise ValueError("step out of range")
|
||||||
|
out.append(
|
||||||
|
bytes([len(dev_b)])
|
||||||
|
+ dev_b
|
||||||
|
+ bytes([len(pn)])
|
||||||
|
+ pn
|
||||||
|
+ bytes([1])
|
||||||
|
+ struct.pack("<H", s)
|
||||||
|
)
|
||||||
|
return b"".join(out)
|
||||||
|
|
||||||
|
|
||||||
|
def _pack_default_blob(default: str, targets: Optional[list]) -> bytes:
|
||||||
|
name_b = str(default).encode("utf-8")
|
||||||
|
if len(name_b) > 250:
|
||||||
|
raise ValueError("default name too long")
|
||||||
|
tlist = list(targets) if targets else []
|
||||||
|
if len(tlist) > 255:
|
||||||
|
raise ValueError("too many targets")
|
||||||
|
out = [bytes([len(name_b)]), name_b, bytes([len(tlist)])]
|
||||||
|
for t in tlist:
|
||||||
|
tb = str(t).encode("utf-8")
|
||||||
|
if len(tb) > 250:
|
||||||
|
raise ValueError("target name too long")
|
||||||
|
out.append(bytes([len(tb)]))
|
||||||
|
out.append(tb)
|
||||||
|
return b"".join(out)
|
||||||
|
|
||||||
|
|
||||||
|
def pack_binary_envelope_v2(
|
||||||
|
*,
|
||||||
|
presets: Optional[Dict[str, Any]] = None,
|
||||||
|
select: Optional[Dict[str, Any]] = None,
|
||||||
|
default: Optional[str] = None,
|
||||||
|
default_targets: Optional[list] = None,
|
||||||
|
brightness_0_255: Optional[int] = None,
|
||||||
|
) -> bytes:
|
||||||
|
"""Build a v2 envelope (native binary sections, no JSON)."""
|
||||||
|
presets_bytes = (
|
||||||
|
_pack_presets_blob(presets) if presets is not None and presets else b""
|
||||||
|
)
|
||||||
|
select_bytes = (
|
||||||
|
_pack_select_blob(select) if select is not None and select else b""
|
||||||
|
)
|
||||||
|
default_bytes = (
|
||||||
|
_pack_default_blob(default, default_targets)
|
||||||
|
if default is not None
|
||||||
|
else b""
|
||||||
|
)
|
||||||
|
|
||||||
|
lp = len(presets_bytes)
|
||||||
|
ls = len(select_bytes)
|
||||||
|
ld = len(default_bytes)
|
||||||
|
if lp > 255 or ls > 255 or ld > 255:
|
||||||
|
raise ValueError("binary envelope section exceeds 255 bytes")
|
||||||
|
|
||||||
|
br_wire = (
|
||||||
|
255
|
||||||
|
if brightness_0_255 is None
|
||||||
|
else brightness_wire_from_0_255(brightness_0_255)
|
||||||
|
)
|
||||||
|
header = bytes([BINARY_ENVELOPE_VERSION_2, br_wire, lp, ls, ld])
|
||||||
|
return header + presets_bytes + select_bytes + default_bytes
|
||||||
|
|
||||||
|
|
||||||
|
def pack_binary_envelope_v1(
|
||||||
|
*,
|
||||||
|
presets: Optional[Dict[str, Any]] = None,
|
||||||
|
select: Optional[Dict[str, Any]] = None,
|
||||||
|
default: Optional[str] = None,
|
||||||
|
default_targets: Optional[list] = None,
|
||||||
|
brightness_0_255: Optional[int] = None,
|
||||||
|
) -> bytes:
|
||||||
|
"""Legacy: JSON UTF-8 fragments (version byte 1). Prefer ``pack_binary_envelope_v2``."""
|
||||||
|
if presets is None:
|
||||||
|
presets_bytes = b""
|
||||||
|
else:
|
||||||
|
presets_bytes = json.dumps(presets, separators=(",", ":")).encode("utf-8")
|
||||||
|
|
||||||
|
if select is None:
|
||||||
|
select_bytes = b""
|
||||||
|
else:
|
||||||
|
select_bytes = json.dumps(select, separators=(",", ":")).encode("utf-8")
|
||||||
|
|
||||||
|
default_obj: Optional[Dict[str, Any]] = None
|
||||||
|
if default is not None:
|
||||||
|
default_obj = {
|
||||||
|
"default": default,
|
||||||
|
"targets": list(default_targets) if default_targets else [],
|
||||||
|
}
|
||||||
|
default_bytes = (
|
||||||
|
json.dumps(default_obj, separators=(",", ":")).encode("utf-8")
|
||||||
|
if default_obj is not None
|
||||||
|
else b""
|
||||||
|
)
|
||||||
|
|
||||||
|
lp = len(presets_bytes)
|
||||||
|
ls = len(select_bytes)
|
||||||
|
ld = len(default_bytes)
|
||||||
|
if lp > 255 or ls > 255 or ld > 255:
|
||||||
|
raise ValueError("binary envelope fragment exceeds 255 bytes")
|
||||||
|
|
||||||
|
br_wire = (
|
||||||
|
255
|
||||||
|
if brightness_0_255 is None
|
||||||
|
else brightness_wire_from_0_255(brightness_0_255)
|
||||||
|
)
|
||||||
|
header = bytes([BINARY_ENVELOPE_VERSION_1, br_wire, lp, ls, ld])
|
||||||
|
return header + presets_bytes + select_bytes + default_bytes
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_preset_record(
|
||||||
|
buf: bytes, off: int
|
||||||
|
) -> Tuple[str, Dict[str, Any], int]:
|
||||||
|
if off + 1 > len(buf):
|
||||||
|
raise ValueError("truncated")
|
||||||
|
nl = buf[off]
|
||||||
|
off += 1
|
||||||
|
if off + nl > len(buf):
|
||||||
|
raise ValueError("truncated")
|
||||||
|
name = buf[off : off + nl].decode("utf-8")
|
||||||
|
off += nl
|
||||||
|
if off + 1 > len(buf):
|
||||||
|
raise ValueError("truncated")
|
||||||
|
pl = buf[off]
|
||||||
|
off += 1
|
||||||
|
if off + pl > len(buf):
|
||||||
|
raise ValueError("truncated")
|
||||||
|
pattern = buf[off : off + pl].decode("utf-8")
|
||||||
|
off += pl
|
||||||
|
if off + 1 > len(buf):
|
||||||
|
raise ValueError("truncated")
|
||||||
|
nc = buf[off]
|
||||||
|
off += 1
|
||||||
|
if off + nc * 3 > len(buf):
|
||||||
|
raise ValueError("truncated")
|
||||||
|
colors: List[str] = []
|
||||||
|
for _ in range(nc):
|
||||||
|
r, g, b = buf[off], buf[off + 1], buf[off + 2]
|
||||||
|
off += 3
|
||||||
|
colors.append(f"#{r:02x}{g:02x}{b:02x}")
|
||||||
|
if off + 16 > len(buf):
|
||||||
|
raise ValueError("truncated")
|
||||||
|
delay, br, auto, n1, n2, n3, n4, n5, n6 = struct.unpack_from(
|
||||||
|
"<HBBhhhhhh", buf, off
|
||||||
|
)
|
||||||
|
off += 16
|
||||||
|
preset = {
|
||||||
|
"p": pattern,
|
||||||
|
"c": colors,
|
||||||
|
"d": delay,
|
||||||
|
"b": br,
|
||||||
|
"a": bool(auto),
|
||||||
|
"n1": n1,
|
||||||
|
"n2": n2,
|
||||||
|
"n3": n3,
|
||||||
|
"n4": n4,
|
||||||
|
"n5": n5,
|
||||||
|
"n6": n6,
|
||||||
|
}
|
||||||
|
return name, preset, off
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_presets_blob(chunk: bytes) -> Dict[str, Any]:
|
||||||
|
if not chunk:
|
||||||
|
return {}
|
||||||
|
off = 0
|
||||||
|
if off + 1 > len(chunk):
|
||||||
|
raise ValueError("truncated")
|
||||||
|
count = chunk[off]
|
||||||
|
off += 1
|
||||||
|
out: Dict[str, Any] = {}
|
||||||
|
for _ in range(count):
|
||||||
|
name, preset, off = _decode_preset_record(chunk, off)
|
||||||
|
out[name] = preset
|
||||||
|
if off != len(chunk):
|
||||||
|
raise ValueError("presets blob length mismatch")
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_select_blob(chunk: bytes) -> Dict[str, Any]:
|
||||||
|
if not chunk:
|
||||||
|
return {}
|
||||||
|
off = 0
|
||||||
|
if off + 1 > len(chunk):
|
||||||
|
raise ValueError("truncated")
|
||||||
|
count = chunk[off]
|
||||||
|
off += 1
|
||||||
|
out: Dict[str, Any] = {}
|
||||||
|
for _ in range(count):
|
||||||
|
if off + 1 > len(chunk):
|
||||||
|
raise ValueError("truncated")
|
||||||
|
dl = chunk[off]
|
||||||
|
off += 1
|
||||||
|
if off + dl > len(chunk):
|
||||||
|
raise ValueError("truncated")
|
||||||
|
device = chunk[off : off + dl].decode("utf-8")
|
||||||
|
off += dl
|
||||||
|
if off + 1 > len(chunk):
|
||||||
|
raise ValueError("truncated")
|
||||||
|
pl = chunk[off]
|
||||||
|
off += 1
|
||||||
|
if off + pl > len(chunk):
|
||||||
|
raise ValueError("truncated")
|
||||||
|
pname = chunk[off : off + pl].decode("utf-8")
|
||||||
|
off += pl
|
||||||
|
if off + 1 > len(chunk):
|
||||||
|
raise ValueError("truncated")
|
||||||
|
has_step = chunk[off]
|
||||||
|
off += 1
|
||||||
|
if has_step:
|
||||||
|
if off + 2 > len(chunk):
|
||||||
|
raise ValueError("truncated")
|
||||||
|
step = struct.unpack_from("<H", chunk, off)[0]
|
||||||
|
off += 2
|
||||||
|
out[device] = [pname, step]
|
||||||
|
else:
|
||||||
|
out[device] = [pname]
|
||||||
|
if off != len(chunk):
|
||||||
|
raise ValueError("select blob length mismatch")
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_default_blob(chunk: bytes) -> Tuple[Optional[str], list]:
|
||||||
|
if not chunk:
|
||||||
|
return None, []
|
||||||
|
off = 0
|
||||||
|
if off + 1 > len(chunk):
|
||||||
|
raise ValueError("truncated")
|
||||||
|
nl = chunk[off]
|
||||||
|
off += 1
|
||||||
|
if off + nl > len(chunk):
|
||||||
|
raise ValueError("truncated")
|
||||||
|
default_name = chunk[off : off + nl].decode("utf-8") if nl else ""
|
||||||
|
off += nl
|
||||||
|
if off + 1 > len(chunk):
|
||||||
|
raise ValueError("truncated")
|
||||||
|
nt = chunk[off]
|
||||||
|
off += 1
|
||||||
|
targets: List[str] = []
|
||||||
|
for _ in range(nt):
|
||||||
|
if off + 1 > len(chunk):
|
||||||
|
raise ValueError("truncated")
|
||||||
|
tl = chunk[off]
|
||||||
|
off += 1
|
||||||
|
if off + tl > len(chunk):
|
||||||
|
raise ValueError("truncated")
|
||||||
|
targets.append(chunk[off : off + tl].decode("utf-8"))
|
||||||
|
off += tl
|
||||||
|
if off != len(chunk):
|
||||||
|
raise ValueError("default blob length mismatch")
|
||||||
|
return default_name, targets
|
||||||
|
|
||||||
|
|
||||||
|
def parse_binary_envelope_v2(buf: bytes) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Decode native-binary v2 envelope into the v1 API dict shape."""
|
||||||
|
if not isinstance(buf, (bytes, bytearray)) or len(buf) < HEADER_LEN:
|
||||||
|
return None
|
||||||
|
if buf[0] != BINARY_ENVELOPE_VERSION_2:
|
||||||
|
return None
|
||||||
|
lp, ls, ld = buf[2], buf[3], buf[4]
|
||||||
|
need = HEADER_LEN + lp + ls + ld
|
||||||
|
if len(buf) != need:
|
||||||
|
return None
|
||||||
|
|
||||||
|
off = HEADER_LEN
|
||||||
|
presets_chunk = buf[off : off + lp]
|
||||||
|
off += lp
|
||||||
|
select_chunk = buf[off : off + ls]
|
||||||
|
off += ls
|
||||||
|
default_chunk = buf[off : off + ld]
|
||||||
|
|
||||||
|
data: Dict[str, Any] = {"v": "1"}
|
||||||
|
br = buf[1]
|
||||||
|
if br < 128:
|
||||||
|
data["b"] = brightness_0_255_from_wire(br)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if lp:
|
||||||
|
data["presets"] = _decode_presets_blob(bytes(presets_chunk))
|
||||||
|
if ls:
|
||||||
|
data["select"] = _decode_select_blob(bytes(select_chunk))
|
||||||
|
if ld:
|
||||||
|
dname, targets = _decode_default_blob(bytes(default_chunk))
|
||||||
|
data["default"] = dname
|
||||||
|
data["targets"] = targets
|
||||||
|
except (ValueError, UnicodeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def parse_binary_envelope_v1(buf: bytes) -> Optional[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Decode legacy v1 bytes (JSON text blobs) into a v1 API dict.
|
||||||
|
Returns None if ``buf`` is not a valid v1 envelope.
|
||||||
|
"""
|
||||||
|
if not isinstance(buf, (bytes, bytearray)) or len(buf) < HEADER_LEN:
|
||||||
|
return None
|
||||||
|
if buf[0] != BINARY_ENVELOPE_VERSION_1:
|
||||||
|
return None
|
||||||
|
lp, ls, ld = buf[2], buf[3], buf[4]
|
||||||
|
need = HEADER_LEN + lp + ls + ld
|
||||||
|
if len(buf) != need:
|
||||||
|
return None
|
||||||
|
|
||||||
|
off = HEADER_LEN
|
||||||
|
presets_chunk = buf[off : off + lp]
|
||||||
|
off += lp
|
||||||
|
select_chunk = buf[off : off + ls]
|
||||||
|
off += ls
|
||||||
|
default_chunk = buf[off : off + ld]
|
||||||
|
|
||||||
|
data: Dict[str, Any] = {"v": "1"}
|
||||||
|
|
||||||
|
br = buf[1]
|
||||||
|
if br < 128:
|
||||||
|
data["b"] = brightness_0_255_from_wire(br)
|
||||||
|
|
||||||
|
if lp:
|
||||||
|
try:
|
||||||
|
data["presets"] = json.loads(presets_chunk.decode("utf-8"))
|
||||||
|
except (ValueError, UnicodeError):
|
||||||
|
return None
|
||||||
|
if ls:
|
||||||
|
try:
|
||||||
|
data["select"] = json.loads(select_chunk.decode("utf-8"))
|
||||||
|
except (ValueError, UnicodeError):
|
||||||
|
return None
|
||||||
|
if ld:
|
||||||
|
try:
|
||||||
|
extra = json.loads(default_chunk.decode("utf-8"))
|
||||||
|
except (ValueError, UnicodeError):
|
||||||
|
return None
|
||||||
|
if isinstance(extra, dict):
|
||||||
|
for k, v in extra.items():
|
||||||
|
data[k] = v
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def parse_binary_envelope(buf: bytes) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Try v2 (native binary), then v1 (JSON fragments)."""
|
||||||
|
d = parse_binary_envelope_v2(buf)
|
||||||
|
if d is not None:
|
||||||
|
return d
|
||||||
|
return parse_binary_envelope_v1(buf)
|
||||||
25
src/util/message.py
Normal file
25
src/util/message.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
"""JSON wire representation for controller messages (binary packing can replace later)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Any, Dict, Union
|
||||||
|
|
||||||
|
|
||||||
|
class Message:
|
||||||
|
"""Round-trip API dicts as compact UTF-8 JSON."""
|
||||||
|
|
||||||
|
def encode(self, data: Dict[str, Any]) -> bytes:
|
||||||
|
"""Encode a JSON-serialisable mapping (typically a v1 API dict) to bytes."""
|
||||||
|
return json.dumps(data, separators=(",", ":")).encode("utf-8")
|
||||||
|
|
||||||
|
def decode(self, payload: Union[str, bytes, bytearray]) -> Dict[str, Any]:
|
||||||
|
"""Decode UTF-8 JSON bytes or string into a dict."""
|
||||||
|
if isinstance(payload, (bytes, bytearray)):
|
||||||
|
text = payload.decode("utf-8")
|
||||||
|
else:
|
||||||
|
text = payload
|
||||||
|
obj = json.loads(text)
|
||||||
|
if not isinstance(obj, dict):
|
||||||
|
raise TypeError("JSON root must be an object")
|
||||||
|
return obj
|
||||||
93
tests/test_binary_envelope.py
Normal file
93
tests/test_binary_envelope.py
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
"""Tests for compact binary controller envelopes (host util)."""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT / "src"))
|
||||||
|
|
||||||
|
from util.binary_envelope import ( # noqa: E402
|
||||||
|
BINARY_ENVELOPE_VERSION_2,
|
||||||
|
brightness_wire_from_0_255,
|
||||||
|
brightness_0_255_from_wire,
|
||||||
|
pack_binary_envelope_v2,
|
||||||
|
parse_binary_envelope,
|
||||||
|
parse_binary_envelope_v2,
|
||||||
|
parse_binary_envelope_v1,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_brightness_round_trip_extremes():
|
||||||
|
assert brightness_0_255_from_wire(brightness_wire_from_0_255(0)) == 0
|
||||||
|
assert brightness_0_255_from_wire(brightness_wire_from_0_255(255)) == 255
|
||||||
|
|
||||||
|
|
||||||
|
def test_pack_parse_v2_brightness_only():
|
||||||
|
raw = pack_binary_envelope_v2(brightness_0_255=128)
|
||||||
|
assert raw[0] == BINARY_ENVELOPE_VERSION_2
|
||||||
|
data = parse_binary_envelope_v2(raw)
|
||||||
|
assert data == {"v": "1", "b": 128}
|
||||||
|
|
||||||
|
|
||||||
|
def test_pack_parse_v2_full():
|
||||||
|
raw = pack_binary_envelope_v2(
|
||||||
|
presets={
|
||||||
|
"a": {
|
||||||
|
"p": "on",
|
||||||
|
"c": ["#ffffff"],
|
||||||
|
"d": 10,
|
||||||
|
"b": 255,
|
||||||
|
"a": True,
|
||||||
|
"n1": 1,
|
||||||
|
"n2": -2,
|
||||||
|
"n3": 3,
|
||||||
|
"n4": 4,
|
||||||
|
"n5": 5,
|
||||||
|
"n6": 6,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
select={"dev": ["a"]},
|
||||||
|
default="a",
|
||||||
|
default_targets=["dev"],
|
||||||
|
brightness_0_255=64,
|
||||||
|
)
|
||||||
|
assert len(raw) <= 250
|
||||||
|
data = parse_binary_envelope_v2(raw)
|
||||||
|
assert data["v"] == "1"
|
||||||
|
assert data["b"] == 64
|
||||||
|
assert data["presets"]["a"]["p"] == "on"
|
||||||
|
assert data["presets"]["a"]["n2"] == -2
|
||||||
|
assert data["select"]["dev"] == ["a"]
|
||||||
|
assert data["default"] == "a"
|
||||||
|
assert data["targets"] == ["dev"]
|
||||||
|
|
||||||
|
merged = parse_binary_envelope(raw)
|
||||||
|
assert merged == data
|
||||||
|
|
||||||
|
|
||||||
|
def test_v2_wire_not_utf8_json():
|
||||||
|
raw = pack_binary_envelope_v2(
|
||||||
|
presets={"x": {"p": "blink", "c": ["#112233"]}},
|
||||||
|
brightness_0_255=None,
|
||||||
|
)
|
||||||
|
assert raw[0] == BINARY_ENVELOPE_VERSION_2
|
||||||
|
assert parse_binary_envelope_v1(raw) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_dont_change_brightness_v2():
|
||||||
|
raw = pack_binary_envelope_v2(brightness_0_255=None)
|
||||||
|
data = parse_binary_envelope_v2(raw)
|
||||||
|
assert "b" not in data
|
||||||
|
|
||||||
|
|
||||||
|
def test_json_wire_not_v2():
|
||||||
|
assert parse_binary_envelope_v2(b'{"v":"1"}') is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_v1_parse_via_dispatcher():
|
||||||
|
import json
|
||||||
|
|
||||||
|
inner = json.dumps({"x": {"p": "on"}}, separators=(",", ":")).encode()
|
||||||
|
raw = bytes([1, 255, len(inner), 0, 0]) + inner
|
||||||
|
d = parse_binary_envelope(raw)
|
||||||
|
assert d["presets"]["x"]["p"] == "on"
|
||||||
Reference in New Issue
Block a user