From 3451ceb6521144229536e1bb455c20b63026d3fc Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Fri, 27 Mar 2026 02:40:02 -0600 Subject: [PATCH] feat: MeshCore decoder, Watchman, test corpus, sparkline TX indicators MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Firmware: - fix(protocol): SNR type i8→i16 to match lora_phy PacketStatus - fix(display): combine splash + waiting into one screen - fix(display): move TX power to row 0 right-justified - feat(display): sparkline distinguishes TX from RX — TX bars are dotted (alternating pixel rows), RX bars are solid. TX-only slots show a short marker. TX takes precedence over RX in the same time slot. Host tool (tools/meshcore_rx.py): - feat: full MeshCore packet decoder with colored ANSI output - feat: GRP_TXT decryption using 276 channels from channels.csv - feat: Watchman loop/collision reporter to #watchman - feat: ADVERT-informed node registry with 12h TTL persistence - feat: transport code fallback for older MeshCore firmware - fix: robust error handling for long-running operation - fix: collision detection uses evidence-based reasoning from advert cache Channel directory (tools/channels.csv): 276 channels Test corpus (corpus/): JSON Schema, 28 hand-crafted vectors, Hypothesis generator --- .gitignore | 3 + .hypothesis/constants/acbb6a9ef6a5a354 | 4 + .../unicode_data/15.0.0/charmap.json.gz | Bin 0 -> 21726 bytes corpus/build_hand.py | 607 +++++++++++ corpus/generate.py | 327 ++++++ corpus/hand/ack_basic.json | 22 + corpus/hand/ack_with_path.json | 26 + corpus/hand/advert_basic.json | 31 + corpus/hand/advert_emoji_name.json | 32 + corpus/hand/advert_feat_flags_as_name.json | 32 + corpus/hand/advert_location.json | 34 + corpus/hand/advert_no_appdata.json | 25 + corpus/hand/advert_sensor.json | 30 + corpus/hand/advert_truncated.json | 20 + corpus/hand/advert_with_path.json | 35 + corpus/hand/anon_req_tc_fallback.json | 30 + corpus/hand/bad_path_len.json | 15 + corpus/hand/control_discover_req.json | 23 + corpus/hand/empty_packet.json | 14 + corpus/hand/grp_txt_basic.json | 26 + corpus/hand/multipart_ack.json | 24 + corpus/hand/noise_packet.json | 14 + corpus/hand/path_3byte_hash.json | 29 + corpus/hand/path_4byte_hash.json | 29 + corpus/hand/req_transport_direct.json | 33 + corpus/hand/response_corrupted_pathlen_1.json | 11 + corpus/hand/response_corrupted_pathlen_2.json | 11 + corpus/hand/response_valid_1hop.json | 14 + corpus/hand/response_valid_2hop.json | 14 + corpus/hand/too_short.json | 14 + corpus/hand/trace_basic.json | 23 + corpus/hand/txt_msg_direct_2b_hash.json | 30 + corpus/hand/txt_msg_flood_with_loops.json | 33 + corpus/schema.json | 128 +++ corpus/validate.py | 207 ++++ src/channel.rs | 2 +- src/display/render.rs | 116 +- src/display/task.rs | 28 +- src/protocol.rs | 2 +- src/radio/task.rs | 2 +- tools/channels.csv | 277 +++++ tools/meshcore_rx.py | 993 ++++++++++++++++++ tools/test_ping.py | 254 ----- 43 files changed, 3309 insertions(+), 315 deletions(-) create mode 100644 .hypothesis/constants/acbb6a9ef6a5a354 create mode 100644 .hypothesis/unicode_data/15.0.0/charmap.json.gz create mode 100755 corpus/build_hand.py create mode 100755 corpus/generate.py create mode 100644 corpus/hand/ack_basic.json create mode 100644 corpus/hand/ack_with_path.json create mode 100644 corpus/hand/advert_basic.json create mode 100644 corpus/hand/advert_emoji_name.json create mode 100644 corpus/hand/advert_feat_flags_as_name.json create mode 100644 corpus/hand/advert_location.json create mode 100644 corpus/hand/advert_no_appdata.json create mode 100644 corpus/hand/advert_sensor.json create mode 100644 corpus/hand/advert_truncated.json create mode 100644 corpus/hand/advert_with_path.json create mode 100644 corpus/hand/anon_req_tc_fallback.json create mode 100644 corpus/hand/bad_path_len.json create mode 100644 corpus/hand/control_discover_req.json create mode 100644 corpus/hand/empty_packet.json create mode 100644 corpus/hand/grp_txt_basic.json create mode 100644 corpus/hand/multipart_ack.json create mode 100644 corpus/hand/noise_packet.json create mode 100644 corpus/hand/path_3byte_hash.json create mode 100644 corpus/hand/path_4byte_hash.json create mode 100644 corpus/hand/req_transport_direct.json create mode 100644 corpus/hand/response_corrupted_pathlen_1.json create mode 100644 corpus/hand/response_corrupted_pathlen_2.json create mode 100644 corpus/hand/response_valid_1hop.json create mode 100644 corpus/hand/response_valid_2hop.json create mode 100644 corpus/hand/too_short.json create mode 100644 corpus/hand/trace_basic.json create mode 100644 corpus/hand/txt_msg_direct_2b_hash.json create mode 100644 corpus/hand/txt_msg_flood_with_loops.json create mode 100644 corpus/schema.json create mode 100755 corpus/validate.py create mode 100644 tools/channels.csv create mode 100755 tools/meshcore_rx.py delete mode 100644 tools/test_ping.py diff --git a/.gitignore b/.gitignore index 9e4df18b..80a544b1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ /target /vendor src/board/mod.rs +__pycache__/ +.hypothesis/ +corpus/generated/ diff --git a/.hypothesis/constants/acbb6a9ef6a5a354 b/.hypothesis/constants/acbb6a9ef6a5a354 new file mode 100644 index 00000000..0c8d2db6 --- /dev/null +++ b/.hypothesis/constants/acbb6a9ef6a5a354 @@ -0,0 +1,4 @@ +# file: /home/swaits/Code/lora-dongle/corpus/generate.py +# hypothesis_version: 6.151.9 + +[1000000.0, -180000000, -90000000, 127, 128, 184, 192, 255, 90000000, 180000000, 4294967295, '*.json', '--count', 'r+PH$q>yK6sv^l*eL)QRA@GDv#IAFm% zE&IPyLH+WC+1~%`=K-(b{3Ej|Uq%s8xwB9v__o8@{XXyD|9*fR@Z|q)-XOTuxwN-z zxW*IkW-8tNe35(Y6zLQ@)Wf;7a-Td3tl^hkLszMMdEOKmt0`=!FKeC;ct5b8l?6Hl zXs#!?>pq_`Hqz6)dMJ7{3tLYlgbIw)ow&-B2;dRDY*Y-lZmNlTxs61A+U1^Kj4etL zf_iPs#d}>EM)E~}Rz;uR1IrHtZAz-tB^B0Nez|;}d&Jh>$G3?#Z$5Uk#Rl~I$?5s- zo;p_Ztq8gmAbkR#6)tX{T96Oz9WQ5rv9_|-%bf(7b3Yy>?cDuccFTb&D*hA{pS?D2 z?-kb`6{5JZ)3$qFb~0R|rwCN#sXi4~cU@k&Gn)!B_ZXM%*WAq-)9H`3&Ckhf^oUaD zkdO7tR(nr9z?iPz0(d~hTsO(D6_$X94X!deL`AsejYUa|N1HNzmZN7`CDlJpea|W! zs%vC*;ygn`0t$VWG;07=0_1j5FNdYfU7-THZsxYPV^_tloiu)sF9@=rzb#iwG$+OsVJR3Su9(!qi_CxD`2}? zRb#~K-b;>V{94b_4cZp|L}9Tp;l$U@NPq8K@Dg8buAzarF16`uvXO#7>eQt`v8%ax zGwz8ioHH%ob_l)JDg;gkrm+R=1-EVTN^HUr{jk<%%H2fXzXow}~H z8~tDD)3~QlK<+Y5@#W=)8-3#6-8A+vgvlAxMLuRWU`)t&-ANS@liZ5KzwOCvr#jyN zlXX`_h4~xqLc9FBEz6O>el0w|(@Tbp&7#-V>-I_YLxyHI`h1^n?7$@r6(N({*)xxP zCxUy9r4+OwZO1BHW!C=9@>K5%oA0olh2GpxQMQXc2=2|ID%m8^pZ&dm`P-&cZTGlv zwU4%0W8$6{&oy*q?h8w{TAt~Bhh|yx%@;N1I?-JmQ`+ppf^E~=KSb>)?4*RtB+Tq6 zAj$nh7kBMfWw&)imVf!UH=nbjKAj)`bs@ecGLN2K``Tg8u1ZTaU742C1$C-w z9=F9bL7=;8`KkqB4zL{Iaf)%$NK?9HxxVmmgM(y8=d#Z~pxHGW{z#Xnzi-07S-MPO zljp7ZY~ih*ZN6Idv9f8#p!+4DF_}$vH+-WdrOLK-OoZ{b!;?LD8wVh46AZAAz0tW; zS3!H_Q6TZWsggOPM2pojvL z!zx)i;Fl>!{XBln4PjllqaNSOk|7I^{HdU%7Tz2p9d5?#Gvllz{q6y(XBU3l-qLT9 ztEXiPDJm!YjYVp=tDAG7z1dP#nX6zb#~%0iHnJx|yYC$lm~?z=zaE)?XgR%YVpou_ zk!x=9P+YGbO8E|zq@!yVE2qK_E$-qd)cE+B!R8oP3ii9}7icfPBr|XU%9ct?RF&_?R*DHS^q|ygS&*0~2esmUniB>MB{s7w;9uyX>GDcKn`4qyP3^ zIg$={?5{!Yy4-y;Wmk6#?N)XWu%-Zb>+cGPCHy(vh}%>Vy!;9DcCmCQ;?qD+u)WS| zqB!DLrbjoARgU=$IcW=*yl)eu*@BebB!1xtx?unc{!S1BXn#qLSDQ@eXcfEp*pRL_ z=v+te+p5FX=Ir3juK$=;Z@GH!y>|DgJ=dF*%~j0m#vT1Fu9b2@M^R>WBI7~zZGYkU zG6GEd`@st0`$h3b{?>-Y@tZQ-lbwm|(0k`(l3tA_`zFg2aM7Lob-u&LqxKD{rkl+6 zO|2iOC}*=sH`*eox(6(GY+Tt4%>`uR8aAivJ4ct;a7`;Rgw3BBBgQ4X&K(h`el~EP z+SELCv7PB0G_0_X3v<&EjTzw8ZEKi%DK9-!eyikuEJi4*!mUcP+IiyD^|R8jzT zgnGcF;`(j9GKuamQOIcXmy@=s?y&>S=xPu7yPD_S%LUJ`T~!iLJbaAp>c)FX%-z2< z+Ufq=R858UY51ajK{xi|C(_x9n7hmao=ap%v~X@$Rpkz4W=-%~@$4)}Z~LeMUmkBi zKb~HjyVYo1tDn<_tgmz=_IiuVCPj53)mFa z%qBn9_F0#QJM8>_z+v{BO0od8grWk~=*l6Mt?qR``V1R3yPh?ir2F^KBg5$ZDWFF{fHa~* z3f4iAozm(3t{xE(u-SW0XZU&gw9zEpciy=cv6Z^-n=pMUHStK}u%oc$FpP7Fv=muze|{-9 zK+$$yR?+1jccNXMExigDzRyAR&9U75Ac_UDa++_}@G9;+%03?H;2j&j^!NdJbJ?qF;if4di*M|8R}D>rv2D2)0N%pf^>aeRBHoLjy(G6PUYVhxYukZT;F2fr`FW zheT`%vfW*XfRgsV&(BLoozy|BEL~W!o8n0dN!yhTg=8w&e{4Q?e(yQHoHvz!o+Rk&|5xhU( zGvMY^@zb#KsG0ke=uDD*lI3OjMW46jqw~&p`%Z++Yv(fzh`bcJ{$uyzU&VBrmSa>+eNKtnziVZOs!poO;x zIryT{3Pxo^e16&I)UOrsXuX}4W}3=jtZhpa`d5ccqYHI=chCp&muJQSdcZ%oe#ibq zJa>DJ5Db@&VX}YxQkV_w$zsJTzRp^rWZ*9bgm-{~q&QI|U)N0?&+OdIp zPKh_{CSLEB9(Oe8=IV&OJLIDEpR_+ba=rpy@Xv^F$205s_+ez#3O-QN!kPWLMnCwr zs&m55k_8A{9W)C*uxc#Nyfz4Y4PSofz1sCPlIJd5bWK?jo%+$s0z^!4rb-r`3=CKC zP;VH;%uddlXI~S-NS;{_x_`2Ma3%O)Z?_{Z_s<&(5=tIjDohGCOF!E<@>`KFdzKw~ zInW|)H5x1-`3JPU@HT#Ale`{ZvW;aa0u?%mtjHDpryk$G4wLrrlIj4X&aO3D;BxDO zrT%A9^ZDh%nm$`omMD4nij>8( zI5zHjq}tNScX??rQ+JRiadEH{f6IhP)J=BL_@HwW{;o;()2+oOx8|x`AyIO}h2aC< z*7#uX&pQ1D6>V4wf$p&6?o~K&rxRAp)zxQxDvx*iWo|Q3^oq`K=cjC$4u7wEBgI39 z-|*#KFmJ$?AitOE{uA8v;p(&jQ0g>%+RanbVI6?_PTLe)*p=8th%NmC5MM>6>(4KS zxbh-3v-mjp7(++k)h$Di-(B`_)je|xV}kV6XD0qCzSnTHIjXaW49Kf0Sv<{-J~ce8 zc+{mnYP;k+=bh;)wH2aEIArT|HF6_p7pXAW)jp-*D%$vZi)v)lX8C7Lmf(7|MrF#7 zV8`Oppn&(T;W;Karo-^Qm+YOO7h$H8))@JH#X-x*b8HcbsH_aRs@hR#Rh)M#Uf$#U z%=K}xE9?9=n{hl+p^zP(a{NTwMqfr4Sl7IBX zw+<1cRK)hmYsXG_?bk&qc@r*a(XdeSrdYE!wJj9@Gd23xuoFnI6A*jwJ!-I-x5Iz4 zvkLe_QvVwu$@4D+fxfwcyrjvmN2-3y?zZ)v`1_cvbc>dw1-c9ofz8f-ZT{HYso6Ex z!bM}?Ket)7J4dXQU$>91i{h*_x_E~fGTZFY}@ORcGEE9CYP;E zE*NryL9J!qN2Y1Rc`*l7wFRhc1M5e6u*)Tz`;D62SX9iR{gi{qIwxJfqiVOFj~tpB zXByd-uwmx6dQID=vJ}$-!-FOZ@;u9<{&_V1ibXiCwHUIcXV%ql;J;vaU@2Je#N6K5@qZwLy^ha`&uvqupE6Y6vE z2LgjLG6N4_y`ZLB?Q}JwP(Z))3>kjrUKRMT$Tz zsFL(MNloEQp&hektU(%K=b`d4Q0+h(V0$%Q`zA3@dj-XZwnO`)#ONhTZ?*fwc*+dH>$#y$PQ1hG%enQ@uqOYBW#Ysh+dYw)x zLHFvj$g1yKTydzLe;q$ni)|jJDDwfn|5GY6l(s4=P90d&VNqg~Hz57hxAW_&3Dls_u3LR93mv$d#C`(3xzcwfq5i}j#CZ{$-tNQUAp|4K!6 zWXNwVpEk=?Ixs_|5;DN2>S2Qk^R}_Z%mCF2?XQC?31JIYU3T}h3rxyg@EA)SvE>bP z8m-VURe`}T+nETa04(aRkFtC$ZUo5yXN3@&- zHGHG)Qaacdy;5n}gcK{6DhT!9pBN+V^SCn&vs_qtB`mEyOMzT!qspp_IvN7u->X<=uV$`e%6FKR?||EfZzUME+5NB?c{gEg;00kkakJfz!Hp~c&?xJUHED&cpiK z24W;2kxQ&T2s1|X+6Q|+Dl@`x!GGe7B*PrYY<%m4(M2H=afz4!r^b&NhnhLcr*Jbt#mg8lXEz&3nhU}bwj1$tqF3o~`|H}I{AXMvA1#E^sx9USBkV!y*D&f7IH0;6 z4Jh_@i#6TdA8K!6DZ&U;vRm$?XMBV}-VXx2&L8)`wr!~&-~d{76!}=u!cV?7vNX6s z^$9bwrKv)UV-zqwM5GJwJv8|%4E1Cp@d+StocO6>k~B%W=pf9C8ff0LE0Eiq`(ADI z|5%>&|D<_{A+VQ3PmLH2du*olBCrsfb;#c4oHTL6vw59R>J-wHlieMbUMgB2B(|R` zXkbM?dUIe!eS$d-mv9UL86+uScN4q4tPZ|5vCjP`{;Vg?eo(^80JdCo@BblexM01< zYce8?xIw>}({O`Eq!IHc>>~}>pwLa3*r8xS9N&npDwQ5=h=J|@x@Kg805|NWd-sPq z4LfMerSr>eY`G}mJ$NIT;2*yOnNjO0!Jg|HlYL!&4(Klo)@xf*(}bOA&5F&Q$>FF=NY}{0L7jRWfu7W3VL@cKU_R3 zf;IRD{fn;ZgK{5~jMonOc6t(3iqh;`lYeu3EJgxa=ZHpdiNHaLV&#NH6lML9N{SlC zJ~ItR8p!m9T`C(1y*ZVziUf*Lu|zs$#|O>#eFZQs@OQQ5!Bz8Fu&pGKc?x`vGAok& zKQ#0tI|ya;tZ|O1-<0cMsiel>6NFF-eTiT}+{c3Aw^5{=V91pSpP2}ymh6VAG1q=U z?KFu?soGK-EJ5vnTZ9rXAx4`&Skm>vq(?X5S!+dmeC$8ot|qey+hHN%O7y49=2^2% zy>=le>;4H2pmu0+w~FF4VZjqe^}o5*I@RrS+mJy zTQ9kIknZ&N;Ub?n&TmK3HsQ)0&ot9eE0HIh*2CS z1R}luwKQ_>P$2 zeRsvEVkS0-c3$OpTfKo=n#$v&V;lp%{##4Bi6QHV8De>bf5bXx+%T-|b`0qK=zFJl zm3o?YygY{rqm6?-U%K(R&t#;rK(vbt5tY| zZfZNIE4n8)`Zja%llpJ+&p)7FJ?u%H1^=0%{~hsxa|2-XWyn8pZUW2#=KnXtBw)%9 zVe9ki9r+)zv^VsK@%z{OUt-B%SNKOl%VUS?{cFyCw#9x0VS8Cq*M0bb z`>!D=8J2LJ?;0H5`g^nWH~;h7{IuYI7ejlSo9B|;fd3Z}M1W&US^akt>A!X}*Tp>X zR@qxXW=itpnVOpjcAWqBg}AAg>Q>5X_YrymtLtCZyRXVjj$*>Cw6}nCjO9_n1K-HO z;{adJ0ecsY|Gh0e7BBeStE0D6$TYom z{;V#nHXt^&|Js?)0BLyq`o*oqpP}>h^1@fE|*XvVE>dy!>&4&G7f{A-E1?tzBy=fnC*z*=hX)L92h6U$I&Kc;+V|6}j-hHQ!B;hrReXa8e1rDAUwcX)e)9rlrL1X4;>!@? z3#+A6!cLaWpY_|lz}{Fwj7|b;U0G@4lz!rKdr};ft%OfpTyDr0%B-ovxQ{7eP^L*V zrLm*+Y=y=(gEn#}#pi@K6q3^aE|sFCb;g9QEs$HkEi;3437hF+qphR3SoAG@Aj@kl z@PJ$4rsXU)8{Wc5V@1-d=|fvP5S<`IJOUtf$N>!$w~vKP-_u^o@5?6djdcMqYgpGy zF3xs^tsGo}ntRVVXQNIHN3UD}LHj5PdxY_@9pb7-Q*^-J=quqokmy#15xe|bTxwrZlWduV^y=oVzCR+I)ph>_{0x|YWN4aZ4fFt3KpvnzwDqey}A0*s%H?P)qNZ`9>F%*KQzhH4ylqy+8S$PjySEnNAq*E1?xp-ijo z>Pb0vouY?AcIGjbtw~N`_IQ3e>i0FHZH{pwKq<18e>=C4{}GS)rablf<0_i-S#dqT zu=OGiUA-gkDq?HYP_AVA>W2wy$aFpDlqiA07=xl2w0nM}a_!?64$O42$BWyCK6eCn zH-3KU$)euZA4a*jY1tf3-B!6U(HP94rqtw$yiBnSv@L~qSOVp0r^1@H`7^y>3G1(s zF9eEp))(o<i4){~5y7_73xug8GE@CN6>#z4W6U z^E=HHgGHtc^iT3F)mkh2IxCZog*)=3i{L_s-EN0$JZ=B~@;czv3KZmYj9R|#FmMG*{fvO}R3w5-$9T|*x#lZ1mFV4|-*f%O=ZD0kyl ziSqP4UA0@>G41sCy?{Be2RGw-a)xO^LNO_bVwe1si=LgBe?~Q;3uY#s(6>D162+p$ zd09SI1!S3lu`^Py2!tF#{xXN@WvQ)1c49#y#tB6s1b>P&Mz{k>J)OZBDlD9)Y!UQ= zmS$y-8V@5ZAW7-9p+UY51b?dT?r2Y=3jIS2J~d6^dIZwp@^y-B z!w^LLLTG{QltJF#KzN==OXnyQM##gYJ~sQ;|7bsr zOm`8*!;s;D@!_~km&=pj!de?>t@P-FeA0fzWg`;hSPI+`92n;X3Bt!6MX#%EuT~ib z{XAFWV8?o+`LRA=!V{vE*;6WSyg;dB?eSe6mfUpH8+#M!!31<-V-g6N{Nqf=T?I{% zYN3Yy!g`OV*|i37UiWs}4p!5yN$DS$zrB)52^$m`CmjSYDku)Xa9Y? zBsM$Mi{-EeCV$q(gVfzWI27{y=$!ZIOJ^EPB*U!%rm-Uw8m;3S<@KU4A$Cq4N`Nt=3;Rebc6kUnEKdceO#Dj1>vhgn1 z&H&S2xWh^%Iwq;VCMlT5{3J2S5PF*kg7P-zE)0&QH@53B8TQ3nK(-*Vs@jW<)5(QX z$0TS*(Ow=f+Sl{fS-uiCq{X=D$>xRXl218cr7z)RtzxI%&Z*`}o zrLVU|cRB^k$#>7rWLQ-apMa@PQ;9H%k``7g*+Vsimj=LzCeN(0s&E=@Dt9o@)90MKJWJQ*6 zIaVo*apo#H?OwAeY7OG45@Txlzut-rhl{0xg)EA!u8LdJ>j>KU z;5g(Sb{u5n(rMX2ER>yT^9WHBbJ$~#bZ>j)2se{Y$BOAK;)tWu=0(xTCgFM(;bxRk zIJ7v6xa;3x=7wjr4j%!)IyS^xe&n8S6?K>&@lJEoWKW+56q#mh=>`Xp88q6AsbHXRUc6$3M=Yd5r&?q8Jg_}UCmCU0iM&WG} zA;nQw{-A9Usbfoo0)w4ms-$#jGu^C^4V;o1iI#sWDoLv?1=~Hs#yQdWhIP-3s=rVJ zD^F(3_NIe6$N>GGlM07&k6B4!%pz?cIBC_SG*OUYo{kWwW4XM0Pt923KEg#4Iynew zUuF6%_3$`N612}$KrW!U&|JUh8F}oKxr2%j+K&(!NCU7C43%UN z;IUzn^5mZkkRR%^tC^FaT;<^HrNr#@=Uab7XW_B&y&xAg&G^au>?58Z!Gnqr-cPr7 z=NzKMo@sPKs=&5KQq73b$avbX042ssoe-+4e7VML#`iW6I5K_IfFRM|xk$7GATS;h znLrkgqQ(K*G85aglFBRr^V1ymh$7)oyEPXa(BGP(t=9{Ot0fE!sE@5C99KpitwKdg z`6Vhb7fD!3)=cFF-bd`AdKE&&7kyXgY+do90T5>0;fR=!zak6}LSM2F0!|(&sBmy6 zsWa6s<`61d1zEmD%(6dsuueXOMeVN_n!f?aBI`>bM~O?XeFMuy$Z$T7VBFNyf{v%7 z(xEZXnwdpO3M?!=#*NhY*P{Xhtu+7A6OFZX5o@>OFd^px<2?#i)<>yJuN`|@LWfvR zHj|Ge{EEV%+Hs&c&6#)Oh0h~WM$@Qb`}8ND{8;_{ln!R4LT^9IUndbl%;*{()<+#z z-u=lf(RpR5lbK_6b- z^hsx_W@2m%FJw$FtSoJQX6LU1jw?MQ1XC5?ZG>51;EHtbIOD>)rq$vrj*4+0d2t{o zvBUS{IS7g`t&6WvoPrvvH+5B1LMZxi>T$jFr%_!2ZyVdz zkfekFa|h-D);^lq)BG*&jNy#dmf6$ok&!R{m?=_Q z%S`wz@}3*qAm+h?P{X~s@Ag|hm$>r1yvRXxpruZ5c@%~l5!}(}x;wTz*MlyW8-UU@ zS77*61Hn%p!B1MEYZ}2XJIdFj2hVJxT_*(`l2`2cjlyd4S(V~9D+Z2Qsf-7e4=40t)Xbb{T`b&&(9l&@ni~}Kv zVCE?>)4#_CYAY06T7H2xq{!dsbXv{h`qv%O`g(%{c_R*3Nl=e%#GI{wi(!(%tFrej zDN%d3Bf*aw`)1Pc_NI~6mx8klo*NZLK?=h$x7kqz1g@mhiV=^yvgSkm%}+>aL=o@LJ?+*2<; z=V}s`%IlHZP^-cr-sOeX!!xh)+-K^F;?kPK*Am%o9Uxrl7pL-1dC>-X|4&XudE~uZ zFtF4}#GRTm=uK|*$7lWfRsgcH_h7q-atVKZN#{STidPcAU6X4HS4E^k|55XvGez0D z?oZ|d&I%7#buHC+mwkYtLODN77K4DfSE*Oeq>MV`JBwB&sg{H=LyMpbA%36zhiZ4e zRhDk+;187z+=d7*6#p!T@+{bP;YL5wk55LhbtPAb7)dL`cQ7bM6uv0M=$nDh7Tu!Z zJgMk#Z*;!SvKjwhD0KG<6mXWs2*>Rm%=#35?4Ocx3fF{J+O=)vK_i${8Z@$E%9zxU z7jcntq%!5Zaith#Ow{Fq&P3PBko?MIDxlJ0?CO6|)v?usQJu!hP$)^$;EYK9%dRN? zq{11Hx{vy)NxM3t)k!ByiFJyFkfQ!pJ4TI{48cY9P-8-k7l(vFB~mjM5rf9W7yA+S z8jeoO6yCC}5r@Jo0`-XU_=;F3(EgvAftiT9Gs<|vQA#2s#x#H0j;fYf!P20*(X7M>fiMiNsTh+*PM zD>sbmrGId%#IJa8qeQO=%Cj(LDpc)XF@r;D2^ViKcA=(FhkVVOJRmC07qW@s zrdk_Q#}l%L5}-=Mr`BiMQgvAccSvGTDrE78GzZL%$i#6&5+ddiA6U9kRCD z1T@@h>;M#el;*JL2W=eFj#QDhAaB-^f?Qy~&ZQMs!2wTbftI}^8b>KMrtbN4@?npw1 zzjzVl5|86@+A8Z2vP(OrPpl@! zC0p+J^8nXsQ2bP0#z6&Tv7Sa1QfIlpLL{+oiV6xwKnE0k3qrN@sb zmI#Egds zoir5%3)L)5-~@mdVq%6Kn(%A!EoCVH zVmla!*TaD3AbJFw#R-ygN_BRZ)Kt01#m0c1YAu`P)vn_R?vv{52y;|Bu zI{o|a!lrq#mcrg-v9?V%8hU9Pil2EJUzo-#(SIv1kJI`Xk=8pgW-G6d)B0pABlWc% z6m1^jx3v+Z9b7Vpm`o;E5u*PMrOgbK{S2k^43zs!ah#m!K*Mk1yC%6GD+=}I3dyQ@ zX`tp-pz)A%W_dBb33n>DvV1+xulxo%@_>RH7W~gAhBtsOATMg@W~UrD3Hzi7UiVaD12! zgo?T~cRoN$86i&L17AdxGC&fF|A7l|?I2g0y3)tu;>yR$$^EP#3y5KOVE0BQrC4f2 zXlztzpQKU_Aai)MNjR18`}=7^6JjJ|LIZ*jvG6^6B0E(^gwg2MY0{>eoOv$)5C}FA zA@r}6kHr}*7sBB|alt*qQSf)a=)%-_Afs;eR-WZ%%)O8~!7~@QwbVZY_UDimqm&s- znJ|q#?j(T?+4Gf@WU!r9=U}+la0P&GU)G-$514w?tEy0$7^pG6<$nPVVwAoxFBnLF zoFoSbQ)Lyo=OnwkGRqe+L;C_6Uvx%)LG-Kj?oVB$l+VAI=nL2U%WN|*dS028QCDzr=$uUg2s8- zi4%TB`h{zhNP>kRcra=aduBzIWS72IR*eLGu_WSKB7NM5e8V$rZ@5rK92>NVNLSK7_opa`U~kCDQcbkvv5qw#HnZ5;GZ1+hsdO{ zGYpOBM15sz4uBiPLXGs_;F&#{pfDj} z20(1Sn~;3*np>j}HRgOFntVqmHnAL$_++Cjh2J2cN)6UW;D_qeh3P`rMIhdfA^Lg5 z&a1b9sPyrdJC_7BsNl4&uP?H32zDJRp26GD92hP%Q%N5gGV zne3F!IP@_m-b|2h^w9)-J+@8aB>L_XZaT^F8EDoLg5fC+M5VcuRSc4X@hA=?rMXm8 zMmc`>QP5y3^}!~9fl_Qg(6spVIZmgNFJVsE#A<{~0Gbvh_iWBBPvQ@U8|Wv_eao`# zTF{bS$?V2!FW9X3qCKsn%i#`y^C-AYeo)c5#%Go@Z#Yc>P>6X>IgKlcZkuMGzR)@_RGmw5`3rs?nhl>EPH|MlU+S!2dcM*4g*iC#kkq)Z51vE+&@3v>s!dcXKn0X@D19c*qd8{{%aA>dcvKTPU> z!#RElPPwl8R5-89LLIEUW&ee4XesxPa(zEe{(nSnPjSA3+}dD{jZqpa{QTGGxhk8& ztz|$?Y{ISbr6jso2GM++dAP&~D*Ieq(NXOF+m37Ply7ZLj$p#A-&@b=TwmoR2}@7jw?Aa?j@=ZTLnH*D|r|E@70Y!B$^OAu4DckLFc zr}gnM^SCKEynOw{gKs#zcjo9z5L?rK?Z(%IG`!4=xJZJys2vLx{_f+8UoAO&hKkNq z?(_@U`WM2AWYz}H)i4|X*Lyd6Kq9ciWDzairw>&6nN1QS|Mq>} z7g-+$opP}TPzT51tX!5-NvYbzJ=8~~BJT89gD{yx?mbN071GPqn$nVp zpZ>IvFUS6EGCy%mADE%ki-Z6*p;kfXkDu7av@dxcVq)B}4ZEtdLBM46r+q9=EnEnZ zb4%>Vd&Fdn>>4$2%v1YtR+!+Yiu+RElyD!ysnhj{rjDDRfMUR>PnnzHvHid+7Ah%h zeY1brIiMUH9#!b`g^DY2Bk*DcxO6hz;Zgq|nOpgmllm&LJ7X2~NBT3;(T zhS*m#J{L*OK(nKhEM)zTfnXnBUy9FJIV0lclp`1uRyKIe=ubQF%E{e}op;W@9cGTV z_MKl&4Wtcj_TKHumG8`D?lDX8kQ05-QH1jXPkTddmqP%|Pfsc;c_0}09G6oA##l+t zueNR8lghL&+tQ4$KBfi+f1oMhLMlisAp?yUe$Tb)(6c}oafFbRgdMTXgzOl;YeNkG z!?olH+}^AsK2U@~3@R;h+^QFwu-*?^4nQL70W!jp)WU3}fw;2-g%U4%JPf+htHr<# zl@~Ju0zSTZ^$szdPO~6|&gw=*IW{hroSJ@BE+_uOA#yrJnf-|_dm?7dK*s^^mQ%h> za*Fp%!A$Q?MqGfGDni8$^&8mA62;6-A4Ye>FFOo@Em|L6-9qc{@0X$v*)U`r*gnm4 z2Z#b&^fH-~eE9Ka_9&(2CxwjUq7Ef$J05=s)}%xMv`V$gRz}}fjSFUaQc?qgC~e2d zy7QraScBqicsa;}4Rtmp#4)2Q)N)VcD)T`7|_5T>jE=**ZD{iRaA^ ze4&Ep?=|C$gKLdh7~5 zCb&zkI#_W%i^rd}r(;JV-}PQ9yl(zfo`qp*MSn1Um+@twxvnc7IKrM99z)HQm1L9C zThy|^oi91FE}e;xuvUn+R`ezeHVYmXfjkq*SH{I?)H7DF4YO9xL4~6Fc|G^%TM10j z?^#={hc$Ko1+$E#1}iAt%z~pG|Hl*P0$t*(^gdt9c8k=VlHb+ zx^j^!6P@5jhA37+4&kJix5>CwaIwo?TCq+ zP*qE{i#pE|Qt&sov`KsiuDEl;b`I<3Sw#KrNb8#3s&V$w9z=}6Ob ziARzk0=qP>)^}O0Ukp{y>L|+}{r}hmA+2MAo(aXfC6}0n8hVfzYoL8YDT-IDJZwXik624 zX|Cw8G?epFngVw=Kfk@CKK}aRURpudCs1p%_9{cFuG)|ozZrb)M+Rzg)^%5(3nu9ac;aT+d6 z{@`jtBN3uMV%Ux&h|lXI9xP&GoI+!q!FhzlUbw^D-yF02>n8o%3=DgBr=ba)zWYFI`DeEsk1hm?}vZ(@ks%M?O)ta|WW zM(NteH8dLRG`NNsyQJg|5hr^d?&N541r%{l&IGBZ-DbSdag94SQ{T%I(rz!fd+N>0sVSU=Pl zSFB&b=>SHfu> zph`g~Rc?XlhvnEJ$$8|-UFpO<3Vy80C)7kDfo08htygmvJH+!RO-2KN!dAahiAoo) zlJd?j7&3UWAFAGV5^PQISU)79n&9Oj$dHV6-)##w5~^f|@o~q)>S;Qxcmo*Tp6S1V zX%(-S=qtU(NoeLjK2x_s(X^i98Ik-nmMLdG#;dhMlF70u_Ja)S`em$*p+DU9OTqA` zAk{(AcscU72b`D|(}a&)Zi|gs zMSaf6&`4uN)2MiS4s3t4`Y>t_r0J{4RmgvX9iA=-!x5?8_!b_c7AYn8&91g&g4Eg+ zABaACZs`;rD}RQC;O?W-UuH?bW!u9MaBWMZky<9G3@5RXg9#R!@+m>hNKqEW(8M88 z?G=;?UqBor$wlCuz#JrzV41RtBcTWo@wdv+--)Vo!}b)n8ha~ z$I_>}FoI*tVPBn@TK#j9>mWi4L>m863j*W|fEHSmgHz0dQ}Mu~G!e^05lh{}2F4%n zoX5IoCRJHN(Otx^?g9)5fPw}R$f>3pn65y@@IO!A{3OSd*hg3@C61`_Hq8mf%0#uT z#$p}srn_QdYk|`(K;-kugYh~!s;asUBHK`ZiSw`D(f$k=YdyAlleINR1vSG-oSp{F zsN#rfcaBcaM!1SCLY_<{R9W$=3V9NSaT)_Z*Gd*wuojNtZBnSr1E$QKpS-0>_C;KT z2Q*va^lIbuT9ov-fB)*rH#dwoQ{Bf`ImLgC8DT2d>u?4`eEHR(01mR5>BsJ>{dn}( zzA(ue7nxwA{h)(ma1w(;Ap@->9&NabOy$&7_0;vnU+#K=*H4fZf>6(JJayqhmoy~C zHD+oc(7q#pY8#+odI4en+{7i&7TH{nqRQp{O-wEr{yQZ6_YhRHUM6kNcf`=4K8$qX zh>2QuE#AV3Sx@6DI(+``C5weX8*PE4Z1|vza?x8SrxW~_`tx-#6klq|)-0{euJY-w z@1yI^DjW%!d46QYiH%ySbWC9|QKrG-PV0eaM`q28mGSk(txEmBCa&Em(ZFUqL^6K0 z;O?bI5>M$dSftTz#f@_<&1(U!*|1#s?9N6afz4N_sc*)Y28|CBusuFe+ZVnh6>dv; zHck&=W9Q|ED~8;|44+y7Bf|{TCJwQGi+=wt;t%~MVKaN_AqGj6^?O7{XR%4M?SBN) z5-shdEgjkKh%(AKbep~?K-~NLWH0nqSJsgkis>9AEEhvW?=JuhW< zsE8kHXt@N+#Kbt83vv+{qzEW^5{2{F_~zC(Hz2p$l%8ZLR56Af*Qdg6rGcaFVyw=c3@~cvz#ilC~T}SI!dS=T) zn9z7`(?vo`62+ymKG)9fB;gb(C1;@9^cSI(MrZPD8nrGpbAYj`=vlMIB+@}?OoOC& zj-7sto_sEJ|1ss}bF_g;uD5YCW{^})82%szI$SEXYK}4bqQcgl8eZ4`4U&X~OGDnN zo+N1BeA$hRPIyItJ3WRY`NZDn`_S@*9!u)F_*i}NHZ z+%U#a*QnVDQ?k6(AeZQ3r|QV>aS=hX-fNY>+CC1-3e_6p6gwl-Dbfv75T5Tv1bVW`e;gRciJiyY?s}<-j9$ z90U3q$rzf;Xf2kBULI>()K@N}l^j;-QI!OqIr>C*douxfbA2Tv`8ol3U0&14TRKfR zxL1&HSY!^v4ILE-}CP+>a zLg#e)HxFB* zjRdZ?evsc0E+bu&BKzBeVDwa^cpWyPc( z<@78pKTB*V8~CIxd=h!%@@dP#>>8IhXvyrcboQ8PY^IC0Gf>;o*;1?wR*iL1Q94@x z6lH}uE2iisIr!Ly;x*nfdP{P0MGVmTSABziw`wPI4VH zn|!1;`OIzdk=*1nyU9m-lUKC#qnYv0zp4=WDa)sJI^}M8Ql6GK$yl_lNJDiP;D)OYi&oT$XThoe=Jt{xmYD>?U!lo7pkA3vCJ|K?R}n<(?x=ym6$eu!RLoYGUu@o zp21mWVZ1UB2t8KgdFs#9P%F0K{A#>}sdtcyL+Xu7piM>67ZE+5>Qc5c4O;<~OAE*M(@4#u(+9*?lc54}}e zKQgvejGHm$8o;#2$`}*!2~{__Ur$Q8CzwseAB&lr^EQYP#ESqRzRLuG{IMw_(h!|& zicTs4*ZH>quC?iJ;>J1GmR!H$T9ZBY0QQHp7s0-89!R_0{?Atf;8usHzB}u9n zN9$G&-Ajfshxoe&(r%3}zDgzVBSO8PXyx5%We1vH`F8`kMnl}V-1g0((Uy(Ii&sVG4rNRWyV+!iz4QZE~GM`-Q{XwIQN1K;#K zwz%%D_T;4eibRtd&#`_{mAjmHqa&q-v(W?dZwnU;4rZUnc{7j5?!4|3JCL8(eP*Ah zZI$Hp{CK{*^k;vcYy>W>+mbL+Th}e=1W&WC=aL@a^2Moj{}r|7I_{h=(Rjb|VXA-l zPgx=W3W+JBunLU`Q_Ef01!phw_u*JS$P}x2o<({R+bjP?Pov%*aFA?iLDsh=L?qOde;{`pJKt~scekBH|r(T6Q=bDmPeB3t`rpN z4e|5_+9xiT#Mt_^)LicAqaN`+OHO)2vmSA1zgT9qK`~C2BMa9fZ%io|je3Ud9hApf z-Q}M;^_*@!r&G^y?0w5r z$23@;Y1lK@>6vr(C}Qbb9{Y03G#^rh%%IV0{U7!Od57`5sjp;+(c^{pgYI(lTS@KjbV*{AC zVNW$c$kiANz;ZC(i}kY{PW!?#V)2dzex0%U{yg7JkYXM8yp4!Bj*`&>cp!C1G;!*H39=c0nxgRyL9LNQNa=F4L^j7`7tER(V4?btFw zV*8a-S&S9G*zacSSFv8^Rdh22(tl8OGQ+W7tMVXF4u$EfplL96Do_4NGV!M6!&s^~atE*sx1klGi&y?Ph0;7y&g6BvF8fedX2yeA+HAr!;cxmK^ZQtX)J{+ zo-IN@kyh^x9|%{4L`}z3f#w7ue2NkNzK{bRks*&r!XuLMhwwnqXE$*@LyL7=Ga@fn?&lHN)QnyPT>*3#j#a?ST*0JY z?dD(YBw+1kVC^Jb?Pgu=q+RXiUF{@Z?Pgx>L#1n8(nm5a41Vz-p|iBsCRjHb6&~k<0D$5wt znraYSLn&bWD%S^@9J;d|Tm{{qq4rEy<(X@FB3DGwenbpvtC|jhtOS>=4p?iN<-nOY z(gi-ML^4vb zjd%b4n=y$Buh1h=OOfs*Qn;MKg#<2TWx@PcPFC#;!w0Rx$Zo8lLS)x!skO6KO6eIu zgz?P;x=KtRSa9TJ?>Ir%84}Lnh5-N;%=W;tt)nGEYz;~aS47^brg)?o2hYoiNsZ!~_c z!RM@}7KefJe?ry#?yp?Q0YPR4Mdd;0|KQ)~iQ{qe38H{d7wQH{cD%zi)P=FB0nf`7F2kH7?-%74j2iQ4#q z)A`8fd@$i1

