// 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 Meshtastic / MeshCore / Reticulum PHY presets // (RMC), listening in each dwell window and logging every raw packet it // demodulates. The OLED shows all three networks live — R M C — behind an // animated acid-house smiley, with per-network activity blips. // // 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 // --- 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 (RMC) ------------------------------------------- // 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 / M / 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", 'M', 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 // --- 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; // --- 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; } 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(); } // --- 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); } 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(); drawAcidSmiley(31, 33); drawMarchingBorder(); // "RMC" pulsing header on the right oled.setFont(u8g2_font_ncenB12_tr); const char* rmc = "RMC"; int rw = oled.getStrWidth(rmc); int rx = 64 + (64 - rw) / 2; if ((frame / 10) & 1) { // strobe invert oled.drawBox(rx - 2, 1, rw + 4, 15); oled.setDrawColor(0); oled.drawStr(rx, 13, rmc); oled.setDrawColor(1); } else { oled.drawStr(rx, 13, rmc); } // three network rows, in R M C order const char order[3] = {'R', 'M', '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 = 27 + r * 12; // baseline bool active = (idx == activeIdx); if (active) oled.drawFrame(64, y - 9, 64, 12); 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); } oled.sendBuffer(); } void setup() { Serial.begin(115200); uint32_t t0 = millis(); while (!Serial && millis() - t0 < 2000) { } Serial.println("\n=== archy-messh Phase 1 — RMC 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); 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 }