// 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" // --- 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 compiled default PHY; sync word 0x12 is a GUESS (open item §1). { "meshcore", 'C', 869.525f, 250.0f, 11, 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 = 1200; // faster cycle so all three feel live // 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; // 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; // --- 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) { if (len <= 16) return false; uint32_t sender, pid; memcpy(&sender, pkt + 4, 4); memcpy(&pid, pkt + 8, 4); // 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; 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; } 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[40]; if (decodeMeshtastic(buf, len, msg, sizeof(msg))) { strncpy(lastMsg, msg, sizeof(lastMsg) - 1); lastMsg[sizeof(lastMsg) - 1] = 0; lastMsgNet = 'T'; lastMsgMs = millis(); Serial.printf(" decoded[T]: %s\n", msg); } } } // --- 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; } static void drawUI() { 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); activeIdx = 0; applyConfig(CONFIGS[0]); dwellStart = millis(); } void loop() { // 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(); } if (rxFlag) { rxFlag = false; drainPacket(); radio.startReceive(); } drawUI(); frame++; delay(45); // ~20 fps }