A low-cost, open-source BCI prototype built on ESP32 + EXG BioAmp Pill + Python
Real-time EEG acquisition · Eye-blink detection · Alpha/Beta brain-state classification
⚠️ Safety Notice: This design has no medical-grade electrical isolation. Always power the system from a USB battery bank — never a mains-connected USB port — when electrodes are in contact with skin.
- Overview
- Prototype Status
- System Architecture
- Hardware Requirements
- Software Dependencies
- Circuit Summary
- Quick Start
- ESP32 Firmware
- Python Analyzer
- Configuration Reference
- Signal Processing Pipeline
- Brain-State Classification
- Serial Data Format
- Dashboard Overview
- Results & Performance
- Known Limitations
- Troubleshooting
- Roadmap
This project implements a complete EEG-BCI stack on accessible, off-the-shelf hardware.
The ESP32 firmware samples differential bio-signals from an EXG BioAmp Pill through the onboard 12-bit ADC, performs adaptive EMA baseline tracking, triggers an LED on blink events, and streams three-value packets over UART at 115200 baud.
The Python analyzer (bci_v2.py) ingests that stream in a background thread, runs Welch PSD analysis, computes relative alpha/beta band powers, and drives a live six-panel Matplotlib dashboard that classifies brain state as FOCUS or RELAX in real time.
No proprietary software. No dedicated EEG dongle. Total BOM cost well under ₹2,000 / $25 USD.
| Dimension | Status |
|---|---|
| Maturity | Alpha — lab prototyping only, not for medical use |
| Channels | Single EEG channel (FP1 vs. FP2 differential) |
| Blink Detection | ✅ Threshold + differential method, ~88% accuracy |
| Band Classification | ✅ Alpha / Beta relative power via Welch PSD |
| Artifact Rejection | ❌ Not yet implemented |
| Multi-channel | ❌ Planned |
| Electrical Isolation | ❌ Not included — use battery-powered hardware only |
Brain (µV)
│
▼
Ag/AgCl Electrodes (FP1, FP2, Ear Lobe)
│
▼
EXG BioAmp Pill ── Gain ≈ 1000× │ BW: 0.5–40 Hz │ 3.3 V single-supply
│
▼
LM358 Op-Amp ── Unity-gain voltage follower (impedance buffer)
│
▼
RC Low-Pass Filter ── 10 kΩ + 100 nF → f_c ≈ 159 Hz
│
▼
ESP32 GPIO34 ── 12-bit ADC │ ~200 Hz sampling
│
├── Adaptive EMA baseline tracking
├── Blink threshold: diff > 300 → LED on GPIO2
└── UART stream @ 115200 baud: "eegValue baseline diff\n"
│
▼
Python bci_v2.py
├── Serial reader thread (512-sample deque)
├── Welch PSD (Hann window · nperseg=128 · noverlap=64)
├── Band power (α: 8–12 Hz · β: 13–30 Hz)
├── β − α state machine (FOCUS / RELAX)
└── Live Matplotlib dashboard (6-panel · TkAgg · ~10 fps)
| Component | Specification / Notes |
|---|---|
| ESP32 | Any dev board with GPIO34 (ADC1_CH6) and GPIO2 · dual-core 240 MHz · 12-bit ADC |
| EXG BioAmp Pill | Single-supply bio-amplifier · Gain ≈ 1000× · BW: 0.5–40 Hz · 3.3 V operation |
| Electrodes | Ag/AgCl disposable gel · FP1 (active) · FP2 (reference) · Ear Lobe (ground) |
| LM358 Op-Amp | DIP-8 or SMD · Op-Amp A only (pins 1–3) wired as voltage follower |
| Resistor | 10 kΩ — between LM358 output and GPIO34 |
| Capacitor | 100 nF — between GPIO34 and GND (RC anti-aliasing filter) |
| LED + Resistor | Any 3–5 mm LED with 220 Ω series resistor on GPIO2 |
| Power | 3.3 V for EXG Pill + LM358 · battery pack required for skin contact |
| USB Cable | USB to host PC · Windows 10+ tested · Linux/macOS also supported |
Python 3.9 or newer is required.
pip install numpy matplotlib scipy pyserialRecommended — virtual environment setup:
python -m venv .venv
# Windows
.venv\Scripts\activate
# Linux / macOS
source .venv/bin/activate
pip install numpy matplotlib scipy pyserialTkAgg not available? Install
PyQt5and swap the backend inbci_v2.py:pip install pyqt5 # Change: matplotlib.use("TkAgg") → matplotlib.use("Qt5Agg")
Firmware (Arduino IDE or PlatformIO):
- Board package:
esp32by Espressif — install via Arduino Board Manager - No external libraries needed — uses only built-in
analogRead,digitalWrite,Serial
See PIN_CONNECTIONS.md for the complete pin-by-pin wiring table, full ASCII schematic, and LM358 pinout diagram.
Signal path in brief:
FP1 ──► EXG IN+ FP2 ──► EXG IN− Ear Lobe ──► EXG GND_E
EXG OUT ──► LM358 Pin 3 (+)
LM358 Pin 1 (OUT) ──► Pin 2 (−) ← unity-gain feedback
LM358 Pin 1 ──── 10 kΩ ──── GPIO34 ──── 100 nF ──── GND
GPIO2 ──── 220 Ω ──── LED (+) ──── LED (−) ──── GND
EXG VCC / LM358 Pin 8 ──► 3.3 V EXG GND / LM358 Pin 4 ──► GND
The RC filter at GPIO34 gives a hardware anti-aliasing cutoff of ≈ 159 Hz — above the 40 Hz EEG upper bound and below the ~200 Hz ADC sampling rate.
-
Open
bci26esp.inoin Arduino IDE. -
Go to Tools → Board → ESP32 Dev Module and select your COM port.
-
Click Upload. Open Serial Monitor at 115200 baud and confirm output like:
2051 2048.34 2 2310 2052.11 257 -
Close Serial Monitor before running the Python script.
- Apply gel electrodes: FP1 (left forehead) · FP2 (right forehead) · Ear Lobe (ground).
- Wipe skin with an alcohol swab first to lower contact impedance.
- Keep electrode leads short and shielded where possible.
Edit the Config class at the top of bci_v2.py:
port = "COM3" # ← change to your port e.g. "COM5" or "/dev/ttyUSB0"
sample_rate = 200 # must match firmware delay(5) cadenceThen run:
python bci_v2.py| Panel | What it shows |
|---|---|
| Raw EEG + Baseline | Blue = ADC samples · Orange dashed = ESP32 EMA adaptive baseline |
| Diff Signal | Red = │eeg − baseline│ · shaded fill = LED trigger zone (diff > 300) |
| Welch PSD | Log-scale power spectrum · α and β frequency bands highlighted |
| Band Power Bars | Relative alpha and beta power normalized to broadband total (1–45 Hz) |
| Score History | β − α ratio over time · green = focus tendency · orange = relax |
| State Indicator | ◉ FOCUS / ○ RELAX with live score and ESP32 LED status note |
Close the Matplotlib window or press Ctrl-C. The serial port and threads shut down cleanly.
File: bci26esp.ino
int eegPin = 34; // ADC1_CH6 — EEG signal input
int ledPin = 2; // Onboard LED — blink event output
float baseline = 0;
float alpha = 0.01; // EMA smoothing factor200 ADC readings are averaged at boot to seed the baseline, preventing a cold-start spike:
for (int i = 0; i < 200; i++) {
baseline += analogRead(eegPin);
delay(5);
}
baseline /= 200;1. Read ADC → eegValue
2. Update EMA: baseline = 0.99 × baseline + 0.01 × eegValue
3. diff = abs(eegValue − baseline)
4. diff > 300 → LED HIGH (blink / spike detected)
5. Serial.println: "eegValue baseline diff"
Compatible with the Arduino Serial Plotter for quick visual debugging.
File: bci_v2.py
main()
├── Opens serial port
├── Spawns serial_reader() ← daemon thread
│ └── parse_line() → fills SharedState deques (eeg, baseline, diff)
├── Builds 6-panel Matplotlib figure
└── FuncAnimation (interval = 100 ms)
└── update() → SharedState.snapshot() → redraws all panels
All buffers and derived values live in SharedState, guarded by a threading.Lock. The serial thread writes; the animation callback reads through snapshot(). No mutable state is shared outside the lock.
def analyse(samples):
samples -= np.mean(samples) # remove DC offset
freqs, psd = welch(samples, fs=200,
window="hann",
nperseg=128,
noverlap=64)
alpha_power = band_power(freqs, psd, 8.0, 12.0)
beta_power = band_power(freqs, psd, 13.0, 30.0)
total_power = band_power(freqs, psd, 1.0, 45.0)
return alpha_power / total_power, beta_power / total_power, freqs, psd| Score (β − α) | Condition | Resulting State |
|---|---|---|
> 0.030 |
Sustained over rolling history | FOCUS |
< 0.008 |
15 consecutive low-score ticks | RELAX |
| Between | Minimum 30-tick hold in effect | No change |
All tunable parameters are in the Config dataclass at the top of bci_v2.py:
@dataclass
class Config:
# ── Serial ──────────────────────────────────────────────
port: str = "COM3" # ← Your COM port
baud_rate: int = 115_200
# ── Signal ──────────────────────────────────────────────
sample_rate: int = 200 # Must match firmware delay(5)
buffer_size: int = 512 # Rolling deque depth (samples)
welch_nperseg: int = 128 # Welch PSD segment length
welch_noverlap: int = 64 # Welch PSD overlap
# ── EEG bands (Hz) ──────────────────────────────────────
alpha_low: float = 8.0
alpha_high: float = 12.0
beta_low: float = 13.0
beta_high: float = 30.0
# ── State machine ────────────────────────────────────────
history_len: int = 40 # Rolling average window (ticks)
focus_threshold: float = 0.030 # β−α threshold → FOCUS
relax_threshold: float = 0.008 # β−α threshold → RELAX
min_state_hold: int = 30 # Min ticks before state can change
relax_streak_needed: int = 15 # Consecutive low ticks → FOCUS→RELAX
# ── LED mirror ───────────────────────────────────────────
diff_spike_threshold: int = 300 # Mirrors ESP32 firmware constantKey tips:
- Keep
sample_rate = 200in sync with firmwaredelay(5). - Increase
buffer_sizeto improve PSD frequency resolution (at cost of latency). - Lower
focus_thresholdif alpha dominance is unusually strong on your hardware. - The EXG Pill's 40 Hz upper bandwidth sufficiently attenuates 50/60 Hz mains — no software notch filter is needed for this prototype.
ADC integer (0–4095)
│
▼
ESP32: EMA baseline subtraction (α = 0.01)
│
▼ UART @ 115200 baud
Python: 512-sample rolling deque
│
▼
DC removal (subtract numpy mean)
│
▼
Welch PSD (Hann window · nperseg=128 · noverlap=64 · density scaling)
│
▼
Trapezoidal band-power integration
├── Alpha 8–12 Hz
├── Beta 13–30 Hz
└── Total 1–45 Hz → normalize → relative powers
│
▼
Score = β_ratio − α_ratio → rolling average → state machine
The classifier uses a transparent, single-variable rule:
- β − α > 0.030 → sustained cognitive engagement → classified as FOCUS
- β − α < 0.008 for 15 consecutive ticks → classified as RELAX
- A minimum 30-tick hold prevents rapid flickering between states.
The logic lives entirely in update_state(). It can be replaced with an SVM, LDA, or small MLP without changing any other part of the pipeline.
One line is emitted per sample at ~200 Hz:
<eegValue> <baseline> <diff>
| Field | Type | Description |
|---|---|---|
eegValue |
integer | Raw 12-bit ADC reading (0–4095) |
baseline |
float | Current EMA baseline value |
diff |
integer | abs(eegValue − baseline) — drives the LED blink logic |
Lines with a token count other than 3 are silently discarded by the Python parser.
┌──────────────────────────────────────────────────────────────────────┐
│ EEG Brain-Computer Interface — Real-Time Analysis │
├────────────────────────────────────────────────────────┬─────────────┤
│ │ │
│ Raw EEG Signal + ESP32 Adaptive Baseline │ Relative │
│ [full width] │ Band Power │
├────────────────────────────────────────────────────────┤ Alpha|Beta │
│ Diff │eeg − baseline│ │ │
│ (shaded region above LED threshold of 300) │ │
├────────────────────────────────────────────────────────┤─────────────┤
│ Welch Power Spectral Density │ ◉ │
│ (log scale · α and β bands highlighted) │ FOCUS │
│ │ +0.0412 │
├────────────────────────────────────────────────────────┼─────────────┤
│ β − α Score History │ LED: ESP32 │
│ (green fill = focus tendency · orange = relax) │ controlled │
└────────────────────────────────────────────────────────┴─────────────┘
| Metric | Value | Notes |
|---|---|---|
| Blink detection accuracy | ~88% | Controlled trials, stable electrode contact |
| Response latency | < 50 ms | Blink peak to LED output trigger |
| Effective sample rate | ~200 Hz | Firmware delay(5) loop cadence |
| ADC resolution | 12-bit | Range 0–4095, ESP32 ADC1 channel 6 |
| Supply voltage | 3.3 V | Single-rail for EXG Pill and LM358 |
| Anti-aliasing cutoff | ≈ 159 Hz | 10 kΩ + 100 nF RC filter at GPIO34 |
| PSD update rate | ~10 Hz | FuncAnimation at 100 ms interval |
- Single channel only — no multi-channel synchronization.
- No electrical isolation — hardware must be battery-powered during any skin contact.
- No artifact rejection — eye movements, jaw clenches, and body motion appear in raw data.
- ESP32 ADC non-linearity — ADC is non-linear below ~100 mV; per-unit variation may require threshold tuning.
- Windows-focused testing — Linux/macOS users should set
port = "/dev/ttyUSB0"and may need udev rules for the CH340/CP210x USB-serial driver. - UI stutter — FuncAnimation can lag on low-end machines; try reducing
buffer_sizeor increasing theintervalargument.
| Symptom | Likely Cause | Fix |
|---|---|---|
Cannot open COMx |
Wrong port or port in use | Close Serial Monitor; check Device Manager or ls /dev/tty* |
| Dashboard opens, no signal | Firmware not flashed or baud mismatch | Re-flash; confirm Serial.begin(115200) matches baud_rate = 115_200 |
All values read 0 0 0 |
No electrode contact or EXG Pill unpowered | Verify 3.3 V at EXG VCC; check electrode placement |
| LED permanently ON | Threshold too low or motion artifact | Sit still; raise diff threshold in firmware (300 → 400) |
| Very noisy PSD | Poor ground or missing RC capacitor | Re-seat ear-lobe electrode; confirm 100 nF cap on GPIO34 |
| Blank Matplotlib window | TkAgg backend missing | pip install tk or switch to Qt5Agg + pip install pyqt5 |
Score stuck at 0.000 |
512-sample buffer not filled yet | Wait ~3 seconds after launch for the buffer to fill |
- Multi-channel EEG with per-channel independent PSDs
- Software IIR notch filter (50/60 Hz) in the Python pipeline
- Firmware impedance self-check on startup
- BLE wireless streaming to a mobile dashboard
- ML-based multi-class brain-state classification (SVM / LDA / MLP)
- EDF/BDF export for MNE-Python and EEGLAB compatibility
- OTA firmware updates over Wi-Fi
- Adaptive per-session threshold calibration
Alpha-quality prototype — not for medical, clinical, or consumer use. Always use battery-powered hardware when electrodes are in contact with skin.