406 lines
16 KiB
C++
Raw Normal View History

// 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 <Arduino.h>
#include <RadioLib.h>
#include <U8g2lib.h>
#include <Wire.h>
#include <math.h>
#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
}