/* EMI Spectrum Analyzer for M5Stack M5StickC-S3 (ESP32-S3) --------------------------------------------------------------------- Picks up electromagnetic interference (EMI) on a short wire antenna, samples it with the ESP32-S3 ADC, runs an FFT, and draws the result as a live spectrum-analyzer bar display with a peak-hold trace. HARDWARE -------- - Board: M5StickC-S3 (ESP32-S3). M5Unified auto-detects the panel. - Antenna: solder / clip a 5-15 cm piece of solid wire to the ADC pin defined by ADC_PIN below. The bare wire acts as an E-field probe; a disconnected pin still shows ambient pickup on the floating input. - IMPORTANT (ESP32-S3): the probe pin MUST be an ADC1 channel, i.e. one of GPIO1..GPIO10. GPIO26 (used on the classic ESP32 StickC) is NOT an ADC pin on the S3. Default below is GPIO8. Confirm which of these GPIOs is broken out on your StickC-S3 header/Grove port and set ADC_PIN to match. Do not use ADC2 pins (they conflict with Wi-Fi). - Optional: a 1 MΩ resistor from ADC_PIN to GND biases the input and tames drift; a small series cap (e.g. 1 nF) blocks DC if you touch the probe to a live circuit. Never connect the probe to mains voltage. EMI TEST EMITTERS (built-in signal generators) ---------------------------------------------- - HOLD Button B = BASEBAND WHITE NOISE. The firmware bit-bangs a randomized bitstream on EMIT_PIN as fast as the CPU allows, with random sub-microsecond dwell times, producing flat noise energy across the audio/EMI span the analyzer shows. - HOLD Button A = RF JAM. The LEDC hardware timer drives EMIT_PIN with a square-wave carrier that sweeps rapidly from RF_MIN_HZ to RF_MAX_HZ. The fast sweep plus the square wave's rich harmonics spread energy far beyond the analyzer window - a short wire on EMIT_PIN radiates it as wideband RF hash. - While either is held the device does nothing but emit and shows a jam screen; on release it returns to the live spectrum display. - EMIT_PIN must be a free GPIO. Keep it a few cm from the probe - direct wiring pins the ADC and saturates the display. These are low-power logic-level radiators for bench testing only; do not use them to interfere with equipment you do not own, and note that deliberately jamming licensed RF is illegal in most jurisdictions. RAK3172(H) LoRa MODEM (via Grove port) -------------------------------------- - The RAK3172 is an STM32WLE5 LoRa module driven with AT commands over UART (default 115200 baud, RUI3 AT firmware). We stream a compact EMI telemetry packet (dominant frequency + magnitude) over LoRa P2P, so no LoRaWAN gateway / network server is required - just a second RAK3172 running the matching P2P settings to receive. - Wiring (Grove -> RAK3172 UART1), CROSS the data lines: StickC-S3 Grove G2 (TX) -> RAK3172 RX (UART1_RX) StickC-S3 Grove G1 (RX) <- RAK3172 TX (UART1_TX) GND <-> GND Power the RAK3172 per its board: a RAK Grove/Unit carrier with an onboard regulator can take the Grove 5V; a bare RAK3172 module is 3.3V ONLY - do not feed it 5V. Confirm before connecting. - The (H) variant is the high-frequency (868/915 MHz) part. Set LORA_FREQ_HZ below to a band that is legal in your region (868.1 MHz EU868, 915.0 MHz US915, etc.). - Grove UART pins vary by revision; verify G1/G2 for your StickC-S3 and swap LORA_RX_PIN / LORA_TX_PIN if the link stays silent. LIBRARIES (install via Arduino IDE Library Manager) --------------------------------------------------- - "M5Unified" by M5Stack - "M5GFX" by M5Stack (pulled in by M5Unified) - "arduinoFFT" by Enrique Condes (v2.x API used below) (RAK3172 needs no library - it is controlled with plain AT strings.) BOARD SETTINGS (Tools menu) --------------------------- - Install the M5Stack boards package, then select "M5StickC-S3" (or a generic "ESP32S3 Dev Module" if that entry is unavailable). - USB CDC On Boot: Enabled (so Serial works over the S3 native USB). - Upload speed 1500000 is fine. */ #include #include // --------------------------------------------------------------------------- // Configuration // --------------------------------------------------------------------------- static const uint8_t ADC_PIN = 8; // ESP32-S3 ADC1 pin (GPIO1..10) static const uint16_t FFT_SAMPLES = 256; // must be a power of two static const double SAMPLING_HZ = 20000.0; // target ADC sample rate static const uint8_t NUM_BARS = 32; // spectrum columns drawn static const float NOISE_FLOOR_DB = 6.0f; // clamp below this (dB) static const float PEAK_DECAY = 0.92f; // peak-hold fall-off per frame static const float BAR_SMOOTH = 0.55f; // 0=snappy, 1=frozen // ---- EMI test emitters (hold Button B = noise, hold Button A = RF jam) ---- static const uint8_t EMIT_PIN = 7; // GPIO radiating the signal static const uint16_t NOISE_BURST = 4096; // toggles per hold-check chunk static const uint32_t RF_MIN_HZ = 500000; // RF sweep start (0.5 MHz) static const uint32_t RF_MAX_HZ = 4000000; // RF sweep end (4 MHz) static const uint32_t RF_STEP_HZ = 25000; // sweep increment per hop static const uint8_t RF_RES_BITS = 2; // low res => high LEDC freq // ---- RAK3172(H) LoRa modem (Grove UART) ----------------------------------- static const int LORA_RX_PIN = 1; // ESP32 RX <- RAK3172 TX (Grove G1) static const int LORA_TX_PIN = 2; // ESP32 TX -> RAK3172 RX (Grove G2) static const uint32_t LORA_BAUD = 115200; // RAK3172 default AT baud static const uint32_t LORA_FREQ_HZ = 868100000; // legal band for your region! static const uint8_t LORA_SF = 7; // spreading factor 5..12 static const uint8_t LORA_TX_POWER = 14; // dBm static const uint32_t LORA_TX_INTERVAL_MS = 2000; // telemetry cadence // --------------------------------------------------------------------------- // FFT working buffers // --------------------------------------------------------------------------- static double vReal[FFT_SAMPLES]; static double vImag[FFT_SAMPLES]; ArduinoFFT FFT(vReal, vImag, FFT_SAMPLES, SAMPLING_HZ); static const uint32_t SAMPLE_PERIOD_US = (uint32_t)(1000000.0 / SAMPLING_HZ); // display state static float barLevel[NUM_BARS]; static float barPeak[NUM_BARS]; // layout, computed from the panel at boot static int screenW, screenH; static int gridTop, gridBottom, gridH; static int barGap, barW; // double-buffered canvas to avoid flicker static M5Canvas canvas(&M5.Display); // dominant peak captured each FFT frame, shared with the LoRa telemetry static float peakFreqHz = 0.0f; static float peakMagDb = 0.0f; // EMI test emitter: true only while the noise jammer is actively running static bool noiseActive = false; // RAK3172 link state (for the on-screen indicator) static HardwareSerial LoRaSerial(1); // second UART on the Grove pins static bool loraReady = false; static bool lastTxOk = false; static uint32_t lastTxMs = 0; // --------------------------------------------------------------------------- // Sampling: block-read the ADC at a fixed cadence into vReal[] // --------------------------------------------------------------------------- static void sampleBlock() { uint32_t next = micros(); for (uint16_t i = 0; i < FFT_SAMPLES; i++) { while ((int32_t)(micros() - next) < 0) { // busy-wait to hold the sample rate steady } next += SAMPLE_PERIOD_US; vReal[i] = (double)analogRead(ADC_PIN); vImag[i] = 0.0; } } // --------------------------------------------------------------------------- // Turn the raw sample block into NUM_BARS logarithmic magnitude values // --------------------------------------------------------------------------- static void computeSpectrum(float outDb[NUM_BARS]) { // remove DC bias so the antenna's resting level does not swamp bin 0 double mean = 0.0; for (uint16_t i = 0; i < FFT_SAMPLES; i++) mean += vReal[i]; mean /= FFT_SAMPLES; for (uint16_t i = 0; i < FFT_SAMPLES; i++) vReal[i] -= mean; FFT.windowing(FFTWindow::Hamming, FFTDirection::Forward); FFT.compute(FFTDirection::Forward); FFT.complexToMagnitude(); // dominant spectral peak, used for the LoRa telemetry packet double fpk = 0.0, mpk = 0.0; FFT.majorPeak(&fpk, &mpk); peakFreqHz = (float)fpk; peakMagDb = 20.0f * log10f((float)mpk + 1.0f); // usable bins are 1 .. FFT_SAMPLES/2 (bin 0 is residual DC) const uint16_t usableBins = FFT_SAMPLES / 2; const uint16_t startBin = 1; const uint16_t span = usableBins - startBin; for (uint8_t b = 0; b < NUM_BARS; b++) { uint16_t from = startBin + (uint32_t)b * span / NUM_BARS; uint16_t to = startBin + (uint32_t)(b + 1) * span / NUM_BARS; if (to <= from) to = from + 1; double acc = 0.0; for (uint16_t k = from; k < to && k < usableBins; k++) acc += vReal[k]; double mag = acc / (to - from); float db = 20.0f * log10f((float)mag + 1.0f); if (db < NOISE_FLOOR_DB) db = NOISE_FLOOR_DB; outDb[b] = db - NOISE_FLOOR_DB; } } // --------------------------------------------------------------------------- // Color ramp: green (low) -> yellow -> red (high) // --------------------------------------------------------------------------- static uint16_t levelColor(float frac) { if (frac < 0.0f) frac = 0.0f; if (frac > 1.0f) frac = 1.0f; uint8_t r, g; if (frac < 0.5f) { // green -> yellow r = (uint8_t)(frac * 2.0f * 255.0f); g = 255; } else { // yellow -> red r = 255; g = (uint8_t)((1.0f - frac) * 2.0f * 255.0f); } return M5.Display.color565(r, g, 0); } // --------------------------------------------------------------------------- // Draw one frame to the off-screen canvas, then push it // --------------------------------------------------------------------------- static void drawSpectrum(const float db[NUM_BARS]) { canvas.fillSprite(BLACK); // header canvas.setTextColor(WHITE, BLACK); canvas.setTextSize(1); canvas.setCursor(3, 2); canvas.print("EMI SPECTRUM"); canvas.setCursor(3, 12); canvas.printf("0-%dkHz", (int)(SAMPLING_HZ / 2000.0)); // hint the emitter controls: A = RF jam, B = broadband noise canvas.setTextColor(M5.Display.color565(120, 120, 120), BLACK); canvas.setCursor(70, 12); canvas.print("A=RF B=noise"); canvas.setTextColor(WHITE, BLACK); // LoRa status, right-aligned: highlighted briefly on each transmit const char* loraTxt; uint16_t loraCol; if (!loraReady) { loraTxt = "LoRa X"; loraCol = M5.Display.color565(255, 80, 80); } else if (millis() - lastTxMs < 300 && lastTxOk) { loraTxt = "LoRa>>"; loraCol = M5.Display.color565(80, 200, 255); } else { loraTxt = "LoRa ok"; loraCol = M5.Display.color565(120, 120, 120); } canvas.setTextColor(loraCol, BLACK); canvas.setCursor(screenW - (int)strlen(loraTxt) * 6 - 2, 2); canvas.print(loraTxt); canvas.setTextColor(WHITE, BLACK); // faint horizontal grid lines for (int i = 1; i < 4; i++) { int y = gridTop + gridH * i / 4; canvas.drawLine(0, y, screenW, y, 0x2104); // dim gray } // find the loudest bar to auto-scale the display float maxDb = 1.0f; for (uint8_t b = 0; b < NUM_BARS; b++) if (db[b] > maxDb) maxDb = db[b]; for (uint8_t b = 0; b < NUM_BARS; b++) { float target = db[b] / maxDb; // 0..1 normalized barLevel[b] = barLevel[b] * BAR_SMOOTH + target * (1.0f - BAR_SMOOTH); if (barLevel[b] > barPeak[b]) barPeak[b] = barLevel[b]; else barPeak[b] *= PEAK_DECAY; int x = b * (barW + barGap); int h = (int)(barLevel[b] * gridH); if (h < 1) h = 1; int y = gridBottom - h; canvas.fillRect(x, y, barW, h, levelColor(barLevel[b])); int py = gridBottom - (int)(barPeak[b] * gridH); if (py < gridTop) py = gridTop; canvas.drawFastHLine(x, py, barW, WHITE); } canvas.pushSprite(0, 0); } // --------------------------------------------------------------------------- // RAK3172: send one AT command, wait for "OK" / "ERROR" or timeout // --------------------------------------------------------------------------- static bool loraSendAT(const char* cmd, uint32_t timeoutMs) { while (LoRaSerial.available()) LoRaSerial.read(); // flush stale bytes LoRaSerial.print(cmd); LoRaSerial.print("\r\n"); String buf; uint32_t start = millis(); while (millis() - start < timeoutMs) { while (LoRaSerial.available()) buf += (char)LoRaSerial.read(); if (buf.indexOf("OK") >= 0) return true; if (buf.indexOf("ERROR") >= 0) return false; delay(2); } return false; } // --------------------------------------------------------------------------- // Bring the RAK3172 up in LoRa P2P mode with the configured RF settings // --------------------------------------------------------------------------- static void loraInit() { LoRaSerial.begin(LORA_BAUD, SERIAL_8N1, LORA_RX_PIN, LORA_TX_PIN); delay(200); loraSendAT("AT", 500); // wake / sync loraSendAT("AT+NWM=0", 1000); // 0 = LoRa P2P (module may reboot) delay(1500); loraSendAT("AT+NWM=0", 1000); // re-assert after possible reboot // AT+P2P = freq : SF : bandwidth(0=125k) : codingrate(0=4/5) : // preamble : txpower(dBm) char cfg[64]; snprintf(cfg, sizeof(cfg), "AT+P2P=%lu:%u:0:0:8:%u", (unsigned long)LORA_FREQ_HZ, LORA_SF, LORA_TX_POWER); loraReady = loraSendAT(cfg, 1000); } // --------------------------------------------------------------------------- // Transmit a 4-byte EMI packet: [0xE3][freqHi][freqLo][magDb] as AT+PSEND hex // --------------------------------------------------------------------------- static void loraSendTelemetry() { uint16_t f = (peakFreqHz < 0.0f) ? 0 : (peakFreqHz > 65535.0f ? 65535 : (uint16_t)peakFreqHz); int dRaw = (int)peakMagDb; uint8_t d = (uint8_t)(dRaw < 0 ? 0 : (dRaw > 255 ? 255 : dRaw)); uint8_t payload[4] = { 0xE3, (uint8_t)(f >> 8), (uint8_t)(f & 0xFF), d }; char hex[sizeof(payload) * 2 + 1]; for (size_t i = 0; i < sizeof(payload); i++) snprintf(hex + i * 2, 3, "%02X", payload[i]); char cmd[32]; snprintf(cmd, sizeof(cmd), "AT+PSEND=%s", hex); lastTxOk = loraSendAT(cmd, 3000); lastTxMs = millis(); } // --------------------------------------------------------------------------- // Full-screen jam banner shown while an emitter is active // --------------------------------------------------------------------------- static void drawJamScreen(const char* title, const char* sub, const char* release, uint16_t bg, uint16_t fg) { canvas.fillSprite(bg); canvas.setTextColor(fg, bg); canvas.setTextSize(2); canvas.setCursor(10, gridTop + gridH / 2 - 18); canvas.print(title); canvas.setTextSize(1); canvas.setCursor(10, gridTop + gridH / 2 + 4); canvas.print(sub); canvas.setCursor(10, gridTop + gridH / 2 + 16); canvas.print(release); canvas.pushSprite(0, 0); } // --------------------------------------------------------------------------- // EMI white-noise jammer: bit-bang a randomized bitstream on EMIT_PIN for as // long as Button B is held. Random dwell spreads energy across the spectrum. // --------------------------------------------------------------------------- static void emitWhiteNoise() { noiseActive = true; drawJamScreen("EMI JAM", "broadband noise", "release B to stop", M5.Display.color565(60, 0, 0), M5.Display.color565(255, 80, 80)); ledcDetach(EMIT_PIN); // take the pin back from LEDC pinMode(EMIT_PIN, OUTPUT); uint32_t lcg = micros() | 1u; // seed the pseudo-random generator while (M5.BtnB.isPressed()) { for (uint16_t i = 0; i < NOISE_BURST; i++) { lcg = lcg * 1664525u + 1013904223u; // fast LCG step digitalWrite(EMIT_PIN, (lcg >> 23) & 0x1); // random bit uint32_t dwell = (lcg >> 17) & 0x3; // random 0..3 us hold if (dwell) delayMicroseconds(dwell); } M5.update(); // refresh button state between bursts } digitalWrite(EMIT_PIN, LOW); ledcAttach(EMIT_PIN, 1000, 10); // restore LEDC ownership, idle silent ledcWriteTone(EMIT_PIN, 0); noiseActive = false; } // --------------------------------------------------------------------------- // RF jammer: sweep a hardware square-wave carrier across RF_MIN..RF_MAX for // as long as Button A is held. The sweep + harmonics radiate wideband RF. // --------------------------------------------------------------------------- static void emitRfJam() { noiseActive = true; drawJamScreen("RF JAM", "carrier sweep", "release A to stop", M5.Display.color565(0, 0, 60), M5.Display.color565(120, 160, 255)); // reattach the LEDC channel at low resolution so it can reach RF rates ledcDetach(EMIT_PIN); ledcAttach(EMIT_PIN, RF_MIN_HZ, RF_RES_BITS); const uint32_t duty = (1u << RF_RES_BITS) / 2; // ~50% square wave uint32_t f = RF_MIN_HZ; while (M5.BtnA.isPressed()) { ledcChangeFrequency(EMIT_PIN, f, RF_RES_BITS); ledcWrite(EMIT_PIN, duty); f += RF_STEP_HZ; if (f > RF_MAX_HZ) f = RF_MIN_HZ; // wrap the sweep delayMicroseconds(200); // dwell per hop M5.update(); // refresh button state } ledcWrite(EMIT_PIN, 0); ledcDetach(EMIT_PIN); ledcAttach(EMIT_PIN, 1000, 10); // restore idle LEDC config ledcWriteTone(EMIT_PIN, 0); noiseActive = false; } // --------------------------------------------------------------------------- void setup() { auto cfg = M5.config(); M5.begin(cfg); M5.Display.setRotation(1); // landscape screenW = M5.Display.width(); screenH = M5.Display.height(); gridTop = 24; gridBottom = screenH - 2; gridH = gridBottom - gridTop; barGap = 1; barW = (screenW - (NUM_BARS - 1) * barGap) / NUM_BARS; if (barW < 1) barW = 1; analogReadResolution(12); // 0..4095 analogSetPinAttenuation(ADC_PIN, ADC_11db); // full ~0-3.3V range // LEDC channel for the EMI test emitter (starts OFF) ledcAttach(EMIT_PIN, 1000, 10); // pin, seed freq, 10-bit resolution ledcWriteTone(EMIT_PIN, 0); canvas.setColorDepth(8); canvas.createSprite(screenW, screenH); for (uint8_t b = 0; b < NUM_BARS; b++) { barLevel[b] = 0.0f; barPeak[b] = 0.0f; } M5.Display.fillScreen(BLACK); loraInit(); // bring up the RAK3172 LoRa P2P link } void loop() { M5.update(); // Hold Button A to emit a swept RF jam (blocks until released) if (M5.BtnA.isPressed()) { emitRfJam(); } // Hold Button B to blast broadband EMI white noise (blocks until released) if (M5.BtnB.isPressed()) { emitWhiteNoise(); } static float db[NUM_BARS]; sampleBlock(); computeSpectrum(db); drawSpectrum(db); // stream the dominant EMI peak over LoRa at a fixed cadence if (loraReady && (millis() - lastTxMs >= LORA_TX_INTERVAL_MS)) { loraSendTelemetry(); } }