diff --git a/firmware/platformio.ini b/firmware/platformio.ini index 5884249..cd19558 100644 --- a/firmware/platformio.ini +++ b/firmware/platformio.ini @@ -16,3 +16,4 @@ build_flags = -D ARCHY_MESSH_FW lib_deps = jgromes/RadioLib@7.7.1 + olikraus/U8g2@2.36.18 diff --git a/firmware/src/main.cpp b/firmware/src/main.cpp index d3f4e9e..2facdaf 100644 --- a/firmware/src/main.cpp +++ b/firmware/src/main.cpp @@ -1,168 +1,250 @@ -// archy-messh Phase 1 — time-multiplexed tri-protocol RX scanner +// archy-messh Phase 1 — tri-protocol RX scanner + trippy acid display // --------------------------------------------------------------------------- -// Target: Heltec WiFi LoRa 32 V3 (ESP32-S3 + single Semtech SX1262). +// Target: Heltec WiFi LoRa 32 V3 (ESP32-S3 + single SX1262 + 128x64 SSD1306). // -// This is the FIRST Phase 1 increment. It does not yet bridge, decode, or -// transmit anything. Its only job is to answer the project's central open -// question (see docs/ARCHITECTURE.md §1-§2): +// 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. // -// Can one SX1262, re-tuned on a schedule, actually hear raw LoRa traffic -// from the Meshtastic, MeshCore and Reticulum networks — and do the PHY -// configs even work on this board? +// 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. // -// It rotates the radio through each network's PHY config, listens for a fixed -// dwell window, and logs every raw packet it demodulates (length, RSSI, SNR, -// hex). No framing/crypto/dedup yet — those layers build on top of a working -// RX loop, so we prove the RX loop first. -// -// What this increment proves WITHOUT any other radios nearby: -// - correct Heltec V3 SX1262 wiring / TCXO / RF-switch (radio.begin() == OK) -// - clean reconfiguration of the single radio between three PHY presets -// What it can only prove WITH real traffic on air: -// - whether time-multiplexed dwell actually catches other nodes' packets +// 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 WiFi LoRa 32 V3 SX1262 pin map (from Heltec V3 schematic) ------- -static const int PIN_LORA_NSS = 8; // SPI chip select -static const int PIN_LORA_DIO1 = 14; // IRQ -static const int PIN_LORA_NRST = 12; // reset -static const int PIN_LORA_BUSY = 13; // busy +// --- 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; - -// Heltec V3 drives the SX1262 reference oscillator from a 1.8V TCXO and uses -// DIO2 as the TX/RX RF switch. Both are board facts, not tunables. static const float TCXO_VOLTAGE = 1.8f; -static const int8_t RX_TX_POWER = 10; // unused in RX-only scan; begin() needs a value +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 -------------------------------------------------- -// Values from docs/ARCHITECTURE.md §1. Items flagged CONFIRM/PLACEHOLDER are -// the project's known open questions — the whole point of this scan is to test -// them against reality, so they are deliberately explicit here. +// --- 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; // coding-rate denominator: 4/cr (so 5 == 4/5) + uint8_t cr; // 4/cr uint8_t syncWord; uint16_t preamble; }; static const PhyConfig CONFIGS[] = { - // Meshtastic EU868 "LongFast" — well documented. NOTE: Meshtastic actually - // hashes the channel name to a slot within the region plan, so a single - // fixed frequency may not match every deployment (ARCHITECTURE.md §1). - { "meshtastic", 869.525f, 250.0f, 11, 5, 0x2b, 16 }, - - // MeshCore compiled default is the same PHY as Meshtastic. Its sync word is - // UNCONFIRMED (ARCHITECTURE.md §1 open item) — 0x12 is RadioLib's private - // default and only a GUESS here. Community has also moved to narrowband - // SF7-8 / BW62.5kHz (Oct 2025), not reflected yet. - { "meshcore", 869.525f, 250.0f, 11, 5, 0x12, 16 }, - - // Reticulum has NO fixed preset — freq/BW/SF/CR are operator-configured per - // deployment. This is a PLACEHOLDER; set it to whatever your actual RNS - // RNode interface uses or this window will hear nothing. - { "reticulum", 867.200f, 125.0f, 8, 5, 0x12, 16 }, + // 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 -// Dwell per network before rotating to the next. 2s is a starting point; the -// coverage/latency tradeoff of this value is exactly what Phase 1 must measure. -static const uint32_t DWELL_MS = 2000; +// --- 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 plumbing -------------------------------------------------- +// --- RX interrupt ----------------------------------------------------------- static volatile bool rxFlag = false; ICACHE_RAM_ATTR static void onDio1() { rxFlag = true; } -static void logResult(const char* what, int state) { - Serial.printf(" %s -> %d%s\n", what, state, - state == RADIOLIB_ERR_NONE ? " (OK)" : " (ERR)"); -} - -// Apply a PHY config to the already-initialised radio and start listening. 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; + 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(const PhyConfig& c) { +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", c.name, state); + Serial.printf(" [%s] RX error state=%d\n", CONFIGS[activeIdx].name, state); return; } - Serial.printf(" [%s] PACKET len=%u rssi=%.1fdBm snr=%.1fdB : ", - c.name, (unsigned)len, radio.getRSSI(), radio.getSNR()); + 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 < 3000) { /* wait briefly for USB CDC */ } + while (!Serial && millis() - t0 < 2000) { } - Serial.println(); - Serial.println("=== archy-messh Phase 1 — tri-protocol RX scanner ==="); + 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); - logResult("radio.begin", state); + 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) delay(1000); + while (true) { drawUI(); frame++; delay(60); } // still show the trip } - - // DIO2 controls the antenna RF switch on the Heltec V3. - logResult("setDio2AsRfSwitch", radio.setDio2AsRfSwitch(true)); + radio.setDio2AsRfSwitch(true); radio.setDio1Action(onDio1); - Serial.printf("Scanning %u configs, %lums dwell each.\n", - (unsigned)NUM_CONFIGS, (unsigned long)DWELL_MS); + activeIdx = 0; + applyConfig(CONFIGS[0]); + dwellStart = millis(); } void loop() { - for (size_t i = 0; i < NUM_CONFIGS; i++) { - const PhyConfig& c = CONFIGS[i]; - Serial.printf("[%s] %.3fMHz BW%.1f SF%u CR4/%u sync0x%02x\n", - c.name, c.freqMHz, c.bwKHz, c.sf, c.cr, c.syncWord); - - if (!applyConfig(c)) { - Serial.printf(" [%s] !! failed to apply PHY config — skipping\n", c.name); - continue; - } - - uint32_t start = millis(); - while (millis() - start < DWELL_MS) { - if (rxFlag) { - rxFlag = false; - drainPacket(c); - radio.startReceive(); // re-arm for the rest of the dwell window - } - delay(2); - } + // 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 }