Compare commits
6 Commits
0da30b6d6b
...
3bb75d49de
| Author | SHA1 | Date | |
|---|---|---|---|
| 3bb75d49de | |||
| 3d77cb448a | |||
| 49383c0003 | |||
| 7d821b9c1c | |||
| 9b7e387ea6 | |||
| b4f0d1891e |
3
.gitmodules
vendored
3
.gitmodules
vendored
@@ -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
|
||||
|
||||
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<>¶´ò”{
|
||||
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: 4575ef16ad...3ee89ce3b4
2
led-tool
2
led-tool
Submodule led-tool updated: eee9327e15...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
|
||||
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