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:
36 files changed
+29
-3
No files matched your search
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
|
Executable
+607
@@ -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}/")
|
||||
Executable
+327
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Executable
+207
@@ -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()
|
||||
Executable
+999
@@ -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()
|
||||
Reference in new issue
Block a user