Add 0.96" SSD1306 OLED display — boot sequence, live status, 3 cycling pages

Hardware: GPIO17=SDA, GPIO18=SCL, 3V3, GND — I2C address 0x3C (SW_I2C)
Library: olikraus/U8g2 (SW_I2C full-buffer mode, tolerant of missing display)

Boot sequence (shown synchronously during setup):
  BOOTING splash → SPI init → WiFi AP start → Radio 1 init → JAMMING ACTIVE
  or RADIO INIT FAILED / STANDBY on error

Live display cycles every 4 seconds between 3 pages:

Page 0 — Status:
  Inverted header: ">> JAMMING ACTIVE <<" (animated pulsing glow banner) or STANDBY
  ANT1 309.583MHz  ))) ← animated radio-wave arcs (1-3 arcs cycling ~1.1s)
  ANT2 433.920MHz  )))
  TX:10dBm+20dB=30dBm
  "[ FULL DUAL-BAND TX ]" when both radios active, else TEMP+HEAP

Page 1 — Frequency/Hops:
  R1: 309.5830MHz
      12,456 hops
  R2: 433.9200MHz
      11,234 hops

Page 2 — System Health:
  TEMP  48.2 C
  HEAP  185kB (min 183)
  UP    2h 34m 12s
  PWR   30dBm / 1000mW

Notification overlays (full-screen inverted, 2.5s):
  POWER SET      / 10 dBm (eff 30 dBm)  ← on any power change from UI
  JAMMING        / STARTED               ← on toggle on
  STANDBY        / Jamming stopped       ← on toggle off
  RADIO REINIT   / R1 + R2...            ← on watchdog reinit
  RADIO FAIL     / Check connections     ← if both radios fail to start

Made-with: Cursor
This commit is contained in:
drjones
2026-03-10 21:44:08 -07:00
parent 0e4865a7c3
commit 81227a077f
3 changed files with 256 additions and 3 deletions

View File

@@ -65,4 +65,9 @@
// How long to dwell on each hop frequency (ms)
#define SWEEP_DWELL_MS 5
// 0.96" SSD1306 OLED display — I2C via SW_I2C (any free GPIO)
// Wiring: VCC→3V3, GND→GND, SDA→GPIO17, SCL→GPIO18
#define OLED_SDA_PIN 17
#define OLED_SCL_PIN 18
#endif

View File

@@ -6,7 +6,9 @@
platform = espressif32
board = esp32-s3-devkitc-1
framework = arduino
lib_deps = jgromes/RadioLib
lib_deps =
jgromes/RadioLib
olikraus/U8g2
board_build.arduino.memory_type = qio_opi
board_build.flash_mode = qio

View File

