feat: trippy acid-house display UI (RMC) on the OLED
Add SSD1306 UI to the Phase 1 scanner: an animated acid smiley inside expanding psychedelic rings, an 'RMC' (Reticulum/Meshtastic/meshCore) strobe header, and three live network rows with per-network packet counts and RX activity blips. Scan loop made non-blocking (millis-based dwell) so the display animates smoothly (~20fps) while the radio rotates. Honest note in-code: the single SX1262 is time-sliced across the three PHYs (ARCHITECTURE.md §2); the UI shows all three armed but true concurrent RX needs added radios (Phase 2). Pins U8g2 2.36.18. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
c3f404872e
commit
1fcb0f4c59
@ -16,3 +16,4 @@ build_flags =
|
||||
-D ARCHY_MESSH_FW
|
||||
lib_deps =
|
||||
jgromes/RadioLib@7.7.1
|
||||
olikraus/U8g2@2.36.18
|
||||
|
||||
@ -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 <Arduino.h>
|
||||
#include <RadioLib.h>
|
||||
#include <U8g2lib.h>
|
||||
#include <Wire.h>
|
||||
#include <math.h>
|
||||
|
||||
// --- 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
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user