^v+=L%E$Wb~v-`S{6w72Rk)XRUQQC@wp!kxDFKN9PIe#h<(<0y_p zki_Nd{{Ey^qinret{In*pZ*JKDe+cuvJZatM|@-#{LJ3>(QjGG!wr4!It zrhO}{^di>#m3Ll)_O8n_uR;5H*nS=?XFOi{VcMh1_A-$i%l@3=m>J@lF++`98sDo- z_APPoh@+$S7#{i7W5ZG9t*39@J;rgr?W$wn&JaV*!`bNU@y;Hsd1h=8xN4d>Yg`FT zHREr){1yv;f~bySI*61iP7032aF#5RWaZC-(j;z3HLoY zm(~!T<61h1vx;{GO5Tu8HxTq1vi(OAc_(HUxYCDw=>w!>!<=NOVR^S9>7b~Qsv5z! zZ(a;kW4v2)9g+%hsm>0H=rmP#fv46iC9E!HN9sshlME zJjL{xpae)+t_h!Rif6+03rhk?-vkG*vTYJ?R;ub2%wJ8Cf$c=6n?nzwN^Otlwb; z*h`<`PHf$Y4d&pj8F|CD+>WPU?iSxpBKIwiKZ9l?#)I=Q+>)NjF?To3>qIwvPN^6vR-agzcUPAl+4W!@zd z?hI+$Ou%zO-AA$-84`2ARG$WBmJNvfRn_pVa-rZ^IyQxnZC=grqM2MY(~D;3qS-A| zt#q&&<+!TR@0n}56jUf|9TweVbA;qIOO%MULpY{`_jGbrBLFkvSo?Dgdpb>B3ZueL zj7d2mg=8_!WHDpIDLPz^xg{h#6OOIz? zHhY8j@V5JXht!R?^);(taJZmyXNeM+UGvQ-oHD|5M22v$c6b#dtByFaBx9>c&`;5z zBQ&g_44o!Ml@9W3b~xRm^%36H^BKITv07!PeWdZfpJvtch+OiR`lYf**z+TWJc5az zZeY++L8SA_)*b=Jk6nXZ@XF#Kb-^PRf$l$m%d^!_xTw_$LNE;-tvyc6C)WGU)~^g~ zI*icX)?o4zx?{)h#W}hg^}MKH|DGWA1QfHjKK?&Gj*#*&Eb#Yqkb|5<(2nX-r2uhN oOQa+Tn0G4OEkU!FGRU$g!_o1Qdz9^S&-2Is2gPsnr)TT{04i}Ku>b%7 literal 0 HcmV?d00001 diff --git a/corpus/build_hand.py b/corpus/build_hand.py new file mode 100755 index 00000000..064591db --- /dev/null +++ b/corpus/build_hand.py @@ -0,0 +1,607 @@ +#!/usr/bin/env python3 +"""Build hand-crafted MeshCore test vectors as individual JSON files.""" +# /// script +# requires-python = ">=3.10" +# dependencies = [] +# /// + +import json +import os +import struct +from pathlib import Path + +OUT = Path(__file__).parent / "hand" +OUT.mkdir(exist_ok=True) + + +def write_case(name: str, case: dict): + path = OUT / f"{name}.json" + path.write_text(json.dumps(case, indent=2, ensure_ascii=False) + "\n") + print(f" {path.name}") + + +# ── Packet building helpers ─────────────────────────────────────── + +def mc_header(payload_type: int, route_type: int, ver: int = 0) -> int: + return (ver << 6) | (payload_type << 2) | route_type + + +def path_len_byte(hop_count: int, hash_size: int) -> int: + return ((hash_size - 1) << 6) | (hop_count & 0x3F) + + +DUMMY_PUBKEY = bytes(range(32)) +DUMMY_SIG = bytes(64) +TS_2026 = struct.pack(" bytes: + hdr = bytes([mc_header(0x04, route_type)]) + hops = path_hops or [] + pl = bytes([path_len_byte(len(hops), hash_size)]) + path = b"".join(hops) + payload = pubkey + timestamp + DUMMY_SIG + app_data + return hdr + pl + path + payload + + +def peer_packet(payload_type: int, route_type: int, dst: int, src: int, + mac: bytes, ciphertext: bytes, + path_hops: list[bytes] | None = None, + hash_size: int = 1, tc: tuple[int, int] | None = None) -> bytes: + hdr = bytes([mc_header(payload_type, route_type)]) + hops = path_hops or [] + pl_byte = bytes([path_len_byte(len(hops), hash_size)]) + path = b"".join(hops) + body = bytes([dst, src]) + mac + ciphertext + tc_bytes = b"" + if tc is not None: + tc_bytes = struct.pack(" bytes: + hdr = bytes([mc_header(0x03, route_type)]) + hops = path_hops or [] + pl_byte = bytes([path_len_byte(len(hops), hash_size)]) + path = b"".join(hops) + return hdr + pl_byte + path + struct.pack(" bytes: + hdr = bytes([mc_header(payload_type, route_type)]) + hops = path_hops or [] + pl_byte = bytes([path_len_byte(len(hops), hash_size)]) + path = b"".join(hops) + return hdr + pl_byte + path + bytes([ch_hash]) + mac + ciphertext + + +def anon_req_packet(dst: int, ephem_pub: bytes, mac: bytes, ciphertext: bytes, + route_type: int = 1, path_hops: list[bytes] | None = None, + hash_size: int = 1) -> bytes: + hdr = bytes([mc_header(0x07, route_type)]) + hops = path_hops or [] + pl_byte = bytes([path_len_byte(len(hops), hash_size)]) + path = b"".join(hops) + return hdr + pl_byte + path + bytes([dst]) + ephem_pub + mac + ciphertext + + +def trace_packet(tag: int, auth_code: int, flags: int, + trace_hashes: bytes = b"", + route_type: int = 1, + path_hops: list[bytes] | None = None, + hash_size: int = 1) -> bytes: + hdr = bytes([mc_header(0x09, route_type)]) + hops = path_hops or [] + pl_byte = bytes([path_len_byte(len(hops), hash_size)]) + path = b"".join(hops) + payload = struct.pack(" bytes: + hdr = bytes([mc_header(0x0A, route_type)]) + pl_byte = bytes([path_len_byte(0, 1)]) + control = bytes([(remaining << 4) | (inner_type & 0x0F)]) + return hdr + pl_byte + control + inner_payload + + +def control_packet(flags_byte: int, payload: bytes, + route_type: int = 1) -> bytes: + hdr = bytes([mc_header(0x0B, route_type)]) + pl_byte = bytes([path_len_byte(0, 1)]) + return hdr + pl_byte + bytes([flags_byte]) + payload + + +# ── Test cases ──────────────────────────────────────────────────── + +print("Building hand-crafted test vectors...") + +# ADVERT: basic chat node with name +write_case("advert_basic", { + "description": "ADVERT flood, chat node with simple ASCII name", + "tags": ["advert", "flood", "name", "chat"], + "packet_hex": advert_packet(bytes([0x81]) + b"TestNode").hex(), + "valid": True, + "expected": { + "route_type": "flood", + "payload_type": "ADVERT", + "payload_ver": 0, + "transport_codes": None, + "path": {"hash_size": 1, "hops": []}, + "payload": { + "pubkey_prefix": DUMMY_PUBKEY[:4].hex(), + "timestamp": 1774600000, + "advert": { + "flags": 0x81, + "node_type": "chat", + "location": None, + "name": "TestNode" + } + } + } +}) + +# ADVERT: repeater with valid GPS location + name +write_case("advert_location", { + "description": "ADVERT flood, repeater with valid GPS coords and name", + "tags": ["advert", "flood", "location", "repeater"], + "packet_hex": advert_packet( + bytes([0x92]) + + struct.pack(" int: + return (ver << 6) | (payload_type << 2) | route_type + + +def path_len_byte(hop_count: int, hash_size: int) -> int: + return ((hash_size - 1) << 6) | (hop_count & 0x3F) + + +# ── Hypothesis strategies ───────────────────────────────────────── + +hash_sizes = st.sampled_from([1, 2, 3]) +route_types_no_tc = st.sampled_from([1, 2]) # flood, direct (no transport codes) +route_types_all = st.sampled_from([0, 1, 2, 3]) +payload_vers = st.sampled_from([0]) # only v0 is well-defined + + +@st.composite +def path_strategy(draw, hash_size=None): + hs = hash_size or draw(hash_sizes) + max_hops = min(63, MAX_PATH_SIZE // hs) + hop_count = draw(st.integers(min_value=0, max_value=min(max_hops, 10))) + hops = [draw(st.binary(min_size=hs, max_size=hs)) for _ in range(hop_count)] + return hs, hops + + +@st.composite +def advert_appdata(draw): + """Generate valid ADVERT app_data.""" + node_type = draw(st.sampled_from([1, 2, 3, 4])) + has_loc = draw(st.booleans()) + has_name = draw(st.booleans()) + flags = node_type + data = b"" + loc = None + name = None + + if has_loc: + flags |= 0x10 + lat = draw(st.integers(min_value=-90_000_000, max_value=90_000_000)) + lon = draw(st.integers(min_value=-180_000_000, max_value=180_000_000)) + data += struct.pack(" MAX_ADVERT_DATA_SIZE: + data = data[:MAX_ADVERT_DATA_SIZE - 1] + if name: + name = data[8 if has_loc else 0:].decode("ascii", errors="replace") + + app_data = bytes([flags]) + data + node_label = NODE_TYPES.get(node_type, f"type={node_type}") + return app_data, {"flags": flags, "node_type": node_label, "location": loc, "name": name} + + +@st.composite +def valid_advert_packet(draw): + """Generate a complete valid ADVERT packet with expected decode.""" + route_type = draw(route_types_no_tc) + ver = draw(payload_vers) + hs, hops = draw(path_strategy()) + pubkey = draw(st.binary(min_size=PUB_KEY_SIZE, max_size=PUB_KEY_SIZE)) + timestamp = draw(st.integers(min_value=0, max_value=0xFFFFFFFF)) + signature = draw(st.binary(min_size=SIGNATURE_SIZE, max_size=SIGNATURE_SIZE)) + app_data, advert_expected = draw(advert_appdata()) + + hdr = bytes([mc_header(0x04, route_type, ver)]) + pl = bytes([path_len_byte(len(hops), hs)]) + path = b"".join(hops) + payload = pubkey + struct.pack("> 2) & 0x0F + + return packet, { + "route_type": ROUTE_TYPES.get(route_type, f"rt{route_type}"), + "payload_type": PAYLOAD_TYPES.get(payload_type, f"0x{payload_type:02x}"), + "error": error if len(packet) < 2 else error + } + + +# ── Generation ──────────────────────────────────────────────────── + +_counter = 0 + + +def save_case(packet: bytes, expected: dict, valid: bool, prefix: str): + global _counter + _counter += 1 + ptype = expected["payload_type"].lower() + name = f"{prefix}_{ptype}_{_counter:04d}" + + tags = [ptype, expected["route_type"]] + if not valid: + tags.append("invalid") + if "error" in expected: + tags.append(expected["error"]) + + case = { + "description": f"Generated {prefix}: {expected['payload_type']} {expected['route_type']}", + "tags": tags, + "packet_hex": packet.hex(), + "valid": valid, + "expected": expected, + } + + # Validate against schema + import jsonschema + schema = json.loads(SCHEMA_PATH.read_text()) + try: + jsonschema.validate(case, schema) + except jsonschema.ValidationError as e: + print(f" SKIP {name}: schema error: {e.message}") + return + + (OUT / f"{name}.json").write_text(json.dumps(case, indent=2, ensure_ascii=False) + "\n") + + +def generate(count: int): + OUT.mkdir(exist_ok=True) + + # Clear previous generated files + for f in OUT.glob("*.json"): + f.unlink() + + global _counter + _counter = 0 + + strategies = [ + ("valid", valid_advert_packet(), True), + ("valid", valid_ack_packet(), True), + ("valid", valid_encrypted_peer_packet(), True), + ("valid", valid_group_packet(), True), + ("invalid", invalid_packet(), False), + ] + + per_strategy = max(1, count // len(strategies)) + + for prefix, strategy, valid in strategies: + for i in range(per_strategy): + example = strategy.example() + packet, expected = example + save_case(packet, expected, valid, prefix) + + total = len(list(OUT.glob("*.json"))) + print(f"Generated {total} test vectors in {OUT}/") + + +def main(): + parser = argparse.ArgumentParser(description="Generate MeshCore test vectors") + parser.add_argument("--count", type=int, default=50, help="Total test cases to generate") + args = parser.parse_args() + generate(args.count) + + +if __name__ == "__main__": + main() diff --git a/corpus/hand/ack_basic.json b/corpus/hand/ack_basic.json new file mode 100644 index 00000000..c99a7f36 --- /dev/null +++ b/corpus/hand/ack_basic.json @@ -0,0 +1,22 @@ +{ + "description": "ACK flood, simple acknowledgment", + "tags": [ + "ack", + "flood" + ], + "packet_hex": "0d00efbeadde", + "valid": true, + "expected": { + "route_type": "flood", + "payload_type": "ACK", + "payload_ver": 0, + "transport_codes": null, + "path": { + "hash_size": 1, + "hops": [] + }, + "payload": { + "ack_crc": 3735928559 + } + } +} diff --git a/corpus/hand/ack_with_path.json b/corpus/hand/ack_with_path.json new file mode 100644 index 00000000..70d6fe82 --- /dev/null +++ b/corpus/hand/ack_with_path.json @@ -0,0 +1,26 @@ +{ + "description": "ACK flood, 2 hops with 1-byte hashes", + "tags": [ + "ack", + "flood", + "path" + ], + "packet_hex": "0d02d1ac9fc28d00", + "valid": true, + "expected": { + "route_type": "flood", + "payload_type": "ACK", + "payload_ver": 0, + "transport_codes": null, + "path": { + "hash_size": 1, + "hops": [ + "d1", + "ac" + ] + }, + "payload": { + "ack_crc": 9290399 + } + } +} diff --git a/corpus/hand/advert_basic.json b/corpus/hand/advert_basic.json new file mode 100644 index 00000000..87045681 --- /dev/null +++ b/corpus/hand/advert_basic.json @@ -0,0 +1,31 @@ +{ + "description": "ADVERT flood, chat node with simple ASCII name", + "tags": [ + "advert", + "flood", + "name", + "chat" + ], + "packet_hex": "1100000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f403fc6690000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000081546573744e6f6465", + "valid": true, + "expected": { + "route_type": "flood", + "payload_type": "ADVERT", + "payload_ver": 0, + "transport_codes": null, + "path": { + "hash_size": 1, + "hops": [] + }, + "payload": { + "pubkey_prefix": "00010203", + "timestamp": 1774600000, + "advert": { + "flags": 129, + "node_type": "chat", + "location": null, + "name": "TestNode" + } + } + } +} diff --git a/corpus/hand/advert_emoji_name.json b/corpus/hand/advert_emoji_name.json new file mode 100644 index 00000000..b3b94cb7 --- /dev/null +++ b/corpus/hand/advert_emoji_name.json @@ -0,0 +1,32 @@ +{ + "description": "ADVERT flood, chat node with emoji in name (multi-byte UTF-8)", + "tags": [ + "advert", + "flood", + "emoji", + "name", + "utf8" + ], + "packet_hex": "1100000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f403fc6690000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000081f09fa68520526170746f72", + "valid": true, + "expected": { + "route_type": "flood", + "payload_type": "ADVERT", + "payload_ver": 0, + "transport_codes": null, + "path": { + "hash_size": 1, + "hops": [] + }, + "payload": { + "pubkey_prefix": "00010203", + "timestamp": 1774600000, + "advert": { + "flags": 129, + "node_type": "chat", + "location": null, + "name": "🦅 Raptor" + } + } + } +} diff --git a/corpus/hand/advert_feat_flags_as_name.json b/corpus/hand/advert_feat_flags_as_name.json new file mode 100644 index 00000000..9e06be63 --- /dev/null +++ b/corpus/hand/advert_feat_flags_as_name.json @@ -0,0 +1,32 @@ +{ + "description": "ADVERT flood, feat1+feat2 bits set but bytes are actually the name", + "tags": [ + "advert", + "flood", + "feat", + "scoring", + "emoji" + ], + "packet_hex": "1100000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f403fc66900000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000e1f09f8f94efb88f204d746e4e6f6465", + "valid": true, + "expected": { + "route_type": "flood", + "payload_type": "ADVERT", + "payload_ver": 0, + "transport_codes": null, + "path": { + "hash_size": 1, + "hops": [] + }, + "payload": { + "pubkey_prefix": "00010203", + "timestamp": 1774600000, + "advert": { + "flags": 225, + "node_type": "chat", + "location": null, + "name": "🏔️ MtnNode" + } + } + } +} diff --git a/corpus/hand/advert_location.json b/corpus/hand/advert_location.json new file mode 100644 index 00000000..43b6c159 --- /dev/null +++ b/corpus/hand/advert_location.json @@ -0,0 +1,34 @@ +{ + "description": "ADVERT flood, repeater with valid GPS coords and name", + "tags": [ + "advert", + "flood", + "location", + "repeater" + ], + "packet_hex": "1100000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f403fc6690000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000092346640023807b4f848696c6c746f7052707472", + "valid": true, + "expected": { + "route_type": "flood", + "payload_type": "ADVERT", + "payload_ver": 0, + "transport_codes": null, + "path": { + "hash_size": 1, + "hops": [] + }, + "payload": { + "pubkey_prefix": "00010203", + "timestamp": 1774600000, + "advert": { + "flags": 146, + "node_type": "repeater", + "location": { + "lat": 37.7749, + "lon": -122.4194 + }, + "name": "HilltopRptr" + } + } + } +} diff --git a/corpus/hand/advert_no_appdata.json b/corpus/hand/advert_no_appdata.json new file mode 100644 index 00000000..8aafcaed --- /dev/null +++ b/corpus/hand/advert_no_appdata.json @@ -0,0 +1,25 @@ +{ + "description": "ADVERT flood, bare minimum (no app_data)", + "tags": [ + "advert", + "flood", + "minimal" + ], + "packet_hex": "1100000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f403fc66900000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "valid": true, + "expected": { + "route_type": "flood", + "payload_type": "ADVERT", + "payload_ver": 0, + "transport_codes": null, + "path": { + "hash_size": 1, + "hops": [] + }, + "payload": { + "pubkey_prefix": "00010203", + "timestamp": 1774600000, + "advert": null + } + } +} diff --git a/corpus/hand/advert_sensor.json b/corpus/hand/advert_sensor.json new file mode 100644 index 00000000..c5865534 --- /dev/null +++ b/corpus/hand/advert_sensor.json @@ -0,0 +1,30 @@ +{ + "description": "ADVERT flood, sensor node type", + "tags": [ + "advert", + "flood", + "sensor" + ], + "packet_hex": "1100000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f403fc669000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008454656d7053656e736f72", + "valid": true, + "expected": { + "route_type": "flood", + "payload_type": "ADVERT", + "payload_ver": 0, + "transport_codes": null, + "path": { + "hash_size": 1, + "hops": [] + }, + "payload": { + "pubkey_prefix": "00010203", + "timestamp": 1774600000, + "advert": { + "flags": 132, + "node_type": "sensor", + "location": null, + "name": "TempSensor" + } + } + } +} diff --git a/corpus/hand/advert_truncated.json b/corpus/hand/advert_truncated.json new file mode 100644 index 00000000..3f111509 --- /dev/null +++ b/corpus/hand/advert_truncated.json @@ -0,0 +1,20 @@ +{ + "description": "ADVERT flood, truncated mid-signature (50 bytes)", + "tags": [ + "invalid", + "advert", + "truncated" + ], + "packet_hex": "1100000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f403fc669000000000000000000000000", + "valid": true, + "expected": { + "route_type": "flood", + "payload_type": "ADVERT", + "payload_ver": 0, + "transport_codes": null, + "path": { + "hash_size": 1, + "hops": [] + } + } +} diff --git a/corpus/hand/advert_with_path.json b/corpus/hand/advert_with_path.json new file mode 100644 index 00000000..4a20e68c --- /dev/null +++ b/corpus/hand/advert_with_path.json @@ -0,0 +1,35 @@ +{ + "description": "ADVERT flood, 4 hops with 1-byte hashes", + "tags": [ + "advert", + "flood", + "path" + ], + "packet_hex": "11042a3d35ed000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f403fc669000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008152656c617965644e6f6465", + "valid": true, + "expected": { + "route_type": "flood", + "payload_type": "ADVERT", + "payload_ver": 0, + "transport_codes": null, + "path": { + "hash_size": 1, + "hops": [ + "2a", + "3d", + "35", + "ed" + ] + }, + "payload": { + "pubkey_prefix": "00010203", + "timestamp": 1774600000, + "advert": { + "flags": 129, + "node_type": "chat", + "location": null, + "name": "RelayedNode" + } + } + } +} diff --git a/corpus/hand/anon_req_tc_fallback.json b/corpus/hand/anon_req_tc_fallback.json new file mode 100644 index 00000000..1d038318 --- /dev/null +++ b/corpus/hand/anon_req_tc_fallback.json @@ -0,0 +1,30 @@ +{ + "description": "ANON_REQ tdirect header but NO transport codes (older firmware)", + "tags": [ + "anon_req", + "tdirect", + "tc_fallback" + ], + "packet_hex": "1f03133542d9202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3fb2e2000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "valid": true, + "expected": { + "route_type": "tdirect", + "payload_type": "ANON_REQ", + "payload_ver": 0, + "transport_codes": null, + "path": { + "hash_size": 1, + "hops": [ + "13", + "35", + "42" + ] + }, + "payload": { + "dst": "d9", + "ephem_prefix": "20212223", + "mac": "b2e2", + "ciphertext_len": 48 + } + } +} diff --git a/corpus/hand/bad_path_len.json b/corpus/hand/bad_path_len.json new file mode 100644 index 00000000..a2e7fa63 --- /dev/null +++ b/corpus/hand/bad_path_len.json @@ -0,0 +1,15 @@ +{ + "description": "TXT_MSG flood, path_len claims 33 hops (4B hash) but data is too short", + "tags": [ + "invalid", + "bad_framing", + "path" + ], + "packet_hex": "09e1000000000000000000000000000000000000000000000000", + "valid": false, + "expected": { + "route_type": "flood", + "payload_type": "TXT_MSG", + "error": "bad_framing" + } +} diff --git a/corpus/hand/control_discover_req.json b/corpus/hand/control_discover_req.json new file mode 100644 index 00000000..cadc5791 --- /dev/null +++ b/corpus/hand/control_discover_req.json @@ -0,0 +1,23 @@ +{ + "description": "CONTROL flood, DISCOVER_REQ sub-type", + "tags": [ + "control", + "flood", + "discover" + ], + "packet_hex": "2d0080033412cdab", + "valid": true, + "expected": { + "route_type": "flood", + "payload_type": "CONTROL", + "payload_ver": 0, + "transport_codes": null, + "path": { + "hash_size": 1, + "hops": [] + }, + "payload": { + "control_subtype": "DISCOVER_REQ" + } + } +} diff --git a/corpus/hand/empty_packet.json b/corpus/hand/empty_packet.json new file mode 100644 index 00000000..e893db91 --- /dev/null +++ b/corpus/hand/empty_packet.json @@ -0,0 +1,14 @@ +{ + "description": "Empty packet (0 bytes)", + "tags": [ + "invalid", + "too_short" + ], + "packet_hex": "", + "valid": false, + "expected": { + "route_type": "flood", + "payload_type": "REQ", + "error": "too_short" + } +} diff --git a/corpus/hand/grp_txt_basic.json b/corpus/hand/grp_txt_basic.json new file mode 100644 index 00000000..bf645987 --- /dev/null +++ b/corpus/hand/grp_txt_basic.json @@ -0,0 +1,26 @@ +{ + "description": "GRP_TXT flood, group text message", + "tags": [ + "grp_txt", + "flood", + "group", + "encrypted" + ], + "packet_hex": "1500ccabcd0000000000000000000000000000000000000000000000000000000000000000", + "valid": true, + "expected": { + "route_type": "flood", + "payload_type": "GRP_TXT", + "payload_ver": 0, + "transport_codes": null, + "path": { + "hash_size": 1, + "hops": [] + }, + "payload": { + "channel_hash": "cc", + "mac": "abcd", + "ciphertext_len": 32 + } + } +} diff --git a/corpus/hand/multipart_ack.json b/corpus/hand/multipart_ack.json new file mode 100644 index 00000000..4b1cec29 --- /dev/null +++ b/corpus/hand/multipart_ack.json @@ -0,0 +1,24 @@ +{ + "description": "MULTIPART flood, remaining=3, inner type=ACK", + "tags": [ + "multipart", + "flood", + "ack" + ], + "packet_hex": "290033bebafeca", + "valid": true, + "expected": { + "route_type": "flood", + "payload_type": "MULTIPART", + "payload_ver": 0, + "transport_codes": null, + "path": { + "hash_size": 1, + "hops": [] + }, + "payload": { + "multipart_remaining": 3, + "multipart_inner": "ACK" + } + } +} diff --git a/corpus/hand/noise_packet.json b/corpus/hand/noise_packet.json new file mode 100644 index 00000000..8140c32e --- /dev/null +++ b/corpus/hand/noise_packet.json @@ -0,0 +1,14 @@ +{ + "description": "Random bytes, not a valid MeshCore packet", + "tags": [ + "invalid", + "noise" + ], + "packet_hex": "03d135839fc28d00", + "valid": false, + "expected": { + "route_type": "tdirect", + "payload_type": "REQ", + "error": "not_meshcore" + } +} diff --git a/corpus/hand/path_3byte_hash.json b/corpus/hand/path_3byte_hash.json new file mode 100644 index 00000000..386e4d2b --- /dev/null +++ b/corpus/hand/path_3byte_hash.json @@ -0,0 +1,29 @@ +{ + "description": "PATH flood, 1 hop with 3-byte hash", + "tags": [ + "path", + "flood", + "3byte_hash", + "encrypted" + ], + "packet_hex": "21811afff3e6e92e4700000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "valid": true, + "expected": { + "route_type": "flood", + "payload_type": "PATH", + "payload_ver": 0, + "transport_codes": null, + "path": { + "hash_size": 3, + "hops": [ + "1afff3" + ] + }, + "payload": { + "dst": "e6", + "src": "e9", + "mac": "2e47", + "ciphertext_len": 46 + } + } +} diff --git a/corpus/hand/path_4byte_hash.json b/corpus/hand/path_4byte_hash.json new file mode 100644 index 00000000..e1d3a29b --- /dev/null +++ b/corpus/hand/path_4byte_hash.json @@ -0,0 +1,29 @@ +{ + "description": "PATH flood, 1 hop with 4-byte hash", + "tags": [ + "path", + "flood", + "4byte_hash", + "encrypted" + ], + "packet_hex": "21c1aabbccdde6e92e4700000000000000000000000000000000", + "valid": true, + "expected": { + "route_type": "flood", + "payload_type": "PATH", + "payload_ver": 0, + "transport_codes": null, + "path": { + "hash_size": 4, + "hops": [ + "aabbccdd" + ] + }, + "payload": { + "dst": "e6", + "src": "e9", + "mac": "2e47", + "ciphertext_len": 16 + } + } +} diff --git a/corpus/hand/req_transport_direct.json b/corpus/hand/req_transport_direct.json new file mode 100644 index 00000000..17772d48 --- /dev/null +++ b/corpus/hand/req_transport_direct.json @@ -0,0 +1,33 @@ +{ + "description": "REQ transport-direct with transport codes and 1-byte path", + "tags": [ + "req", + "tdirect", + "transport_codes", + "encrypted" + ], + "packet_hex": "033412785602ccdda1b2ef010000000000000000000000000000000000000000000000000000000000000000", + "valid": true, + "expected": { + "route_type": "tdirect", + "payload_type": "REQ", + "payload_ver": 0, + "transport_codes": { + "tc1": 4660, + "tc2": 22136 + }, + "path": { + "hash_size": 1, + "hops": [ + "cc", + "dd" + ] + }, + "payload": { + "dst": "a1", + "src": "b2", + "mac": "ef01", + "ciphertext_len": 32 + } + } +} diff --git a/corpus/hand/response_corrupted_pathlen_1.json b/corpus/hand/response_corrupted_pathlen_1.json new file mode 100644 index 00000000..bd188ef6 --- /dev/null +++ b/corpus/hand/response_corrupted_pathlen_1.json @@ -0,0 +1,11 @@ +{ + "description": "RESPONSE flood, bit-corrupted path_len (0xc7 should be small), LoRa bit error", + "tags": ["invalid", "response", "flood", "corrupted", "real_capture", "bit_error"], + "packet_hex": "05c735935ed17863f6ec7bbbd9bf8e5ea7d44b6a0e8f6318", + "valid": false, + "expected": { + "route_type": "flood", + "payload_type": "RESPONSE", + "error": "bad_framing" + } +} diff --git a/corpus/hand/response_corrupted_pathlen_2.json b/corpus/hand/response_corrupted_pathlen_2.json new file mode 100644 index 00000000..111aa42d --- /dev/null +++ b/corpus/hand/response_corrupted_pathlen_2.json @@ -0,0 +1,11 @@ +{ + "description": "RESPONSE flood, bit-corrupted path_len (0x32 should be 0x02 — bits 4,5 flipped), LoRa bit error", + "tags": ["invalid", "response", "flood", "corrupted", "real_capture", "bit_error"], + "packet_hex": "0532af1a5bd17863f6ec7bbbd9bfbe5ea7d44b6a0e8f6318", + "valid": false, + "expected": { + "route_type": "flood", + "payload_type": "RESPONSE", + "error": "bad_framing" + } +} diff --git a/corpus/hand/response_valid_1hop.json b/corpus/hand/response_valid_1hop.json new file mode 100644 index 00000000..5041e87b --- /dev/null +++ b/corpus/hand/response_valid_1hop.json @@ -0,0 +1,14 @@ +{ + "description": "RESPONSE flood, 1 hop (real capture — same message as corrupted variants)", + "tags": ["response", "flood", "path", "real_capture"], + "packet_hex": "0501ac5bd17863f6ec7bbbd9bf8e5ea7d44b6a0e8f6318", + "valid": true, + "expected": { + "route_type": "flood", + "payload_type": "RESPONSE", + "payload_ver": 0, + "transport_codes": null, + "path": {"hash_size": 1, "hops": ["ac"]}, + "payload": {"dst": "5b", "src": "d1", "mac": "7863", "ciphertext_len": 16} + } +} diff --git a/corpus/hand/response_valid_2hop.json b/corpus/hand/response_valid_2hop.json new file mode 100644 index 00000000..eb01f5cc --- /dev/null +++ b/corpus/hand/response_valid_2hop.json @@ -0,0 +1,14 @@ +{ + "description": "RESPONSE flood, 2 hops [ac>ea] (real capture — same message as corrupted variants)", + "tags": ["response", "flood", "path", "real_capture"], + "packet_hex": "0502acea5bd17863f6ec7bbbd9bf8e5ea7d44b6a0e8f6318", + "valid": true, + "expected": { + "route_type": "flood", + "payload_type": "RESPONSE", + "payload_ver": 0, + "transport_codes": null, + "path": {"hash_size": 1, "hops": ["ac", "ea"]}, + "payload": {"dst": "5b", "src": "d1", "mac": "7863", "ciphertext_len": 16} + } +} diff --git a/corpus/hand/too_short.json b/corpus/hand/too_short.json new file mode 100644 index 00000000..e9f72c7d --- /dev/null +++ b/corpus/hand/too_short.json @@ -0,0 +1,14 @@ +{ + "description": "Packet too short (1 byte)", + "tags": [ + "invalid", + "too_short" + ], + "packet_hex": "11", + "valid": false, + "expected": { + "route_type": "flood", + "payload_type": "ADVERT", + "error": "too_short" + } +} diff --git a/corpus/hand/trace_basic.json b/corpus/hand/trace_basic.json new file mode 100644 index 00000000..a32e8a20 --- /dev/null +++ b/corpus/hand/trace_basic.json @@ -0,0 +1,23 @@ +{ + "description": "TRACE flood with tag, auth, and 2 trace hops", + "tags": [ + "trace", + "flood" + ], + "packet_hex": "250078563412ddccbbaa001122", + "valid": true, + "expected": { + "route_type": "flood", + "payload_type": "TRACE", + "payload_ver": 0, + "transport_codes": null, + "path": { + "hash_size": 1, + "hops": [] + }, + "payload": { + "trace_tag": 305419896, + "trace_hops": 2 + } + } +} diff --git a/corpus/hand/txt_msg_direct_2b_hash.json b/corpus/hand/txt_msg_direct_2b_hash.json new file mode 100644 index 00000000..c03b9fc9 --- /dev/null +++ b/corpus/hand/txt_msg_direct_2b_hash.json @@ -0,0 +1,30 @@ +{ + "description": "TXT_MSG direct, 2-byte hashes, 2 hops", + "tags": [ + "txt_msg", + "direct", + "2byte_hash", + "encrypted" + ], + "packet_hex": "0a42ac98d14d5b43abcd000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "valid": true, + "expected": { + "route_type": "direct", + "payload_type": "TXT_MSG", + "payload_ver": 0, + "transport_codes": null, + "path": { + "hash_size": 2, + "hops": [ + "ac98", + "d14d" + ] + }, + "payload": { + "dst": "5b", + "src": "43", + "mac": "abcd", + "ciphertext_len": 48 + } + } +} diff --git a/corpus/hand/txt_msg_flood_with_loops.json b/corpus/hand/txt_msg_flood_with_loops.json new file mode 100644 index 00000000..15749c2e --- /dev/null +++ b/corpus/hand/txt_msg_flood_with_loops.json @@ -0,0 +1,33 @@ +{ + "description": "TXT_MSG flood with routing loop (ac98 appears 3x)", + "tags": [ + "txt_msg", + "flood", + "loop", + "2byte_hash" + ], + "packet_hex": "0945ac98d14dac98ff01ac985b43abcd0000000000000000000000000000000000000000000000000000000000000000", + "valid": true, + "expected": { + "route_type": "flood", + "payload_type": "TXT_MSG", + "payload_ver": 0, + "transport_codes": null, + "path": { + "hash_size": 2, + "hops": [ + "ac98", + "d14d", + "ac98", + "ff01", + "ac98" + ] + }, + "payload": { + "dst": "5b", + "src": "43", + "mac": "abcd", + "ciphertext_len": 32 + } + } +} diff --git a/corpus/schema.json b/corpus/schema.json new file mode 100644 index 00000000..add8bb8d --- /dev/null +++ b/corpus/schema.json @@ -0,0 +1,128 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "MeshCore Packet Test Case", + "description": "A single test vector for MeshCore packet decoders", + "type": "object", + "required": ["description", "tags", "packet_hex", "valid", "expected"], + "additionalProperties": false, + "properties": { + "description": { + "type": "string", + "minLength": 1 + }, + "tags": { + "type": "array", + "items": { "type": "string" }, + "minItems": 1 + }, + "packet_hex": { + "type": "string", + "pattern": "^[0-9a-f]*$", + "description": "Raw MeshCore packet as lowercase hex (no 0x prefix)" + }, + "valid": { + "type": "boolean", + "description": "Whether this packet should parse successfully" + }, + "expected": { + "type": "object", + "required": ["route_type", "payload_type"], + "properties": { + "route_type": { + "type": "string", + "enum": ["flood", "direct", "tflood", "tdirect"] + }, + "payload_type": { + "type": "string", + "enum": [ + "REQ", "RESPONSE", "TXT_MSG", "ACK", "ADVERT", + "GRP_TXT", "GRP_DATA", "ANON_REQ", "PATH", + "TRACE", "MULTIPART", "CONTROL", "RAW_CUSTOM" + ] + }, + "payload_ver": { "type": "integer", "minimum": 0, "maximum": 3 }, + "transport_codes": { + "oneOf": [ + { "type": "null" }, + { + "type": "object", + "required": ["tc1", "tc2"], + "properties": { + "tc1": { "type": "integer" }, + "tc2": { "type": "integer" } + }, + "additionalProperties": false + } + ] + }, + "path": { + "type": "object", + "required": ["hash_size", "hops"], + "properties": { + "hash_size": { "type": "integer", "minimum": 1, "maximum": 4 }, + "hops": { + "type": "array", + "items": { "type": "string", "pattern": "^[0-9a-f]+$" } + } + }, + "additionalProperties": false + }, + "error": { + "type": "string", + "enum": ["too_short", "not_meshcore", "bad_framing"], + "description": "Expected error category for invalid packets" + }, + "payload": { + "type": "object", + "description": "Payload-type-specific decoded fields", + "properties": { + "dst": { "type": "string", "pattern": "^[0-9a-f]{2}$" }, + "src": { "type": "string", "pattern": "^[0-9a-f]{2}$" }, + "mac": { "type": "string", "pattern": "^[0-9a-f]+$" }, + "ciphertext_len": { "type": "integer", "minimum": 0 }, + "channel_hash": { "type": "string", "pattern": "^[0-9a-f]{2}$" }, + "ack_crc": { "type": "integer" }, + "pubkey_prefix": { "type": "string", "pattern": "^[0-9a-f]+$" }, + "timestamp": { "type": "integer" }, + "advert": { + "oneOf": [ + { "type": "null" }, + { + "type": "object", + "properties": { + "flags": { "type": "integer", "minimum": 0, "maximum": 255 }, + "node_type": { "type": "string" }, + "location": { + "oneOf": [ + { "type": "null" }, + { + "type": "object", + "required": ["lat", "lon"], + "properties": { + "lat": { "type": "number" }, + "lon": { "type": "number" } + }, + "additionalProperties": false + } + ] + }, + "name": { "oneOf": [{ "type": "string" }, { "type": "null" }] } + }, + "additionalProperties": false + } + ] + }, + "ephem_prefix": { "type": "string", "pattern": "^[0-9a-f]+$" }, + "trace_tag": { "type": "integer" }, + "trace_hops": { "type": "integer" }, + "multipart_remaining": { "type": "integer" }, + "multipart_inner": { "type": "string" }, + "control_subtype": { "type": "string" } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + } +} diff --git a/corpus/validate.py b/corpus/validate.py new file mode 100755 index 00000000..80742076 --- /dev/null +++ b/corpus/validate.py @@ -0,0 +1,207 @@ +#!/usr/bin/env python3 +"""Validate MeshCore test corpus against schema and decoder.""" +# /// script +# requires-python = ">=3.10" +# dependencies = ["jsonschema", "cobs", "pyserial", "pycryptodome"] +# /// + +import json +import re +import sys +from pathlib import Path + +import jsonschema + +# Add tools/ to path so we can import the decoder +sys.path.insert(0, str(Path(__file__).parent.parent / "tools")) +from meshcore_rx import decode_meshcore_packet # noqa: E402 + +CORPUS_DIR = Path(__file__).parent +SCHEMA_PATH = CORPUS_DIR / "schema.json" + +# ANSI escape stripper +_ANSI_RE = re.compile(r"\033\[[0-9;]*m") + + +def strip_ansi(s: str) -> str: + return _ANSI_RE.sub("", s) + + +def load_schema() -> dict: + return json.loads(SCHEMA_PATH.read_text()) + + +def find_test_cases() -> list[Path]: + cases = [] + for d in ["hand", "generated"]: + p = CORPUS_DIR / d + if p.is_dir(): + cases.extend(sorted(p.glob("*.json"))) + return cases + + +def validate_schema(case: dict, schema: dict, path: Path) -> list[str]: + """Validate a test case against the JSON schema. Returns list of errors.""" + errors = [] + try: + jsonschema.validate(case, schema) + except jsonschema.ValidationError as e: + errors.append(f"Schema: {e.message}") + return errors + + +def validate_decode(case: dict, path: Path) -> list[str]: + """Run the decoder and check output against expected fields.""" + errors = [] + packet = bytes.fromhex(case["packet_hex"]) if case["packet_hex"] else b"" + expected = case["expected"] + + # Run decoder — must not crash + try: + raw_output = decode_meshcore_packet(packet) + except Exception as e: + errors.append(f"Decoder crashed: {e}") + return errors + + output = strip_ansi(raw_output) + + if case["valid"]: + # Valid packet: should contain the expected payload type name + ptype = expected["payload_type"] + if ptype not in output: + errors.append(f"Expected '{ptype}' in output, got: {output[:120]}") + + # Check route type + rtype = expected["route_type"] + if rtype not in output: + errors.append(f"Expected route '{rtype}' in output, got: {output[:120]}") + + # Check path hops if specified + if "path" in expected: + exp_path = expected["path"] + for hop in exp_path["hops"]: + if hop not in output: + errors.append(f"Expected hop '{hop}' in output") + + # Check payload-specific fields + payload = expected.get("payload") + if payload: + _check_payload(payload, output, errors) + + # Should NOT contain error markers + for marker in ["", "", ""]: + if marker in output: + errors.append(f"Valid packet produced error marker: {marker}") + + else: + # Invalid packet: must not crash. Error marker check is best-effort + # (random mutations may accidentally produce parseable packets). + pass # decoder didn't crash — that's the key requirement + + return errors + + +def _check_payload(payload: dict, output: str, errors: list[str]): + """Check payload-specific fields in the decoder output.""" + if "ack_crc" in payload: + crc_hex = f"0x{payload['ack_crc']:08x}" + if crc_hex not in output: + errors.append(f"Expected CRC '{crc_hex}' in output") + + if "dst" in payload: + if payload["dst"] not in output: + errors.append(f"Expected dst='{payload['dst']}' in output") + + if "src" in payload: + if payload["src"] not in output: + errors.append(f"Expected src='{payload['src']}' in output") + + if "mac" in payload: + if payload["mac"] not in output: + errors.append(f"Expected mac='{payload['mac']}' in output") + + if "channel_hash" in payload: + if payload["channel_hash"] not in output: + errors.append(f"Expected ch='{payload['channel_hash']}' in output") + + if "ciphertext_len" in payload: + ct_str = f"[{payload['ciphertext_len']}B]" + if ct_str not in output: + errors.append(f"Expected '{ct_str}' in output") + + if "pubkey_prefix" in payload: + pk = payload["pubkey_prefix"] + if pk not in output: + errors.append(f"Expected pubkey prefix '{pk}' in output") + + if "advert" in payload and payload["advert"] is not None: + advert = payload["advert"] + if "name" in advert and advert["name"] is not None: + if advert["name"] not in output: + errors.append(f"Expected name '{advert['name']}' in output") + if "node_type" in advert: + if advert["node_type"] not in output: + errors.append(f"Expected node_type '{advert['node_type']}' in output") + if "location" in advert and advert["location"] is not None: + loc = advert["location"] + lat_str = f"{loc['lat']:.4f}" + if lat_str not in output: + errors.append(f"Expected lat '{lat_str}' in output") + + if "ephem_prefix" in payload: + ep = payload["ephem_prefix"] + if ep not in output: + errors.append(f"Expected ephemeral prefix '{ep}' in output") + + if "multipart_remaining" in payload: + r = payload["multipart_remaining"] + if f"remaining={r}" not in output.replace(" ", ""): + errors.append(f"Expected remaining={r} in output") + + if "multipart_inner" in payload: + inner = payload["multipart_inner"] + if inner not in output: + errors.append(f"Expected inner type '{inner}' in output") + + if "control_subtype" in payload: + sub = payload["control_subtype"] + if sub not in output: + errors.append(f"Expected control subtype '{sub}' in output") + + +def main(): + schema = load_schema() + cases = find_test_cases() + + if not cases: + print("No test cases found!") + sys.exit(1) + + passed = 0 + failed = 0 + total_errors: list[tuple[str, list[str]]] = [] + + for path in cases: + case = json.loads(path.read_text()) + name = f"{path.parent.name}/{path.stem}" + + errors = validate_schema(case, schema, path) + errors.extend(validate_decode(case, path)) + + if errors: + failed += 1 + total_errors.append((name, errors)) + print(f" \033[1;31mFAIL\033[0m {name}") + for e in errors: + print(f" {e}") + else: + passed += 1 + print(f" \033[32mPASS\033[0m {name}") + + print() + print(f"\033[1m{passed + failed} tests: {passed} passed, {failed} failed\033[0m") + sys.exit(1 if failed else 0) + + +if __name__ == "__main__": + main() diff --git a/src/channel.rs b/src/channel.rs index 12363608..c3c8e371 100644 --- a/src/channel.rs +++ b/src/channel.rs @@ -33,7 +33,7 @@ pub struct RadioStatus { pub rx_count: u32, pub tx_count: u32, pub last_rssi: Option, - pub last_snr: Option, + pub last_snr: Option, } /// Radio state machine states. diff --git a/src/display/render.rs b/src/display/render.rs index 04da0321..5013120b 100644 --- a/src/display/render.rs +++ b/src/display/render.rs @@ -26,49 +26,22 @@ const SPARK_H: i32 = 19; // y=45..63 const RSSI_MIN: i16 = -120; const RSSI_MAX: i16 = 0; -/// Render the boot splash screen. -pub fn splash(target: &mut impl DrawTarget, board_name: &str, version: &str) { - let _ = target.clear(BinaryColor::Off); - - let title_style = MonoTextStyle::new(&FONT_9X15_BOLD, BinaryColor::On); - let sub_style = MonoTextStyle::new(&FONT_6X10, BinaryColor::On); - - Text::with_alignment( - "DongLoRa", - Point::new(W / 2, 24), - title_style, - Alignment::Center, - ) - .draw(target) - .ok(); - - Text::with_alignment(version, Point::new(W / 2, 42), sub_style, Alignment::Center) - .draw(target) - .ok(); - - Text::with_alignment( - board_name, - Point::new(W / 2, 54), - sub_style, - Alignment::Center, - ) - .draw(target) - .ok(); -} - /// Render the main status dashboard. pub fn dashboard( target: &mut impl DrawTarget, status: &RadioStatus, rssi_history: &[i16; RSSI_HISTORY_LEN], + tx_history: &[bool; RSSI_HISTORY_LEN], rssi_count: usize, + board_name: &str, + version: &str, ) { let _ = target.clear(BinaryColor::Off); let style = MonoTextStyle::new(&FONT_6X10, BinaryColor::On); match status.config { Some(cfg) => { - // Row 0: state + frequency (full precision) + // Row 0: state + frequency, TX power right-justified let mut buf: String<32> = String::new(); let freq_mhz = cfg.freq_hz / 1_000_000; let freq_khz = (cfg.freq_hz % 1_000_000) / 1_000; @@ -76,6 +49,11 @@ pub fn dashboard( Text::new(&buf, Point::new(0, FONT_H - 1), style) .draw(target) .ok(); + buf.clear(); + let _ = write!(buf, "{}dBm", cfg.tx_power_dbm); + Text::with_alignment(&buf, Point::new(W - 1, FONT_H - 1), style, Alignment::Right) + .draw(target) + .ok(); // State indicator: inverted "RX" or "TX", plain "IDLE" match status.state { @@ -133,13 +111,9 @@ pub fn dashboard( .draw(target) .ok(); - // Row 2: TX power + counters + // Row 2: packet counters buf.clear(); - let _ = write!( - buf, - "{}dBm RX:{} TX:{}", - cfg.tx_power_dbm, status.rx_count, status.tx_count - ); + let _ = write!(buf, "RX:{} TX:{}", status.rx_count, status.tx_count); Text::new(&buf, Point::new(0, FONT_H * 3 - 1), style) .draw(target) .ok(); @@ -167,16 +141,33 @@ pub fn dashboard( .draw(target) .ok(); - // RSSI sparkline - rssi_sparkline(target, rssi_history, rssi_count); + // RSSI sparkline (TX slots shown as dotted bars) + rssi_sparkline(target, rssi_history, tx_history, rssi_count); } None => { - Text::new("IDLE", Point::new(0, FONT_H - 1), style) + let title_style = MonoTextStyle::new(&FONT_9X15_BOLD, BinaryColor::On); + Text::with_alignment( + "DongLoRa", + Point::new(W / 2, 24), + title_style, + Alignment::Center, + ) + .draw(target) + .ok(); + Text::with_alignment(version, Point::new(W / 2, 38), style, Alignment::Center) .draw(target) .ok(); - Text::new("Waiting for host...", Point::new(0, FONT_H * 3 - 1), style) + Text::with_alignment(board_name, Point::new(W / 2, 50), style, Alignment::Center) .draw(target) .ok(); + Text::with_alignment( + "Waiting for host...", + Point::new(W / 2, 63), + style, + Alignment::Center, + ) + .draw(target) + .ok(); } } } @@ -185,10 +176,13 @@ pub fn dashboard( /// /// Each bar represents one time slot. The display task advances the /// slot index on a fixed timer, so each bar covers a constant duration -/// regardless of packet rate. Bars with no packets in a slot show as empty. +/// regardless of packet rate. RX slots draw solid bars; TX slots draw +/// dotted bars (alternating pixel rows). TX takes precedence if both +/// occurred in the same slot. fn rssi_sparkline( target: &mut impl DrawTarget, history: &[i16; RSSI_HISTORY_LEN], + tx_history: &[bool; RSSI_HISTORY_LEN], count: usize, ) { if count == 0 { @@ -204,13 +198,20 @@ fn rssi_sparkline( } else { (count - RSSI_HISTORY_LEN + i) % RSSI_HISTORY_LEN }; + let is_tx = tx_history[idx]; let rssi = history[idx]; - if rssi <= RSSI_MIN { - continue; // empty slot - } - let clamped = rssi.clamp(RSSI_MIN, RSSI_MAX); - let bar_h = ((clamped - RSSI_MIN) as i32 * SPARK_H) / (RSSI_MAX - RSSI_MIN) as i32; + // TX slots with no RSSI: show a short fixed-height dotted bar + let bar_h = if rssi <= RSSI_MIN { + if is_tx { + SPARK_H / 3 // small marker for TX-only slots + } else { + continue; // empty slot + } + } else { + let clamped = rssi.clamp(RSSI_MIN, RSSI_MAX); + ((clamped - RSSI_MIN) as i32 * SPARK_H) / (RSSI_MAX - RSSI_MIN) as i32 + }; if bar_h == 0 { continue; } @@ -218,10 +219,23 @@ fn rssi_sparkline( let x = (RSSI_HISTORY_LEN - n + i) as i32 * 2; let y = SPARK_TOP + SPARK_H - bar_h; - Rectangle::new(Point::new(x, y), Size::new(2, bar_h as u32)) - .into_styled(fill) - .draw(target) - .ok(); + if is_tx { + // Dotted bar: draw every other pixel row + for row in 0..bar_h { + if row % 2 == 0 { + Rectangle::new(Point::new(x, y + row), Size::new(2, 1)) + .into_styled(fill) + .draw(target) + .ok(); + } + } + } else { + // Solid bar for RX + Rectangle::new(Point::new(x, y), Size::new(2, bar_h as u32)) + .into_styled(fill) + .draw(target) + .ok(); + } } } diff --git a/src/display/task.rs b/src/display/task.rs index f5ac17d0..5021492c 100644 --- a/src/display/task.rs +++ b/src/display/task.rs @@ -29,8 +29,10 @@ const NO_SIGNAL: i16 = -121; struct DisplayState { rssi_history: [i16; RSSI_HISTORY_LEN], + tx_history: [bool; RSSI_HISTORY_LEN], rssi_count: usize, current_slot_rssi: i16, + current_slot_tx: bool, display_on: bool, disconnected: bool, last_status: RadioStatus, @@ -40,8 +42,10 @@ impl DisplayState { fn new() -> Self { Self { rssi_history: [NO_SIGNAL; RSSI_HISTORY_LEN], + tx_history: [false; RSSI_HISTORY_LEN], rssi_count: 0, current_slot_rssi: NO_SIGNAL, + current_slot_tx: false, display_on: true, disconnected: true, last_status: RadioStatus::default(), @@ -55,12 +59,19 @@ impl DisplayState { } } - /// Advance to the next time slot, committing the current slot's RSSI. + /// Mark the current time slot as having a transmit. + fn record_tx(&mut self) { + self.current_slot_tx = true; + } + + /// Advance to the next time slot, committing the current slot's data. fn advance_slot(&mut self) { let idx = self.rssi_count % RSSI_HISTORY_LEN; self.rssi_history[idx] = self.current_slot_rssi; + self.tx_history[idx] = self.current_slot_tx; self.rssi_count += 1; self.current_slot_rssi = NO_SIGNAL; + self.current_slot_tx = false; } } @@ -80,15 +91,10 @@ pub async fn display_task( } let _ = display.set_brightness(ssd1306::prelude::Brightness::BRIGHTEST).await; - // Splash screen - render::splash(&mut display, BOARD_NAME, env!("CARGO_PKG_VERSION")); - let _ = display.flush().await; - Timer::after_millis(1500).await; - let mut state = DisplayState::new(); let mut receiver = status.receiver().unwrap(); - // Initial dashboard + // Show splash/waiting screen (dashboard with no config renders it) render_and_flush(&mut display, &state).await; loop { @@ -108,6 +114,9 @@ pub async fn display_task( state.record_rssi(rssi); } } + if radio_status.tx_count != state.last_status.tx_count { + state.record_tx(); + } state.last_status = radio_status; if state.display_on { @@ -133,8 +142,10 @@ pub async fn display_task( state.disconnected = true; state.last_status = RadioStatus::default(); state.rssi_history = [NO_SIGNAL; RSSI_HISTORY_LEN]; + state.tx_history = [false; RSSI_HISTORY_LEN]; state.rssi_count = 0; state.current_slot_rssi = NO_SIGNAL; + state.current_slot_tx = false; render_and_flush(&mut display, &state).await; } }, @@ -165,7 +176,10 @@ where display, &state.last_status, &state.rssi_history, + &state.tx_history, state.rssi_count, + BOARD_NAME, + env!("CARGO_PKG_VERSION"), ); let _ = display.flush().await; } diff --git a/src/protocol.rs b/src/protocol.rs index 5e5c70ba..adc279ce 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -68,7 +68,7 @@ pub enum Response { Config(RadioConfig), RxPacket { rssi: i16, - snr: i8, + snr: i16, payload: Vec, }, TxDone, diff --git a/src/radio/task.rs b/src/radio/task.rs index c0c1192a..957d4c0a 100644 --- a/src/radio/task.rs +++ b/src/radio/task.rs @@ -50,7 +50,7 @@ pub async fn radio_task( Ok((len, pkt_status)) => { state.rx_count = state.rx_count.wrapping_add(1); let rssi = pkt_status.rssi; - let snr = pkt_status.snr as i8; + let snr = pkt_status.snr; state.last_rssi = Some(rssi); state.last_snr = Some(snr); status.sender().send(state.clone()); diff --git a/tools/channels.csv b/tools/channels.csv new file mode 100644 index 00000000..ae19d782 --- /dev/null +++ b/tools/channels.csv @@ -0,0 +1,277 @@ +channel_name,hashtag,key_hex,channel_hash,description,region +Public,False,8b3387e9c5cdea6ac9e5edbaa115cd72,17,"Default public channel (fixed PSK, not hashtag-derived)",global +#test,True,9cd8fcf22a47333b591d96a2b848b73f,156,Global testing channel,global +#testing,True,cde5e82cf515647dcb547a79a4f065d1,205,Testing (alternate name),global +#ping,True,3cae16fd067ba9c32a98be22e9b98525,60,Ping-bot testing channel,global +#emergency,True,e1ad578d25108e344808f30dfdaaf926,225,Emergency communications,global +#hamradio,True,83c8b01997654265938da8765cbc7db9,131,HAM radio operators,global +#qrv,True,4d6de18b9bb6ef3f7c7197e8441044ed,77,HAM QSO coordination/logging,global +#hard-software,True,c4419524d9ce72ac093fdd2a0a856595,196,Hardware/software discussion & troubleshooting,global +#blackout,True,75420460f335577fa27cdd20dacf9edd,117,Power outage info/notifications,global +#warnings,True,31d44d29ed676f8020dc09211a2ee9d8,49,General warnings channel,global +#aurora-alert,True,e823127951fc6a425abe0c65ac07b323,232,Aurora/northern lights alerts,global +#flachwitze,True,ec98cd8821fada7fe21ff9e1df6c619f,236,Jokes channel (German flat jokes),global +#general,True,4c49f3f24629f5ee4ad5b3965db47985,76,General chat (common default),global +#switzerland,True,8ad1ce57ad257627090ed28413c1f0b7,138,Swiss national channel,CH +#svizzera,True,e571e6921d9c19d1c393f2ba9286abba,229,Italian-speaking Switzerland,CH +#suisse,True,2b8dd2d22f0820d9945316be97359ab1,43,French-speaking Switzerland,CH +#chtest,True,ee95ca5221f5b41941b26d89fd702c37,238,Swiss testing channel,CH +#bern,True,7658cadca2e40ba98cb13ce19782e994,118,Bern region,CH +#zuerich,True,15d09e6787401c4fdbef4bf10ffcef53,21,Zürich canton/city,CH +#wallis,True,1e65af5328cec2c29d03c77a5dfb8cb8,30,Wallis/Valais canton,CH +#freiburg,True,ec1c88f03aad858a6d74ec36ac04c420,236,"Freiburg (DE, near CH border)",CH/DE +#netzstatus,True,e84d5ce15046d5a7f69db3a40f604851,232,"Network status, mainly Germany",DE +#muenchen,True,3f50b3350a8ed17a303f07e9641dff4a,63,Munich area,DE +#hansemesh,True,21ddee26dc5215153c3903165fd4262f,33,HanseMesh community (Hamburg),DE +#de-sn-dd,True,c252cf274c306e76d9531fdf96f8dfa0,194,Dresden/Saxony test channel,DE +#berlin,True,8bffd7b0bf481d92afc625e409b88a16,139,Berlin (mentioned in MeshCore tutorial),DE +#hamburg,True,6ee6b4b98d5856392596cc1929eb2e36,110,Hamburg area,DE +#koeln,True,cc2de011842824b10ee2e28e97f91339,204,Cologne area,DE +#frankfurt,True,0d8e969330d62f9356b5e4d415ea7f98,13,Frankfurt area,DE +#stuttgart,True,74710f9e61866f184734bc367d9f492a,116,Stuttgart area,DE +#deutschland,True,6870d6013221f0278122cb8c88990794,104,Germany national,DE +#ruhrgebiet,True,6b5f2c549d9de0116749aa087e3caa4e,107,Ruhr area,DE +#nuernberg,True,a6e4f905a64512843b12ec20750f4161,166,Nuremberg area,DE +#dresden,True,a6f1bb7dbc598884b7a15be59e61ecd6,166,Dresden area,DE +#leipzig,True,0cc55d55809a7b048c39dd01c3254381,12,Leipzig area,DE +#hannover,True,b9430bfc2908b23efcae886f16eb425b,185,Hannover area,DE +#duesseldorf,True,1dfb747e50229101f2232e391d1eb5a7,29,Düsseldorf area,DE +#dortmund,True,37951d1ca4e428fd24c57f3087f160f0,55,Dortmund area,DE +#essen,True,80174f513e9099612b537bc1cd450a41,128,Essen area,DE +#austria,True,faaa5ef01081222e319a8205357321f4,250,Austria national channel,AT +#wien,True,aba9d5e2fb16d2b02111db5926819065,171,Vienna,AT +#graz,True,ea00635b84c245d519d29dc4044a43a1,234,Graz,AT +#salzburg,True,132d8883191473fc8a82f3447909a37d,19,Salzburg,AT +#innsbruck,True,dee607ccd01ca7ef375c0567c554db97,222,Innsbruck,AT +#linz,True,9b417e09eb452be6d068c90b3c46303d,155,Linz,AT +#italy,True,f8d4dd36f6b9476eb4cec18a1536b17d,248,Italy (English name variant),IT +#italia,True,17fa9f15bec81ed7da12116253db5a20,23,Italy (Italian name variant),IT +#roma,True,3523b8022329e6648738f0e5448ffcc1,53,Rome,IT +#milano,True,ea8929998e608b67fe5c2bcf29473d8f,234,Milan,IT +#nederland,True,c14f7b1e0416e4fa74f5f7b81a707b50,193,Netherlands national,NL +#amsterdam,True,d768f5a0aa65f8c54e4ea521bd49eb4f,215,Amsterdam,NL +#rotterdam,True,e3f3ad251e512a029bd27161b14f0bdd,227,Rotterdam,NL +#utrecht,True,042e458dfb05b17cbdee57f93df4be4c,4,Utrecht,NL +#denhaag,True,84eed36f62c7b22527dbf8883585ad14,132,The Hague,NL +#eindhoven,True,aceb8cceae584a3890c8db173668ae2b,172,Eindhoven,NL +#groningen,True,6a18329035bd3d2be69fed9094ffa5f4,106,Groningen,NL +#localmesh,True,13934f45750ce5f54311771bc52dc2a9,19,LocalMesh NL community,NL +#belgium,True,7085b78ed010599094f8c8e7d1aa0e27,112,Belgium national,BE +#belgie,True,559a87a20b1170a11656ffbe15d27c07,85,Belgium (Dutch),BE +#brussel,True,58648303d8e2b4e35f4301c4551d6307,88,Brussels,BE +#antwerpen,True,5900168627ddb2963d3fd8dba3fecbb9,89,Antwerp,BE +#gent,True,82f82c2835bd5fbda1ab0eb134364532,130,Ghent,BE +#france,True,edcb362b38e74b99025a7e551d925d20,237,France national,FR +#paris,True,d0fc2b1ec400eb8669010ce0311a00fb,208,Paris,FR +#lyon,True,0b4b602516088ea6d245be43102643b0,11,Lyon,FR +#marseille,True,c91ed99a23e560300b60325c0b9fb3ea,201,Marseille,FR +#uk,True,22b2eed34b5cc429ce1dc5e88635ff84,34,United Kingdom,UK +#london,True,9881d2b7ab9105a41a8d0f6ba449447e,152,London (mentioned in MeshCore tutorial),UK +#manchester,True,5ddc4f781fa3f504e9f514e77df13790,93,Manchester,UK +#birmingham,True,13b1c76ef007b4ad0c3b168610337513,19,Birmingham,UK +#scotland,True,f4fce403cfd56f7089920d065718f29c,244,Scotland,UK +#ireland,True,1b2a12acc5db1517d9d407946756b1da,27,Ireland,IE +#dublin,True,8792638977132bc05a1f72d6bb913694,135,Dublin,IE +#seattle,True,ef627a9bbbb549347fdb76bf0cd3bc14,239,Seattle (Puget Mesh),US-WA +#bot,True,eb50a1bcb3e4e5d7bf69a57c9dada211,235,Bot channel (Puget Mesh),US-WA +#bot-tacoma,True,278df637e12a0dda01c826e1fd9779fd,39,Bot channel Tacoma,US-WA +#bot-van,True,bf07fbf914e37710561cb741e914efeb,191,Bot channel Vancouver,US-WA/CA +#capitolhill,True,7f281916c8ec32e13c5ef687d182160a,127,Capitol Hill Seattle,US-WA +#protest,True,195e9167baf010c4538d8532b6d7296e,25,Protest coordination,US-WA +#queer,True,5754476f162d93bbee3de0efba136860,87,LGBTQ+ community,US-WA +#seahawks,True,4973d6ceb6c35a09894a83688847c8a3,73,Seattle Seahawks,US-WA +#sports,True,e8ee81f3aabf105d9ba2d2d4bd94fe4a,232,Sports discussion,US-WA +#salishmesh,True,c1ec55555132c7598393c869715b7555,193,Salish Mesh (Vancouver BC area),CA-BC +#dwebyvr,True,b8f956aab74dae02f70f11ddba36de53,184,DWeb Vancouver,CA-BC +#zspace,True,fc86b8cceea87091d45f166c7a698f78,252,ZSpace Vancouver,CA-BC +#portland,True,45c6bc719c15b9fb809f48f594359877,69,Portland OR,US-OR +#pnw,True,98059014c708581fbf0a398cfe8a486d,152,Pacific Northwest,US-PNW +#tacoma,True,9ba83a22a865c9baaffc6bc8b6a33d2f,155,Tacoma WA,US-WA +#idahomesh,True,60baad727ec5235cee359135aeecb3f4,96,Idaho statewide mesh,US-ID +#nodakmesh,True,156134e4a3f6d9826a39e0b7f85afdcd,21,North Dakota mesh,US-ND +#florida,True,c44bd74eac2c81dcb6dfb217727b05cd,196,Florida statewide,US-FL +#miami,True,d81c566a5d337d588ebd250df6fc1b63,216,Miami,US-FL +#tampa,True,8d10508c39d5d8e6c5e3e8fab41d1c09,141,Tampa,US-FL +#orlando,True,3553bdbd9b3a54da760624a27dcda156,53,Orlando,US-FL +#jacksonville,True,6cf63df8459d99d4a4a6177acf6661f6,108,Jacksonville,US-FL +#texas,True,c4a214e133de5e9ad276f99fdbd7216f,196,Texas statewide,US-TX +#austin,True,b2e6f9af95d959734d71cdf90ca62533,178,Austin TX,US-TX +#dallas,True,5b2efa4e2ad0a2b83c5486ca4dd244de,91,Dallas TX,US-TX +#houston,True,c001fbacad2d97676395ca37e2576345,192,Houston TX,US-TX +#sanantonio,True,c2e72548c615d1cf2796a6f77b9545b0,194,San Antonio TX,US-TX +#dfw,True,5b7dc809ba579affccab5462c537244e,91,Dallas-Fort Worth,US-TX +#california,True,55da17570958a6a311939c99582e6f60,85,California statewide,US-CA +#losangeles,True,3dd9373dcea0294bd05ab067cc58e9d4,61,Los Angeles,US-CA +#sanfrancisco,True,ae3608083279fd2ad03f0d6a99617d25,174,San Francisco,US-CA +#sandiego,True,08623bbd90a96ecdc1f5c34e7292b35f,8,San Diego,US-CA +#bayarea,True,7f9a5fd3070ad14e337ba100ca53a89b,127,Bay Area,US-CA +#sacramento,True,a6d6927f0b48762cf1346e2ae95cec14,166,Sacramento,US-CA +#socal,True,f4018307615ac79d2e5ef17bb44654d4,244,Southern California,US-CA +#norcal,True,f2438510715f5d9d55eb4370664330f5,242,Northern California,US-CA +#sanjose,True,ce964c28170b1c043d06073e6fcd83a7,206,San Jose,US-CA +#sfbay,True,5bd50243f42644439c21dfe367eea031,91,SF Bay,US-CA +#baymesh,True,1be0e569ad371f72d1e36da7242ae2f0,27,Bay Mesh community,US-CA +#boston,True,9587d847a7208da684c89cc1f525bc03,149,Boston MA,US-MA +#newyork,True,82a78024dd7edf6c9be298c919632e25,130,New York,US-NY +#nyc,True,6e6554655f84ca26fbc09d81d15d6b96,110,New York City,US-NY +#chicago,True,c1c289b131e5222370cbc2048445844b,193,Chicago IL,US-IL +#denver,True,b24355a0d22ed2bf393ec530d75810b4,178,Denver CO,US-CO +#phoenix,True,027850d9410fa98809819c96644ec04c,2,Phoenix AZ,US-AZ +#atlanta,True,3c8f15665f99a349a97427e7c312ee0e,60,Atlanta GA,US-GA +#nashville,True,717490d2561c2e1f211b13e95073fb16,113,Nashville TN,US-TN +#charlotte,True,b67efd607080d98640774cb31181b0ce,182,Charlotte NC,US-NC +#raleigh,True,1a2999ed1fea10e2cdf456ff118193d9,26,Raleigh NC,US-NC +#minneapolis,True,3283cca82b7b0ac50e8014c344cb8a86,50,Minneapolis MN,US-MN +#stlouis,True,f366422991a19e745f65096e59b43d51,243,St. Louis MO,US-MO +#pittsburgh,True,926cb5461a1ef572619da576ef7de04b,146,Pittsburgh PA,US-PA +#detroit,True,bd01f26bf7d8c90952753157a41c61ac,189,Detroit MI,US-MI +#washingtondc,True,0067d451cc79f26bc9924b3fc53f28d7,0,Washington DC,US-DC +#dc,True,0f3aa71fed514f5c16ebaf265ef05b2e,15,DC (short form),US-DC +#colorado,True,ef61c9e5a3286053746f7603044bcb08,239,Colorado statewide,US-CO +#ohio,True,e89e8c2f72ebae476c4ded78a360d0ea,232,Ohio statewide,US-OH +#virginia,True,07655778c8adeb16c1a0470cc3516b01,7,Virginia statewide,US-VA +#georgia,True,6621a99c4c21a1f4897a9833a95ac962,102,Georgia statewide,US-GA +#michigan,True,4b0dc02774aa2ad8fe120d4ab0d17f1c,75,Michigan statewide,US-MI +#newengland,True,4ad1e19832319b916ab4f3a0fbb0a38e,74,New England region,US-NE +#appalachia,True,ab353b2bf5d281d97cd73d897ef8e149,171,Appalachian region,US +#hawaii,True,3fd57495820328594e1641d14583faa6,63,Hawaii,US-HI +#alaska,True,2a5841192b151422baec71537b0b5238,42,Alaska,US-AK +#vegas,True,d45435b6467e7ded33c28f6796bf5183,212,Las Vegas,US-NV +#lasvegas,True,b82b823d6dcc845ead47cee8cfa758b3,184,Las Vegas (long form),US-NV +#saltlakecity,True,07e180549b35782901d407832b3ec6a6,7,Salt Lake City,US-UT +#slc,True,fd31a70b220eb3e2430e59c15432ef8c,253,SLC (short form),US-UT +#utah,True,ee42ff5f055f2e83527e4efb91b10d0a,238,Utah statewide,US-UT +#canada,True,8373bb1055f34164c6dd2663927cb6d9,131,Canada national,CA +#toronto,True,e0d3774dd1da4dc5c55d8bd731555334,224,Toronto,CA-ON +#vancouver,True,16d6034be448ab86d11858cfa4c57c9c,22,Vancouver,CA-BC +#montreal,True,0c4c03b5fbea5b80f89e2a2a16ed3f40,12,Montreal,CA-QC +#calgary,True,bbbb1ad23fe1cdd7739667418204a57d,187,Calgary,CA-AB +#ottawa,True,7871ec72b45617696c35c970bddd8124,120,Ottawa,CA-ON +#edmonton,True,ce451a3a24ee89ee31564ae29e9dee04,206,Edmonton,CA-AB +#winnipeg,True,3c3294aac8a83b1143ab6bedee5b5226,60,Winnipeg,CA-MB +#australia,True,eadb84fa1da64c44b77c40fd11b9d78e,234,Australia national,AU +#sydney,True,57fe5284a5b905835193868d9bdbe1e9,87,Sydney,AU-NSW +#melbourne,True,4d73731d9450ccb9673eb923c0f40af2,77,Melbourne,AU-VIC +#brisbane,True,e4bd09784621decac600d0d4d857e3b2,228,Brisbane (MBUG community),AU-QLD +#perth,True,9873c6cf14dac409b5a573574cff5ce1,152,Perth,AU-WA +#adelaide,True,9ad0c536f3eabc7c2f0434d68e242009,154,Adelaide,AU-SA +#canberra,True,c716951221e6c17faf5f22b6245ce64b,199,Canberra,AU-ACT +#newzealand,True,1870a961e4aa06f62b02b835efcacb71,24,New Zealand national,NZ +#nz,True,eb87ee8817ba71315ac7be9c733b523a,235,NZ (short form),NZ +#auckland,True,7c6f1c71a5a3d6823a1d7bfd2a349ac8,124,Auckland,NZ +#wellington,True,c2da57d74f78996ad71bbe3e22446f16,194,Wellington,NZ +#christchurch,True,7b88b875fc5ae76305c8a15349549a3b,123,Christchurch,NZ +#gisborne,True,d8b45a1eb52d0bd45655d5f2a72d571f,216,Gisborne (mentioned in MeshCore tutorial),NZ +#weather,True,88f502554fee92a1625cfb311546e7cb,136,Weather updates,topical +#jokes,True,abe610d61dee508c0ac18793c36c41c3,171,Jokes channel,topical +#quotes,True,086be6f920280b7cc51d6ad91d6a786c,8,Quotes channel,topical +#secret,True,be657c0527e122bf93bc735999cd7e0d,190,Secret channel (Brisbane MBUG),topical +#chat,True,d0bdd6d71538138ed979eec00d98ad97,208,General chat,topical +#news,True,ecadb1a7d803db8958bea1302ca6e8be,236,News channel,topical +#help,True,dcc67fae2067046832af7b2b0b743165,220,Help channel,topical +#sos,True,9ed2c78bcd68ac7ce2a2fc3bb4045114,158,SOS / distress,topical +#prepper,True,1eec1a7df7080a392cb490473c4a9920,30,Prepper community,topical +#offgrid,True,aaa26662bebf122262692d0bca61dcef,170,Off-grid living,topical +#radio,True,266f225baced1b2a868dcc8e9c69a304,38,Radio hobby,topical +#lora,True,0749ea267c6be7b54ca1dbbae7dba0aa,7,LoRa technology discussion,topical +#meshcore,True,2fa78a5aef618e7c2a78f0ab5c8869b3,47,MeshCore project discussion,topical +#meshtastic,True,73a2e13ff0dea9ed19b24b2ab753650f,115,Meshtastic discussion,topical +#iot,True,233b42534ed82b8ae6a88d785f1b27fd,35,IoT / sensors,topical +#solar,True,7592a730ff1af2b40b95589fbafcca01,117,Solar power,topical +#emcomm,True,a9a49340642dfc4c562d7849b7c8a258,169,Emergency communications,topical +#ares,True,44419e394cde859e45710f288db939a8,68,ARES (Amateur Radio Emergency Service),topical +#gmrs,True,2ad811e84ab4ad0e3a648d8fcf3d650a,42,GMRS radio,topical +#cb,True,a35b5f87bbb67859a02e7666c823de7a,163,CB radio,topical +#hiking,True,2370a013053e384e5f18918bc2b26baf,35,Hiking,topical +#camping,True,c011b7c2abf33748cd9bd5a78c2b4955,192,Camping,topical +#hunting,True,0c0c28fd9bbf096ee06e01af840037af,12,Hunting,topical +#fishing,True,a671ca13cc499898aea4596e804ecf20,166,Fishing,topical +#skiing,True,b3baf3586d347ecaf2c113106a564207,179,Skiing,topical +#biking,True,9c0591d708b7c7970c9ad6712e3c4d05,156,Biking,topical +#ham,True,5270db3979da687fa133fec6684cd952,82,HAM radio (short form),topical +#cq,True,bc9da8bdebcaeee40fefff7dd16855f1,188,CQ (calling all stations),topical +#dx,True,adf77706da76b66bb0a313c1484d3be4,173,DX (long distance contacts),topical +#repeater,True,289991a3077903263f2d31493887c651,40,Repeater discussion,topical +#firmware,True,6163564e7449c1c842e58dcf2cd1ab4f,97,Firmware discussion,topical +#debug,True,fd7a60ed4796efcd1965c2de466105cb,253,Debug channel,topical +#dev,True,d41bcba61e9dca7177c7b8533d23a0bc,212,Development channel,topical +#admin,True,889334b7e486938c776dbdde120da9de,136,Admin channel,topical +#monitor,True,131479235e65b7c591307f699d64dc74,19,Monitoring channel,topical +#alert,True,678c7d2e08c019e113ace03eaaa128ad,103,Alert channel,topical +#local,True,d2d35fa76be9875ed254db80397483a5,210,Local community,topical +#neighborhood,True,53403881955f0e11573ba1e5d3a82c4b,83,Neighborhood chat,topical +#community,True,b9323ce55a729068ed8e3ed49b9b84b5,185,Community channel,topical +#events,True,d2cbcc606979cdb05c7bd66f49f23baa,210,Events channel,topical +#trade,True,c2be982890fb3a63113de637b9aee6fe,194,Trading/swap channel,topical +#marketplace,True,354a741c2975c843ec0706089390b968,53,Marketplace,topical +#gaming,True,3802c79121f195ea50ad9ab2aa2c402a,56,Gaming,topical +#music,True,b025ab29b0a5f56fb68a474741d7a2cc,176,Music,topical +#tech,True,5177b749eefbfab3c90a21b4e2518c5f,81,Technology,topical +#science,True,8abc980d3764fdabda5dfa6b6bc3a6cc,138,Science,topical +#crypto,True,0aa7c45d99be27024b82a1474c693951,10,Cryptocurrency,topical +#bitcoin,True,94bf6fd2688b1456c071ebad63d7021d,148,Bitcoin,topical +#linux,True,02c74eda5d8ec8b9bb945049fb2f55c6,2,Linux,topical +#arduino,True,ce8c596922eff9274f3b1e19ad296754,206,Arduino,topical +#raspberry,True,469bc476d8263b64b6f90ca868b04b91,70,Raspberry Pi,topical +#esp32,True,9f8aac4c48973b07bbdc47acbaea3e8e,159,ESP32 projects,topical +#heltec,True,71af5d9a209e8b17a19ee22aa7b6be23,113,Heltec devices,topical +#lilygo,True,a33dd4b025cc82b81f97318a63a9d5d3,163,LilyGo devices,topical +#rak,True,1f4734675fdfcabfaa67a0a059c65c5f,31,RAK Wireless devices,topical +#antenna,True,e8979157107155ade90822e29b186ee8,232,Antenna discussion,topical +#rf,True,46aaa16f78b3969040aad2b6b694b59e,70,RF engineering,topical +#preppers,True,4877173813de9668b9ef33adbc1b8f37,72,Preppers community,topical +#survival,True,e1f465ea51df09fc901389758f1c5f01,225,Survival skills,topical +#grid-down,True,94ddbd51f2ee0bd47426a6f3ed79be99,148,Grid-down scenarios,topical +#wildfire,True,b9124c3aecc0c813634f79f144b93f83,185,Wildfire alerts,topical +#hurricane,True,c13a2f9d18400e5d93bae4bb3afef40c,193,Hurricane alerts,topical +#earthquake,True,0c7082c04a1a90502a5f32fc6a9f6524,12,Earthquake alerts,topical +#flood,True,f2a0fd0abe4c9fc6865b5f8eebf319d6,242,Flood alerts,topical +#tornado,True,76e577e8187d0bab89052343f3835039,118,Tornado alerts,topical +#storm,True,113761b9e31a5c30e0ee4fc78dc7310b,17,Storm alerts,topical +#traffic,True,c1216dacbb7e4c3b9a3356e52f1c27fa,193,Traffic updates,topical +#fire,True,3d74a070077293ab66baf3aa724349ff,61,Fire alerts,topical +#police,True,79b89aa870ea85dbb8396f401e547d99,121,Police scanner / alerts,topical +#ems,True,f714461f26d43d9a17b2530c3ad29bc2,247,EMS / first responders,topical +#search-rescue,True,cd8ab9e4944ee656ae6e961a4437c551,205,Search and rescue,topical +#volunteer,True,2d3d64746075cb99407d681e547b99cb,45,Volunteer coordination,topical +#data,True,b0d9c01c8278a711c68ffdca98ef1245,176,Data/telemetry channel,topical +#sensor,True,6218df72386dd48259024327e8ebf66f,98,Sensor data channel,topical +#tracker,True,70258e69aeb03d1cd2e16072e1fe14b5,112,Asset/pet tracker,topical +#gps,True,af4b9f722a6200b1eb2696660017d0df,175,GPS tracking,topical +#boat,True,c03117578e38c3f1eb5ae8c3d0019753,192,Boating/maritime,topical +#sailing,True,c2f6052f8b89639f81c8ff4def32deae,194,Sailing,topical +#marine,True,7f4b40aac63642805a54a959beb07694,127,Marine channel,topical +#aviation,True,e17538c835ee0990b349ca1879b4f57a,225,Aviation,topical +#drone,True,4460931543103de42d2f85093a80273c,68,Drone / UAV,topical +#fpv,True,6d2752bc94797cfce43e2138eab7a772,109,FPV flying,topical +#farm,True,4a2f1b2147165cf942402a4c6bd7d42d,74,Farming / agriculture,topical +#ranch,True,5885e728fc06bfcbe7d6c9020c13ff10,88,Ranch / livestock,topical +#tucson,True,cf989ccea881cf5ddcf40d87bbfc441e,207,Tucson AZ,US-AZ +#albuquerque,True,2a59b6ce7d3ce661664217f9b8255e86,42,Albuquerque NM,US-NM +#boise,True,f4e641f9c90ac3bd5bc442ef379f6eb3,244,Boise ID,US-ID +#spokane,True,dfcf2a863b3baa99e3d3274bfb56439f,223,Spokane WA,US-WA +#olympia,True,baec83dd15ef758ad8bf8f357237c9cb,186,Olympia WA,US-WA +#bellingham,True,6000c8e3c1343fd920749eb3ad2a42b4,96,Bellingham WA,US-WA +#eugene,True,e6ce6f5e71d88bb90c0b7dacc2235662,230,Eugene OR,US-OR +#bend,True,291ddea6fc1c1742f7ad425819b66f7b,41,Bend OR,US-OR +#reno,True,e9edf1edf57a04b8f66e75829b4edc06,233,Reno NV,US-NV +#provo,True,72744153a7a2d84ed69338be4dc3fc31,63,Provo,US-UT +#orem,True,97236968b8a6c0cf297f1c1365539d0d,157,Orem,US-UT +#ogden,True,cdeec2a40203056a6c6f2d1fb5f50b81,245,Ogden,US-UT +#logan,True,b0ee430ede14470d5ff8fafbe092ee50,145,Logan,US-UT +#stgeorge,True,7792659def2811af89a02270b2918235,128,St. George,US-UT +#parkcity,True,bbb64d6ae8eaeaf4302f501844f64511,25,Park City,US-UT +#lehi,True,18f20a96d251665105d59df6ca5bcc5c,98,Lehi,US-UT +#byu,True,65633d6a99bbcb173cb01afeae3a51d0,194,Brigham Young University,US-UT +#uvu,True,cb305b9b3d0ab33fce2186ee6b9c58ff,91,Utah Valley University,US-UT +#uofu,True,c08fe0d4d37b4b7f233f2977296979d5,178,University of Utah,US-UT +#usu,True,ceb222637d010d1c5551a5035e86f89d,104,Utah State University,US-UT +#utahcounty,True,f191a8a833d2ae61b7fbfa8c9891cc0c,23,Utah County,US-UT +#daviscounty,True,926847daaed0baa7758df5ef8af76f32,11,Davis County,US-UT +#webercounty,True,364726e43d1fb9b8079ededb03f00764,172,Weber County,US-UT +#wasatchfront,True,eb9fa3bfa390eed3737634bed39c7460,44,Wasatch Front metro corridor,US-UT +#wasatch,True,15bf0e80d3d88092d38e4c82da7e523e,0,Wasatch region,US-UT +#saltlake,True,56327c6958d9108213f8638fb2a2b7a4,173,Salt Lake (alternate),US-UT +#tooele,True,092cbdcf351bdbe77ce6c00e1dbeb294,184,Tooele,US-UT +#sanpete,True,4ae01748b17bfc90e13004f19e734bbc,151,Sanpete County,US-UT +#watchman,True,11489fe661e776b3ccdcf864bd14f7d6,245,Watchman mesh health alerts,global diff --git a/tools/meshcore_rx.py b/tools/meshcore_rx.py new file mode 100755 index 00000000..844966f9 --- /dev/null +++ b/tools/meshcore_rx.py @@ -0,0 +1,993 @@ +#!/usr/bin/env python3 +"""DongLoRa MeshCore receiver: decode and display MeshCore packets.""" +# /// script +# requires-python = ">=3.10" +# dependencies = ["cobs", "pyserial", "pycryptodome"] +# /// + +import base64 +import csv +import glob +import hashlib +import hmac +import json +import struct +import serial +import sys +import tempfile +import time +from cobs import cobs +from Crypto.Cipher import AES +from datetime import datetime, timezone +from pathlib import Path + + +# ── ANSI colors ─────────────────────────────────────────────────── + +RST = "\033[0m" +BOLD = "\033[1m" +DIM = "\033[2m" +RED = "\033[31m" +GRN = "\033[32m" +YEL = "\033[33m" +BLU = "\033[34m" +MAG = "\033[35m" +CYN = "\033[36m" +WHT = "\033[37m" +BRED = "\033[1;31m" +BGRN = "\033[1;32m" +BYEL = "\033[1;33m" +BBLU = "\033[1;34m" +BMAG = "\033[1;35m" +BCYN = "\033[1;36m" +BWHT = "\033[1;37m" + +TYPE_COLORS = { + "ADVERT": BGRN, + "ACK": DIM, + "TXT_MSG": BCYN, + "REQ": BYEL, + "RESPONSE": BYEL, + "GRP_TXT": BMAG, + "GRP_DATA": BMAG, + "ANON_REQ": YEL, + "PATH": BBLU, + "TRACE": BLU, + "MULTIPART": YEL, + "CONTROL": BLU, + "RAW_CUSTOM": DIM, +} + + +def _rssi_color(rssi: int) -> str: + if rssi >= -70: + return GRN + if rssi >= -100: + return YEL + return RED + + +def _snr_color(snr: int) -> str: + if snr >= 0: + return GRN + if snr >= -10: + return YEL + return RED + + +# ── MeshCore channel crypto ──────────────────────────────────────── + +def _channel_secret_from_hashtag(name: str) -> bytes: + """Derive 32-byte secret from a hashtag channel name (e.g., '#test').""" + h = hashlib.sha256(name.encode()).digest() + return h[:16] + b"\x00" * 16 + + +def _channel_secret_from_psk(psk_b64: str) -> bytes: + """Decode a base64 PSK into a 32-byte secret (16-byte key + 16 zero bytes).""" + key = base64.b64decode(psk_b64) + return key[:16] + b"\x00" * 16 + + +def _channel_hash(secret: bytes) -> int: + """Compute the 1-byte channel hash from a 32-byte secret.""" + # Hash over the key portion (16 bytes for hashtag/PSK channels) + key_len = 16 if secret[16:] == b"\x00" * 16 else 32 + return hashlib.sha256(secret[:key_len]).digest()[0] + + +def _grp_verify_and_decrypt(secret: bytes, mac_bytes: bytes, ciphertext: bytes) -> bytes | None: + """Verify MAC then decrypt a GRP_TXT/GRP_DATA payload. Returns plaintext or None.""" + if not ciphertext or len(ciphertext) % 16 != 0: + return None + # HMAC-SHA256 over ciphertext, truncated to 2 bytes + key_len = 16 if secret[16:] == b"\x00" * 16 else 32 + computed_mac = hmac.new(secret[:key_len], ciphertext, hashlib.sha256).digest()[:2] + if computed_mac != mac_bytes: + return None + # AES-128-ECB decrypt + cipher = AES.new(secret[:16], AES.MODE_ECB) + plaintext = b"" + for i in range(0, len(ciphertext), 16): + plaintext += cipher.decrypt(ciphertext[i:i + 16]) + return plaintext + + +def _parse_grp_plaintext(plaintext: bytes) -> tuple[int, str] | None: + """Parse decrypted GRP_TXT: timestamp(4) + flags(1) + text. Returns (timestamp, text).""" + if len(plaintext) < 5: + return None + timestamp = struct.unpack_from(" bytes: + """Build an encrypted GRP_TXT payload (channel_hash + mac + ciphertext).""" + ch = _channel_hash(secret) + # Plaintext: timestamp(4) + flags(1) + "sender: text\0" + zero-pad + plaintext = struct.pack(" bytes: + """Wrap encrypted GRP_TXT payload into a full MeshCore packet.""" + header = bytes([0x15]) # GRP_TXT flood: (5 << 2) | 1 + path_len = bytes([0x40]) # 0 hops, 2-byte hash mode (hash_size_code=1 << 6) + return header + path_len + channel_payload + + +def _grp_transmit(ser, channel_name: str, sender: str, text: str): + """Encrypt and transmit a GRP_TXT message, then resume RX.""" + secret = _KNOWN_CHANNELS.get(channel_name) + if secret is None: + return + payload = _grp_encrypt(secret, sender, text) + packet = _grp_build_packet(payload) + try: + send_cmd(ser, {"type": "Transmit", "payload": packet}, + f"TX GRP_TXT → {channel_name}") + except Exception as e: + print(f" {RED}[TX failed: {e}]{RST}") + # Always try to resume RX, even if TX failed + try: + send_cmd(ser, {"type": "StartRx"}, "StartRx (resume)") + except Exception as e: + print(f" {RED}[StartRx failed: {e}]{RST}") + + +# ── Loop/collision aggregator ───────────────────────────────────── + +REPORT_CHANNEL = "#watchman" +REPORT_SENDER = "Watchman" +REPORT_WINDOW = 60 # seconds — collect events before reporting +REPORT_COOLDOWN = 3600 # seconds — suppress repeat reports for same issue + + +def _nodes_for_hash(hash_hex: str, hash_size: int) -> int: + """Count how many known nodes share this path hash.""" + return sum(1 for pk in _known_nodes if pk[:hash_size * 2] == hash_hex) + + +class LoopAggregator: + def __init__(self): + self._pending: list[dict] = [] + self._cooldown: dict[str, float] = {} + self._window_start = time.monotonic() + + def record(self, dupes: dict[str, int], hash_size: int, all_hops: list[str]): + """Record duplicate hops from one packet.""" + first_hop = all_hops[0] if all_hops else None + self._pending.append({ + "dupes": dupes, "hash_size": hash_size, "hops": all_hops, + "first_hop": first_hop, + }) + + def maybe_report(self, ser) -> bool: + if not self._pending: + self._window_start = time.monotonic() + return False + if time.monotonic() - self._window_start < REPORT_WINDOW: + return False + self._send_report(ser) + return True + + def _send_report(self, ser): + now = time.monotonic() + messages: list[str] = [] + + # Aggregate: (hash, hash_size) → {packets, relayers, senders} + agg: dict[tuple[str, int], dict] = {} + for ev in self._pending: + for h, count in ev["dupes"].items(): + key = (h, ev["hash_size"]) + if key not in agg: + agg[key] = {"packets": 0, "relayers": set(), "senders": set()} + agg[key]["packets"] += 1 + if ev["first_hop"]: + agg[key]["senders"].add(ev["first_hop"]) + for hop in ev["hops"]: + if hop != h: + agg[key]["relayers"].add(hop) + + for (h, hs), info in agg.items(): + issue_key = f"{h}:{hs}" + if issue_key in self._cooldown and now - self._cooldown[issue_key] < REPORT_COOLDOWN: + continue + + n_known = _nodes_for_hash(h, hs) + pkts = info["packets"] + senders = sorted(info["senders"]) + sender_str = ",".join(senders[:3]) if senders else "?" + if len(senders) > 3: + sender_str += f"+{len(senders) - 3}" + relayer_str = ",".join(sorted(info["relayers"])[:3]) + if len(info["relayers"]) > 3: + relayer_str += f"+{len(info['relayers']) - 3}" + + if n_known == 1: + # Exactly one node matches → confirmed loop + msg = f"Loop: {h} re-sent its own traffic ({pkts} pkt)." + if relayer_str: + msg += f" Relayed by {relayer_str}." + messages.append(msg) + elif hs >= 2: + if n_known > 1: + # 2B+ hash collision — rare, definitely a routing issue + msg = (f"Routing conflict: {n_known} nodes share {hs}B " + f"hash {h} ({pkts} pkt).") + messages.append(msg) + else: + # 2B+ duplicate, unknown node → almost certainly a loop + msg = f"Probable loop: {h} appeared {pkts}x." + if relayer_str: + msg += f" Relayed by {relayer_str}." + messages.append(msg) + else: + # 1B hash — can't distinguish loop from collision + if n_known > 1: + msg = (f"1B hash {h} duplicated in {pkts} pkt " + f"({n_known} known nodes share it). " + f"Sender {sender_str}: use 2B routing!") + messages.append(msg) + else: + msg = (f"1B hash {h} duplicated in {pkts} pkt. " + f"Sender {sender_str}: use 2B routing to clarify!") + messages.append(msg) + + self._cooldown[issue_key] = now + + if not messages: + self._reset() + return + + full = " | ".join(messages) + max_len = MAX_GRP_TEXT - len(REPORT_SENDER) - 2 + if len(full) > max_len: + full = full[:max_len - 5] + "+more" + + print(f" {BYEL}>>> Reporting to {REPORT_CHANNEL}: {full}{RST}") + _grp_transmit(ser, REPORT_CHANNEL, REPORT_SENDER, full) + self._reset() + + def _reset(self): + self._pending.clear() + self._window_start = time.monotonic() + + +_aggregator = LoopAggregator() + + +# Open-source VID 1209, PID "WA" (0x5741) +USB_VID_PID = "1209:5741" + + +def find_serial_port() -> str | None: + """Find the serial port for our USB device.""" + import subprocess + + # Try to find by USB VID:PID + for path in sorted(glob.glob("/dev/ttyACM*")) + sorted(glob.glob("/dev/ttyUSB*")): + try: + result = subprocess.run( + ["udevadm", "info", "--query=property", f"--name={path}"], + capture_output=True, + text=True, + timeout=2, + ) + vid = "" + pid = "" + for line in result.stdout.splitlines(): + if line.startswith("ID_VENDOR_ID="): + vid = line.split("=", 1)[1].lower() + elif line.startswith("ID_MODEL_ID="): + pid = line.split("=", 1)[1].lower() + if f"{vid}:{pid}" == USB_VID_PID: + return path + except Exception: + continue + + # Fallback: first ttyACM device + ports = sorted(glob.glob("/dev/ttyACM*")) + return ports[0] if ports else None + + +def wait_for_device() -> str: + """Poll until the USB device appears.""" + print("Waiting for DongLoRa...", end="", flush=True) + while True: + port = find_serial_port() + if port: + print(f" found {port}") + time.sleep(0.3) # let the device settle + return port + print(".", end="", flush=True) + time.sleep(0.5) + + +def open_serial(port: str) -> serial.Serial: + return serial.Serial(port, timeout=2) + + +# ── COBS framing ─────────────────────────────────────────────────── + + +def cobs_frame(payload: bytes) -> bytes: + return cobs.encode(payload) + b"\x00" + + +def read_frame(ser: serial.Serial) -> bytes | None: + buf = b"" + while True: + b = ser.read(1) + if not b: + return None + if b == b"\x00": + break + buf += b + if not buf: + return None + try: + return cobs.decode(buf) + except cobs.DecodeError: + return None + + +# ── Postcard serialization ───────────────────────────────────────── + + +def varint(n: int) -> bytes: + out = [] + while n >= 0x80: + out.append((n & 0x7F) | 0x80) + n >>= 7 + out.append(n & 0x7F) + return bytes(out) + + +def zigzag(n: int) -> bytes: + return varint((n << 1) ^ (n >> 31) if n >= 0 else ((-n - 1) << 1) | 1) + + +def encode_radio_config(cfg: dict) -> bytes: + out = varint(cfg["freq_hz"]) + out += varint(cfg["bw"]) + out += varint(cfg["sf"]) + out += varint(cfg["cr"]) + out += varint(cfg["sync_word"]) + out += zigzag(cfg["tx_power_dbm"]) + return out + + +def encode_command(cmd: dict) -> bytes: + kind = cmd["type"] + if kind == "Ping": + return varint(0) + elif kind == "GetConfig": + return varint(1) + elif kind == "SetConfig": + return varint(2) + encode_radio_config(cmd["config"]) + elif kind == "StartRx": + return varint(3) + elif kind == "StopRx": + return varint(4) + elif kind == "Transmit": + out = varint(5) + config = cmd.get("config") + if config is None: + out += b"\x00" # Option::None + else: + out += b"\x01" + encode_radio_config(config) # Option::Some + payload = cmd["payload"] + out += varint(len(payload)) + payload + return out + elif kind == "DisplayOn": + return varint(6) + elif kind == "DisplayOff": + return varint(7) + else: + raise ValueError(f"Unknown command type: {kind}") + + +def decode_varint(data: bytes) -> tuple[int, bytes]: + n, shift = 0, 0 + for i, b in enumerate(data): + n |= (b & 0x7F) << shift + shift += 7 + if not (b & 0x80): + return n, data[i + 1 :] + return n, b"" + + +def decode_zigzag_varint(data: bytes) -> tuple[int, bytes]: + n, rest = decode_varint(data) + return (n >> 1) ^ -(n & 1), rest + + +def decode_response(data: bytes) -> dict: + if not data: + return {"type": "Empty"} + variant = data[0] + rest = data[1:] + if variant == 0: + return {"type": "Pong"} + elif variant == 1: + return {"type": "Config", "raw": rest.hex()} + elif variant == 2: + rssi, rest = decode_zigzag_varint(rest) + snr, rest = decode_zigzag_varint(rest) + plen, rest = decode_varint(rest) + payload = rest[:plen] + return {"type": "RxPacket", "rssi": rssi, "snr": snr, "payload": payload} + elif variant == 3: + return {"type": "TxDone"} + elif variant == 4: + return {"type": "Ok"} + elif variant == 5: + code = rest[0] if rest else -1 + return {"type": "Error", "code": code} + else: + return {"type": f"Unknown({variant})", "raw": rest.hex()} + + +# ── MeshCore packet decoder ────────────────────────────────────── + +ROUTE_NAMES = {0: "tflood", 1: "flood", 2: "direct", 3: "tdirect"} + +PAYLOAD_NAMES = { + 0x00: "REQ", + 0x01: "RESPONSE", + 0x02: "TXT_MSG", + 0x03: "ACK", + 0x04: "ADVERT", + 0x05: "GRP_TXT", + 0x06: "GRP_DATA", + 0x07: "ANON_REQ", + 0x08: "PATH", + 0x09: "TRACE", + 0x0A: "MULTIPART", + 0x0B: "CONTROL", + 0x0F: "RAW_CUSTOM", +} + +NODE_TYPES = {0x01: "chat", 0x02: "repeater", 0x03: "room", 0x04: "sensor"} + +MAX_PATH_SIZE = 64 + +# ── Node registry (persistent, pruned at 12h) ──────────────────── + +ADVERT_MAX_AGE = 12 * 3600 # seconds +_ADVERT_FILE = Path(tempfile.gettempdir()) / "donglora_adverts.json" + +# In-memory: pubkey_hex → {"name": str, "seen": float (unix timestamp)} +_known_nodes: dict[str, dict] = {} + + +def _load_adverts(): + """Load persisted adverts from disk, pruning stale entries.""" + global _known_nodes + try: + data = json.loads(_ADVERT_FILE.read_text()) + cutoff = time.time() - ADVERT_MAX_AGE + _known_nodes = {k: v for k, v in data.items() if v.get("seen", 0) > cutoff} + except (FileNotFoundError, json.JSONDecodeError, ValueError): + _known_nodes = {} + + +def _save_adverts(): + """Persist current adverts to disk (atomic write).""" + try: + tmp = _ADVERT_FILE.with_suffix(".tmp") + tmp.write_text(json.dumps(_known_nodes)) + tmp.rename(_ADVERT_FILE) + except OSError: + pass + + +def _lookup_hash(hash_hex: str, hash_size: int) -> list[str]: + """Find node names whose pubkey prefix matches a path hash.""" + return [ + v["name"] for pk_hex, v in _known_nodes.items() + if pk_hex[:hash_size * 2] == hash_hex + ] + + +def _register_node(pubkey: bytes, name: str): + """Register or update a node from an ADVERT.""" + pk_hex = pubkey.hex() + _known_nodes[pk_hex] = {"name": name, "seen": time.time()} + _save_adverts() + + +def _utf8_score(data: bytes) -> int: + """Score how cleanly data decodes as UTF-8. Higher = more replacement chars.""" + return data.decode("utf-8", errors="replace").count("\ufffd") + + +def _best_name(candidates: list[bytes]) -> bytes: + """Pick the candidate with the fewest UTF-8 replacement chars; longest wins ties.""" + return min(candidates, key=lambda d: (_utf8_score(d), -len(d))) + + +def _decode_advert_appdata(app_data: bytes) -> tuple[str, str]: + """Decode advert app_data. Returns (display_string, node_name).""" + if not app_data: + return "", "" + flags = app_data[0] + node_type = flags & 0x0F + node_label = NODE_TYPES.get(node_type, f"type={node_type}") + parts = [f"{DIM}flags={RST}{flags:02x} {DIM}node={RST}{node_label}"] + + # Parse location if flagged and coords are valid + off = 1 + loc_str = "" + if flags & 0x10 and off + 8 <= len(app_data): + lat_raw = struct.unpack_from(" str | None: + """Check for duplicate hops in a path. Returns a warning line or None.""" + if len(hops) < 2: + return None + counts: dict[str, int] = {} + for h in hops: + counts[h] = counts.get(h, 0) + 1 + dupes = {h: c for h, c in counts.items() if c > 1} + if not dupes: + return None + + # Feed the aggregator for deferred reporting + _aggregator.record(dupes, hash_size, hops) + + parts = [] + for h, c in dupes.items(): + n_known = _nodes_for_hash(h, hash_size) + if n_known == 1: + # Exactly one node matches → confirmed loop + parts.append(f"{BRED}{h} x{c} LOOP{RST}") + elif hash_size >= 2: + if n_known > 1: + # 2B+ collision is rare and real + parts.append(f"{BCYN}{h} x{c} COLLISION {DIM}({n_known} nodes){RST}") + else: + # 2B+ duplicate, unknown node → almost certainly a loop + parts.append(f"{BRED}{h} x{c} LOOP{RST}") + else: + # 1B hash — ambiguous: could be loop or collision + if n_known > 1: + parts.append(f"{YEL}{h} x{c} {DIM}(loop or collision? {n_known} nodes share this 1B hash){RST}") + else: + parts.append(f"{YEL}{h} x{c} {DIM}(loop or collision? 1B hash){RST}") + + return f"{LOOP_INDENT}\u26a0 {', '.join(parts)}" + + +def _try_decode_body(data: bytes, pos: int, type_name: str, type_color: str, + payload_type: int, payload_ver: int, route_name: str, + tc_str: str) -> str | None: + """Try to parse path + payload starting at pos. Returns decoded string or None.""" + if pos >= len(data): + return None + path_len_byte = data[pos] + pos += 1 + hash_size = (path_len_byte >> 6) + 1 + hop_count = path_len_byte & 0x3F + + path_bytes = hop_count * hash_size + if path_bytes > MAX_PATH_SIZE or pos + path_bytes > len(data): + return None + + hops_str = "" + loop_warn = "" + hops: list[str] = [] + if hop_count > 0: + for i in range(hop_count): + h = data[pos + i * hash_size : pos + (i + 1) * hash_size] + hops.append(h.hex()) + hops_str = f" {DIM}path=[{RST}{'>'.join(hops)}{DIM}]{RST}" + warn = _detect_loops(hops, hash_size) + if warn: + loop_warn = f"\n{warn}" + pos += path_bytes + + payload = data[pos:] + prefix = f"{type_color}{type_name}{RST} {route_name}{tc_str}{hops_str}" + if payload_ver != 0: + prefix += f" {DIM}v{payload_ver}{RST}" + + if payload_type == 0x04: + result = _decode_advert(prefix, payload) + elif payload_type == 0x03: + result = _decode_ack(prefix, payload) + elif payload_type in (0x00, 0x01, 0x02, 0x08): + result = _decode_encrypted_peer(prefix, type_name, payload) + elif payload_type in (0x05, 0x06): + result = _decode_encrypted_group(prefix, payload) + elif payload_type == 0x07: + result = _decode_anon_req(prefix, payload) + elif payload_type == 0x09: + result = _decode_trace(prefix, payload) + elif payload_type == 0x0A: + result = _decode_multipart(prefix, payload) + elif payload_type == 0x0B: + result = _decode_control(prefix, payload) + else: + result = f"{prefix} {DIM}{payload.hex()}{RST}" if payload else prefix + + return result + loop_warn + + +def decode_meshcore_packet(data: bytes) -> str: + """Decode a MeshCore packet into a human-readable string.""" + if len(data) < 2: + return f"{RED}{RST} {DIM}{data.hex()}{RST}" + + header = data[0] + if header == 0xFF: + return f"{DIM} {data[1:].hex()}{RST}" + + route_type = header & 0x03 + payload_type = (header >> 2) & 0x0F + payload_ver = (header >> 6) & 0x03 + route_name = ROUTE_NAMES.get(route_type, f"rt{route_type}") + type_name = PAYLOAD_NAMES.get(payload_type, f"0x{payload_type:02x}") + type_color = TYPE_COLORS.get(type_name, WHT) + + has_tc = route_type in (0, 3) + + # Try with transport codes first if indicated, fallback without + if has_tc and len(data) >= 6: + tc1, tc2 = struct.unpack_from("{RST} {type_color}{type_name}{RST} " + f"{route_name}{ver_str} {DIM}{data[1:].hex()}{RST}") + + +def _decode_advert(prefix: str, payload: bytes) -> str: + if len(payload) < 100: + return f"{prefix} {RED}=100>{RST} {DIM}{payload.hex()}{RST}" + pubkey = payload[0:32] + timestamp = struct.unpack_from(" str: + if len(payload) < 4: + return f"{prefix} {RED}{RST} {DIM}{payload.hex()}{RST}" + crc = struct.unpack_from(" str: + if len(payload) < 4: + return f"{prefix} {RED}{RST} {DIM}{payload.hex()}{RST}" + dst = payload[0] + src = payload[1] + mac = payload[2:4] + ct_len = len(payload) - 4 + return f"{prefix} {DIM}dst={RST}{dst:02x} {DIM}src={RST}{src:02x} {DIM}mac={RST}{mac.hex()} {DIM}[{ct_len}B]{RST}" + + +def _decode_encrypted_group(prefix: str, payload: bytes) -> str: + if len(payload) < 3: + return f"{prefix} {RED}{RST} {DIM}{payload.hex()}{RST}" + ch = payload[0] + mac_bytes = payload[1:3] + ciphertext = payload[3:] + + # Try to decrypt with known channels matching this hash + candidates = _CHANNEL_BY_HASH.get(ch, []) + for chan_name, secret in candidates: + plaintext = _grp_verify_and_decrypt(secret, mac_bytes, ciphertext) + if plaintext is not None: + parsed = _parse_grp_plaintext(plaintext) + if parsed: + ts, text = parsed + try: + ts_str = datetime.fromtimestamp(ts, tz=timezone.utc).strftime("%H:%M:%S") + except (OSError, OverflowError, ValueError): + ts_str = f"epoch={ts}" + return (f"{prefix} {BGRN}{chan_name}{RST} " + f"{DIM}ts={RST}{ts_str}Z {BWHT}{text}{RST}") + # MAC matched but plaintext unparseable + return (f"{prefix} {BGRN}{chan_name}{RST} " + f"{DIM}[decrypted but unparseable]{RST}") + + # No known channel matched — show raw info + return (f"{prefix} {DIM}ch={RST}{ch:02x} {DIM}mac={RST}{mac_bytes.hex()} " + f"{DIM}[{len(ciphertext)}B]{RST}") + + +def _decode_anon_req(prefix: str, payload: bytes) -> str: + if len(payload) < 35: + return f"{prefix} {RED}{RST} {DIM}{payload.hex()}{RST}" + dst = payload[0] + ephem_pub = payload[1:33] + mac = payload[33:35] + ct_len = len(payload) - 35 + return f"{prefix} {DIM}dst={RST}{dst:02x} {DIM}ephem={RST}{ephem_pub[:4].hex()}.. {DIM}mac={RST}{mac.hex()} {DIM}[{ct_len}B]{RST}" + + +def _decode_trace(prefix: str, payload: bytes) -> str: + if len(payload) < 9: + return f"{prefix} {RED}{RST} {DIM}{payload.hex()}{RST}" + tag = struct.unpack_from("'.join(hops)}{DIM}]{RST}" if hops else "" + return f"{prefix} {DIM}tag={RST}0x{tag:08x}{hops_str}" + + +def _decode_multipart(prefix: str, payload: bytes) -> str: + if len(payload) < 1: + return f"{prefix} {RED}{RST}" + remaining = (payload[0] >> 4) & 0x0F + inner_type = payload[0] & 0x0F + inner_name = PAYLOAD_NAMES.get(inner_type, f"0x{inner_type:02x}") + return f"{prefix} {DIM}remaining={RST}{remaining} {DIM}inner={RST}{inner_name} {DIM}[{len(payload) - 1}B]{RST}" + + +def _decode_control(prefix: str, payload: bytes) -> str: + if len(payload) < 1: + return f"{prefix} {RED}{RST}" + sub_type = (payload[0] >> 4) & 0x0F + sub_names = {0x8: "DISCOVER_REQ", 0x9: "DISCOVER_RESP"} + sub_name = sub_names.get(sub_type, f"sub=0x{sub_type:x}") + if sub_type == 0x8 and len(payload) >= 6: + type_filter = payload[1] + tag = struct.unpack_from("= 6: + node_type = payload[0] & 0x0F + snr_raw = struct.unpack_from(" dict | None: + payload = encode_command(cmd) + frame = cobs_frame(payload) + print(f"{DIM}>>>{RST} {label}") + ser.write(frame) + ser.flush() + resp_data = read_frame(ser) + if resp_data is None: + print(f" {YEL}timeout{RST}") + return None + resp = decode_response(resp_data) + print(f"{DIM}<<<{RST} {resp}") + return resp + + +RADIO_CONFIG = { + "freq_hz": 910_525_000, + "bw": 6, # Khz62 = variant index 6 + "sf": 7, + "cr": 0, # Cr4_5 = variant index 0 + "sync_word": 0x3444, + "tx_power_dbm": 14, +} + + +def _format_rssi_snr(rssi: int, snr: int) -> str: + rc = _rssi_color(rssi) + sc = _snr_color(snr) + line = f"{rc}RSSI:{rssi:4d}dBm{RST} {sc}SNR:{snr:3d}dB{RST}" + # Flag bogus dongle responses (SNR outside SX126x range -32..+32) + if not (-32 <= snr <= 32): + line += f" {BRED}[bad SNR]{RST}" + return line + + +def configure_and_listen(ser: serial.Serial): + send_cmd(ser, {"type": "SetConfig", "config": RADIO_CONFIG}, "SetConfig 910.525/62.5k/SF7/CR4_5") + send_cmd(ser, {"type": "StartRx"}, "StartRx") + + print(f"\n{BWHT}Listening for packets{RST} {DIM}(Ctrl+C to stop){RST}\n") + ser.timeout = 1 + + while True: + try: + _aggregator.maybe_report(ser) + except Exception as e: + print(f" {RED}[report error: {e}]{RST}") + + data = read_frame(ser) + if data is None: + continue + try: + resp = decode_response(data) + if resp["type"] == "RxPacket": + payload = resp["payload"] + decoded = decode_meshcore_packet(payload) + rssi_snr = _format_rssi_snr(resp["rssi"], resp["snr"]) + print( + f" {rssi_snr} " + f"{DIM}len:{len(payload):3d}{RST} " + f"{decoded}" + ) + else: + print(f" {DIM}{resp}{RST}") + except Exception as e: + print(f" {RED}[decode error: {e}] raw={data.hex()}{RST}") + + +def main(): + _load_adverts() + n = len(_known_nodes) + if n: + print(f"{DIM}Loaded {n} known node(s) from {_ADVERT_FILE}{RST}") + + port = sys.argv[1] if len(sys.argv) > 1 else None + + while True: + if port is None: + port = wait_for_device() + + try: + print(f"Opening {port}") + ser = open_serial(port) + ser.reset_input_buffer() + configure_and_listen(ser) + except (serial.SerialException, ConnectionError, OSError) as e: + print(f"\n{RED}Disconnected: {e}{RST}") + print("Will reconnect when device reappears...") + port = None + time.sleep(1) + except KeyboardInterrupt: + print(f"\n{DIM}Stopping...{RST}") + try: + ser.timeout = 2 + send_cmd(ser, {"type": "StopRx"}, "StopRx") + except Exception: + pass + break + + +if __name__ == "__main__": + main() diff --git a/tools/test_ping.py b/tools/test_ping.py deleted file mode 100644 index e611d203..00000000 --- a/tools/test_ping.py +++ /dev/null @@ -1,254 +0,0 @@ -#!/usr/bin/env python3 -"""DongLoRa test tool: ping, configure radio, listen for packets.""" -# /// script -# requires-python = ">=3.10" -# dependencies = ["cobs", "pyserial"] -# /// - -import glob -import struct -import serial -import sys -import time -from cobs import cobs - -# Open-source VID 1209, PID "WA" (0x5741) -USB_VID_PID = "1209:5741" - - -def find_serial_port() -> str | None: - """Find the serial port for our USB device.""" - import subprocess - - # Try to find by USB VID:PID - for path in sorted(glob.glob("/dev/ttyACM*")) + sorted(glob.glob("/dev/ttyUSB*")): - try: - result = subprocess.run( - ["udevadm", "info", "--query=property", f"--name={path}"], - capture_output=True, - text=True, - timeout=2, - ) - vid = "" - pid = "" - for line in result.stdout.splitlines(): - if line.startswith("ID_VENDOR_ID="): - vid = line.split("=", 1)[1].lower() - elif line.startswith("ID_MODEL_ID="): - pid = line.split("=", 1)[1].lower() - if f"{vid}:{pid}" == USB_VID_PID: - return path - except Exception: - continue - - # Fallback: first ttyACM device - ports = sorted(glob.glob("/dev/ttyACM*")) - return ports[0] if ports else None - - -def wait_for_device() -> str: - """Poll until the USB device appears.""" - print("Waiting for DongLoRa...", end="", flush=True) - while True: - port = find_serial_port() - if port: - print(f" found {port}") - time.sleep(0.3) # let the device settle - return port - print(".", end="", flush=True) - time.sleep(0.5) - - -def open_serial(port: str) -> serial.Serial: - return serial.Serial(port, timeout=2) - - -# ── COBS framing ─────────────────────────────────────────────────── - - -def cobs_frame(payload: bytes) -> bytes: - return cobs.encode(payload) + b"\x00" - - -def read_frame(ser: serial.Serial) -> bytes | None: - buf = b"" - while True: - b = ser.read(1) - if not b: - return None - if b == b"\x00": - break - buf += b - return cobs.decode(buf) if buf else None - - -# ── Postcard serialization ───────────────────────────────────────── - - -def varint(n: int) -> bytes: - out = [] - while n >= 0x80: - out.append((n & 0x7F) | 0x80) - n >>= 7 - out.append(n & 0x7F) - return bytes(out) - - -def zigzag(n: int) -> bytes: - return varint((n << 1) ^ (n >> 31) if n >= 0 else ((-n - 1) << 1) | 1) - - -def encode_radio_config(cfg: dict) -> bytes: - out = varint(cfg["freq_hz"]) - out += varint(cfg["bw"]) - out += varint(cfg["sf"]) - out += varint(cfg["cr"]) - out += varint(cfg["sync_word"]) - out += zigzag(cfg["tx_power_dbm"]) - return out - - -def encode_command(cmd: dict) -> bytes: - kind = cmd["type"] - if kind == "Ping": - return varint(0) - elif kind == "GetConfig": - return varint(1) - elif kind == "SetConfig": - return varint(2) + encode_radio_config(cmd["config"]) - elif kind == "StartRx": - return varint(3) - elif kind == "StopRx": - return varint(4) - elif kind == "DisplayOn": - return varint(7) - elif kind == "DisplayOff": - return varint(8) - else: - raise ValueError(f"Unknown command type: {kind}") - - -def decode_varint(data: bytes) -> tuple[int, bytes]: - n, shift = 0, 0 - for i, b in enumerate(data): - n |= (b & 0x7F) << shift - shift += 7 - if not (b & 0x80): - return n, data[i + 1 :] - return n, b"" - - -def decode_zigzag_varint(data: bytes) -> tuple[int, bytes]: - n, rest = decode_varint(data) - return (n >> 1) ^ -(n & 1), rest - - -def decode_response(data: bytes) -> dict: - variant = data[0] - rest = data[1:] - if variant == 0: - return {"type": "Pong"} - elif variant == 1: - return {"type": "Config", "raw": rest.hex()} - elif variant == 2: - rssi, rest = decode_zigzag_varint(rest) - snr, rest = decode_zigzag_varint(rest) - plen, rest = decode_varint(rest) - payload = rest[:plen] - return {"type": "RxPacket", "rssi": rssi, "snr": snr, "payload": payload} - elif variant == 3: - return {"type": "TxDone"} - elif variant == 4: - return {"type": "Ok"} - elif variant == 5: - code = rest[0] if rest else -1 - return {"type": "Error", "code": code} - else: - return {"type": f"Unknown({variant})", "raw": rest.hex()} - - -# ── Main ─────────────────────────────────────────────────────────── - - -def send_cmd(ser: serial.Serial, cmd: dict, label: str) -> dict | None: - payload = encode_command(cmd) - frame = cobs_frame(payload) - print(f">>> {label}") - ser.write(frame) - ser.flush() - resp_data = read_frame(ser) - if resp_data is None: - print(" timeout") - return None - resp = decode_response(resp_data) - print(f"<<< {resp}") - return resp - - -RADIO_CONFIG = { - "freq_hz": 910_525_000, - "bw": 6, # Khz62 = variant index 6 - "sf": 7, - "cr": 0, # Cr4_5 = variant index 0 - "sync_word": 0x3444, - "tx_power_dbm": 14, -} - - -def configure_and_listen(ser: serial.Serial): - send_cmd(ser, {"type": "Ping"}, "Ping") - send_cmd(ser, {"type": "SetConfig", "config": RADIO_CONFIG}, "SetConfig 910.525/62.5k/SF7/CR4_5") - send_cmd(ser, {"type": "StartRx"}, "StartRx") - - print("\nListening for packets (Ctrl+C to stop)...\n") - ser.timeout = None - while True: - data = read_frame(ser) - if data is None: - raise ConnectionError("device disconnected") - resp = decode_response(data) - if resp["type"] == "RxPacket": - payload = resp["payload"] - try: - text = payload.decode("utf-8", errors="replace") - except Exception: - text = payload.hex() - print( - f" RSSI:{resp['rssi']:4d}dBm " - f"SNR:{resp['snr']:3d}dB " - f"len:{len(payload):3d} " - f"{text}" - ) - else: - print(f" {resp}") - - -def main(): - port = sys.argv[1] if len(sys.argv) > 1 else None - - while True: - if port is None: - port = wait_for_device() - - try: - print(f"Opening {port}") - ser = open_serial(port) - ser.reset_input_buffer() - configure_and_listen(ser) - except (serial.SerialException, ConnectionError, OSError) as e: - print(f"\nDisconnected: {e}") - print("Will reconnect when device reappears...") - port = None - time.sleep(1) - except KeyboardInterrupt: - print("\nStopping...") - try: - ser.timeout = 2 - send_cmd(ser, {"type": "StopRx"}, "StopRx") - except Exception: - pass - break - - -if __name__ == "__main__": - main()