// archy-messh Phase 1 — tri-protocol RX scanner + trippy acid display // --------------------------------------------------------------------------- // Target: Heltec WiFi LoRa 32 V3 (ESP32-S3 + single SX1262 + 128x64 SSD1306). // // The radio rotates through the Reticulum / meshTastic / meshCore PHY presets // (RTC), listening in each dwell window and logging every raw packet it // demodulates. The OLED shows all three networks live — R T C — behind an // animated acid-house smiley, with per-network activity blips. Received // Meshtastic packets are decoded on-chip (AES-128-CTR + protobuf). // // HONEST HARDWARE NOTE: the single onboard SX1262 can only be tuned to ONE // PHY at a time (ARCHITECTURE.md §2), so "all three active" is fast time- // slicing, not literal simultaneity. True concurrent reception needs added // radios (Phase 2 / Option B). The UI presents all three as armed; the scan // cycles between them faster than a human notices. // // This increment still does NOT decode/bridge/transmit — that is the next // roadmap step (firmware/README.md). It proves RX + the device UX. // --------------------------------------------------------------------------- #include #include #include #include #include #include "mbedtls/aes.h" #include "esp_mac.h" #include "esp_random.h" #include // rweather/Crypto — AES-128-ECB for MeshCore DM cipher #include // rweather/Crypto — HMAC-SHA256 for MeshCore packet MAC #include // NVS-backed persistent identity seed extern "C" { #include "ed_25519.h" // orlp/ed25519 (vendored, lib/ed25519) — identity, sign, ECDH } // --- Heltec V3 SX1262 pin map (from the Heltec V3 schematic) ---------------- static const int PIN_LORA_NSS = 8; static const int PIN_LORA_DIO1 = 14; static const int PIN_LORA_NRST = 12; static const int PIN_LORA_BUSY = 13; static const int PIN_LORA_SCK = 9; static const int PIN_LORA_MISO = 11; static const int PIN_LORA_MOSI = 10; static const float TCXO_VOLTAGE = 1.8f; static const int8_t RX_TX_POWER = 10; // --- Heltec V3 OLED + power ------------------------------------------------ static const int PIN_OLED_SDA = 17; static const int PIN_OLED_SCL = 18; static const int PIN_OLED_RST = 21; static const int PIN_VEXT = 36; // drives Vext rail; LOW = ON (powers OLED) SX1262 radio = new Module(PIN_LORA_NSS, PIN_LORA_DIO1, PIN_LORA_NRST, PIN_LORA_BUSY); U8G2_SSD1306_128X64_NONAME_F_HW_I2C oled(U8G2_R0, PIN_OLED_RST, PIN_OLED_SCL, PIN_OLED_SDA); // --- The three PHY presets (RTC) ------------------------------------------- // Values from docs/ARCHITECTURE.md §1. Items flagged are open questions the // scan is meant to test against reality. struct PhyConfig { const char* name; char letter; // R / T / C for the display float freqMHz; float bwKHz; uint8_t sf; uint8_t cr; // 4/cr uint8_t syncWord; uint16_t preamble; }; static const PhyConfig CONFIGS[] = { // meshTastic EU868 "LongFast" — confirmed correct (received live packets). { "meshtastic", 'T', 869.525f, 250.0f, 11, 5, 0x2b, 16 }, // MeshCore narrowband — read live from the "HP Pro Desk" node's SELF_INFO // (companion serial protocol, 2026-07-01): 869.618 MHz / 62.5 kHz / SF8 / CR5. { "meshcore", 'C', 869.618f, 62.5f, 8, 5, 0x12, 16 }, // Reticulum has no fixed preset; PLACEHOLDER — set to your RNS RNode config. { "reticulum", 'R', 867.200f, 125.0f, 8, 5, 0x12, 16 }, }; static const size_t NUM_CONFIGS = sizeof(CONFIGS) / sizeof(CONFIGS[0]); static const uint32_t DWELL_MS = 3000; // camp long enough to sit through a // flood-rebroadcast burst on each PHY; // tune vs. how "live" C/R need to feel // Device identity — this single name is what the bridge will present on ALL // three networks once the participation/TX layer lands, so a message addressed // to "Reticutasticore" on Meshtastic, MeshCore or Reticulum reaches this device: // - Meshtastic: User.long_name = DEVICE_NAME, short_name = DEVICE_SHORT (<=4) // - MeshCore: advertised contact name = DEVICE_NAME // - Reticulum: LXMF delivery display_name = DEVICE_NAME // (RX-only today; the constants exist now so TX inherits them with no churn.) static const char* DEVICE_NAME = "Reticutasticore"; static const char* DEVICE_SHORT = "RTC"; // --- Runtime stats ---------------------------------------------------------- struct NetStat { uint32_t pkts = 0; int16_t lastRssi = 0; bool seen = false; uint32_t lastRxMs = 0; // for the RX activity blip }; static NetStat stats[NUM_CONFIGS]; static int activeIdx = 0; static uint32_t totalPkts = 0; static uint32_t dwellStart = 0; static uint32_t frame = 0; // Outbound message queued from the serial console ("T:hello", or bare "hello" // == all nets). Flushed on the matching PHY window so the radio is tuned right. static char txText[200] = {0}; static bool txPending = false; static char txNets[4] = {0}; // subset of "TCR"; empty == all // Last decoded human-readable event, shown along the bottom of the display. static char lastMsg[40] = {0}; static char lastMsgNet = ' '; static uint32_t lastMsgMs = 0; // Meshtastic node identity (this device's presence on the mesh). static uint32_t nodeNum = 0; static char nodeId[12] = {0}; // "!xxxxxxxx" static uint32_t lastAnnounceMs = 0; static const uint32_t ANNOUNCE_INTERVAL_MS = 60000; // re-announce every 60s // Message modal: on an inbound text we play an intro animation (upside-down // spinning striped smiley + lightning bolts) then a Meshtastic-style modal // stating the network, sender and text. Network-agnostic — any protocol that // decodes a message drives it. static bool msgModalActive = false; static uint32_t msgModalStart = 0; static char modalNet[14] = {0}; static char modalSender[20] = {0}; static char modalText[80] = {0}; static const uint32_t MODAL_INTRO_MS = 1400; static const uint32_t MODAL_TOTAL_MS = 6500; static void triggerMessageModal(const char* net, const char* sender, const char* text) { strncpy(modalNet, net, sizeof(modalNet) - 1); modalNet[sizeof(modalNet) - 1] = 0; strncpy(modalSender, sender, sizeof(modalSender) - 1); modalSender[sizeof(modalSender) - 1] = 0; strncpy(modalText, text, sizeof(modalText) - 1); modalText[sizeof(modalText) - 1] = 0; msgModalActive = true; msgModalStart = millis(); } // --- RX interrupt ----------------------------------------------------------- static volatile bool rxFlag = false; ICACHE_RAM_ATTR static void onDio1() { rxFlag = true; } static bool applyConfig(const PhyConfig& c) { radio.standby(); bool ok = true; ok &= radio.setFrequency(c.freqMHz) == RADIOLIB_ERR_NONE; ok &= radio.setBandwidth(c.bwKHz) == RADIOLIB_ERR_NONE; ok &= radio.setSpreadingFactor(c.sf) == RADIOLIB_ERR_NONE; ok &= radio.setCodingRate(c.cr) == RADIOLIB_ERR_NONE; ok &= radio.setSyncWord(c.syncWord) == RADIOLIB_ERR_NONE; ok &= radio.setPreambleLength(c.preamble) == RADIOLIB_ERR_NONE; if (!ok) return false; return radio.startReceive() == RADIOLIB_ERR_NONE; } // --- On-chip Meshtastic decode --------------------------------------------- // Default-channel ("LongFast") packets use AES-128-CTR with a PUBLIC key. // Validated offline against real captured packets (firmware/tools/ // meshtastic_decode.py). Wire format: 16-byte header + encrypted Data protobuf. static const uint8_t MT_KEY[16] = { 0xd4, 0xf1, 0xbb, 0x3a, 0x20, 0x29, 0x07, 0x59, 0xf0, 0xbc, 0xff, 0xab, 0xcf, 0x4e, 0x69, 0x01}; static uint32_t readVarint(const uint8_t* b, size_t len, size_t& i) { uint32_t v = 0; int shift = 0; while (i < len) { uint8_t c = b[i++]; v |= (uint32_t)(c & 0x7f) << shift; shift += 7; if (!(c & 0x80)) break; } return v; } // Decode a Meshtastic packet into a short human string (text, or node name for // NodeInfo). Returns false if not decodable / not an interesting type. static bool decodeMeshtastic(const uint8_t* pkt, size_t len, char* out, size_t outsz, bool* isText, uint32_t* senderOut) { if (len <= 16) return false; uint32_t sender, pid; memcpy(&sender, pkt + 4, 4); memcpy(&pid, pkt + 8, 4); if (senderOut) *senderOut = sender; if (isText) *isText = false; // CTR nonce = packetId (8 LE, high 4 zero) + fromNode (4 LE) + extraNonce (4 = 0) uint8_t nonce[16]; memset(nonce, 0, 16); memcpy(nonce + 0, &pid, 4); memcpy(nonce + 8, &sender, 4); size_t plen = len - 16; if (plen > 240) return false; uint8_t plain[240]; mbedtls_aes_context aes; mbedtls_aes_init(&aes); mbedtls_aes_setkey_enc(&aes, MT_KEY, 128); size_t nc_off = 0; uint8_t stream[16]; memset(stream, 0, 16); mbedtls_aes_crypt_ctr(&aes, plen, &nc_off, nonce, stream, pkt + 16, plain); mbedtls_aes_free(&aes); // Data protobuf: field 1 = portnum (varint), field 2 = payload (bytes) size_t i = 0; uint8_t port = 0; const uint8_t* payload = nullptr; size_t payLen = 0; while (i < plen) { uint8_t tag = plain[i++]; uint8_t field = tag >> 3, wt = tag & 7; if (wt == 0) { uint32_t v = readVarint(plain, plen, i); if (field == 1) port = (uint8_t)v; } else if (wt == 2) { uint32_t l = readVarint(plain, plen, i); if (field == 2) { payload = plain + i; payLen = l; } i += l; } else break; } if (!payload) return false; if (port == 1) { // TEXT_MESSAGE_APP size_t n = payLen < outsz - 1 ? payLen : outsz - 1; memcpy(out, payload, n); out[n] = 0; if (isText) *isText = true; return true; } if (port == 4) { // NODEINFO_APP -> User protobuf; field 3 = shortName size_t j = 0; const uint8_t* sn = nullptr; size_t snl = 0; while (j < payLen) { uint8_t tag = payload[j++]; uint8_t f = tag >> 3, w = tag & 7; if (w == 2) { uint32_t l = readVarint(payload, payLen, j); if (f == 3) { sn = payload + j; snl = l; } j += l; } else if (w == 0) { readVarint(payload, payLen, j); } else break; } if (sn) { size_t n = snl < outsz - 1 ? snl : outsz - 1; memcpy(out, sn, n); out[n] = 0; return true; } } return false; } // AES-128-CTR (symmetric, so same call encrypts) with the Meshtastic default key. static void mtCryptCtr(uint32_t sender, uint32_t pid, const uint8_t* in, uint8_t* out, size_t n) { uint8_t nonce[16]; memset(nonce, 0, 16); memcpy(nonce + 0, &pid, 4); memcpy(nonce + 8, &sender, 4); mbedtls_aes_context aes; mbedtls_aes_init(&aes); mbedtls_aes_setkey_enc(&aes, MT_KEY, 128); size_t nc_off = 0; uint8_t stream[16]; memset(stream, 0, 16); mbedtls_aes_crypt_ctr(&aes, n, &nc_off, nonce, stream, in, out); mbedtls_aes_free(&aes); } // Announce this device as a Meshtastic node named DEVICE_NAME. Broadcasts a // NodeInfo (User protobuf) on the LongFast primary channel (hash 0x08) so it // appears in other Meshtastic clients — and, because we advertise no public // key, DMs to us fall back to channel encryption, which we can already decode. static void announceMeshtastic() { // User protobuf: field1 id, field2 long_name, field3 short_name uint8_t user[64]; size_t u = 0; size_t idl = strlen(nodeId), lnl = strlen(DEVICE_NAME), snl = strlen(DEVICE_SHORT); user[u++] = 0x0a; user[u++] = idl; memcpy(user + u, nodeId, idl); u += idl; user[u++] = 0x12; user[u++] = lnl; memcpy(user + u, DEVICE_NAME, lnl); u += lnl; user[u++] = 0x1a; user[u++] = snl; memcpy(user + u, DEVICE_SHORT, snl); u += snl; // Data protobuf: portnum=4 (NODEINFO_APP), payload=User uint8_t data[96]; size_t d = 0; data[d++] = 0x08; data[d++] = 0x04; // portnum = 4 data[d++] = 0x12; data[d++] = (uint8_t)u; memcpy(data + d, user, u); d += u; uint32_t pid = esp_random(); uint8_t enc[96]; mtCryptCtr(nodeNum, pid, data, enc, d); // 16-byte header + ciphertext uint8_t pkt[128]; uint32_t dest = 0xffffffff; memcpy(pkt + 0, &dest, 4); memcpy(pkt + 4, &nodeNum, 4); memcpy(pkt + 8, &pid, 4); pkt[12] = 0x63; // flags: hop_limit=3, hop_start=3 pkt[13] = 0x08; // channel hash (LongFast primary) pkt[14] = 0x00; // next_hop pkt[15] = 0x00; // relay_node memcpy(pkt + 16, enc, d); radio.standby(); int st = radio.transmit(pkt, 16 + d); Serial.printf(" [T] announce '%s' (%s) id=0x%08x -> tx=%d : ", DEVICE_NAME, nodeId, pid, st); for (size_t i = 0; i < 16 + d; i++) Serial.printf("%02x", pkt[i]); // TX hex for self-verify Serial.println(); radio.startReceive(); } // Send a text message on the Meshtastic LongFast primary channel. Same wire // format as announceMeshtastic() but portnum=1 (TEXT_MESSAGE_APP), payload = // the raw UTF-8 text. Assumes the radio is already tuned to the 'T' PHY. static void sendMeshtasticText(const char* text) { size_t tl = strlen(text); if (tl > 200) tl = 200; // Data protobuf: field1 portnum=1 (varint), field2 payload=text (bytes) uint8_t data[224]; size_t d = 0; data[d++] = 0x08; data[d++] = 0x01; // portnum = 1 data[d++] = 0x12; data[d++] = (uint8_t)tl; // payload, len-delimited memcpy(data + d, text, tl); d += tl; uint32_t pid = esp_random(); uint8_t enc[224]; mtCryptCtr(nodeNum, pid, data, enc, d); uint8_t pkt[240]; uint32_t dest = 0xffffffff; memcpy(pkt + 0, &dest, 4); memcpy(pkt + 4, &nodeNum, 4); memcpy(pkt + 8, &pid, 4); pkt[12] = 0x63; // flags: hop_limit=3, hop_start=3 pkt[13] = 0x08; // channel hash (LongFast primary) pkt[14] = 0x00; // next_hop pkt[15] = 0x00; // relay_node memcpy(pkt + 16, enc, d); radio.standby(); int st = radio.transmit(pkt, 16 + d); Serial.printf(" [T] TX text id=0x%08x -> tx=%d : \"%s\"\n", pid, st, text); radio.startReceive(); } // --- MeshCore identity + advert TX ----------------------------------------- // MeshCore nodes prove identity with an Ed25519 keypair. To appear as a contact // in other clients we broadcast a signed ADVERT (PAYLOAD_TYPE_ADVERT = 0x04): // on-air: [header 0x12][path_len 0x00][pub_key 32][timestamp 4 LE][sig 64][app_data] // signed message = pub_key || timestamp || app_data (verified in MeshCore // Mesh.cpp onRecvPacket PAYLOAD_TYPE_ADVERT). app_data = flags(0x81 = chat // node + has-name) followed by the UTF-8 name. static uint8_t mcSeed[32]; // persistent 32-byte identity seed (NVS) static uint8_t mcPrv[64]; // orlp expanded private key, derived from seed static uint8_t mcPub[32]; // Ed25519 public key = MeshCore identity static uint32_t lastMcAdvertMs = 0; static const uint32_t MC_ADVERT_INTERVAL_MS = 30000; // Build-time epoch base (2026-07-01 UTC) + uptime — advert timestamps just need // to be fresh/monotonic; the node has no RTC. static const uint32_t MC_EPOCH_BASE = 1782864000UL; static uint32_t mcNow() { return MC_EPOCH_BASE + millis() / 1000; } static void initMeshCoreIdentity() { Preferences prefs; prefs.begin("meshcore", false); // NVS key "prv" holds the 32-byte seed (unchanged from the first identity so // peers that already added us keep recognising the same public key). if (prefs.getBytes("prv", mcSeed, 32) != 32) { for (int i = 0; i < 32; i++) mcSeed[i] = (uint8_t)esp_random(); // HW CSPRNG prefs.putBytes("prv", mcSeed, 32); Serial.println("MeshCore: generated new Ed25519 identity"); } prefs.end(); ed25519_create_keypair(mcPub, mcPrv, mcSeed); // seed -> (pub, expanded prv) Serial.print("MeshCore identity pub: "); for (int i = 0; i < 32; i++) Serial.printf("%02x", mcPub[i]); Serial.println(); } static void sendMeshCoreAdvert() { uint8_t app_data[40]; size_t ad = 0; app_data[ad++] = 0x81; // flags: chat node + has name size_t nl = strlen(DEVICE_NAME); if (nl > 32) nl = 32; memcpy(app_data + ad, DEVICE_NAME, nl); ad += nl; uint32_t ts = mcNow(); uint8_t message[32 + 4 + 40]; size_t ml = 0; memcpy(message + ml, mcPub, 32); ml += 32; memcpy(message + ml, &ts, 4); ml += 4; memcpy(message + ml, app_data, ad); ml += ad; uint8_t sig[64]; ed25519_sign(sig, message, ml, mcPub, mcPrv); uint8_t pkt[2 + 32 + 4 + 64 + 40]; size_t p = 0; pkt[p++] = 0x12; // route=flood(2) | type=advert(4) pkt[p++] = 0x00; // path_len = 0 (direct/flood) memcpy(pkt + p, mcPub, 32); p += 32; memcpy(pkt + p, &ts, 4); p += 4; memcpy(pkt + p, sig, 64); p += 64; memcpy(pkt + p, app_data, ad); p += ad; radio.standby(); int st = radio.transmit(pkt, p); Serial.printf(" [C] advert '%s' ts=%u -> tx=%d (%u bytes) : ", DEVICE_NAME, ts, st, (unsigned)p); for (size_t i = 0; i < p; i++) Serial.printf("%02x", pkt[i]); // TX hex for self-verify Serial.println(); radio.startReceive(); } // Send an encrypted MeshCore direct message to a contact (Ed25519 pubkey). // Mirror of the RX path: plaintext = timestamp(4) + flags(1) + text, then // encryptThenMAC = HMAC-SHA256(2) prefix + AES-128-ECB(zero-padded plaintext), // wrapped as [header][path_len 0][dest_hash][src_hash][cipher]. static void sendMeshCoreText(const uint8_t* dest_pub, const char* text) { uint32_t ts = mcNow(); uint8_t plain[176]; size_t pl = 0; memcpy(plain + pl, &ts, 4); pl += 4; plain[pl++] = 0x00; // flags: attempt 0, TXT_TYPE_PLAIN size_t tl = strlen(text); if (tl > 140) tl = 140; memcpy(plain + pl, text, tl); pl += tl; uint8_t secret[32]; ed25519_key_exchange(secret, dest_pub, mcPrv); // ECDH shared secret // AES-128-ECB over the plaintext, zero-padded to a 16-byte boundary. uint8_t cipher[2 + 176 + 16]; uint8_t* enc = cipher + 2; size_t el = 0; AES128 aes; aes.setKey(secret, 16); size_t off = 0; while (pl - off >= 16) { aes.encryptBlock(enc + el, plain + off); el += 16; off += 16; } if (pl - off > 0) { uint8_t tmp[16]; memset(tmp, 0, 16); memcpy(tmp, plain + off, pl - off); aes.encryptBlock(enc + el, tmp); el += 16; } // 2-byte truncated HMAC-SHA256 over the ciphertext (key = 32-byte secret). SHA256 sha; sha.resetHMAC(secret, 32); sha.update(enc, el); sha.finalizeHMAC(secret, 32, cipher, 2); size_t clen = 2 + el; uint8_t pkt[4 + sizeof(cipher)]; size_t p = 0; pkt[p++] = 0x0A; // route=flood(2) | type=TXT_MSG(2) pkt[p++] = 0x00; // path_len = 0 pkt[p++] = dest_pub[0]; // dest_hash pkt[p++] = mcPub[0]; // src_hash memcpy(pkt + p, cipher, clen); p += clen; radio.standby(); int st = radio.transmit(pkt, p); Serial.printf(" [C] TX dm -> tx=%d (%u bytes) : \"%s\"\n", st, (unsigned)p, text); radio.startReceive(); } // --- MeshCore contacts (heard adverts) + DM receive ------------------------ // To decrypt a direct message we need the SENDER's full public key. MeshCore // packets only carry a 1-byte src_hash (pubkey prefix), so we remember pubkeys // from the adverts we hear and match on that hash. struct McContact { uint8_t pub[32]; char name[24]; bool used; }; static const int MC_MAX_CONTACTS = 8; static McContact mcContacts[MC_MAX_CONTACTS]; static int mcContactRR = 0; // round-robin eviction cursor static void mcStoreContact(const uint8_t* pub, const char* name) { for (int i = 0; i < MC_MAX_CONTACTS; i++) // update existing if (mcContacts[i].used && memcmp(mcContacts[i].pub, pub, 32) == 0) { strncpy(mcContacts[i].name, name, sizeof(mcContacts[i].name) - 1); return; } for (int i = 0; i < MC_MAX_CONTACTS; i++) // fill a free slot if (!mcContacts[i].used) { mcContacts[i].used = true; memcpy(mcContacts[i].pub, pub, 32); strncpy(mcContacts[i].name, name, sizeof(mcContacts[i].name) - 1); return; } int i = mcContactRR; mcContactRR = (mcContactRR + 1) % MC_MAX_CONTACTS; // evict memcpy(mcContacts[i].pub, pub, 32); strncpy(mcContacts[i].name, name, sizeof(mcContacts[i].name) - 1); mcContacts[i].name[sizeof(mcContacts[i].name) - 1] = 0; } // MeshCore Utils::MACThenDecrypt — verify a 2-byte truncated HMAC-SHA256 over // the ciphertext (key = 32-byte shared secret), then AES-128-ECB decrypt (key = // first 16 bytes). Returns plaintext length (multiple of 16) or 0 on bad MAC. static int mcMACThenDecrypt(const uint8_t* secret, uint8_t* dest, const uint8_t* src, int src_len) { if (src_len <= 2 || (src_len - 2) % 16 != 0) return 0; uint8_t hmac[2]; SHA256 sha; sha.resetHMAC(secret, 32); sha.update(src + 2, src_len - 2); sha.finalizeHMAC(secret, 32, hmac, 2); if (memcmp(hmac, src, 2) != 0) return 0; // MAC mismatch AES128 aes; aes.setKey(secret, 16); int n = src_len - 2; const uint8_t* sp = src + 2; uint8_t* dp = dest; for (int o = 0; o < n; o += 16) { aes.decryptBlock(dp, sp); dp += 16; sp += 16; } return n; } // Parse a received MeshCore packet: store adverts, decrypt DMs addressed to us. static void handleMeshCorePacket(const uint8_t* buf, size_t len) { if (len < 2) return; uint8_t header = buf[0]; uint8_t route = header & 0x03; uint8_t ptype = (header >> 2) & 0x0F; size_t i = 1; if (route == 0x00 || route == 0x03) i += 4; // transport codes if (i >= len) return; uint8_t plb = buf[i++]; uint8_t hsize = (plb >> 6) + 1, hcount = plb & 0x3F; i += (size_t)hsize * hcount; // skip path hashes if (i >= len) return; const uint8_t* payload = buf + i; int payload_len = (int)len - (int)i; if (ptype == 0x04) { // ADVERT if (payload_len < 32 + 4 + 64 + 1) return; char name[24] = {0}; const uint8_t* app = payload + 32 + 4 + 64; int app_len = payload_len - (32 + 4 + 64); if (app_len > 1) { int nl = app_len - 1; if (nl > 23) nl = 23; memcpy(name, app + 1, nl); } mcStoreContact(payload, name); strncpy(lastMsg, name[0] ? name : "advert", sizeof(lastMsg) - 1); lastMsg[sizeof(lastMsg) - 1] = 0; lastMsgNet = 'C'; lastMsgMs = millis(); Serial.printf(" decoded[C](advert): %s\n", name); return; } if (ptype == 0x02) { // TXT_MSG (direct message) if (payload_len < 2 + 2 + 16) return; uint8_t dest_hash = payload[0], src_hash = payload[1]; if (dest_hash != mcPub[0]) return; // not addressed to us const uint8_t* macData = payload + 2; int macLen = payload_len - 2; for (int c = 0; c < MC_MAX_CONTACTS; c++) { if (!mcContacts[c].used || mcContacts[c].pub[0] != src_hash) continue; uint8_t secret[32]; ed25519_key_exchange(secret, mcContacts[c].pub, mcPrv); // ECDH uint8_t plain[192]; int n = mcMACThenDecrypt(secret, plain, macData, macLen); if (n > 5) { plain[n < (int)sizeof(plain) ? n : (int)sizeof(plain) - 1] = 0; uint8_t txt_type = plain[4] & 0x03; const char* text = (const char*)&plain[5]; if (txt_type == 0) { // TXT_TYPE_PLAIN strncpy(lastMsg, text, sizeof(lastMsg) - 1); lastMsg[sizeof(lastMsg) - 1] = 0; lastMsgNet = 'C'; lastMsgMs = millis(); Serial.printf(" decoded[C](dm from %s): %s\n", mcContacts[c].name, text); triggerMessageModal("meshCore", mcContacts[c].name, text); } return; } } Serial.printf(" [C] DM for us but no matching contact (src_hash=%02x)\n", src_hash); } } static void drainPacket() { size_t len = radio.getPacketLength(); uint8_t buf[256]; if (len > sizeof(buf)) len = sizeof(buf); int state = radio.readData(buf, len); if (state != RADIOLIB_ERR_NONE) { Serial.printf(" [%s] RX error state=%d\n", CONFIGS[activeIdx].name, state); return; } NetStat& s = stats[activeIdx]; s.pkts++; s.seen = true; s.lastRssi = (int16_t)radio.getRSSI(); s.lastRxMs = millis(); totalPkts++; Serial.printf(" [%s] PACKET len=%u rssi=%ddBm snr=%.1fdB : ", CONFIGS[activeIdx].name, (unsigned)len, s.lastRssi, radio.getSNR()); for (size_t i = 0; i < len; i++) Serial.printf("%02x", buf[i]); Serial.println(); // meshTastic packets we can decode on-chip today. if (CONFIGS[activeIdx].letter == 'T') { char msg[64]; bool isText = false; uint32_t snd = 0; if (decodeMeshtastic(buf, len, msg, sizeof(msg), &isText, &snd)) { strncpy(lastMsg, msg, sizeof(lastMsg) - 1); lastMsg[sizeof(lastMsg) - 1] = 0; lastMsgNet = 'T'; lastMsgMs = millis(); Serial.printf(" decoded[T]%s: %s\n", isText ? "(text)" : "(info)", msg); if (isText) { char sid[16]; snprintf(sid, sizeof(sid), "!%08x", snd); triggerMessageModal("meshTastic", sid, msg); } } } // MeshCore: store heard adverts, decrypt direct messages addressed to us. if (CONFIGS[activeIdx].letter == 'C') { handleMeshCorePacket(buf, len); } } // --- The trippy acid display ------------------------------------------------ // Left: an acid-house smiley wobbling inside expanding psychedelic rings. // Right: "RMC" and the three network rows with live counts + RX blips. static void drawAcidSmiley(int cx, int cy) { // expanding ripple rings (the trip) for (int k = 0; k < 3; k++) { int r = (int)((frame * 2 + k * 13) % 42); if (r > 3) oled.drawCircle(cx, cy, r, U8G2_DRAW_ALL); } // face: filled disc, eyes + smile cut back out in background colour oled.setDrawColor(1); oled.drawDisc(cx, cy, 18, U8G2_DRAW_ALL); oled.setDrawColor(0); oled.drawBox(cx - 8, cy - 8, 3, 7); // left eye oled.drawBox(cx + 5, cy - 8, 3, 7); // right eye for (int dx = -10; dx <= 10; dx++) { // grinning acid smile int dy = 4 + (100 - dx * dx) / 22; // corners up, dips in the middle oled.drawPixel(cx + dx, cy + dy); oled.drawPixel(cx + dx, cy + dy + 1); } oled.setDrawColor(1); } // Periodic freak-out: the face becomes a spinning striped ball with wide-open // googly eyes and a gaping mouth. static void drawAcidSmileyFreaky(int cx, int cy) { const int R = 18; oled.setDrawColor(1); oled.drawDisc(cx, cy, R, U8G2_DRAW_ALL); // rotating stripes carved into the ball (the spin) float th = frame * 0.35f; float c = cosf(th), s = sinf(th); oled.setDrawColor(0); for (int off = -R; off <= R; off += 4) { float px = cx - s * off, py = cy + c * off; oled.drawLine((int)(px - c * R), (int)(py - s * R), (int)(px + c * R), (int)(py + s * R)); } oled.setDrawColor(1); oled.drawCircle(cx, cy, R, U8G2_DRAW_ALL); // crisp rim // wide-open googly eyes: white halo -> dark iris -> white pupil highlight, // so they read against the striped ball (a plain white disc would vanish) for (int sgn = -1; sgn <= 1; sgn += 2) { int ex = cx + sgn * 7; oled.setDrawColor(1); oled.drawDisc(ex, cy - 6, 4, U8G2_DRAW_ALL); // halo oled.setDrawColor(0); oled.drawDisc(ex, cy - 6, 3, U8G2_DRAW_ALL); // iris oled.setDrawColor(1); oled.drawDisc(ex, cy - 6, 1, U8G2_DRAW_ALL); // pupil } // gaping mouth: white ring around a dark O oled.setDrawColor(1); oled.drawDisc(cx, cy + 7, 5, U8G2_DRAW_ALL); oled.setDrawColor(0); oled.drawDisc(cx, cy + 7, 3, U8G2_DRAW_ALL); oled.setDrawColor(1); } static void drawMarchingBorder() { for (int x = 0; x < 128; x++) { if (((x + frame) & 3) == 0) { oled.drawPixel(x, 0); oled.drawPixel(x, 63); } } for (int y = 0; y < 64; y++) { if (((y + frame) & 3) == 0) { oled.drawPixel(0, y); oled.drawPixel(127, y); } } } static const PhyConfig* configByLetter(char L) { for (size_t i = 0; i < NUM_CONFIGS; i++) if (CONFIGS[i].letter == L) return &CONFIGS[i]; return nullptr; } static int indexByLetter(char L) { for (size_t i = 0; i < NUM_CONFIGS; i++) if (CONFIGS[i].letter == L) return (int)i; return -1; } // --- Message-received animation + modal ------------------------------------ static void drawBolt(int x, int y, int h) { oled.drawLine(x, y, x - 3, y + h / 2); oled.drawLine(x - 3, y + h / 2, x + 3, y + h / 2); oled.drawLine(x + 3, y + h / 2, x - 1, y + h); } // Greedy word-wrap for a fixed-width font (6px/char here). static void drawWrapped(const char* text, int x, int y0, int cpl, int lineh, int maxlines) { int n = strlen(text), i = 0, line = 0; while (i < n && line < maxlines) { int end = i + cpl; if (end < n) { int b = end; while (b > i && text[b] != ' ') b--; if (b > i) end = b; } else end = n; char buf[40]; int len = end - i; if (len > 39) len = 39; memcpy(buf, text + i, len); buf[len] = 0; oled.drawStr(x, y0 + line * lineh, buf); i = end; while (i < n && text[i] == ' ') i++; line++; } } // Intro: the striped smiley spins UPSIDE DOWN (eyes below, mouth above) while // lightning bolts flash — a beat before the modal, à la Meshtastic. static void drawMsgIntro() { oled.clearBuffer(); int cx = 64, cy = 30; const int R = 22; oled.setDrawColor(1); oled.drawDisc(cx, cy, R, U8G2_DRAW_ALL); float th = -frame * 0.5f; // reversed spin float c = cosf(th), s = sinf(th); oled.setDrawColor(0); for (int off = -R; off <= R; off += 4) { float px = cx - s * off, py = cy + c * off; oled.drawLine((int)(px - c * R), (int)(py - s * R), (int)(px + c * R), (int)(py + s * R)); } oled.setDrawColor(1); oled.drawCircle(cx, cy, R, U8G2_DRAW_ALL); for (int sgn = -1; sgn <= 1; sgn += 2) { // eyes BELOW centre (upside down) int ex = cx + sgn * 8; oled.setDrawColor(1); oled.drawDisc(ex, cy + 8, 4, U8G2_DRAW_ALL); oled.setDrawColor(0); oled.drawDisc(ex, cy + 8, 3, U8G2_DRAW_ALL); oled.setDrawColor(1); oled.drawDisc(ex, cy + 8, 1, U8G2_DRAW_ALL); } oled.setDrawColor(1); oled.drawDisc(cx, cy - 9, 5, U8G2_DRAW_ALL); // mouth ABOVE oled.setDrawColor(0); oled.drawDisc(cx, cy - 9, 3, U8G2_DRAW_ALL); oled.setDrawColor(1); if ((frame / 2) & 1) { drawBolt(22, 5, 22); drawBolt(106, 5, 22); } else { drawBolt(14, 12, 20); drawBolt(114, 10, 22); } oled.setFont(u8g2_font_5x8_tf); const char* t = "INCOMING"; oled.drawStr((128 - oled.getStrWidth(t)) / 2, 62, t); oled.sendBuffer(); } // Modal: network banner + sender + wrapped message text. static void drawMsgModal() { oled.clearBuffer(); oled.drawRFrame(1, 1, 126, 62, 3); oled.drawBox(1, 1, 126, 13); // header bar oled.setDrawColor(0); oled.setFont(u8g2_font_6x10_tf); oled.drawStr((128 - oled.getStrWidth(modalNet)) / 2, 11, modalNet); oled.setDrawColor(1); oled.setFont(u8g2_font_5x8_tf); char sl[28]; snprintf(sl, sizeof(sl), "from %s", modalSender); oled.drawStr(6, 24, sl); oled.setFont(u8g2_font_6x10_tf); drawWrapped(modalText, 6, 36, 19, 11, 3); oled.sendBuffer(); } static void drawUI() { if (msgModalActive) { uint32_t e = millis() - msgModalStart; if (e < MODAL_INTRO_MS) { drawMsgIntro(); return; } if (e < MODAL_TOTAL_MS) { drawMsgModal(); return; } msgModalActive = false; } oled.clearBuffer(); // Every ~9s the smiley freaks out (spins, stripes, eyes + mouth open) for ~2.5s. bool freaky = (millis() % 9000) < 2500; if (freaky) drawAcidSmileyFreaky(31, 33); else drawAcidSmiley(31, 33); drawMarchingBorder(); // "RTC" punk header on the right oled.setFont(u8g2_font_bubble_tr); const char* rtc = "RTC"; int rw = oled.getStrWidth(rtc); int rx = 64 + (64 - rw) / 2; if ((frame / 10) & 1) { // strobe invert oled.drawBox(64, 0, 64, 22); oled.setDrawColor(0); oled.drawStr(rx, 19, rtc); oled.setDrawColor(1); } else { oled.drawStr(rx, 19, rtc); } // three network rows, in R T C order const char order[3] = {'R', 'T', 'C'}; oled.setFont(u8g2_font_6x10_tf); uint32_t now = millis(); for (int r = 0; r < 3; r++) { int idx = indexByLetter(order[r]); if (idx < 0) continue; const NetStat& s = stats[idx]; int y = 32 + r * 11; // baseline (below the taller header) bool active = (idx == activeIdx); if (active) oled.drawFrame(64, y - 9, 64, 11); char line[24]; if (s.seen) snprintf(line, sizeof(line), "%c %4lu", order[r], (unsigned long)s.pkts); else snprintf(line, sizeof(line), "%c --", order[r]); oled.drawStr(67, y, line); // RX activity blip (filled when a packet landed in the last 400ms) bool blip = s.seen && (now - s.lastRxMs < 400); if (blip) oled.drawDisc(121, y - 4, 3, U8G2_DRAW_ALL); else oled.drawCircle(121, y - 4, 3, U8G2_DRAW_ALL); } // last decoded event along the bottom of the right column if (lastMsg[0]) { oled.setFont(u8g2_font_4x6_tf); char line[24]; snprintf(line, sizeof(line), "%c:%s", lastMsgNet, lastMsg); line[15] = 0; // truncate to fit the ~60px column oled.drawStr(66, 62, line); } oled.sendBuffer(); } void setup() { Serial.begin(115200); uint32_t t0 = millis(); while (!Serial && millis() - t0 < 2000) { } Serial.println("\n=== archy-messh Phase 1 — RTC scanner + acid display ==="); // Power the OLED rail, then reset the panel, then bring up U8g2. pinMode(PIN_VEXT, OUTPUT); digitalWrite(PIN_VEXT, LOW); delay(60); pinMode(PIN_OLED_RST, OUTPUT); digitalWrite(PIN_OLED_RST, LOW); delay(20); digitalWrite(PIN_OLED_RST, HIGH); delay(20); oled.begin(); oled.setBusClock(400000); // Boot splash: the device identity, shared across all three networks. oled.clearBuffer(); drawAcidSmiley(64, 24); oled.setFont(u8g2_font_6x10_tf); int nw = oled.getStrWidth(DEVICE_NAME); oled.drawStr((128 - nw) / 2, 52, DEVICE_NAME); oled.setFont(u8g2_font_5x8_tf); const char* sub = "R T C bridge"; int sw = oled.getStrWidth(sub); oled.drawStr((128 - sw) / 2, 62, sub); oled.sendBuffer(); Serial.printf("Device identity (all networks): %s / %s\n", DEVICE_NAME, DEVICE_SHORT); delay(2200); SPI.begin(PIN_LORA_SCK, PIN_LORA_MISO, PIN_LORA_MOSI, PIN_LORA_NSS); const PhyConfig& first = CONFIGS[0]; int state = radio.begin(first.freqMHz, first.bwKHz, first.sf, first.cr, first.syncWord, RX_TX_POWER, first.preamble, TCXO_VOLTAGE); Serial.printf("radio.begin -> %d %s\n", state, state == RADIOLIB_ERR_NONE ? "(OK)" : "(ERR)"); if (state != RADIOLIB_ERR_NONE) { Serial.println("!! SX1262 init failed — check wiring/TCXO. Halting."); while (true) { drawUI(); frame++; delay(60); } // still show the trip } radio.setDio2AsRfSwitch(true); radio.setDio1Action(onDio1); radio.setOutputPower(14); // EU868-safe TX power for announces // Derive this device's Meshtastic NodeNum from the last 4 bytes of the MAC. uint8_t mac[6]; esp_read_mac(mac, ESP_MAC_WIFI_STA); nodeNum = ((uint32_t)mac[2] << 24) | ((uint32_t)mac[3] << 16) | ((uint32_t)mac[4] << 8) | mac[5]; snprintf(nodeId, sizeof(nodeId), "!%08x", nodeNum); Serial.printf("Meshtastic node: %s (%s)\n", DEVICE_NAME, nodeId); initMeshCoreIdentity(); activeIdx = 0; applyConfig(CONFIGS[0]); dwellStart = millis(); } // Read a line from the serial console into the TX queue. Syntax: // "hello" -> send "hello" on all networks // "T:hello" -> send only on Meshtastic (T / C / R prefix, case-insensitive) static void pollSerialTx() { static char buf[210]; static size_t n = 0; while (Serial.available()) { char c = Serial.read(); if (c == '\r') continue; if (c == '\n') { buf[n] = 0; if (n > 0) { const char* msg = buf; strcpy(txNets, "TCR"); // default: all networks if (n >= 2 && buf[1] == ':') { char L = toupper(buf[0]); if (L == 'T' || L == 'C' || L == 'R') { txNets[0] = L; txNets[1] = 0; msg = buf + 2; } } strncpy(txText, msg, sizeof(txText) - 1); txText[sizeof(txText) - 1] = 0; txPending = true; Serial.printf(" [tx] queued for %s: \"%s\"\n", txNets, txText); } n = 0; } else if (n < sizeof(buf) - 1) { buf[n++] = c; } } } // If a message is queued for the currently-tuned network, send it now. static void flushTxOnWindow() { if (!txPending) return; char L = CONFIGS[activeIdx].letter; char* p = strchr(txNets, L); if (!p) return; // not queued for this network if (L == 'T') { sendMeshtasticText(txText); } else if (L == 'C') { // DM the text to every MeshCore contact we've heard an advert from. int sent = 0; for (int c = 0; c < MC_MAX_CONTACTS; c++) if (mcContacts[c].used) { sendMeshCoreText(mcContacts[c].pub, txText); sent++; } if (sent == 0) Serial.printf(" [C] no known contacts yet — nobody to DM\n"); } else { // Reticulum TX not built yet — acknowledge, don't fake it. Serial.printf(" [%c] TX not implemented yet (msg: \"%s\")\n", L, txText); } // Remove this network from the pending set; done when none remain. memmove(p, p + 1, strlen(p)); // includes the NUL terminator if (txNets[0] == 0) txPending = false; } void loop() { pollSerialTx(); // Non-blocking scan: rotate the radio when the dwell window elapses, so the // display keeps animating smoothly the whole time. if (millis() - dwellStart >= DWELL_MS) { activeIdx = (activeIdx + 1) % NUM_CONFIGS; applyConfig(CONFIGS[activeIdx]); dwellStart = millis(); } flushTxOnWindow(); if (rxFlag) { rxFlag = false; drainPacket(); radio.startReceive(); } // Announce ourselves as a Meshtastic node while tuned to that PHY. if (CONFIGS[activeIdx].letter == 'T' && (lastAnnounceMs == 0 || millis() - lastAnnounceMs > ANNOUNCE_INTERVAL_MS)) { announceMeshtastic(); lastAnnounceMs = millis(); } // Advertise ourselves as a MeshCore chat node while tuned to that PHY, so we // show up as a contact in other MeshCore clients. if (CONFIGS[activeIdx].letter == 'C' && (lastMcAdvertMs == 0 || millis() - lastMcAdvertMs > MC_ADVERT_INTERVAL_MS)) { sendMeshCoreAdvert(); lastMcAdvertMs = millis(); } drawUI(); frame++; delay(45); // ~20 fps }