8 Commits

Author SHA1 Message Date
3bb75d49de feat(util): add binary envelope packing and message helpers
Includes tests for v1/v2 envelope round-trips.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-03 14:56:37 +12:00
3d77cb448a chore: add vertical stand OpenSCAD model
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-03 14:56:35 +12:00
49383c0003 feat(espnow): add espnow-sender utility
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-03 14:56:35 +12:00
7d821b9c1c chore(db): add local preset fixtures
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-03 14:56:35 +12:00
9b7e387ea6 chore(scripts): add dev-run helper
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-03 14:56:34 +12:00
b4f0d1891e chore(submodule): bump led-driver and led-tool; register led-simulator
led-simulator was already a gitlink; add the missing .gitmodules entry.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-03 14:56:30 +12:00
0da30b6d6b fix(submodule): update led-tool pointer to existing commit 2026-04-30 23:28:39 +12:00
6cbb728d9a feat(patterns): add new pattern suite and improve mobile controls
Add a broad set of LED patterns with metadata/tests and update zone/profile preset seeding, while refining mobile/desktop UI behavior for scrolling, brightness controls, and bulk pattern sending.
2026-04-23 20:07:55 +12:00
70 changed files with 1340 additions and 168 deletions

View 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
View File

@@ -4,3 +4,6 @@
[submodule "led-tool"]
path = led-tool
url = git@git.technical.kiwi:technicalkiwi/led-tool.git
[submodule "led-simulator"]
path = led-simulator
url = git@git.technical.kiwi:technicalkiwi/led-simulator.git

View File

