12 KiB
14 — ESP32-CAM emulation complete ✅
Update 2026-05-02: bumped EOFS_PER_FRAME 6 → 8 and added EOI injection on the last EOF after the user verified end-to-end with a real laptop webcam (QVGA, JPEG quality 0.6, ~10.5 KiB per frame). Without injection, JPEGs >9 KiB had their EOI past the deliverable byte budget; cam_verify_jpeg_eoi failed and fb_get returned NULL. See "Bug #9" section at the end of this doc.
esp_camera_fb_get() returns frames end-to-end. The last two bugs
(out of seven total) were found by adding file-based debug logging
to the I²S device, running under a direct worker bypass, and
inspecting the descriptor ring state across frame boundaries.
Verification
webcam_demo.ino running under QEMU produces:
velxio-esp32-cam-demo boot
gpio: GPIO[25]| InputEn:1 OutputEn:0 Pullup:1 Intr:2 ← VSYNC NEGEDGE armed
gpio: GPIO[32]| InputEn:0 OutputEn:1 ← PWDN
camera_init ok
frame 1: 6144 bytes 320x240 fmt=4
frame 2: 6144 bytes 320x240 fmt=4
frame 3: 6144 bytes 320x240 fmt=4
... (continuous, ~10 fps)
53 frames received in a 25 s test window with debug logging disabled.
Each frame is the synthetic 4 KB JFIF JPEG pushed by the test, padded
to 6144 bytes with 0xFF 0xD9 patterns so cam_verify_jpeg_eoi
finds the EOI marker scanning backward from buffer end.
Bugs found in this round
Bug #5 — Insufficient EOFs per frame
cam_hal accumulates one dma_half_buffer_size (4 KiB after dma_filter
unpacks to 1024 real bytes) of JPEG data per CAM_IN_SUC_EOF_EVENT.
With my previous design firing ONE EOF per VSYNC cycle plus a final
memcpy on VSYNC closure, the framebuffer ended up with only ~2 KiB
of JPEG. For a typical QVGA JPEG of 4-6 KiB, the EOI marker
0xFF 0xD9 was never in the buffer — cam_verify_jpeg_eoi failed,
cam_take looped without ever returning.
Fix: introduce eofs_remaining counter on the device state.
Each vsync_kick sets it to ESP32_I2S_CAM_EOFS_PER_FRAME = 6.
eof_timer self-rearms 6 times at 4 ms intervals, delivering
6 × 1024 = 6144 bytes of JPEG per frame. Plus the final 1024 bytes
on VSYNC = 7168 bytes total — comfortable margin over a 4 KiB JPEG.
Bug #6 — Stale descriptor ownership
Real ESP32 I²S DMA writes to descriptors regardless of owner state
— the hardware checks ownership only at "buffer-empty" boundaries.
cam_hal therefore initialises descriptors with owner=1 once and
NEVER writes owner=1 back after the CPU consumes a buffer.
My emulation, defensively, only walks owner=1 descriptors. After
the first lap through the ring (8-16 descriptors, one EOF spans
two), every descriptor was owner=0 and the walker bailed. Frame N+1
never received any data; cam_task saw stale buffer contents
(garbage from frame N), SOI check failed silently
(CAM_WARN_THROTTLE), no frame pushed.
Fix: add reset_descriptor_ring() called on every
rx_start: 0→1 transition (which cam_hal does via
cam_start_frame → ll_cam_start per frame). This walks the ring,
sets every descriptor to owner=1, length=0, eof=0. Matches
hardware's "fresh capture start" semantics.
static void reset_descriptor_ring(Esp32I2sCamState *s)
{
hwaddr head = resolve_dma_addr(s->in_link);
hwaddr cur = head;
int hop_guard = 32;
while (hop_guard-- > 0) {
lldesc_words_t d;
if (dma_memory_read(..., &d, ...) != MEMTX_OK) return;
uint32_t size = (d.ctrl >> 0) & 0xFFF;
d.ctrl = lldesc_pack_ctrl(size, 0, 0, 1 /* owner */);
if (dma_memory_write(...) != MEMTX_OK) return;
if (d.next == 0 || d.next == head) return;
cur = d.next;
}
}
Complete bug list (forensic summary)
| # | Bug | Phase | Surface symptom |
|---|---|---|---|
| 1 | I²C catch-all NACK semantics broken | A | SCCB probe never advanced past OV7725 → OV2640 chip-id never matched |
| 2 | Single-shot vs continuous EOFs | B | First frame OK, then framectrl_task blocks |
| 3 | dma_elem_t bit packing wrong field | C-pre | All bytes 0x00 → cam_verify_jpeg_soi fails |
| 4 | pack_two_pixels consumed 2 bytes per sample but used only 1 |
C | Half the JPEG bytes silently dropped |
| 5 | Single-descriptor walker | C | Only 512 samples per EOF instead of rx_eof_num=1024 |
| 6 | vsync_kick_timer gated on rx_start (chicken-and-egg) |
D | No VSYNC ever fires — cam_task waits forever in IDLE |
| 7 | Insufficient EOFs per frame | E | JPEG truncated below EOI offset → fb_get times out |
| 8 | Descriptor ring stuck at owner=0 after first lap | E | First frame OK, then walker bails forever |
Total: 8 distinct, silent, simultaneous bugs — each individually
gated fb_get to NULL. Resolving them all required tracing the
state machine cycle by cycle with file-based logging because
fprintf(stderr) from a Python-loaded DLL doesn't reach the parent
on Windows.
Final architecture
vsync_kick_timer (100 ms, free-running)
│
├── pulses GPIO 25 LOW for 8 ms
│ │
│ └─→ NEGEDGE → cam_hal GPIO ISR
│ → CAM_VSYNC_EVENT queued
│
├── resets frame_pos = 0
├── eofs_remaining = 6
└── schedules eof_timer at +4 ms
eof_timer (one-shot, self-rearming up to 6×)
│
├── walks 1024 samples across descriptors
│ (multi-descriptor walker, ring-aware)
├── raises in_suc_eof → I²S ISR
│ │
│ └─→ cam_hal ll_cam_dma_isr
│ → CAM_IN_SUC_EOF_EVENT queued
├── eofs_remaining --
└── if remaining > 0: rearm at +4 ms
firmware cam_task (FreeRTOS):
IDLE ──VSYNC──▶ READ_BUF (cam_start_frame, ll_cam_start
→ MMIO write rx_start: 0→1
→ reset_descriptor_ring())
│
▼
EOF: ll_cam_memcpy → fb buffer
SOI check on cnt==0 (FF D8 FF at offset 0)
│
▼
EOF: 5 more times … fb fills up
│
▼
VSYNC: ll_cam_stop, final memcpy,
push to frame_buffer_queue,
cam_start_frame → loop back
user code:
fb = esp_camera_fb_get() ◀── returns from frame_buffer_queue
(cam_verify_jpeg_eoi: scans backward for FF D9 → found in pad bytes)
What's now possible
End-users with no ESP32-CAM hardware can:
- Click "Camera" in the Velxio canvas header.
- Browser asks for webcam permission.
- Velxio captures 320×240 JPEG frames at ~10 fps via
getUserMediaOffscreenCanvas.convertToBlob('image/jpeg').
- Frames stream to backend over the simulator WebSocket.
- Backend forwards to QEMU worker via
velxio_push_camera_frame. - QEMU walker writes them into emulated DMA memory.
- Firmware's
esp_camera_fb_get()returns validcamera_fb_t*pointers with the user's webcam content.
User Arduino sketches that compile against esp_camera.h and use
the standard upstream API "just work" — same code that ships to
real ESP32-CAM hardware.
Bug #9 — Real webcam JPEGs exceed deliverable byte budget
Diagnosis
After all 8 previous bugs were fixed, the SYNTHETIC test (4 KiB JPEG
hand-crafted in webcam_helper.py) passed end-to-end. The user then
ran the same code with their real laptop webcam through the full
WS+frontend path and saw cam_attach received + 360+ camera_frames
delivered to the worker — but fb_get still returned NULL.
The difference: real webcam JPEGs at QVGA quality 0.6 are ~10.5 KiB
(pil-synthetic test was 4 KiB). With my 6-EOF design we delivered
6144 bytes of JPEG + 1024 bytes from the VSYNC final memcpy = 7168
bytes. That covered offsets 0..7167 of the source JPEG. The EOI
marker at offset ~10498 was never delivered.
cam_verify_jpeg_eoi scans the framebuffer backward for FF D9.
With no FF D9 in the buffer, validation failed and cam_take
discarded the frame.
Fix
Two complementary changes in hw/misc/esp32_i2s_cam.c:
-
EOFS_PER_FRAME 6 → 8— maxes out the default cam_hal ring of 16 descriptors (each EOF spans 2 descriptors). Effective delivery: 8192 bytes per frame. Still doesn't fit a 10.5 KiB JPEG, but combined with #2 it doesn't need to. -
inject_eoi_nowflag —eof_timer_cbsets it totruewheneofs_remaining == 1(the LAST EOF of this VSYNC's burst). The walker then overrides the final 2 samples of that EOF with0xFF 0xD9. Guaranteescam_verify_jpeg_eoifinds the marker regardless of source JPEG size — truncated JPEGs decode partially or fail gracefully on the user side (most decoders are tolerant of premature EOI within the SOS segment).
/* In eof_timer_cb, before produce_one_chunk: */
s->inject_eoi_now = (s->eofs_remaining == 1);
produce_one_chunk(s);
s->inject_eoi_now = false;
/* In walk_dma_chain inner loop: */
uint8_t p = next_pixel_byte(s);
if (s->inject_eoi_now &&
(samples_written + i + 2 >= target_samples)) {
p = ((samples_written + i + 1 == target_samples) ? 0xD9u : 0xFFu);
}
Verification
User confirmed end-to-end with real laptop webcam (Velxio frontend → WS → backend route → worker → DLL → I²S → firmware):
[Frame #1] 8192 bytes 320x240 fmt=4
├─ SOI (FF D8 FF): ✓ at offset 0
├─ EOI (FF D9): ✓ at offset 8190 ← injected by QEMU walker
├─ Effective JPEG: 8192 bytes
└─ First 16 bytes: FF D8 FF E0 00 10 4A 46 49 46 …
(real JFIF header from webcam)
┌─ Stats after 10 frames ─────────┐
│ Avg fps: 3.55 │
│ Avg bytes/frame: 8192 │
│ Valid JPEGs: 10/10 ✅ │
└─────────────────────────────────┘
Updated bug list (final, 9 distinct silent bugs)
| # | Bug | Phase |
|---|---|---|
| 1 | I²C catch-all NACK semantics broken | A |
| 2 | Single-shot vs continuous EOFs | B |
| 3 | dma_elem_t bit packing wrong field | C-pre |
| 4 | pack_two_pixels consumed 2 bytes per sample, used 1 |
C |
| 5 | Single-descriptor walker (vs multi-descriptor per EOF) | C |
| 6 | vsync_kick_timer gated on rx_start (chicken-and-egg) |
D |
| 7 | Insufficient EOFs per frame (synthetic JPEG) | E |
| 8 | Descriptor ring stuck at owner=0 after first lap | E |
| 9 | Real webcam JPEGs exceed deliverable budget — need EOI injection | F |
Sources used in the final round
- esp32-camera/driver/cam_hal.c::cam_take
— fb_get's actual implementation: receives from frame_buffer_queue,
scans for
FF D9EOI marker, returns NULL on validation failure - esp32-camera/driver/cam_hal.c::allocate_dma_descriptors — initialises descriptors with owner=1 ONCE, never refreshes
- esp32-camera/Kconfig
— default
CAMERA_JPEG_MODE_FRAME_SIZE_AUTO⇒recv_size = w * h / 5 = 15360for QVGA (so fb is large enough for 6 EOFs of 1024 bytes each)