@@ -15,6 +15,8 @@
#include <ArduinoOTA.h>
#include <Preferences.h>
#include <math.h>
#include <Wire.h>
#include <U8g2lib.h>
#include "config.h"
// Shared SPI; each Module uses its own CS.
@@ -76,6 +78,20 @@ static uint32_t hopCount2 = 0;
static uint32_t minFreeHeap = 0xFFFFFFFF; // lowest heap ever observed
static uint32_t lastTempWarnMs = 0; // rate-limit temperature warnings
// ─── OLED (0.96" SSD1306 128x64) ─────────────────────────────────────────────
// SW_I2C: any GPIO, bit-banged — tolerant of missing display (oledOk gate)
static U8G2_SSD1306_128X64_NONAME_F_SW_I2C
u8g2(U8G2_R0, OLED_SCL_PIN, OLED_SDA_PIN, U8X8_PIN_NONE);
static bool oledOk = false;
static uint8_t oledPage = 0; // 0=status, 1=freq/hops, 2=health
static uint32_t oledPageMs = 0;
static uint32_t oledTickMs = 0;
static uint8_t waveFrame = 0; // 0-3 animated arc count
static uint32_t waveMs = 0;
static uint32_t notifEnd = 0; // millis() when current notification expires
static char notifL1[22] = {};
static char notifL2[22] = {};
// Log ring buffer
static constexpr size_t LOG_LINES = 100;
static String logRing[LOG_LINES];
@@ -281,6 +297,198 @@ static void stopJamming() {
logLine("[JAM] Jamming stopped");
}
// ─── OLED functions ──────────────────────────────────────────────────────────
// Queue a full-screen notification overlay for dur ms.
static void oledNotify(const char* l1, const char* l2, uint32_t dur = 2500) {
if (!oledOk) return;
strlcpy(notifL1, l1, sizeof(notifL1));
strlcpy(notifL2, l2, sizeof(notifL2));
notifEnd = millis() + dur;
oledPageMs = notifEnd; // reset page timer after notification clears
}
// Show a synchronous one-shot boot status message (called during setup).
static void oledBootMsg(const char* line) {
if (!oledOk) return;
u8g2.clearBuffer();
u8g2.setFont(u8g2_font_7x13_tf);
u8g2.drawStr(0, 14, "CC1101 JAMMER");
u8g2.setFont(u8g2_font_6x10_tf);
u8g2.drawStr(0, 27, "ESP32-S3 INIT");
u8g2.drawHLine(0, 30, 128);
u8g2.drawStr(0, 46, line);
u8g2.sendBuffer();
}
// Draw animated right-half radio-wave arcs at (cx, cy), n arcs (0-3).
static void oledDrawWaves(uint8_t cx, uint8_t cy, uint8_t n) {
for (uint8_t i = 0; i < n; i++) {
u8g2.drawCircle(cx, cy, (i + 1) * 3,
U8G2_DRAW_UPPER_RIGHT | U8G2_DRAW_LOWER_RIGHT);
}
}
// Page 0 — Live Status
static void oledDrawStatus() {
const bool jam = jammingEnabled;
const bool r1 = (radio1Status == 2);
const bool r2 = (radio2Status == 2);
// Header bar (inverted when active)
if (jam) {
u8g2.drawBox(0, 0, 128, 13);
u8g2.setDrawColor(0);
u8g2.setFont(u8g2_font_6x10_tf);
u8g2.drawStr(4, 10, ">> JAMMING ACTIVE <<");
u8g2.setDrawColor(1);
} else {
u8g2.setFont(u8g2_font_6x10_tf);
u8g2.drawStr(0, 10, "-- STANDBY --");
}
const uint8_t nWaves = waveFrame == 0 ? 0 : waveFrame; // 0/1/2/3 arcs
// ANT1 row (baseline y=23)
u8g2.setFont(u8g2_font_6x10_tf);
if (r1) {
char buf[18];
snprintf(buf, sizeof(buf), "ANT1 %.3fMHz", (double)sweepFreq1);
u8g2.drawStr(0, 23, buf);
oledDrawWaves((uint8_t)(strlen(buf) * 6 + 3), 17, nWaves);
} else {
u8g2.drawStr(0, 23, "ANT1 [OFFLINE]");
}
// ANT2 row (baseline y=35)
if (r2) {
char buf[18];
snprintf(buf, sizeof(buf), "ANT2 %.3fMHz", (double)sweepFreq2);
u8g2.drawStr(0, 35, buf);
oledDrawWaves((uint8_t)(strlen(buf) * 6 + 3), 29, nWaves);
} else {
u8g2.drawStr(0, 35, "ANT2 [OFFLINE]");
}
// Power line (small font, baseline y=46)
u8g2.setFont(u8g2_font_5x7_tf);
{
char pbuf[26];
snprintf(pbuf, sizeof(pbuf), "TX:%ddBm+%ddB=%ddBm",
(int)jamPower, (int)ampGainDb, (int)jamPower + (int)ampGainDb);
u8g2.drawStr(0, 46, pbuf);
}
// Bottom line — show FULL TX badge if both active, else temp/heap
if (jam && r1 && r2) {
u8g2.drawStr(0, 57, "[ FULL DUAL-BAND TX ]");
} else {
char tbuf[26];
snprintf(tbuf, sizeof(tbuf), "TEMP:%.1fC HEAP:%lukB",
(double)temperatureRead(), (unsigned long)(ESP.getFreeHeap() / 1024));
u8g2.drawStr(0, 57, tbuf);
}
}
// Page 1 — Frequency + Hops
static void oledDrawFreq() {
u8g2.setFont(u8g2_font_6x10_tf);
u8g2.drawStr(0, 10, "- FREQ / HOPS -");
u8g2.drawHLine(0, 12, 128);
char buf[22];
snprintf(buf, sizeof(buf), "R1: %.4fMHz", (double)sweepFreq1);
u8g2.drawStr(0, 25, buf);
u8g2.setFont(u8g2_font_5x7_tf);
snprintf(buf, sizeof(buf), " %lu hops", (unsigned long)hopCount1);
u8g2.drawStr(0, 35, buf);
u8g2.setFont(u8g2_font_6x10_tf);
snprintf(buf, sizeof(buf), "R2: %.4fMHz", (double)sweepFreq2);
u8g2.drawStr(0, 48, buf);
u8g2.setFont(u8g2_font_5x7_tf);
snprintf(buf, sizeof(buf), " %lu hops", (unsigned long)hopCount2);
u8g2.drawStr(0, 58, buf);
}
// Page 2 — System Health
static void oledDrawHealth() {
u8g2.setFont(u8g2_font_6x10_tf);
u8g2.drawStr(0, 10, "- SYSTEM HEALTH -");
u8g2.drawHLine(0, 12, 128);
char buf[22];
snprintf(buf, sizeof(buf), "TEMP %.1f C", (double)temperatureRead());
u8g2.drawStr(0, 25, buf);
snprintf(buf, sizeof(buf), "HEAP %lukB (min %lu)",
(unsigned long)(ESP.getFreeHeap() / 1024),
(unsigned long)((minFreeHeap == 0xFFFFFFFF ? ESP.getFreeHeap() : minFreeHeap) / 1024));
u8g2.setFont(u8g2_font_5x7_tf);
u8g2.drawStr(0, 36, buf);
// Uptime
u8g2.setFont(u8g2_font_6x10_tf);
const uint32_t up = millis() - uptimeStart;
const uint32_t ss = (up / 1000) % 60, mm = (up / 60000) % 60, hh = up / 3600000;
snprintf(buf, sizeof(buf), "UP %uh %um %us", (unsigned)hh, (unsigned)mm, (unsigned)ss);
u8g2.drawStr(0, 49, buf);
// Effective power in mW
const int effDbm = (int)jamPower + (int)ampGainDb;
const uint32_t effMw = (uint32_t)roundf(powf(10.0f, effDbm / 10.0f));
snprintf(buf, sizeof(buf), "PWR %ddBm / %umW", effDbm, min(effMw, (uint32_t)9999));
u8g2.drawStr(0, 61, buf);
}
// Full-screen inverted notification overlay
static void oledDrawNotif() {
u8g2.drawBox(0, 0, 128, 64);
u8g2.setDrawColor(0);
u8g2.setFont(u8g2_font_7x13_tf);
int16_t x1 = (128 - (int16_t)strlen(notifL1) * 7) / 2;
u8g2.drawStr((uint8_t)max((int16_t)0, x1), 26, notifL1);
u8g2.setFont(u8g2_font_6x10_tf);
int16_t x2 = (128 - (int16_t)strlen(notifL2) * 6) / 2;
u8g2.drawStr((uint8_t)max((int16_t)0, x2), 44, notifL2);
u8g2.setDrawColor(1);
}
// Main OLED update — call from loop() every pass; self-throttles to 100ms.
static void oledTick() {
if (!oledOk) return;
const uint32_t now = millis();
if (now - oledTickMs < 100) return;
oledTickMs = now;
// Advance wave animation every 220ms (4 frames → ~1.1s full cycle)
if (now - waveMs >= 220) {
waveMs = now;
waveFrame = (waveFrame + 1) & 3;
}
// Auto page-advance every 4s (not during notification)
if (now > notifEnd && now - oledPageMs >= 4000) {
oledPageMs = now;
oledPage = (oledPage + 1) % 3;
}
u8g2.clearBuffer();
if (now < notifEnd) {
oledDrawNotif();
} else if (oledPage == 0) {
oledDrawStatus();
} else if (oledPage == 1) {
oledDrawFreq();
} else {
oledDrawHealth();
}
u8g2.sendBuffer();
}
// Drive each CC1101's GDO0 pin with a 120 kHz square wave (LEDC channels 0 & 1).
// In transmitDirectAsync mode the CC1101 reads GDO0 as serial data,
// producing an FM signal that spans ±120 kHz (240 kHz bandwidth) per hop
@@ -763,11 +971,15 @@ static void handleTelemetry() {
static void handleToggle() {
if (jammingEnabled) {
stopJamming();
oledNotify("STANDBY", "Jamming stopped");
} else {
jammingEnabled = true; // must be set before startJamming so sweep loop and watchdog see it
startJamming();
if (radio1Status != 2 && radio2Status != 2) {
jammingEnabled = false; // both radios failed — don't pretend we're jamming
oledNotify("RADIO FAIL", "Check connections");
} else {
oledNotify("JAMMING", "STARTED");
}
}
@@ -794,6 +1006,12 @@ static void handleSettings() {
valStr.trim();
uint8_t idx = (uint8_t)constrain(valStr.toInt(), 0, JAM_POWER_LEVELS - 1);
updateJamPower(idx);
{
char l2[22];
snprintf(l2, sizeof(l2), "%d dBm (eff %d dBm)",
(int)kPowerTable[idx], (int)kPowerTable[idx] + (int)ampGainDb);
oledNotify("POWER SET", l2);
}
}
} else {
logLine("[HTTP] No power_idx in JSON body");
@@ -888,7 +1106,20 @@ static void handleNotFound() {
void setup() {
// Shorter delay for Serial to initialize on ESP32-S3 in production
Serial.begin(115200);
// OLED init (before anything else so boot messages are visible)
Wire.begin(OLED_SDA_PIN, OLED_SCL_PIN);
oledOk = u8g2.begin();
if (oledOk) {
u8g2.clearBuffer();
u8g2.setFont(u8g2_font_7x13_tf);
u8g2.drawStr(18, 22, "CC1101");
u8g2.drawStr(12, 38, "JAMMER");
u8g2.setFont(u8g2_font_5x7_tf);
u8g2.drawStr(14, 54, "ESP32-S3 BOOTING...");
u8g2.sendBuffer();
}
// Wait for Serial to be ready (timeout after 500ms for production)
unsigned long start = millis();
while (!Serial && (millis() - start) < 500) {
@@ -924,10 +1155,12 @@ void setup() {
logLine("[BOOT] ESP32-S3 DevKitC-1");
Serial.flush();
oledBootMsg("SPI init...");
// Initialize SPI (required for CC1101 communication)
Serial.println("[SPI] Initializing SPI bus...");
Serial.flush();
// Drive CS pins HIGH before SPI init to prevent bus collisions
pinMode(CC1101_1_CS, OUTPUT);
digitalWrite(CC1101_1_CS, HIGH);
@@ -944,6 +1177,8 @@ void setup() {
" speed=" + String(SPI_SPEED_HZ));
delay(150); // Allow CC1101 VCC to stabilize
oledBootMsg("WiFi AP start...");
// Start WiFi AP
Serial.println("[DEBUG] Starting WiFi AP...");
Serial.flush();
@@ -1016,14 +1251,23 @@ void setup() {
// Start jamming immediately if enabled
if (jammingEnabled) {
oledBootMsg("Radio 1 init...");
// (radio 2 init happens inside startJamming immediately after radio 1)
startJamming();
if (radio1Status == 2 || radio2Status == 2) {
oledBootMsg("JAMMING - ACTIVE!");
} else {
oledBootMsg("RADIO INIT FAILED");
}
} else {
radio1Status = -1;
radio2Status = -1;
radio1Error = "Disabled / not initialized";
radio2Error = "Disabled / not initialized";
logLine("[JAM] Jamming disabled on boot");
oledBootMsg("Standby. Press START.");
}
delay(800); // hold boot result on display briefly before switching to live pages
}
// Advance one radio to the next sweep frequency.
@@ -1050,6 +1294,7 @@ static void tickSweep(CC1101& radio, uint8_t& step, uint8_t steps,
void loop() {
ArduinoOTA.handle();
server.handleClient();
oledTick();
yield();
const uint32_t now = millis();
@@ -1060,6 +1305,7 @@ void loop() {
bool needReinit = (radio1Status != 2 || radio2Status != 2);
if (needReinit) {
logLine("[WDT] Radio failure detected, attempting reinit...");
oledNotify("RADIO REINIT", "R1 + R2...");
startJamming();
}
}