← SHEET 02 · ASSEMBLIES EL-007
ESP32 Web Oscilloscope
| PART NO | EL-007 |
|---|---|
| MATL / SYSTEM | ESP32 · 12-BIT ADC · WIFI SOFTAP |
| TOOLS | Arduino IDE · Wokwi · C++ · JS |
A browser-based scope that lives entirely on one ESP32. A hardware timer fires an
ISR at a fixed rate, pulls a 12-bit ADC sample into a ring buffer, and checks it
against a rising-edge trigger; once armed and fired, a pre-trigger + post-trigger
window is streamed as a binary WebSocket frame to whatever laptop or phone joined
the chip’s own SoftAP, where a <canvas> draws the trace. No PC software, no
drivers, no USB — connect to the WiFi network and open a page. It’s built for
hobby-level signals (0–3.3 V, DC-coupled) and stated honestly: this is not a real
scope, bandwidth tops out around a few kHz, and the SAR ADC has known nonlinearity.
OVERVIEW & MOTIVATION
Most “I need to see a waveform” situations in hobby electronics don’t need a real scope’s bandwidth or accuracy — they need to know whether a signal is toggling, roughly what shape it has, and where it crosses a level. An ESP32 already has a 12-bit ADC, a CPU fast enough to run a timed sampling loop, and a radio to serve a web page — so the whole front end (sampling, triggering, transport, and display) fits on one chip with almost no external hardware. The design goal was a minimal, self-contained “good enough” scope: timer-driven sampling for a known, steady rate; a ring buffer so a trigger event can keep samples from before it fired; and a WebSocket stream into a canvas UI so there’s nothing to install on the viewing device.
COMPONENTS & BOM
The point of the design is that it’s nearly component-free — the ESP32 does essentially everything; the only external parts are a probe-protection network.
| REF | COMPONENT | SPEC | ROLE |
|---|---|---|---|
| U1 | ESP32 DevKit (WROOM-32) | ESP32-D0WDQ6, 12-bit SAR ADC, WiFi | sampling, triggering, SoftAP + WebSocket server |
| R1 | series resistor | ~1 kΩ | current-limit into the clamp network, crude anti-alias with stray C |
| D1 | clamp diode | 1N4148 (or similar), to 3V3 | clips excursions above the 3.3 V rail |
| D2 | clamp diode | 1N4148, to GND | clips excursions below 0 V |
WIRING
| ESP32 PIN | NET | ROLE |
|---|---|---|
| GPIO34 | PROBE_ADC | ADC1_CHANNEL_6, input-only — sample source for the ring buffer |
| GPIO2 | STATUS_LED | on-board LED (most DevKit boards), toggles per captured frame |
| 3V3 | PROTO_3V3 | upper clamp rail for the probe protection network (D1) |
| GND | PROTO_GND | lower clamp rail (D2) + common return |
ADC1 (not ADC2) is used deliberately: ADC2 shares hardware with the WiFi radio and reads unreliably once the SoftAP is running.
FIRMWARE
Sampling runs entirely inside a hardware-timer ISR so the rate is independent of
whatever the WiFi/HTTP/WebSocket stack is doing in loop(). The ISR reads the ADC
through adc1_get_raw() (a direct register read, fast enough for a 10 kHz ISR),
writes into a power-of-two ring buffer, and does the trigger comparison inline:
void IRAM_ATTR onTimer() {
portENTER_CRITICAL_ISR(&timerMux);
uint16_t sample = (uint16_t)adc1_get_raw(PROBE_ADC_CH);
ring[ringHead] = sample;
if (armed) {
if (prevSample < triggerLevel && sample >= triggerLevel) {
armed = false;
triggerHead = ringHead;
postCount = FRAME_SAMPLES - PRETRIG_SAMPLES;
}
} else if (postCount > 0 && --postCount == 0) {
frameReady = true;
}
prevSample = sample;
ringHead = (ringHead + 1) & RING_MASK;
portEXIT_CRITICAL_ISR(&timerMux);
}
A capture frame is 1024 samples: 256 pre-trigger (already sitting in the ring
buffer when the edge fires) and 768 post-trigger, so the trace shows the signal
approaching the trigger point, not just after it. loop() only handles the parts
that can tolerate jitter — serving the HTTP page (WebServer, built into the
ESP32 core) and pumping the WebSocket (WebSocketsServer by Markus Sattler /
Links2004, arduinoWebSockets in the Library Manager) — and copies the windowed
slice out of the ring buffer once frameReady is set:
if (frameReady) {
uint32_t startIdx = (triggerHead - PRETRIG_SAMPLES) & RING_MASK;
frameReady = false;
for (int i = 0; i < FRAME_SAMPLES; i++) frame[i] = ring[(startIdx + i) & RING_MASK];
wsServer.broadcastBIN((uint8_t*)frame, sizeof(frame));
}
Frames go out as raw binary (Uint16Array on the JS side) — no per-sample text
parsing. On connect, the firmware sends one text/JSON message
({"fs":10000,"bits":12,"n":1024,"pretrig":256}) so the browser knows the sample
rate, resolution, frame length, and where the trigger sits in the frame without
hardcoding it client-side. The trigger-level slider sends a small text command
back ("L2048") that the firmware parses and applies inside a critical section.
The UI itself is a single HTML/JS/CSS page stored as a PROGMEM string and served
at / — the canvas draws a grid, a red pre-trigger marker, and the trace scaled
from raw 12-bit counts to 0–3.3 V.
SIMULATION
Wokwi’s ESP32 model runs real WiFi (including SoftAP) inside the simulator, so
the transport half of this project — HTTP page, WebSocket handshake, binary
frames, trigger-level commands — sim-checks end to end. What it can’t do is
generate a fast analog waveform: diagram.json substitutes a potentiometer
on GPIO34 (wiper → D34, ends → 3V3/GND) as a hand-turned, slow signal
source. Steps: open the project on wokwi.com, load diagram.json, paste in
sketch.ino, add the WebSockets (Links2004) library through the Library
Manager, and run — the Serial Monitor prints the SoftAP IP once it’s up. Since a
host browser can’t join a simulated WiFi network directly, Wokwi forwards the
sim’s HTTP/WebSocket traffic through its own network-gateway URL for the running
project; open that forwarded address to reach the same / page and :81 socket
a real WiFi client would see. Turning the potentiometer slowly across the
trigger-level slider’s value produces a capture frame and redraws the canvas —
confirming the pipeline, not the 10 kS/s / kHz-bandwidth targets, which need real
hardware and a real signal generator to check.
STATUS
Design and simulation only — no bench build. The sketch is written against a
concrete timer configuration (100 µs alarm period → 10 kS/s target, 12-bit ADC
width), but that number is a design target read off the timer setup, not a
measured sample rate; nothing here has been scoped against a reference signal or
compared to a real oscilloscope. Bandwidth (“a few kHz”) is likewise an estimate
from the R1/stray-capacitance rough low-pass and the ADC’s known settling
behavior, not a bench-measured -3 dB point. The Wokwi sim exercises the
WiFi/WebSocket/UI pipeline at slow potentiometer-turn rates; it does not validate
timing at the design sample rate. diagram.json parses as valid JSON and mirrors
the pinout used in the firmware and the wiring diagram above. Not yet built:
physical assembly, R1/D1/D2 protection network on a real board, and any bench
verification of sample rate, trigger accuracy, or ADC linearity.
USE CASES & APPLICATIONS
Field debugging where a real scope isn’t on hand — checking whether a GPIO is toggling, whether a sensor’s analog output looks sane, or roughly where a signal crosses a threshold, using only a phone or laptop already carried around. Classroom or workshop signal demos: cheap enough to hand a student a board and a browser tab instead of scope time. Slow-changing analog logging (battery voltage sag, a sensor drifting over minutes) fits the same trigger-and-stream pipeline even better than fast signals do, since the ADC nonlinearity and kHz-class bandwidth limits matter less at DC-ish rates.
FILES
sketch.ino— full firmware: timer ISR sampling, ring buffer, rising-edge trigger with pre-trigger capture, SoftAP,WebServer-served HTML/JS UI, andWebSocketsServer-streamed binary frames. Flash via Arduino IDE with the ESP32 board package and the WebSockets (Links2004) library installed.diagram.json— Wokwi wiring: ESP32 DevKit-C v4 + a potentiometer standing in for the probe signal on GPIO34.README.md— Wokwi run steps (including the simulator’s network-gateway URL for reaching the served page) and real-probe protection notes (series R- clamp diodes, DC-coupled 0–3.3 V only).