refactor: relocate MeshCore tools to examples/meshcore/

MeshCore is a protocol implementation ON TOP of DongLoRa, not part of the
dongle itself. Moving it to examples/ makes the separation clear while
keeping it accessible as a real-world usage example.
This commit is contained in:
swaits committed 2026-03-29 20:42:58 -06:00
1 parent 8790ebfd7b
commit 8a5d8c419e
36 files changed
+29 -3

No files matched your search

+26
View File
@@ -0,0 +1,26 @@
# MeshCore Example
This is a **protocol implementation** built on top of DongLoRa. The DongLoRa
firmware itself is protocol-agnostic — it just gives you a LoRa radio pipe.
MeshCore is one of many possible protocols you can run over it.
## What's here
- **meshcore_rx.py** — MeshCore packet decoder, Watchman mesh health reporter
- **channels.csv** — 276 known MeshCore channels (Public + hashtag channels)
- **corpus/** — Test vectors for MeshCore packet decoders
## Usage
```sh
uv run examples/meshcore/meshcore_rx.py [PORT]
```
Connects to a DongLoRa device, configures the radio for the MeshCore
frequency/modulation, and decodes received packets in real time.
## Running the test corpus
```sh
uv run examples/meshcore/corpus/validate.py
```
+277
View File
@@ -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
1 channel_name hashtag key_hex channel_hash description region
2 Public False 8b3387e9c5cdea6ac9e5edbaa115cd72 17 Default public channel (fixed PSK, not hashtag-derived) global
3 #test True 9cd8fcf22a47333b591d96a2b848b73f 156 Global testing channel global
4 #testing True cde5e82cf515647dcb547a79a4f065d1 205 Testing (alternate name) global
5 #ping True 3cae16fd067ba9c32a98be22e9b98525 60 Ping-bot testing channel global
6 #emergency True e1ad578d25108e344808f30dfdaaf926 225 Emergency communications global
7 #hamradio True 83c8b01997654265938da8765cbc7db9 131 HAM radio operators global
8 #qrv True 4d6de18b9bb6ef3f7c7197e8441044ed 77 HAM QSO coordination/logging global
9 #hard-software True c4419524d9ce72ac093fdd2a0a856595 196 Hardware/software discussion & troubleshooting global
10 #blackout True 75420460f335577fa27cdd20dacf9edd 117 Power outage info/notifications global
11 #warnings True 31d44d29ed676f8020dc09211a2ee9d8 49 General warnings channel global
12 #aurora-alert True e823127951fc6a425abe0c65ac07b323 232 Aurora/northern lights alerts global
13 #flachwitze True ec98cd8821fada7fe21ff9e1df6c619f 236 Jokes channel (German flat jokes) global
14 #general True 4c49f3f24629f5ee4ad5b3965db47985 76 General chat (common default) global
15 #switzerland True 8ad1ce57ad257627090ed28413c1f0b7 138 Swiss national channel CH
16 #svizzera True e571e6921d9c19d1c393f2ba9286abba 229 Italian-speaking Switzerland CH
17 #suisse True 2b8dd2d22f0820d9945316be97359ab1 43 French-speaking Switzerland CH
18 #chtest True ee95ca5221f5b41941b26d89fd702c37 238 Swiss testing channel CH
19 #bern True 7658cadca2e40ba98cb13ce19782e994 118 Bern region CH
20 #zuerich True 15d09e6787401c4fdbef4bf10ffcef53 21 Zürich canton/city CH
21 #wallis True 1e65af5328cec2c29d03c77a5dfb8cb8 30 Wallis/Valais canton CH
22 #freiburg True ec1c88f03aad858a6d74ec36ac04c420 236 Freiburg (DE, near CH border) CH/DE
23 #netzstatus True e84d5ce15046d5a7f69db3a40f604851 232 Network status, mainly Germany DE
24 #muenchen True 3f50b3350a8ed17a303f07e9641dff4a 63 Munich area DE
25 #hansemesh True 21ddee26dc5215153c3903165fd4262f 33 HanseMesh community (Hamburg) DE
26 #de-sn-dd True c252cf274c306e76d9531fdf96f8dfa0 194 Dresden/Saxony test channel DE
27 #berlin True 8bffd7b0bf481d92afc625e409b88a16 139 Berlin (mentioned in MeshCore tutorial) DE
28 #hamburg True 6ee6b4b98d5856392596cc1929eb2e36 110 Hamburg area DE
29 #koeln True cc2de011842824b10ee2e28e97f91339 204 Cologne area DE
30 #frankfurt True 0d8e969330d62f9356b5e4d415ea7f98 13 Frankfurt area DE
31 #stuttgart True 74710f9e61866f184734bc367d9f492a 116 Stuttgart area DE
32 #deutschland True 6870d6013221f0278122cb8c88990794 104 Germany national DE
33 #ruhrgebiet True 6b5f2c549d9de0116749aa087e3caa4e 107 Ruhr area DE
34 #nuernberg True a6e4f905a64512843b12ec20750f4161 166 Nuremberg area DE
35 #dresden True a6f1bb7dbc598884b7a15be59e61ecd6 166 Dresden area DE
36 #leipzig True 0cc55d55809a7b048c39dd01c3254381 12 Leipzig area DE
37 #hannover True b9430bfc2908b23efcae886f16eb425b 185 Hannover area DE
38 #duesseldorf True 1dfb747e50229101f2232e391d1eb5a7 29 Düsseldorf area DE
39 #dortmund True 37951d1ca4e428fd24c57f3087f160f0 55 Dortmund area DE
40 #essen True 80174f513e9099612b537bc1cd450a41 128 Essen area DE
41 #austria True faaa5ef01081222e319a8205357321f4 250 Austria national channel AT
42 #wien True aba9d5e2fb16d2b02111db5926819065 171 Vienna AT
43 #graz True ea00635b84c245d519d29dc4044a43a1 234 Graz AT
44 #salzburg True 132d8883191473fc8a82f3447909a37d 19 Salzburg AT
45 #innsbruck True dee607ccd01ca7ef375c0567c554db97 222 Innsbruck AT
46 #linz True 9b417e09eb452be6d068c90b3c46303d 155 Linz AT
47 #italy True f8d4dd36f6b9476eb4cec18a1536b17d 248 Italy (English name variant) IT
48 #italia True 17fa9f15bec81ed7da12116253db5a20 23 Italy (Italian name variant) IT
49 #roma True 3523b8022329e6648738f0e5448ffcc1 53 Rome IT
50 #milano True ea8929998e608b67fe5c2bcf29473d8f 234 Milan IT
51 #nederland True c14f7b1e0416e4fa74f5f7b81a707b50 193 Netherlands national NL
52 #amsterdam True d768f5a0aa65f8c54e4ea521bd49eb4f 215 Amsterdam NL
53 #rotterdam True e3f3ad251e512a029bd27161b14f0bdd 227 Rotterdam NL
54 #utrecht True 042e458dfb05b17cbdee57f93df4be4c 4 Utrecht NL
55 #denhaag True 84eed36f62c7b22527dbf8883585ad14 132 The Hague NL
56 #eindhoven True aceb8cceae584a3890c8db173668ae2b 172 Eindhoven NL
57 #groningen True 6a18329035bd3d2be69fed9094ffa5f4 106 Groningen NL
58 #localmesh True 13934f45750ce5f54311771bc52dc2a9 19 LocalMesh NL community NL
59 #belgium True 7085b78ed010599094f8c8e7d1aa0e27 112 Belgium national BE
60 #belgie True 559a87a20b1170a11656ffbe15d27c07 85 Belgium (Dutch) BE
61 #brussel True 58648303d8e2b4e35f4301c4551d6307 88 Brussels BE
62 #antwerpen True 5900168627ddb2963d3fd8dba3fecbb9 89 Antwerp BE
63 #gent True 82f82c2835bd5fbda1ab0eb134364532 130 Ghent BE
64 #france True edcb362b38e74b99025a7e551d925d20 237 France national FR
65 #paris True d0fc2b1ec400eb8669010ce0311a00fb 208 Paris FR
66 #lyon True 0b4b602516088ea6d245be43102643b0 11 Lyon FR
67 #marseille True c91ed99a23e560300b60325c0b9fb3ea 201 Marseille FR
68 #uk True 22b2eed34b5cc429ce1dc5e88635ff84 34 United Kingdom UK
69 #london True 9881d2b7ab9105a41a8d0f6ba449447e 152 London (mentioned in MeshCore tutorial) UK
70 #manchester True 5ddc4f781fa3f504e9f514e77df13790 93 Manchester UK
71 #birmingham True 13b1c76ef007b4ad0c3b168610337513 19 Birmingham UK
72 #scotland True f4fce403cfd56f7089920d065718f29c 244 Scotland UK
73 #ireland True 1b2a12acc5db1517d9d407946756b1da 27 Ireland IE
74 #dublin True 8792638977132bc05a1f72d6bb913694 135 Dublin IE
75 #seattle True ef627a9bbbb549347fdb76bf0cd3bc14 239 Seattle (Puget Mesh) US-WA
76 #bot True eb50a1bcb3e4e5d7bf69a57c9dada211 235 Bot channel (Puget Mesh) US-WA
77 #bot-tacoma True 278df637e12a0dda01c826e1fd9779fd 39 Bot channel Tacoma US-WA
78 #bot-van True bf07fbf914e37710561cb741e914efeb 191 Bot channel Vancouver US-WA/CA
79 #capitolhill True 7f281916c8ec32e13c5ef687d182160a 127 Capitol Hill Seattle US-WA
80 #protest True 195e9167baf010c4538d8532b6d7296e 25 Protest coordination US-WA
81 #queer True 5754476f162d93bbee3de0efba136860 87 LGBTQ+ community US-WA
82 #seahawks True 4973d6ceb6c35a09894a83688847c8a3 73 Seattle Seahawks US-WA
83 #sports True e8ee81f3aabf105d9ba2d2d4bd94fe4a 232 Sports discussion US-WA
84 #salishmesh True c1ec55555132c7598393c869715b7555 193 Salish Mesh (Vancouver BC area) CA-BC
85 #dwebyvr True b8f956aab74dae02f70f11ddba36de53 184 DWeb Vancouver CA-BC
86 #zspace True fc86b8cceea87091d45f166c7a698f78 252 ZSpace Vancouver CA-BC
87 #portland True 45c6bc719c15b9fb809f48f594359877 69 Portland OR US-OR
88 #pnw True 98059014c708581fbf0a398cfe8a486d 152 Pacific Northwest US-PNW
89 #tacoma True 9ba83a22a865c9baaffc6bc8b6a33d2f 155 Tacoma WA US-WA
90 #idahomesh True 60baad727ec5235cee359135aeecb3f4 96 Idaho statewide mesh US-ID
91 #nodakmesh True 156134e4a3f6d9826a39e0b7f85afdcd 21 North Dakota mesh US-ND
92 #florida True c44bd74eac2c81dcb6dfb217727b05cd 196 Florida statewide US-FL
93 #miami True d81c566a5d337d588ebd250df6fc1b63 216 Miami US-FL
94 #tampa True 8d10508c39d5d8e6c5e3e8fab41d1c09 141 Tampa US-FL
95 #orlando True 3553bdbd9b3a54da760624a27dcda156 53 Orlando US-FL
96 #jacksonville True 6cf63df8459d99d4a4a6177acf6661f6 108 Jacksonville US-FL
97 #texas True c4a214e133de5e9ad276f99fdbd7216f 196 Texas statewide US-TX
98 #austin True b2e6f9af95d959734d71cdf90ca62533 178 Austin TX US-TX
99 #dallas True 5b2efa4e2ad0a2b83c5486ca4dd244de 91 Dallas TX US-TX
100 #houston True c001fbacad2d97676395ca37e2576345 192 Houston TX US-TX
101 #sanantonio True c2e72548c615d1cf2796a6f77b9545b0 194 San Antonio TX US-TX
102 #dfw True 5b7dc809ba579affccab5462c537244e 91 Dallas-Fort Worth US-TX
103 #california True 55da17570958a6a311939c99582e6f60 85 California statewide US-CA
104 #losangeles True 3dd9373dcea0294bd05ab067cc58e9d4 61 Los Angeles US-CA
105 #sanfrancisco True ae3608083279fd2ad03f0d6a99617d25 174 San Francisco US-CA
106 #sandiego True 08623bbd90a96ecdc1f5c34e7292b35f 8 San Diego US-CA
107 #bayarea True 7f9a5fd3070ad14e337ba100ca53a89b 127 Bay Area US-CA
108 #sacramento True a6d6927f0b48762cf1346e2ae95cec14 166 Sacramento US-CA
109 #socal True f4018307615ac79d2e5ef17bb44654d4 244 Southern California US-CA
110 #norcal True f2438510715f5d9d55eb4370664330f5 242 Northern California US-CA
111 #sanjose True ce964c28170b1c043d06073e6fcd83a7 206 San Jose US-CA
112 #sfbay True 5bd50243f42644439c21dfe367eea031 91 SF Bay US-CA
113 #baymesh True 1be0e569ad371f72d1e36da7242ae2f0 27 Bay Mesh community US-CA
114 #boston True 9587d847a7208da684c89cc1f525bc03 149 Boston MA US-MA
115 #newyork True 82a78024dd7edf6c9be298c919632e25 130 New York US-NY
116 #nyc True 6e6554655f84ca26fbc09d81d15d6b96 110 New York City US-NY
117 #chicago True c1c289b131e5222370cbc2048445844b 193 Chicago IL US-IL
118 #denver True b24355a0d22ed2bf393ec530d75810b4 178 Denver CO US-CO
119 #phoenix True 027850d9410fa98809819c96644ec04c 2 Phoenix AZ US-AZ
120 #atlanta True 3c8f15665f99a349a97427e7c312ee0e 60 Atlanta GA US-GA
121 #nashville True 717490d2561c2e1f211b13e95073fb16 113 Nashville TN US-TN
122 #charlotte True b67efd607080d98640774cb31181b0ce 182 Charlotte NC US-NC
123 #raleigh True 1a2999ed1fea10e2cdf456ff118193d9 26 Raleigh NC US-NC
124 #minneapolis True 3283cca82b7b0ac50e8014c344cb8a86 50 Minneapolis MN US-MN
125 #stlouis True f366422991a19e745f65096e59b43d51 243 St. Louis MO US-MO
126 #pittsburgh True 926cb5461a1ef572619da576ef7de04b 146 Pittsburgh PA US-PA
127 #detroit True bd01f26bf7d8c90952753157a41c61ac 189 Detroit MI US-MI
128 #washingtondc True 0067d451cc79f26bc9924b3fc53f28d7 0 Washington DC US-DC
129 #dc True 0f3aa71fed514f5c16ebaf265ef05b2e 15 DC (short form) US-DC
130 #colorado True ef61c9e5a3286053746f7603044bcb08 239 Colorado statewide US-CO
131 #ohio True e89e8c2f72ebae476c4ded78a360d0ea 232 Ohio statewide US-OH
132 #virginia True 07655778c8adeb16c1a0470cc3516b01 7 Virginia statewide US-VA
133 #georgia True 6621a99c4c21a1f4897a9833a95ac962 102 Georgia statewide US-GA
134 #michigan True 4b0dc02774aa2ad8fe120d4ab0d17f1c 75 Michigan statewide US-MI
135 #newengland True 4ad1e19832319b916ab4f3a0fbb0a38e 74 New England region US-NE
136 #appalachia True ab353b2bf5d281d97cd73d897ef8e149 171 Appalachian region US
137 #hawaii True 3fd57495820328594e1641d14583faa6 63 Hawaii US-HI
138 #alaska True 2a5841192b151422baec71537b0b5238 42 Alaska US-AK
139 #vegas True d45435b6467e7ded33c28f6796bf5183 212 Las Vegas US-NV
140 #lasvegas True b82b823d6dcc845ead47cee8cfa758b3 184 Las Vegas (long form) US-NV
141 #saltlakecity True 07e180549b35782901d407832b3ec6a6 7 Salt Lake City US-UT
142 #slc True fd31a70b220eb3e2430e59c15432ef8c 253 SLC (short form) US-UT
143 #utah True ee42ff5f055f2e83527e4efb91b10d0a 238 Utah statewide US-UT
144 #canada True 8373bb1055f34164c6dd2663927cb6d9 131 Canada national CA
145 #toronto True e0d3774dd1da4dc5c55d8bd731555334 224 Toronto CA-ON
146 #vancouver True 16d6034be448ab86d11858cfa4c57c9c 22 Vancouver CA-BC
147 #montreal True 0c4c03b5fbea5b80f89e2a2a16ed3f40 12 Montreal CA-QC
148 #calgary True bbbb1ad23fe1cdd7739667418204a57d 187 Calgary CA-AB
149 #ottawa True 7871ec72b45617696c35c970bddd8124 120 Ottawa CA-ON
150 #edmonton True ce451a3a24ee89ee31564ae29e9dee04 206 Edmonton CA-AB
151 #winnipeg True 3c3294aac8a83b1143ab6bedee5b5226 60 Winnipeg CA-MB
152 #australia True eadb84fa1da64c44b77c40fd11b9d78e 234 Australia national AU
153 #sydney True 57fe5284a5b905835193868d9bdbe1e9 87 Sydney AU-NSW
154 #melbourne True 4d73731d9450ccb9673eb923c0f40af2 77 Melbourne AU-VIC
155 #brisbane True e4bd09784621decac600d0d4d857e3b2 228 Brisbane (MBUG community) AU-QLD
156 #perth True 9873c6cf14dac409b5a573574cff5ce1 152 Perth AU-WA
157 #adelaide True 9ad0c536f3eabc7c2f0434d68e242009 154 Adelaide AU-SA
158 #canberra True c716951221e6c17faf5f22b6245ce64b 199 Canberra AU-ACT
159 #newzealand True 1870a961e4aa06f62b02b835efcacb71 24 New Zealand national NZ
160 #nz True eb87ee8817ba71315ac7be9c733b523a 235 NZ (short form) NZ
161 #auckland True 7c6f1c71a5a3d6823a1d7bfd2a349ac8 124 Auckland NZ
162 #wellington True c2da57d74f78996ad71bbe3e22446f16 194 Wellington NZ
163 #christchurch True 7b88b875fc5ae76305c8a15349549a3b 123 Christchurch NZ
164 #gisborne True d8b45a1eb52d0bd45655d5f2a72d571f 216 Gisborne (mentioned in MeshCore tutorial) NZ
165 #weather True 88f502554fee92a1625cfb311546e7cb 136 Weather updates topical
166 #jokes True abe610d61dee508c0ac18793c36c41c3 171 Jokes channel topical
167 #quotes True 086be6f920280b7cc51d6ad91d6a786c 8 Quotes channel topical
168 #secret True be657c0527e122bf93bc735999cd7e0d 190 Secret channel (Brisbane MBUG) topical
169 #chat True d0bdd6d71538138ed979eec00d98ad97 208 General chat topical
170 #news True ecadb1a7d803db8958bea1302ca6e8be 236 News channel topical
171 #help True dcc67fae2067046832af7b2b0b743165 220 Help channel topical
172 #sos True 9ed2c78bcd68ac7ce2a2fc3bb4045114 158 SOS / distress topical
173 #prepper True 1eec1a7df7080a392cb490473c4a9920 30 Prepper community topical
174 #offgrid True aaa26662bebf122262692d0bca61dcef 170 Off-grid living topical
175 #radio True 266f225baced1b2a868dcc8e9c69a304 38 Radio hobby topical
176 #lora True 0749ea267c6be7b54ca1dbbae7dba0aa 7 LoRa technology discussion topical
177 #meshcore True 2fa78a5aef618e7c2a78f0ab5c8869b3 47 MeshCore project discussion topical
178 #meshtastic True 73a2e13ff0dea9ed19b24b2ab753650f 115 Meshtastic discussion topical
179 #iot True 233b42534ed82b8ae6a88d785f1b27fd 35 IoT / sensors topical
180 #solar True 7592a730ff1af2b40b95589fbafcca01 117 Solar power topical
181 #emcomm True a9a49340642dfc4c562d7849b7c8a258 169 Emergency communications topical
182 #ares True 44419e394cde859e45710f288db939a8 68 ARES (Amateur Radio Emergency Service) topical
183 #gmrs True 2ad811e84ab4ad0e3a648d8fcf3d650a 42 GMRS radio topical
184 #cb True a35b5f87bbb67859a02e7666c823de7a 163 CB radio topical
185 #hiking True 2370a013053e384e5f18918bc2b26baf 35 Hiking topical
186 #camping True c011b7c2abf33748cd9bd5a78c2b4955 192 Camping topical
187 #hunting True 0c0c28fd9bbf096ee06e01af840037af 12 Hunting topical
188 #fishing True a671ca13cc499898aea4596e804ecf20 166 Fishing topical
189 #skiing True b3baf3586d347ecaf2c113106a564207 179 Skiing topical
190 #biking True 9c0591d708b7c7970c9ad6712e3c4d05 156 Biking topical
191 #ham True 5270db3979da687fa133fec6684cd952 82 HAM radio (short form) topical
192 #cq True bc9da8bdebcaeee40fefff7dd16855f1 188 CQ (calling all stations) topical
193 #dx True adf77706da76b66bb0a313c1484d3be4 173 DX (long distance contacts) topical
194 #repeater True 289991a3077903263f2d31493887c651 40 Repeater discussion topical
195 #firmware True 6163564e7449c1c842e58dcf2cd1ab4f 97 Firmware discussion topical
196 #debug True fd7a60ed4796efcd1965c2de466105cb 253 Debug channel topical
197 #dev True d41bcba61e9dca7177c7b8533d23a0bc 212 Development channel topical
198 #admin True 889334b7e486938c776dbdde120da9de 136 Admin channel topical
199 #monitor True 131479235e65b7c591307f699d64dc74 19 Monitoring channel topical
200 #alert True 678c7d2e08c019e113ace03eaaa128ad 103 Alert channel topical
201 #local True d2d35fa76be9875ed254db80397483a5 210 Local community topical
202 #neighborhood True 53403881955f0e11573ba1e5d3a82c4b 83 Neighborhood chat topical
203 #community True b9323ce55a729068ed8e3ed49b9b84b5 185 Community channel topical
204 #events True d2cbcc606979cdb05c7bd66f49f23baa 210 Events channel topical
205 #trade True c2be982890fb3a63113de637b9aee6fe 194 Trading/swap channel topical
206 #marketplace True 354a741c2975c843ec0706089390b968 53 Marketplace topical
207 #gaming True 3802c79121f195ea50ad9ab2aa2c402a 56 Gaming topical
208 #music True b025ab29b0a5f56fb68a474741d7a2cc 176 Music topical
209 #tech True 5177b749eefbfab3c90a21b4e2518c5f 81 Technology topical
210 #science True 8abc980d3764fdabda5dfa6b6bc3a6cc 138 Science topical
211 #crypto True 0aa7c45d99be27024b82a1474c693951 10 Cryptocurrency topical
212 #bitcoin True 94bf6fd2688b1456c071ebad63d7021d 148 Bitcoin topical
213 #linux True 02c74eda5d8ec8b9bb945049fb2f55c6 2 Linux topical
214 #arduino True ce8c596922eff9274f3b1e19ad296754 206 Arduino topical
215 #raspberry True 469bc476d8263b64b6f90ca868b04b91 70 Raspberry Pi topical
216 #esp32 True 9f8aac4c48973b07bbdc47acbaea3e8e 159 ESP32 projects topical
217 #heltec True 71af5d9a209e8b17a19ee22aa7b6be23 113 Heltec devices topical
218 #lilygo True a33dd4b025cc82b81f97318a63a9d5d3 163 LilyGo devices topical
219 #rak True 1f4734675fdfcabfaa67a0a059c65c5f 31 RAK Wireless devices topical
220 #antenna True e8979157107155ade90822e29b186ee8 232 Antenna discussion topical
221 #rf True 46aaa16f78b3969040aad2b6b694b59e 70 RF engineering topical
222 #preppers True 4877173813de9668b9ef33adbc1b8f37 72 Preppers community topical
223 #survival True e1f465ea51df09fc901389758f1c5f01 225 Survival skills topical
224 #grid-down True 94ddbd51f2ee0bd47426a6f3ed79be99 148 Grid-down scenarios topical
225 #wildfire True b9124c3aecc0c813634f79f144b93f83 185 Wildfire alerts topical
226 #hurricane True c13a2f9d18400e5d93bae4bb3afef40c 193 Hurricane alerts topical
227 #earthquake True 0c7082c04a1a90502a5f32fc6a9f6524 12 Earthquake alerts topical
228 #flood True f2a0fd0abe4c9fc6865b5f8eebf319d6 242 Flood alerts topical
229 #tornado True 76e577e8187d0bab89052343f3835039 118 Tornado alerts topical
230 #storm True 113761b9e31a5c30e0ee4fc78dc7310b 17 Storm alerts topical
231 #traffic True c1216dacbb7e4c3b9a3356e52f1c27fa 193 Traffic updates topical
232 #fire True 3d74a070077293ab66baf3aa724349ff 61 Fire alerts topical
233 #police True 79b89aa870ea85dbb8396f401e547d99 121 Police scanner / alerts topical
234 #ems True f714461f26d43d9a17b2530c3ad29bc2 247 EMS / first responders topical
235 #search-rescue True cd8ab9e4944ee656ae6e961a4437c551 205 Search and rescue topical
236 #volunteer True 2d3d64746075cb99407d681e547b99cb 45 Volunteer coordination topical
237 #data True b0d9c01c8278a711c68ffdca98ef1245 176 Data/telemetry channel topical
238 #sensor True 6218df72386dd48259024327e8ebf66f 98 Sensor data channel topical
239 #tracker True 70258e69aeb03d1cd2e16072e1fe14b5 112 Asset/pet tracker topical
240 #gps True af4b9f722a6200b1eb2696660017d0df 175 GPS tracking topical
241 #boat True c03117578e38c3f1eb5ae8c3d0019753 192 Boating/maritime topical
242 #sailing True c2f6052f8b89639f81c8ff4def32deae 194 Sailing topical
243 #marine True 7f4b40aac63642805a54a959beb07694 127 Marine channel topical
244 #aviation True e17538c835ee0990b349ca1879b4f57a 225 Aviation topical
245 #drone True 4460931543103de42d2f85093a80273c 68 Drone / UAV topical
246 #fpv True 6d2752bc94797cfce43e2138eab7a772 109 FPV flying topical
247 #farm True 4a2f1b2147165cf942402a4c6bd7d42d 74 Farming / agriculture topical
248 #ranch True 5885e728fc06bfcbe7d6c9020c13ff10 88 Ranch / livestock topical
249 #tucson True cf989ccea881cf5ddcf40d87bbfc441e 207 Tucson AZ US-AZ
250 #albuquerque True 2a59b6ce7d3ce661664217f9b8255e86 42 Albuquerque NM US-NM
251 #boise True f4e641f9c90ac3bd5bc442ef379f6eb3 244 Boise ID US-ID
252 #spokane True dfcf2a863b3baa99e3d3274bfb56439f 223 Spokane WA US-WA
253 #olympia True baec83dd15ef758ad8bf8f357237c9cb 186 Olympia WA US-WA
254 #bellingham True 6000c8e3c1343fd920749eb3ad2a42b4 96 Bellingham WA US-WA
255 #eugene True e6ce6f5e71d88bb90c0b7dacc2235662 230 Eugene OR US-OR
256 #bend True 291ddea6fc1c1742f7ad425819b66f7b 41 Bend OR US-OR
257 #reno True e9edf1edf57a04b8f66e75829b4edc06 233 Reno NV US-NV
258 #provo True 72744153a7a2d84ed69338be4dc3fc31 63 Provo US-UT
259 #orem True 97236968b8a6c0cf297f1c1365539d0d 157 Orem US-UT
260 #ogden True cdeec2a40203056a6c6f2d1fb5f50b81 245 Ogden US-UT
261 #logan True b0ee430ede14470d5ff8fafbe092ee50 145 Logan US-UT
262 #stgeorge True 7792659def2811af89a02270b2918235 128 St. George US-UT
263 #parkcity True bbb64d6ae8eaeaf4302f501844f64511 25 Park City US-UT
264 #lehi True 18f20a96d251665105d59df6ca5bcc5c 98 Lehi US-UT
265 #byu True 65633d6a99bbcb173cb01afeae3a51d0 194 Brigham Young University US-UT
266 #uvu True cb305b9b3d0ab33fce2186ee6b9c58ff 91 Utah Valley University US-UT
267 #uofu True c08fe0d4d37b4b7f233f2977296979d5 178 University of Utah US-UT
268 #usu True ceb222637d010d1c5551a5035e86f89d 104 Utah State University US-UT
269 #utahcounty True f191a8a833d2ae61b7fbfa8c9891cc0c 23 Utah County US-UT
270 #daviscounty True 926847daaed0baa7758df5ef8af76f32 11 Davis County US-UT
271 #webercounty True 364726e43d1fb9b8079ededb03f00764 172 Weber County US-UT
272 #wasatchfront True eb9fa3bfa390eed3737634bed39c7460 44 Wasatch Front metro corridor US-UT
273 #wasatch True 15bf0e80d3d88092d38e4c82da7e523e 0 Wasatch region US-UT
274 #saltlake True 56327c6958d9108213f8638fb2a2b7a4 173 Salt Lake (alternate) US-UT
275 #tooele True 092cbdcf351bdbe77ce6c00e1dbeb294 184 Tooele US-UT
276 #sanpete True 4ae01748b17bfc90e13004f19e734bbc 151 Sanpete County US-UT
277 #watchman True 11489fe661e776b3ccdcf864bd14f7d6 245 Watchman mesh health alerts global
+607
View File
@@ -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("<I", 1774600000) # ~2026-03-24
def advert_packet(app_data: bytes, route_type: int = 1,
path_hops: list[bytes] | None = None,
hash_size: int = 1, pubkey: bytes = DUMMY_PUBKEY,
timestamp: bytes = TS_2026) -> 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("<HH", tc[0], tc[1])
return hdr + tc_bytes + pl_byte + path + body
def ack_packet(crc: int, route_type: int = 1,
path_hops: list[bytes] | None = None,
hash_size: int = 1) -> 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("<I", crc)
def group_packet(payload_type: int, ch_hash: int, mac: bytes,
ciphertext: bytes, route_type: int = 1,
path_hops: list[bytes] | None = None,
hash_size: int = 1) -> 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("<II", tag, auth_code) + bytes([flags]) + trace_hashes
return hdr + pl_byte + path + payload
def multipart_packet(remaining: int, inner_type: int, inner_payload: bytes,
route_type: int = 1) -> 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("<i", 37774900) + struct.pack("<i", -122419400)
+ b"HilltopRptr"
).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": 0x92,
"node_type": "repeater",
"location": {"lat": 37.7749, "lon": -122.4194},
"name": "HilltopRptr"
}
}
}
})
# ADVERT: emoji name (multi-byte UTF-8)
emoji_name = "\U0001f985 Raptor" # 🦅 Raptor
write_case("advert_emoji_name", {
"description": "ADVERT flood, chat node with emoji in name (multi-byte UTF-8)",
"tags": ["advert", "flood", "emoji", "name", "utf8"],
"packet_hex": advert_packet(bytes([0x81]) + emoji_name.encode()).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": emoji_name
}
}
}
})
# ADVERT: feat flags set but data is actually name (scoring test)
write_case("advert_feat_flags_as_name", {
"description": "ADVERT flood, feat1+feat2 bits set but bytes are actually the name",
"tags": ["advert", "flood", "feat", "scoring", "emoji"],
"packet_hex": advert_packet(
bytes([0xe1]) + "\U0001f3d4\ufe0f MtnNode".encode()
).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": 0xe1,
"node_type": "chat",
"location": None,
"name": "\U0001f3d4\ufe0f MtnNode"
}
}
}
})
# ADVERT: no app_data (bare 100-byte advert)
write_case("advert_no_appdata", {
"description": "ADVERT flood, bare minimum (no app_data)",
"tags": ["advert", "flood", "minimal"],
"packet_hex": advert_packet(b"").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": None
}
}
})
# ADVERT: sensor node type
write_case("advert_sensor", {
"description": "ADVERT flood, sensor node type",
"tags": ["advert", "flood", "sensor"],
"packet_hex": advert_packet(bytes([0x84]) + b"TempSensor").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": 0x84,
"node_type": "sensor",
"location": None,
"name": "TempSensor"
}
}
}
})
# ADVERT: with path (4 hops, 1-byte hash)
write_case("advert_with_path", {
"description": "ADVERT flood, 4 hops with 1-byte hashes",
"tags": ["advert", "flood", "path"],
"packet_hex": advert_packet(
bytes([0x81]) + b"RelayedNode",
path_hops=[b"\x2a", b"\x3d", b"\x35", b"\xed"]
).hex(),
"valid": True,
"expected": {
"route_type": "flood",
"payload_type": "ADVERT",
"payload_ver": 0,
"transport_codes": None,
"path": {"hash_size": 1, "hops": ["2a", "3d", "35", "ed"]},
"payload": {
"pubkey_prefix": DUMMY_PUBKEY[:4].hex(),
"timestamp": 1774600000,
"advert": {
"flags": 0x81,
"node_type": "chat",
"location": None,
"name": "RelayedNode"
}
}
}
})
# ACK: basic
write_case("ack_basic", {
"description": "ACK flood, simple acknowledgment",
"tags": ["ack", "flood"],
"packet_hex": ack_packet(0xDEADBEEF).hex(),
"valid": True,
"expected": {
"route_type": "flood",
"payload_type": "ACK",
"payload_ver": 0,
"transport_codes": None,
"path": {"hash_size": 1, "hops": []},
"payload": {"ack_crc": 0xDEADBEEF}
}
})
# ACK: with 1-byte hash path
write_case("ack_with_path", {
"description": "ACK flood, 2 hops with 1-byte hashes",
"tags": ["ack", "flood", "path"],
"packet_hex": ack_packet(
0x008DC29F, path_hops=[b"\xd1", b"\xac"]
).hex(),
"valid": True,
"expected": {
"route_type": "flood",
"payload_type": "ACK",
"payload_ver": 0,
"transport_codes": None,
"path": {"hash_size": 1, "hops": ["d1", "ac"]},
"payload": {"ack_crc": 0x008DC29F}
}
})
# TXT_MSG: direct, 2-byte hashes
write_case("txt_msg_direct_2b_hash", {
"description": "TXT_MSG direct, 2-byte hashes, 2 hops",
"tags": ["txt_msg", "direct", "2byte_hash", "encrypted"],
"packet_hex": peer_packet(
0x02, 2, 0x5b, 0x43, b"\xab\xcd", bytes(48),
path_hops=[b"\xac\x98", b"\xd1\x4d"], hash_size=2
).hex(),
"valid": True,
"expected": {
"route_type": "direct",
"payload_type": "TXT_MSG",
"payload_ver": 0,
"transport_codes": None,
"path": {"hash_size": 2, "hops": ["ac98", "d14d"]},
"payload": {"dst": "5b", "src": "43", "mac": "abcd", "ciphertext_len": 48}
}
})
# TXT_MSG: flood with loops (duplicate hops)
write_case("txt_msg_flood_with_loops", {
"description": "TXT_MSG flood with routing loop (ac98 appears 3x)",
"tags": ["txt_msg", "flood", "loop", "2byte_hash"],
"packet_hex": peer_packet(
0x02, 1, 0x5b, 0x43, b"\xab\xcd", bytes(32),
path_hops=[b"\xac\x98", b"\xd1\x4d", b"\xac\x98", b"\xff\x01", b"\xac\x98"],
hash_size=2
).hex(),
"valid": True,
"expected": {
"route_type": "flood",
"payload_type": "TXT_MSG",
"payload_ver": 0,
"transport_codes": None,
"path": {"hash_size": 2, "hops": ["ac98", "d14d", "ac98", "ff01", "ac98"]},
"payload": {"dst": "5b", "src": "43", "mac": "abcd", "ciphertext_len": 32}
}
})
# REQ: with transport codes (transport direct)
write_case("req_transport_direct", {
"description": "REQ transport-direct with transport codes and 1-byte path",
"tags": ["req", "tdirect", "transport_codes", "encrypted"],
"packet_hex": peer_packet(
0x00, 3, 0xa1, 0xb2, b"\xef\x01", bytes(32),
path_hops=[b"\xcc", b"\xdd"], hash_size=1,
tc=(0x1234, 0x5678)
).hex(),
"valid": True,
"expected": {
"route_type": "tdirect",
"payload_type": "REQ",
"payload_ver": 0,
"transport_codes": {"tc1": 0x1234, "tc2": 0x5678},
"path": {"hash_size": 1, "hops": ["cc", "dd"]},
"payload": {"dst": "a1", "src": "b2", "mac": "ef01", "ciphertext_len": 32}
}
})
# ANON_REQ: route_type=3 but no actual TC (older firmware, TC fallback)
ephem = bytes(range(0x20, 0x40)) # 32-byte ephemeral pubkey
write_case("anon_req_tc_fallback", {
"description": "ANON_REQ tdirect header but NO transport codes (older firmware)",
"tags": ["anon_req", "tdirect", "tc_fallback"],
"packet_hex": (
bytes([mc_header(0x07, 3)])
+ bytes([path_len_byte(3, 1)]) + b"\x13\x35\x42"
+ bytes([0xd9]) + ephem + b"\xb2\xe2" + bytes(48)
).hex(),
"valid": True,
"expected": {
"route_type": "tdirect",
"payload_type": "ANON_REQ",
"payload_ver": 0,
"transport_codes": None,
"path": {"hash_size": 1, "hops": ["13", "35", "42"]},
"payload": {
"dst": "d9",
"ephem_prefix": ephem[:4].hex(),
"mac": "b2e2",
"ciphertext_len": 48
}
}
})
# GRP_TXT: basic group text
write_case("grp_txt_basic", {
"description": "GRP_TXT flood, group text message",
"tags": ["grp_txt", "flood", "group", "encrypted"],
"packet_hex": group_packet(0x05, 0xCC, b"\xab\xcd", bytes(32)).hex(),
"valid": True,
"expected": {
"route_type": "flood",
"payload_type": "GRP_TXT",
"payload_ver": 0,
"transport_codes": None,
"path": {"hash_size": 1, "hops": []},
"payload": {"channel_hash": "cc", "mac": "abcd", "ciphertext_len": 32}
}
})
# TRACE: basic trace packet
write_case("trace_basic", {
"description": "TRACE flood with tag, auth, and 2 trace hops",
"tags": ["trace", "flood"],
"packet_hex": trace_packet(
0x12345678, 0xAABBCCDD, 0x00, b"\x11\x22"
).hex(),
"valid": True,
"expected": {
"route_type": "flood",
"payload_type": "TRACE",
"payload_ver": 0,
"transport_codes": None,
"path": {"hash_size": 1, "hops": []},
"payload": {"trace_tag": 0x12345678, "trace_hops": 2}
}
})
# MULTIPART: ACK inner type
write_case("multipart_ack", {
"description": "MULTIPART flood, remaining=3, inner type=ACK",
"tags": ["multipart", "flood", "ack"],
"packet_hex": multipart_packet(
3, 0x03, struct.pack("<I", 0xCAFEBABE)
).hex(),
"valid": True,
"expected": {
"route_type": "flood",
"payload_type": "MULTIPART",
"payload_ver": 0,
"transport_codes": None,
"path": {"hash_size": 1, "hops": []},
"payload": {"multipart_remaining": 3, "multipart_inner": "ACK"}
}
})
# CONTROL: DISCOVER_REQ
write_case("control_discover_req", {
"description": "CONTROL flood, DISCOVER_REQ sub-type",
"tags": ["control", "flood", "discover"],
"packet_hex": control_packet(
0x80, bytes([0x03]) + struct.pack("<I", 0xABCD1234)
).hex(),
"valid": True,
"expected": {
"route_type": "flood",
"payload_type": "CONTROL",
"payload_ver": 0,
"transport_codes": None,
"path": {"hash_size": 1, "hops": []},
"payload": {"control_subtype": "DISCOVER_REQ"}
}
})
# PATH: 3-byte hash, 1 hop
write_case("path_3byte_hash", {
"description": "PATH flood, 1 hop with 3-byte hash",
"tags": ["path", "flood", "3byte_hash", "encrypted"],
"packet_hex": peer_packet(
0x08, 1, 0xe6, 0xe9, b"\x2e\x47", bytes(46),
path_hops=[b"\x1a\xff\xf3"], hash_size=3
).hex(),
"valid": True,
"expected": {
"route_type": "flood",
"payload_type": "PATH",
"payload_ver": 0,
"transport_codes": None,
"path": {"hash_size": 3, "hops": ["1afff3"]},
"payload": {"dst": "e6", "src": "e9", "mac": "2e47", "ciphertext_len": 46}
}
})
# PATH: 4-byte hash, 1 hop
write_case("path_4byte_hash", {
"description": "PATH flood, 1 hop with 4-byte hash",
"tags": ["path", "flood", "4byte_hash", "encrypted"],
"packet_hex": peer_packet(
0x08, 1, 0xe6, 0xe9, b"\x2e\x47", bytes(16),
path_hops=[b"\xaa\xbb\xcc\xdd"], hash_size=4
).hex(),
"valid": True,
"expected": {
"route_type": "flood",
"payload_type": "PATH",
"payload_ver": 0,
"transport_codes": None,
"path": {"hash_size": 4, "hops": ["aabbccdd"]},
"payload": {"dst": "e6", "src": "e9", "mac": "2e47", "ciphertext_len": 16}
}
})
# ── Invalid packets ───────────────────────────────────────────────
# Too short: 1 byte
write_case("too_short", {
"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"
}
})
# Bad path_len: claims 33 hops but only 24 bytes remain
bad_pkt = bytes([mc_header(0x02, 1), 0xe1]) + bytes(24)
write_case("bad_path_len", {
"description": "TXT_MSG flood, path_len claims 33 hops (4B hash) but data is too short",
"tags": ["invalid", "bad_framing", "path"],
"packet_hex": bad_pkt.hex(),
"valid": False,
"expected": {
"route_type": "flood",
"payload_type": "TXT_MSG",
"error": "bad_framing"
}
})
# Noise: random bytes that don't parse as meshcore
write_case("noise_packet", {
"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"
}
})
# Empty packet
write_case("empty_packet", {
"description": "Empty packet (0 bytes)",
"tags": ["invalid", "too_short"],
"packet_hex": "",
"valid": False,
"expected": {
"route_type": "flood",
"payload_type": "REQ",
"error": "too_short"
}
})
# Bad advert: too short for pubkey+timestamp+signature
short_advert = advert_packet(b"") # 102 bytes: hdr + path_len + 100 payload
truncated = short_advert[:50] # cut in half
write_case("advert_truncated", {
"description": "ADVERT flood, truncated mid-signature (50 bytes)",
"tags": ["invalid", "advert", "truncated"],
"packet_hex": truncated.hex(),
"valid": True, # parses as ADVERT but payload is bad
"expected": {
"route_type": "flood",
"payload_type": "ADVERT",
"payload_ver": 0,
"transport_codes": None,
"path": {"hash_size": 1, "hops": []}
}
})
print(f"\nDone! {len(list(OUT.glob('*.json')))} test vectors written to {OUT}/")
+327
View File
@@ -0,0 +1,327 @@
#!/usr/bin/env python3
"""Generate MeshCore test vectors using Hypothesis (property-based testing)."""
# /// script
# requires-python = ">=3.10"
# dependencies = ["hypothesis", "jsonschema"]
# ///
import argparse
import json
import struct
import sys
from pathlib import Path
from hypothesis import given, settings, HealthCheck, Phase
from hypothesis import strategies as st
CORPUS_DIR = Path(__file__).parent
OUT = CORPUS_DIR / "generated"
SCHEMA_PATH = CORPUS_DIR / "schema.json"
# MeshCore constants
MAX_PAYLOAD = 184
MAX_PATH_SIZE = 64
MAX_ADVERT_DATA_SIZE = 32
PUB_KEY_SIZE = 32
SIGNATURE_SIZE = 64
ROUTE_TYPES = {0: "tflood", 1: "flood", 2: "direct", 3: "tdirect"}
PAYLOAD_TYPES = {
0: "REQ", 1: "RESPONSE", 2: "TXT_MSG", 3: "ACK", 4: "ADVERT",
5: "GRP_TXT", 6: "GRP_DATA", 7: "ANON_REQ", 8: "PATH",
9: "TRACE", 10: "MULTIPART", 11: "CONTROL", 15: "RAW_CUSTOM",
}
NODE_TYPES = {1: "chat", 2: "repeater", 3: "room", 4: "sensor"}
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)
# ── 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("<ii", lat, lon)
loc = {"lat": round(lat / 1e6, 4), "lon": round(lon / 1e6, 4)}
if has_name:
flags |= 0x80
# Generate ASCII names (to avoid UTF-8 scoring ambiguity in generated tests)
name = draw(st.text(
alphabet=st.characters(whitelist_categories=("L", "N", "P", "Z"),
max_codepoint=127),
min_size=1, max_size=20
))
name_bytes = name.encode("ascii")
data += name_bytes
if len(data) + 1 > 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("<I", timestamp) + signature + app_data
packet = hdr + pl + path + payload
return packet, {
"route_type": ROUTE_TYPES[route_type],
"payload_type": "ADVERT",
"payload_ver": ver,
"transport_codes": None,
"path": {"hash_size": hs, "hops": [h.hex() for h in hops]},
"payload": {
"pubkey_prefix": pubkey[:4].hex(),
"timestamp": timestamp,
"advert": advert_expected,
}
}
@st.composite
def valid_ack_packet(draw):
route_type = draw(route_types_no_tc)
hs, hops = draw(path_strategy())
crc = draw(st.integers(min_value=0, max_value=0xFFFFFFFF))
hdr = bytes([mc_header(0x03, route_type)])
pl = bytes([path_len_byte(len(hops), hs)])
path = b"".join(hops)
packet = hdr + pl + path + struct.pack("<I", crc)
return packet, {
"route_type": ROUTE_TYPES[route_type],
"payload_type": "ACK",
"payload_ver": 0,
"transport_codes": None,
"path": {"hash_size": hs, "hops": [h.hex() for h in hops]},
"payload": {"ack_crc": crc}
}
@st.composite
def valid_encrypted_peer_packet(draw):
ptype = draw(st.sampled_from([0, 1, 2, 8])) # REQ/RESPONSE/TXT_MSG/PATH
route_type = draw(route_types_no_tc)
hs, hops = draw(path_strategy())
dst = draw(st.integers(min_value=0, max_value=255))
src = draw(st.integers(min_value=0, max_value=255))
mac = draw(st.binary(min_size=2, max_size=2))
ct_len = draw(st.integers(min_value=0, max_value=64))
ct = draw(st.binary(min_size=ct_len, max_size=ct_len))
hdr = bytes([mc_header(ptype, route_type)])
pl = bytes([path_len_byte(len(hops), hs)])
path = b"".join(hops)
packet = hdr + pl + path + bytes([dst, src]) + mac + ct
return packet, {
"route_type": ROUTE_TYPES[route_type],
"payload_type": PAYLOAD_TYPES[ptype],
"payload_ver": 0,
"transport_codes": None,
"path": {"hash_size": hs, "hops": [h.hex() for h in hops]},
"payload": {
"dst": f"{dst:02x}",
"src": f"{src:02x}",
"mac": mac.hex(),
"ciphertext_len": ct_len
}
}
@st.composite
def valid_group_packet(draw):
ptype = draw(st.sampled_from([5, 6])) # GRP_TXT/GRP_DATA
route_type = draw(route_types_no_tc)
hs, hops = draw(path_strategy())
ch = draw(st.integers(min_value=0, max_value=255))
mac = draw(st.binary(min_size=2, max_size=2))
ct_len = draw(st.integers(min_value=0, max_value=64))
ct = draw(st.binary(min_size=ct_len, max_size=ct_len))
hdr = bytes([mc_header(ptype, route_type)])
pl = bytes([path_len_byte(len(hops), hs)])
path = b"".join(hops)
packet = hdr + pl + path + bytes([ch]) + mac + ct
return packet, {
"route_type": ROUTE_TYPES[route_type],
"payload_type": PAYLOAD_TYPES[ptype],
"payload_ver": 0,
"transport_codes": None,
"path": {"hash_size": hs, "hops": [h.hex() for h in hops]},
"payload": {
"channel_hash": f"{ch:02x}",
"mac": mac.hex(),
"ciphertext_len": ct_len
}
}
@st.composite
def invalid_packet(draw):
"""Generate an invalid MeshCore packet via mutation."""
mutation = draw(st.sampled_from(["truncate", "bad_path_len", "random"]))
if mutation == "truncate":
# Valid packet truncated to random length
pkt, _ = draw(valid_ack_packet())
cut = draw(st.integers(min_value=0, max_value=max(0, len(pkt) - 2)))
packet = pkt[:cut]
if len(packet) < 2:
error = "too_short"
else:
error = "bad_framing"
elif mutation == "bad_path_len":
# Header + impossible path_len
route_type = draw(route_types_no_tc)
ptype = draw(st.sampled_from(list(PAYLOAD_TYPES.keys())))
hdr = bytes([mc_header(ptype, route_type)])
# path_len claiming huge path
bad_pl = draw(st.integers(min_value=0xC0, max_value=0xFF))
tail = draw(st.binary(min_size=1, max_size=20))
packet = hdr + bytes([bad_pl]) + tail
error = "bad_framing"
else: # random
packet = draw(st.binary(min_size=2, max_size=50))
error = "not_meshcore"
header = packet[0] if packet else 0
route_type = header & 0x03
payload_type = (header >> 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()
@@ -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
}
}
}
@@ -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
}
}
}
@@ -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"
}
}
}
}
@@ -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"
}
}
}
}
@@ -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"
}
}
}
}
@@ -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"
}
}
}
}
@@ -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
}
}
}
@@ -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"
}
}
}
}
@@ -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": []
}
}
}
@@ -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"
}
}
}
}
@@ -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
}
}
}
@@ -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"
}
}
@@ -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"
}
}
}
@@ -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"
}
}
@@ -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
}
}
}
@@ -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"
}
}
}
@@ -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"
}
}
@@ -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
}
}
}
@@ -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
}
}
}
@@ -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
}
}
}
@@ -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"
}
}
@@ -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"
}
}
@@ -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}
}
}
@@ -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}
}
}
@@ -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"
}
}
@@ -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
}
}
}
@@ -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
}
}
}
@@ -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
}
}
}
+128
View File
@@ -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
}
}
}
+207
View File
@@ -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 meshcore/ directory to path so we can import the decoder
sys.path.insert(0, str(Path(__file__).parent.parent))
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 ["<too short>", "<not meshcore>", "<bad framing>"]:
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()
+999
View File
@@ -0,0 +1,999 @@
#!/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("<I", plaintext, 0)[0]
# flags_and_attempt at byte 4: (attempt & 3) | (txt_type << 2)
text = plaintext[5:].split(b"\x00", 1)[0] # null-terminated, strip zero padding
return timestamp, text.decode("utf-8", errors="replace")
# Known channels: name → 32-byte secret
# Public channel has a hardcoded PSK; hashtag channels derive from name
_KNOWN_CHANNELS: dict[str, bytes] = {}
# channel_hash (1 byte) → list of (name, secret) for matching
_CHANNEL_BY_HASH: dict[int, list[tuple[str, bytes]]] = {}
def _register_channel(name: str, secret: bytes):
_KNOWN_CHANNELS[name] = secret
h = _channel_hash(secret)
_CHANNEL_BY_HASH.setdefault(h, []).append((name, secret))
_CHANNELS_CSV = Path(__file__).parent / "channels.csv"
def _init_channels():
"""Load channels from channels.csv. Hashtag channels use key derivation;
PSK channels (hashtag=False) use the raw key_hex."""
if not _CHANNELS_CSV.is_file():
print(f"{RED}channels.csv not found at {_CHANNELS_CSV}{RST}")
return
with open(_CHANNELS_CSV, newline="") as f:
for row in csv.DictReader(f):
name = row["channel_name"]
is_hashtag = row["hashtag"].strip().lower() == "true"
key_hex = row["key_hex"].strip()
if is_hashtag:
secret = _channel_secret_from_hashtag(name)
else:
# PSK channel — raw 16-byte key
secret = bytes.fromhex(key_hex) + b"\x00" * 16
_register_channel(name, secret)
_init_channels()
# ── GRP_TXT transmit (encrypt + send) ─────────────────────────────
MAX_GRP_TEXT = 163 # max bytes for "SenderName: message" in a GRP_TXT
def _grp_encrypt(secret: bytes, sender: str, text: str) -> 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("<I", int(time.time())) + b"\x00"
plaintext += f"{sender}: {text}\x00".encode("utf-8")[:MAX_GRP_TEXT]
pad_len = (16 - len(plaintext) % 16) % 16
plaintext += b"\x00" * pad_len
# AES-128-ECB encrypt
cipher = AES.new(secret[:16], AES.MODE_ECB)
ciphertext = b""
for i in range(0, len(plaintext), 16):
ciphertext += cipher.encrypt(plaintext[i:i + 16])
# HMAC-SHA256 over ciphertext, truncated to 2 bytes
key_len = 16 if secret[16:] == b"\x00" * 16 else 32
mac = hmac.new(secret[:key_len], ciphertext, hashlib.sha256).digest()[:2]
return bytes([ch]) + mac + ciphertext
def _grp_build_packet(channel_payload: bytes) -> 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("<i", app_data, off)[0]
lon_raw = struct.unpack_from("<i", app_data, off + 4)[0]
lat, lon = lat_raw / 1e6, lon_raw / 1e6
if -90 <= lat <= 90 and -180 <= lon <= 180:
loc_str = f"{DIM}loc={RST}{lat:.4f},{lon:.4f}"
off += 8
# Build candidate name slices — try with/without feat fields
off_with_feat = off
if flags & 0x20 and off_with_feat + 2 <= len(app_data):
off_with_feat += 2
if flags & 0x40 and off_with_feat + 2 <= len(app_data):
off_with_feat += 2
name = ""
if flags & 0x80:
candidates = []
if off_with_feat < len(app_data):
candidates.append(app_data[off_with_feat:])
if off < len(app_data) and off != off_with_feat:
candidates.append(app_data[off:])
# Also try raw name-at-byte-1 as a last resort (ignoring all flags)
if 1 not in (off, off_with_feat) and 1 < len(app_data):
candidates.append(app_data[1:])
if candidates:
best = _best_name(candidates)
name = best.decode("utf-8", errors="replace")
if loc_str:
parts.append(loc_str)
if name:
parts.append(f"{BWHT}{name}{RST}")
return " ".join(parts), name
LOOP_INDENT = " " * 38 # align under decoded packet text (after RSSI/SNR/len)
def _detect_loops(hops: list[str], hash_size: int) -> 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_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}<too short>{RST} {DIM}{data.hex()}{RST}"
header = data[0]
if header == 0xFF:
return f"{DIM}<no-retransmit marker> {data[1:].hex()}{RST}"
route_type = header & 0x03
payload_type = (header >> 2) & 0x0F
payload_ver = (header >> 6) & 0x03
# MeshCore only implements PAYLOAD_VER_1 (0x00) — versions 1-3 are reserved
# and rejected by real nodes. Packets with ver > 0 are noise or non-MeshCore.
if payload_ver != 0:
route_name = ROUTE_NAMES.get(route_type, f"rt{route_type}")
type_name = PAYLOAD_NAMES.get(payload_type, f"0x{payload_type:02x}")
return (f"{RED}<not meshcore>{RST} {DIM}hdr=0x{header:02x} "
f"(ver={payload_ver} — only v0 exists) {data[1:].hex()}{RST}")
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("<HH", data, 1)
tc_str = f" {DIM}tc={tc1:04x}:{tc2:04x}{RST}"
result = _try_decode_body(data, 5, type_name, type_color,
payload_type, payload_ver, route_name, tc_str)
if result is not None:
return result
# TC parse failed — retry without (older firmware compatibility)
result = _try_decode_body(data, 1, type_name, type_color,
payload_type, payload_ver, route_name, "")
if result is not None:
return result
else:
result = _try_decode_body(data, 1, type_name, type_color,
payload_type, payload_ver, route_name, "")
if result is not None:
return result
# Both attempts failed — show what we know from the header
return (f"{RED}<bad framing>{RST} {type_color}{type_name}{RST} "
f"{route_name} {DIM}{data[1:].hex()}{RST}")
def _decode_advert(prefix: str, payload: bytes) -> str:
if len(payload) < 100:
return f"{prefix} {RED}<bad advert: {len(payload)}B, need >=100>{RST} {DIM}{payload.hex()}{RST}"
pubkey = payload[0:32]
timestamp = struct.unpack_from("<I", payload, 32)[0]
app_data = payload[100:]
try:
ts = datetime.fromtimestamp(timestamp, tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
except (OSError, OverflowError, ValueError):
ts = f"epoch={timestamp}"
app_str, name = _decode_advert_appdata(app_data)
_register_node(pubkey, name)
return f"{prefix} {DIM}pub={RST}{pubkey[:4].hex()}.. {DIM}ts={RST}{ts}Z {app_str}"
def _decode_ack(prefix: str, payload: bytes) -> str:
if len(payload) < 4:
return f"{prefix} {RED}<bad ack: {len(payload)}B>{RST} {DIM}{payload.hex()}{RST}"
crc = struct.unpack_from("<I", payload, 0)[0]
return f"{prefix} {DIM}crc={RST}0x{crc:08x}"
def _decode_encrypted_peer(prefix: str, type_name: str, payload: bytes) -> str:
if len(payload) < 4:
return f"{prefix} {RED}<bad {type_name}: {len(payload)}B>{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}<bad group: {len(payload)}B>{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}<bad anon_req: {len(payload)}B>{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}<bad trace: {len(payload)}B>{RST} {DIM}{payload.hex()}{RST}"
tag = struct.unpack_from("<I", payload, 0)[0]
flags = payload[8]
trace_hash_size = (flags & 0x03) + 1
trace_hashes = payload[9:]
n_hashes = len(trace_hashes) // trace_hash_size if trace_hash_size else 0
hops = []
for i in range(n_hashes):
h = trace_hashes[i * trace_hash_size : (i + 1) * trace_hash_size]
hops.append(h.hex())
hops_str = f" {DIM}trace=[{RST}{'>'.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}<empty multipart>{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}<empty control>{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("<I", payload, 2)[0]
return f"{prefix} {sub_name} {DIM}filter={RST}0x{type_filter:02x} {DIM}tag={RST}0x{tag:08x}"
elif sub_type == 0x9 and len(payload) >= 6:
node_type = payload[0] & 0x0F
snr_raw = struct.unpack_from("<b", payload, 1)[0]
tag = struct.unpack_from("<I", payload, 2)[0]
return f"{prefix} {sub_name} {DIM}node={RST}{NODE_TYPES.get(node_type, f'0x{node_type:02x}')} {DIM}snr={RST}{snr_raw / 4:.1f}dB {DIM}tag={RST}0x{tag:08x}"
return f"{prefix} {sub_name} {DIM}{payload[1:].hex()}{RST}"
# ── Main ───────────────────────────────────────────────────────────
def send_cmd(ser: serial.Serial, cmd: dict, label: str) -> 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": 5, # CR 4/5 — denominator value
"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()