@@ -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 1030 s, -1=off)",
"n4": "Spark gap max (ms)",
"min_delay": 10,
"max_delay": 10000,
"max_colors": 10
},
"twinkle": {
"n1": "Twinkle activity (1255, higher = more changes)",
"n2": "Density (0255, 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
}
}
{"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}}

File diff suppressed because one or more lines are too long

BIN
db/presets/1.bin Normal file

Binary file not shown.

3
db/presets/10.bin Normal file
View File

@@ -0,0 +1,3 @@
PRST1xœ%ÎÁ
Â0Ð_ñšCSµJîæ'D$¶«
ÄݦˆˆÿntOovæ²opxz´zޱ ¦P

2
db/presets/11.bin Normal file
View File

@@ -0,0 +1,2 @@
PRST1xњ%ОAВ …б»<·,J5\Е4
К $84SX4Ж»eхеНШЅ B

1
db/presets/12.bin Normal file
View File

@@ -0,0 +1 @@
PRST1xœ%ÎA л|·, ŠÐK˜ÆP;* 

2
db/presets/13.bin Normal file
View 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
View 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

Binary file not shown.

BIN
db/presets/2.bin Normal file

Binary file not shown.

2
db/presets/3.bin Normal file
View File

@@ -0,0 +1,2 @@
PRST1xœUÎÁ
Â0ЙsM5Uò+"²µ«â¦lSDÄwiNž³3‡ý@èɈPJ2fª•Uþn×.ˆ§³Ã¨éþ¨Â‹å>‡‰3½}×9ÐZ bÕ•ÄÛÀè­]cß<08>¡qh7f-·”ù’&ûÁãûF9/.

2
db/presets/30.bin Normal file
View 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

Binary file not shown.

2
db/presets/32.bin Normal file
View File

@@ -0,0 +1,2 @@
PRST1xœ%ͽÂ0 àW©Ž5C~•&VÆ
¡@<40>)uª4K…xwR<}ç»Á° —ks <DjÎ)¦ …É•B™ë¸ž¯µža;l¼×Ú{Üž9 ïÂ4×Á­ÐSt l«kæ[a'ì…ƒpN¦œ|ˆô}ýmðý-‰

1
db/presets/33.bin Normal file
View 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

Binary file not shown.

2
db/presets/35.bin Normal file
View File

@@ -0,0 +1,2 @@
PRST1xœ%ͽÂ0 àW©Ž5C~•&VÆ
¡@<40>)uª4K…xwR<}ç»Á° —ks <DjÎ)¦ …É•B™ë¸ž¯µža;l¼×Ú{Üž9 ïÂ4×Á­ÐSt l«kæ[a'ì…ƒpN¦œ|ˆô}ýmðý-‰

1
db/presets/36.bin Normal file
View 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

Binary file not shown.

BIN
db/presets/38.bin Normal file

Binary file not shown.

3
db/presets/39.bin Normal file
View File

@@ -0,0 +1,3 @@
PRST1xœUÎÁ0„áw¯=¤jú*†<>
[m\[²”ƒ1¾»…ž<}ÉÌåÿ ºÁÂsŸ$P˜]Î$ño'Y`¯88ÒÚ{ô
7 ÷GŽ´”£5Fa"voX£ÜšlbÛè2ÆvãXé*¦rªœ+—<>YLC˜JM³·1•ºAÈo5qeî¿?ªð9±

BIN
db/presets/4.bin Normal file

Binary file not shown.

4
db/presets/40.bin Normal file
View 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
View 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
View 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

Binary file not shown.

2
db/presets/44.bin Normal file
View File

@@ -0,0 +1,2 @@
PRST1xœEÎM
Â0à«ÈsEÿ¢ôE$¶£â¤$Ó…ˆww0 góÁ{o1o°„ŠìÊì™)Ã`õ"”Y˜r<CB9C>°ÇFgƒk÷‡0-:k

3
db/presets/45.bin Normal file
View File

@@ -0,0 +1,3 @@
PRST1xœ=ŽA0E¯B>Û.
€KC*ŒØ¤¶¤Æxw<1B>Í{™7y!ØÁ€)s5';9
\å1Eï¡°XfJA~mø·1ú˜ußkÙÕZo^ls\®ÉÍw”å¸mµÂDÞ>a:Q»r„á´Bh¤ Z)aW°/8tÇ‚ÓKŠ7çip“üÙàý)<¡

3
db/presets/46.bin Normal file
View 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
View File

@@ -0,0 +1,2 @@
PRST1x<EFBFBD>5־A0…ב«<D791>ַ¶ @Dׂ- —0ֶT©<54>X[2ֶxwG׳ש&‎»˜yXh°M\₪<>׀<EFBFBD><D780>ֹ8<>0[
’ור/חט#%ט=ֺ¾†q”·r\¹כ<C2B9>ƒMע¥©*…ֹzף„מd5 Gh¦ֵ*„Zz+6b-1l ¿´™m¦ֻל2ֺLסגה"7ֹy5<79>־ד:G

2
db/presets/48.bin Normal file
View File

@@ -0,0 +1,2 @@
PRST1xœ-ÎÁ Ð_1ã•ÔZŽúÆ´«’ 4°Õã¿»Š§7;sÙ¢»,˜
/îNP˜3å(í¿8¥<38>r<EFBFBD>Ýa©õ¶ìŽÙ_®©ÈÐh­0RpOØN¢9ÁržI!ˆ<C393>ØËWö{­+]eSéL9<4C>} ƒåƒ÷ªù0¿

2
db/presets/49.bin Normal file
View 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>

BIN
db/presets/5.bin Normal file

Binary file not shown.

2
db/presets/50.bin Normal file
View File

@@ -0,0 +1,2 @@
PRST1xœ5ÎA0Ы<C390>϶ˆ¦è%Œ!F <20>´ÃÂïîhu6o2ÿ/æ ïVSâ"Ѹ’碟\"(lŽ™¢—ø—tÿ¤Kˆ æÒZ-#·ò£µ¸*Üâ<Nì)I¥ÖZa Å=`ZYÝΆãN
¾i„¦0RðMæ˜i3§ÌùËÃ}^¨›ù­Âë

BIN
db/presets/51.bin Normal file

Binary file not shown.

BIN
db/presets/52.bin Normal file

Binary file not shown.

2
db/presets/53.bin Normal file
View File

@@ -0,0 +1,2 @@
PRST1xœ5Î=Â0 †á«Tk†þQ<C3BE>À%*T%Ô@¥TŽ; ÄÝIáå±ôzðÞ¾å¨ET Ž ·JT,V•ŧšÃð·0‰ ‡Ë>¸8™S¨ËÒ`äÙ¾A]Zíª¤²²<C2B2>¯@M¢ÎÉ7 v;÷-hã˜é2§ÌygpŸf¦1ýTáû^
7˜

3
db/presets/54.bin Normal file
View File

@@ -0,0 +1,3 @@
PRST1x<EFBFBD>Ν
Β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
View 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
View File

@@ -0,0 +1 @@
PRST1xœ5ŽAƒ E¯b¾[¨U+WiŒ¡2¶¦ `š¦éÝ nxÌŒ|ù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
View 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
View File

@@ -0,0 +1,2 @@
PRST1xœ=ÎÍ
Â0àWé5‡ô?ìM"} ‰vÕBMJD|wSž¾afû†5O!rˆ;³zç

3
db/presets/59.bin Normal file
View File

@@ -0,0 +1,3 @@
PRST1x°Mна
б0 ЮW▒вз╘SzTЯ%D╓╨Lm├┬ЬНfКе\╬ДOЫ ╦'а┌)С"┤ЬЙ°ВP3╔ ⌡©П}LЖ└Й8≈dуNЖр²╝╘©?8P√⌠Zk┘√╪{ц6р╨▒#,╖▒┌≥Жb
k└%Л4╜

2
db/presets/6.bin Normal file
View 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ìûøèÇù’Æë

4
db/presets/60.bin Normal file
View File

@@ -0,0 +1,4 @@
PRST1xœMÎA0Ы˜ï¶RÉ€KcŠŒBR[Ò c¼»­l\½Éÿùɼáí“ANr˜ÙFÙ
V+ÂÑçê?½b
8ö½éj<EFBFBD>—Ç,žS.ŒÖ
µù´›<04>Ä<EFBFBD>|ªL½¨)

BIN
db/presets/61.bin Normal file

Binary file not shown.

3
db/presets/62.bin Normal file
View File

@@ -0,0 +1,3 @@
PRST1xœ5ŽA0E¯B>Û.
€KCªŒBRÚ¦ c¼»ÅÙ¼7óÿb>ðv"0Í\D눙Š)¤8@!ZÙ—xOºò抲mµŒÜJ­W϶:n
÷4¾ö4K¹ÖZ¡'gß0<C39F>¨]8ÀpZHÁW0ÕVðõÞô˜ÇŒSF“qθlˆ)<GGÝØË«¾?ð¹<

3
db/presets/7.bin Normal file
View File

@@ -0,0 +1,3 @@
PRST1xœMŽ1Â0 Eïò»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

Binary file not shown.

2
db/presets/9.bin Normal file
View File

@@ -0,0 +1,2 @@
PRST1xœ%ÎK
Ã0 Ы”éÖ‹$ýâ«”ÜFn ŽPJï^ÇÖæI£Í|Áf&hlFæÃ6¹HPXLŒ$œãÀù|d…~àhË WxŠ{O<69>®iFòæÝî»I1@GI¤À-tޏ«œ*çÊ¥r­Ü*÷Â"Á:Oƒs<>´ò”{

View File

@@ -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
View 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
View 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
View 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
View 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)

1
led-simulator Submodule

Submodule led-simulator added at 7ce56b64df

123
led_bar_vertical_stand.scad Normal file
View 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
View 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

View File

@@ -4,6 +4,7 @@ document.addEventListener('DOMContentLoaded', () => {
const patternsCloseButton = document.getElementById('patterns-close-btn');
const patternsList = document.getElementById('patterns-list');
const patternAddButton = document.getElementById('pattern-add-btn');
const patternSendAllButton = document.getElementById('pattern-send-all-btn');
const patternEditorModal = document.getElementById('pattern-editor-modal');
const patternEditorCloseButton = document.getElementById('pattern-editor-close-btn');
const patternCreateBtn = document.getElementById('pattern-create-btn');
@@ -24,6 +25,71 @@ document.addEventListener('DOMContentLoaded', () => {
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) => {
if (!meta || typeof meta !== 'object') {
return '';
@@ -424,4 +490,93 @@ document.addEventListener('DOMContentLoaded', () => {
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.');
}
});
}
});

View File

@@ -175,39 +175,6 @@ async function postDriverSequence(sequence, targetMacs, delayS) {
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', () => {
const presetsButton = document.getElementById('presets-btn');
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
const presetId = currentEditId || payload.name;
// 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 deviceNames = tabDeviceNamesFromSection(section);
const presetId = currentEditId || payload.name;
await sendPresetViaEspNow(presetId, payload, deviceNames, true, true, '1');
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');
}
// 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 deviceNames = tabDeviceNamesFromSection(section);
@@ -1388,18 +1356,18 @@ document.addEventListener('DOMContentLoaded', () => {
if (saved && typeof saved === 'object') {
if (currentEditId) {
// 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 {
// POST returns { id: preset }
const entries = Object.entries(saved);
if (entries.length > 0) {
const [newId, presetData] = entries[0];
await sendPresetViaEspNow(newId, presetData, deviceNames, true, false);
await sendPresetViaEspNow(newId, presetData, deviceNames, false, false, '2');
}
}
} else {
// 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();
@@ -1454,7 +1422,14 @@ const coercePresetInt = (v, def = 0) => {
// 1) preset payload (optionally with save)
// 2) optional select for device names (never with save)
// 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 {
const baseColors = Array.isArray(preset.colors) && preset.colors.length
? preset.colors
@@ -1462,10 +1437,11 @@ const sendPresetViaEspNow = async (presetId, preset, deviceNames, saveToDevice =
const paletteColors = await getCurrentProfilePaletteColors();
const colors = resolveColorsWithPaletteRefs(baseColors, preset.palette_refs, paletteColors);
const wirePresetId = devicePresetId != null ? String(devicePresetId) : String(presetId);
const presetMessage = {
v: '1',
presets: {
[presetId]: {
[wirePresetId]: {
pattern: preset.pattern || 'off',
colors,
delay: typeof preset.delay === 'number' ? preset.delay : 100,
@@ -1486,7 +1462,7 @@ const sendPresetViaEspNow = async (presetId, preset, deviceNames, saveToDevice =
presetMessage.save = true;
}
if (setDefault) {
presetMessage.default = presetId;
presetMessage.default = wirePresetId;
}
const names = Array.isArray(deviceNames) ? deviceNames : [];
@@ -1502,7 +1478,7 @@ const sendPresetViaEspNow = async (presetId, preset, deviceNames, saveToDevice =
const select = {};
names.forEach((name) => {
if (name) {
select[name] = [presetId];
select[name] = [wirePresetId];
}
});
if (Object.keys(select).length > 0) {
@@ -1879,7 +1855,8 @@ const createPresetButton = (presetId, preset, zoneId, isSelected = false) => {
button.classList.add('active');
selectedPresets[zoneId] = presetId;
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);
});
});

View File

@@ -149,6 +149,40 @@ header h1 {
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 */
body.preset-ui-run .edit-mode-only {
display: none !important;
@@ -248,7 +282,8 @@ body.preset-ui-run .edit-mode-only {
display: block;
overflow-y: auto;
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 {
@@ -528,6 +563,12 @@ body.preset-ui-run .edit-mode-only {
row-gap: 0.3rem;
align-content: start;
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 */
@@ -949,7 +990,7 @@ body.preset-ui-run .edit-mode-only {
}
/* Mobile-friendly layout */
@media (max-width: 800px) {
@media (max-width: 1000px) {
header {
flex-direction: row;
align-items: center;
@@ -1001,6 +1042,9 @@ body.preset-ui-run .edit-mode-only {
min-width: 280px;
max-width: 95vw;
padding: 1.25rem;
max-height: calc(100dvh - 1rem);
overflow-y: auto;
padding-bottom: calc(1.25rem + env(safe-area-inset-bottom, 0px));
}
.form-row {
@@ -1018,6 +1062,10 @@ body.preset-ui-run .edit-mode-only {
width: 100%;
height: 100%;
background-color: rgba(0,0,0,0.7);
overflow-y: auto;
padding: 1rem;
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
}
.modal.active {
display: flex;
@@ -1030,6 +1078,20 @@ body.preset-ui-run .edit-mode-only {
border-radius: 8px;
min-width: 400px;
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 {
display: block;
@@ -1200,9 +1262,11 @@ body.preset-ui-run .edit-mode-only {
min-height: 80px;
}
/* Presets list: 3 columns and vertical scroll (defined above); mobile same */
@media (max-width: 800px) {
@media (max-width: 1000px) {
#presets-list-zone {
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 */

View File

@@ -1,5 +1,47 @@
// Zone management JavaScript
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 toggle = document.querySelector('.ui-mode-toggle');
@@ -468,37 +510,17 @@ async function loadZoneContent(zoneId) {
const legacyAttr = legacyOk ? ` data-device-names="${escapeHtmlAttr(names.join(","))}"` : "";
container.innerHTML = `
<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">
<!-- Presets will be loaded here by presets.js -->
</div>
</div>
`;
// Wire up per-zone brightness slider to send global brightness via ESPNow.
const brightnessSlider = container.querySelector('#zone-brightness-slider');
let brightnessSendTimeout = null;
if (brightnessSlider) {
brightnessSlider.addEventListener('input', (e) => {
const val = parseInt(e.target.value, 10) || 0;
if (brightnessSendTimeout) {
clearTimeout(brightnessSendTimeout);
}
brightnessSendTimeout = setTimeout(() => {
if (typeof window.sendEspnowRaw === 'function') {
try {
window.sendEspnowRaw({ v: '1', b: val, save: true });
} catch (err) {
console.error('Failed to send brightness via ESPNow:', err);
}
}
}, 150);
});
// Keep header and menu brightness controls in sync.
const brightnessSlider = document.getElementById('header-brightness-slider');
const menuBrightnessSlider = document.getElementById('menu-brightness-slider');
if (menuBrightnessSlider && brightnessSlider) {
menuBrightnessSlider.value = brightnessSlider.value;
}
// 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.
document.querySelectorAll('.ui-mode-toggle').forEach((btn) => {
btn.addEventListener('click', async () => {

View File

@@ -15,6 +15,10 @@
</div>
</div>
<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 edit-mode-only" id="devices-btn">Devices</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>
<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>
<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" class="edit-mode-only" data-target="devices-btn">Devices</button>
<button type="button" class="edit-mode-only" data-target="zones-btn">Tabs</button>
@@ -245,6 +253,7 @@
<h2>Patterns</h2>
<div class="modal-actions">
<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 id="patterns-list" class="profiles-list"></div>
<div class="modal-actions">

508
src/util/binary_envelope.py Normal file
View 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 — 0127 scales to device 0255; 128255 = leave unchanged
2: byte length of presets section (0255)
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 0255 to wire 0127."""
v = max(0, min(255, int(value)))
return (v * 127 + 127) // 255
def brightness_0_255_from_wire(wire: int) -> int:
"""Map wire 0127 to device brightness 0255."""
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
View 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

View 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"