diff --git a/firmware/README.md b/firmware/README.md index 1943bed..67e51d1 100644 --- a/firmware/README.md +++ b/firmware/README.md @@ -5,20 +5,30 @@ Embedded firmware for the single-device **3-in-1**: one Heltec WiFi LoRa 32 V3 Meshtastic / MeshCore / Reticulum PHY configs. See `../docs/ARCHITECTURE.md` §2-§3 for why Phase 1 is a time-multiplexed single radio (Option A). -## Current increment: tri-protocol RX scanner +## Current increment: RTC scanner + acid display + Meshtastic decode -`src/main.cpp` is the **first** Phase 1 step. It does not bridge, decode, or -transmit yet — it rotates the SX1262 through each network's PHY preset, listens -for a fixed dwell window, and logs every raw LoRa packet it demodulates -(length, RSSI, SNR, hex). The point is to answer the project's central open -question before building any protocol stacks: *can one re-tuned radio actually -hear all three networks?* +`src/main.cpp` rotates the single SX1262 through each network's PHY preset +(**R**eticulum / mesh**T**astic / mesh**C**ore = **RTC**), listens per dwell +window, logs every raw packet, and **decodes Meshtastic on-chip** (AES-128-CTR +via mbedtls + protobuf → text / node names). The 128×64 OLED shows an animated +acid-house UI: a smiley (that periodically freaks out — spins, stripes, eyes + +mouth open), a punk `RTC` header, three live network rows (packet counts + RX +blips), and the last decoded event. -**Validated on hardware (2026-07-01):** flashed to a Heltec V3, the scanner -`radio.begin()`'d cleanly, cycled all three presets, and received **live -Meshtastic broadcast packets** (payloads prefixed `ffffffff`) at −36 to -−41 dBm during the Meshtastic dwell. The single-radio time-multiplex premise -holds. MeshCore/Reticulum windows were silent — expected, see open items below. +Device identity `Reticutasticore` / `RTC` is baked in (`DEVICE_NAME`) as the +name this device will present on all three networks once the TX/participation +layer lands — so a message addressed to it will be routable. + +**Validated on hardware (2026-07-01):** +- radio inits clean, all three presets cycle, display renders. +- **Meshtastic**: live broadcast packets at −34 to −44 dBm; on-chip decode + matches the offline reference (`tools/meshtastic_decode.py`, verified against + real captures — decoded node "Arch Optiplex"/"ARCH"). +- **MeshCore**: received real packets too — so the `0x12` sync-word guess is + **correct** (was an open item). +- **Reticulum**: silent — placeholder PHY config + likely no RNode nodes near. + +Still RX-only: no bridging or transmit yet. ## Open items baked into the config table diff --git a/firmware/src/main.cpp b/firmware/src/main.cpp index 2facdaf..8587d7a 100644 --- a/firmware/src/main.cpp +++ b/firmware/src/main.cpp @@ -2,10 +2,11 @@ // --------------------------------------------------------------------------- // 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. +// 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- @@ -22,6 +23,7 @@ #include #include #include +#include "mbedtls/aes.h" // --- Heltec V3 SX1262 pin map (from the Heltec V3 schematic) ---------------- static const int PIN_LORA_NSS = 8; @@ -43,12 +45,12 @@ 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) ------------------------------------------- +// --- 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 / M / C for the display + char letter; // R / T / C for the display float freqMHz; float bwKHz; uint8_t sf; @@ -58,8 +60,8 @@ struct PhyConfig { }; static const PhyConfig CONFIGS[] = { - // Meshtastic EU868 "LongFast" — confirmed correct (received live packets). - { "meshtastic", 'M', 869.525f, 250.0f, 11, 5, 0x2b, 16 }, + // 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. @@ -68,6 +70,16 @@ static const PhyConfig CONFIGS[] = { 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; @@ -81,6 +93,11 @@ 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; } @@ -98,6 +115,77 @@ static bool applyConfig(const PhyConfig& c) { 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]; @@ -114,6 +202,16 @@ static void drainPacket() { 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 ------------------------------------------------ @@ -139,6 +237,37 @@ static void drawAcidSmiley(int cx, int cy) { 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); } @@ -160,32 +289,35 @@ static int indexByLetter(char L) { static void drawUI() { oled.clearBuffer(); - drawAcidSmiley(31, 33); + // 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(); - // "RMC" pulsing header on the right - oled.setFont(u8g2_font_ncenB12_tr); - const char* rmc = "RMC"; - int rw = oled.getStrWidth(rmc); + // "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(rx - 2, 1, rw + 4, 15); - oled.setDrawColor(0); oled.drawStr(rx, 13, rmc); oled.setDrawColor(1); + oled.drawBox(64, 0, 64, 22); + oled.setDrawColor(0); oled.drawStr(rx, 19, rtc); oled.setDrawColor(1); } else { - oled.drawStr(rx, 13, rmc); + oled.drawStr(rx, 19, rtc); } - // three network rows, in R M C order - const char order[3] = {'R', 'M', 'C'}; + // 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 = 27 + r * 12; // baseline + int y = 32 + r * 11; // baseline (below the taller header) bool active = (idx == activeIdx); - if (active) oled.drawFrame(64, y - 9, 64, 12); + 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]); @@ -196,6 +328,15 @@ static void drawUI() { 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(); } @@ -204,7 +345,7 @@ void setup() { uint32_t t0 = millis(); while (!Serial && millis() - t0 < 2000) { } - Serial.println("\n=== archy-messh Phase 1 — RMC scanner + acid display ==="); + 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); @@ -214,6 +355,20 @@ void setup() { 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,