ESP32-S3 USB camera returns no frame
You wired a USB webcam to an ESP32-S3, the USB host stack initialises without complaint, and every request for an image comes back empty. Nothing in the log says why. Six things cause this in practice, and they are worth checking in the order below — the first two account for most of it and neither is a software bug.
First, find out how far it got
"No frame" covers three very different failures, and they have nothing in common except the outcome. Before changing anything, work out which one you have:
| What the log shows | Stage reached | Go to |
|---|---|---|
| No device-connected event at all | The camera never enumerated. The host stack does not know anything is plugged in. | Cause 1, 2 |
E ENUM: ... errors at connect |
Enumeration started and failed partway. | Cause 3 |
Device found, then Failed to negotiate or a CTRL timeout |
The camera is talking. The stream request was refused. | Cause 4, 5 |
1 — The camera is on the wrong USB port
This is the single most common cause, and it costs people entire evenings.
A typical ESP32-S3 dev board has two USB-C sockets. They are the same shape, they both power the board, and only one of them can host your camera:
| Port | Usually labelled | What it is |
|---|---|---|
| USB-OTG / native USB | USB |
Wired to the S3's own USB peripheral on GPIO19/20 (D−/D+). This is the only port that can act as a USB host. Your camera goes here. |
| UART bridge | COM, UART, PROG |
A separate USB-to-serial chip (CP210x/CH340). It talks to your PC for flashing and the serial monitor. It has no connection to the S3's USB peripheral and can never host anything. |
Because you flash over the UART port, that is the socket already in your hand — and plugging the camera into it produces exactly the symptom in this article: power reaches the camera (its LED may even light), nothing else happens, and the log stays silent forever.
Two consequences follow, and both surprise people the first time:
- You will need an OTG or A-to-C adapter, because the camera has a USB-A plug and the board has a C socket.
- Once the camera occupies the native port, you cannot use it for the serial console
at the same time. Keep the UART port connected to your PC for
idf.py monitor— see reading logs below.
2 — The serial console is holding the USB pins
You moved the camera to the native port and there is still no connect event. This is the cause that is genuinely hard to find, because nothing anywhere reports it.
On the ESP32-S3, GPIO19 and GPIO20 are shared between two peripherals: the USB-OTG controller (which you need for USB host) and the USB-Serial-JTAG peripheral (which provides a console over that same connector). Only one can drive the pads.
ESP-IDF's default configuration enables USB-Serial-JTAG as the secondary console. So out of the box, that peripheral takes the pins, USB host never sees a bus, and your camera never enumerates — silently, with no error, because from the host stack's point of view nothing was ever plugged in.
The fix is to make the console UART-only and leave the pads to the OTG controller:
# sdkconfig.defaults
CONFIG_ESP_CONSOLE_UART_DEFAULT=y
CONFIG_ESP_CONSOLE_SECONDARY_NONE=y
In menuconfig the same settings are under Component config → ESP
System Settings → Channel for console output, with the secondary console set to
No secondary console.
usb_host_install() needs nothing special — it just needs the pins to
be free. If you went looking for a host-mode switch and could not find one, that is
why: the only thing standing between you and a working bus was another peripheral
holding the pads.3 — Enumeration aborts on the config descriptor
Now you get a connect event, immediately followed by something like:
E (1234) ENUM: Configuration descriptor larger than control transfer max length E (1234) ENUM: CHECK_SHORT_CONFIG_DESC FAILED
A webcam's configuration descriptor is unusually large. It enumerates every format the camera supports, every frame size within each format, and every frame rate within each size — a modest camera easily runs to a couple of kilobytes. ESP-IDF's USB host allocates a 256-byte control transfer buffer by default, which is ample for a keyboard and nowhere near enough for a camera. Enumeration aborts before the UVC driver is ever handed the device.
# sdkconfig.defaults
CONFIG_USB_HOST_CONTROL_TRANSFER_MAX_SIZE=3000
CONFIG_USB_HOST_HW_BUFFER_BIAS_IN=y
The second line biases the shared USB hardware FIFO toward IN transfers. A camera is almost entirely inbound traffic, so this measurably helps isochronous frame throughput later — it is not required for enumeration, but you want it on for the same reasons.
While you are in there, turn on the descriptor dump. It costs nothing at runtime and it is the difference between guessing and knowing in the next section:
CONFIG_UVC_PRINTF_CONFIGURATION_DESCRIPTOR=y
At connect, the driver then prints the parsed descriptor: the VideoStreaming interface, the formats, and every frame size and interval the camera actually offers. That list is the ground truth for cause 4.
4 — The stream format does not match
The camera is enumerated and the driver has it. Opening the stream still fails, or succeeds and never delivers a frame.
UVC stream negotiation is an exact match, not a best fit. When you call
uvc_host_stream_open(), the resolution, the frame rate and the format you
ask for must all correspond to a mode the camera advertises. Ask for MJPEG 640×480
at 30 fps on a camera that offers 640×480 at 25 and 15, and you do not get 25
— you get a failure. There is no rounding and no fallback.
Two habits make this reliable:
- Enumerate before you open. Call
uvc_host_get_frame_list(), walk the modes the camera actually reports, and pick from that list rather than hard-coding a resolution you assumed. - Open with
fps = 0to take the device's default frame interval instead of asserting one. That single change fixes a large share of negotiation failures on its own, because frame rate is the field people most often guess wrong.
When you are writing the HTTP handler for a device with no serial console attached, put
the reason in the response body rather than returning a bare error. We ended up making
the generated handler in ESP-GenUI return a 503 whose body
distinguishes "no USB camera detected" from "stream negotiation failed
(<esp_err>)" from "streaming, waiting for MJPEG" — because on a
board powered from a wall adapter, the browser is the only diagnostic channel you have
left. It is a five-minute change that turns a blank image into an actionable
message.
5 — Full-Speed USB cannot carry the frame rate
Negotiation succeeds, frames start, and you get corruption, stalls, or a stream that dies after a few images.
The ESP32-S3's built-in USB PHY is Full-Speed only — about 12 Mbit/s, and the usable isochronous share is lower still. That is a hard ceiling, and a webcam's default mode was designed for a High-Speed PC port.
A worked example with a common test camera: a Logitech C270 (VID 046d,
PID 0825) defaults to 30 fps at VGA. Even compressed, that comfortably
exceeds what Full-Speed will carry. The camera is fine, the code is fine, the pipe is too
small.
Two fixes, in order of preference:
- Negotiate the lowest frame rate the camera advertises — that is, the largest frame interval in its list. For a polled snapshot feed, where the browser asks for a still every second or two, frame rate is the cheapest thing you own.
- Drop the resolution to QVGA (320×240) if VGA still struggles. Quartering the pixel count buys more headroom than any amount of tuning.
Also budget your RAM. UVC plus Wi-Fi plus an HTTP server is memory-heavy, and frame buffers are large. On a PSRAM-equipped board, push the Wi-Fi and LWIP buffers off-chip and reserve internal RAM for the USB host's DMA-capable transfers, which cannot live in PSRAM:
CONFIG_SPIRAM=y CONFIG_SPIRAM_IGNORE_NOTFOUND=y CONFIG_SPIRAM_TRY_ALLOCATE_WIFI_LWIP=y CONFIG_SPIRAM_MALLOC_RESERVE_INTERNAL=150000
If your board has octal PSRAM, it needs the octal mode setting too — and getting that wrong has its own dramatic failure mode, covered in the OPI flash and PSRAM article.
6 — The camera is not getting enough power
Worth ruling out early because it is cheap to test and produces intermittent symptoms that look like software flakiness.
In host mode the ESP32-S3 is expected to supply 5 V VBUS to the device. Some dev boards do not connect VBUS to the native port at all when the board is powered from the other socket; others supply it through a link or jumper you have to close. A webcam that draws more than the port provides will enumerate, start streaming, and then drop off the bus under load.
- Confirm 5 V is actually present on the native port's VBUS pin with a meter.
- Try a powered USB hub between board and camera. If the problem disappears, it was power.
- Prefer a camera without an IR illuminator ring or a motorised mount for bench testing — those are the hungriest parts of a cheap webcam.
Reading logs on a board with no free serial port
The awkward part of all this: the camera occupies the native USB port, which is the port the console would otherwise use. Diagnosing a USB problem while the USB port is busy is the whole difficulty.
Three ways out, in increasing order of effort:
- Use the UART port for the monitor. On a DevKitC-1 and most dual-port boards,
connect the UART socket to your PC and run
idf.py monitorthere while the camera sits on the native port. This is why the console config in cause 2 sets UART as the primary console rather than disabling logging. - Stream the log over Wi-Fi. Install a capture hook with
esp_log_set_vprintf()early in startup, keep the output in a ring buffer, and serve it over HTTP. You then read the realESP_LOGoutput — Wi-Fi, USB host, UVC, all of it — from a browser, on a board with nothing attached but power. This is by far the most useful debugging tool for a headless camera build, and it is perhaps forty lines of code. - Return the reason in your HTTP responses, as described in cause 4. Less complete than a log stream, but it survives having no debugging infrastructure at all.
v4l2-ctl --list-formats-ext). Cheap
webcams do fail, and proving the camera is good first saves you from debugging your
firmware against broken hardware.