Solution Architecture
ESP-VISION is organized around a MicroPython firmware build, board-specific hardware backends, shared platform services, and Python-facing vision modules. Code is layered by whether it touches MicroPython (mp_obj_t / py/*.h).
ESP-VISION layered architecture overview
Layered Overview

Bindings (
modules/): theUSER_C_MODULESlayer. The main modulesimage,sensor,display,espdl, andtfliteself-register viaMP_REGISTER_MODULE.py_imageio.cprovides theimage.ImageIOtype, andpy_helper.cis shared helper code. Bindings only do object conversion and light API adaptation; heavy logic lives in pure C orplatform/.Platform services (
platform/): self-written ESP32 glue.ev_channel.c/ev_mux.c/ev_control_transport.c/ev_stdio.c(EV-MUX / EV-ATP transport),preview.c(EV-MUX JPEG preview),display.c(generic display layer),sdcard.c(mount at/sdcard),usb_msc.c(exposes theffatpartition over TinyUSB MSC),jpeg.c(hardware or software JPEG),debug.c, andmain.c(startup init plus the soft-reset loop).imlib component (
components/imlib/): pure-C vision algorithms, an IDF component maintained as MIT, derived from OpenMVlib/imlib.Board backends (
boards/<BOARD>/): per-board configuration and the real camera/display/sdcard implementations. P4X and S31 useesp_video/V4L2; P4X also uses PPA, while S3 usesesp32-camera.MicroPython + overlay: MicroPython v1.28.0 is the fixed baseline; project changes live in
overlay/micropython/and are applied to a generated build copy underbuild/micropython/.
Capture-to-Output Data Flow

sensor.snapshot() captures a frame into a reusable framebuffer wrapped as an image.Image. Scripts then run imlib processing, ESP-DL inference, or TFLite Micro inference on the image, and send it to the LCD with display.write() or to the host preview with img.flush().
Low-Level Transport
ESP-VISION host transport has three layers: physical sinks, routed streams, and EV-MUX logical channels.
Physical Sinks
platform/ev_channel.c defines the physical write targets:
sink |
Meaning |
|---|---|
|
USB-Serial-JTAG. Available on boards that expose this peripheral; it is the preferred fallback when no host holds USB-OTG CDC open. |
|
USB-OTG CDC. When available, opening the port (DTR) makes it the automatic route for every stream. It also provides MSC file preview. |
|
Wired auxiliary sink for runtime redirection ( |
|
MicroPython stdout fallback. |
|
Drops output; |
cdc reports two states: present means the USB-OTG interface is enumerated; ready means a host holds the port open (DTR asserted). ready alone decides routing — there is no activation RPC, lease, or heartbeat anywhere in the design.
Routed Streams
ev_stream_t is the physical routing abstraction. There are fewer streams than EV-MUX channels. A route applies to a complete stream, not to one logical channel: once a stream is bound to a sink, every channel assigned to that stream uses that sink. Device-to-host frames are written to it, and host-to-device frames for those channels are accepted only from it.
One rule decides the automatic route of every stream: the active sink is ``cdc`` while a host holds the USB-OTG port open (DTR), otherwise it is the board’s preferred fallback (USJ when available, then the console fallback). A dedicated transport task watches the DTR edge and applies it to both streams; route.changed (reason cdc_connected / cdc_disconnected) is emitted per stream on every transition. Host disconnect or crash drops DTR, so the fallback is automatic — no keepalive is required.
stream |
Carries |
Routing policy |
|---|---|---|
|
Normal work plane: |
Follows the active sink (DTR rule above). |
|
Debug and system traffic: |
Follows the active sink, so the connected USB port carries every logic channel. |
route.bind pins one stream to a fixed sink (runtime redirection, e.g. debug to uart for a wired log tap); a manually bound stream leaves the DTR rule until route.auto restores it.
EV-MUX Channels
EV-MUX channels describe end-to-end protocol semantics; they are not physical ports. Each channel is assigned to exactly one stream, and a host must not select a sink independently for an individual channel. direction describes frame direction. Device-to-host frames use the current sink of their stream; host-to-device frames are authorized against the current sink of their stream.
channel |
direction |
assigned stream |
payload type |
Purpose |
|---|---|---|---|---|
|
bidirectional |
|
JSON |
Normal host work control such as |
|
bidirectional |
|
JSON; |
System/debug EV-ATP requests, responses, events, and errors. |
|
Host -> Device |
|
text |
Host input to the REPL; not an output stream. |
|
Host -> Device |
|
small binary/text |
Host signals such as Ctrl-C. |
|
Device -> Host |
|
text |
Python |
|
Device -> Host |
|
text |
Python exceptions and C stderr. |
|
Device -> Host |
|
text |
ESP-IDF |
|
Device -> Host |
|
binary JPEG |
Preview frames produced continuously by |
EV-MUX Frame Format
All EV-MUX frames are length-prefixed, and payloads may be binary:
\x1eEVMUX/1 h=<metadata_len> p=<payload_len> c=<crc32>\r\n
<metadata JSON bytes>
<payload bytes>
\x1f
h and p are authoritative lengths; 0x1f is only an EOF guard and resynchronization aid. Firmware-generated frames include a payload CRC32. Host-to-device command frames may use c=00000000; the firmware receiver treats zero as “skip CRC validation”.
EV-ATP Control
EV-ATP RPC is split by semantics:
user.rpccarries normal IDE operation:hello,capabilities,script.write,script.run, anddevice.control.debug.rpccarries system/debug commands:transport.state,route.get,route.bind,route.auto,debug.info, anddebug.capture_frame. Responses return to the sink the request arrived on.debug.capture_framereturns its image as the binary payload of thedebug.rpcresponse frame (contentType=image/jpegpluswidth/heightin metadata); there is no separate data channel.
route.changed is a routing event on user.rpc / user; it is not a debug.rpc event. It reports a stream-level sink change (reason cdc_connected / cdc_disconnected), so the host must update the connection state of every channel assigned to that stream.
Routing itself needs no RPC: opening the USB-OTG port makes it the active sink, and closing it selects the board’s fallback. There is intentionally no activation, lease, or heartbeat method — the wire state (DTR) is the whole routing protocol.
Host Integration Contract
The host uses exactly one physical USB connection at a time. On dual-USB boards, USB-OTG and USJ are equivalent choices and either one carries every channel. The connection sequence is:
Open the chosen port. EV-MUX is enabled at boot, so no REPL access is required: the device emits a bootstrap
helloevent when a USB link appears (and again after every soft reset).Send a framed
hellorequest (or consume the boot event), then requestcapabilities. Verify thatfirmware.idisesp-vision, checkevMuxVersion, and negotiate the advertised features/channels.Use the device: REPL channels,
script.write/script.run, preview,debug.rpc, andlog.idfare all live on the opened port. No activation step and no heartbeat exist; the port is the route.On disconnect just close the port. If USB-OTG closes (or the cable drops), all streams select the board’s fallback automatically and a
route.changedevent reports it.
sensor.evmux() remains as a debug toggle (for example to recover a plain text REPL with sensor.evmux(False)); boards can opt out of boot-time enablement by defining ESP_VISION_EV_MUX_DEFAULT_ENABLED to 0 in boardconfig.h.
The hello, capabilities, and debug.info device payloads all contain the same firmware object. Its stable id is esp-vision. Its version is the ESP-IDF PROJECT_VER derived from Git: an official release build reports its YYYY.MM.DD tag, while an untagged development build includes the -N-g<commit> suffix (and may include -dirty). Hosts may display or compare this build identity for update UX, but protocol compatibility must be decided from evMuxVersion and advertised capabilities rather than the release string.
The host demultiplexer must inspect metadata.channel on every frame and dispatch by channel. It must not infer frame type from the port or a preceding frame. A host should maintain a static channel -> stream table and a dynamic stream -> sink table; route changes update only the latter.
Receive-Side Demultiplexing
The firmware receiver keeps state only for byte-stream frame assembly. Once a complete EV-MUX frame arrives, its assigned stream is first validated against the actual ingress sink, then it is dispatched immediately by metadata.channel / type / method:
repl.stdinis queued into the REPL input ring buffer.repl.signalschedules signals such as keyboard interrupt.user.rpc/debug.rpcfirst select the RPC domain, then execute EV-ATP control logic bymethod.
Ingress authorization is a single whitelist: the discovery methods hello / capabilities are accepted on either USB sink; every other channel is accepted only from the current route of its assigned stream. This also permits framed control on UART after an explicit route.bind. Business logic must not infer frame type from a “current mode”.
Transport Execution Model
EV-MUX is enabled at boot by default (ESP_VISION_EV_MUX_DEFAULT_ENABLED), so the control plane is always discoverable without REPL access; after every soft reset the mux state and routes return to the same deterministic defaults.
Frame reception does not depend on the REPL. A dedicated transport task (ev_transport, see platform/ev_control_transport.c) owns the receive path: it pumps the TinyUSB device stack, drains the USJ/CDC/UART ingress ring buffers into the per-ingress frame parsers, dispatches complete frames, and applies the DTR routing rule (active sink switch and route.changed emission). mp_hal_stdin_rx_chr() no longer parses frames; when EV-MUX is enabled it only consumes framed repl.stdin bytes.
Dispatch is split by execution context:
Transport-task RPCs (answered immediately, even while user code runs):
hello,capabilities,transport.state,route.*, anddevice.control.repl.signalschedules the keyboard interrupt from the transport task, so host Ctrl-C reaches a running script.VM-task RPCs (queued to the interpreter and executed from
mp_hal_stdin_rx_chr()context):script.write,script.run,debug.info, anddebug.capture_frame— anything touching MicroPython objects, the VFS, or the camera. A full queue is answered with aVM_BUSYerror.repl.stdinpayloads are queued into the framed REPL input ring and consumed by the REPL loop.
While EV-MUX is enabled, every physical byte stream carries frames only: the low-level USJ/CDC/UART receive paths do not interpret 0x03 as Ctrl-C (interrupt semantics belong to the framed repl.signal channel), and unframed bytes are discarded by the frame parser’s SOF resynchronization.
Known Transport Issues
The following USB transport issues are known and tracked separately from the normal EV-MUX / EV-ATP routing contract:
USB MSC currently exposes the
ffat/vfspartition directly through TinyUSB callbacks. MSC writes are not coordinated with the MicroPython VFS or IDE file writes, so concurrent host MSC access and script/file operations can corrupt the filesystem. Until write coordination is implemented, MSC should be treated as read-only or mutually exclusive with IDE file writes.
Implementation notes (observability retained):
Frame writes use a bounded write-all loop with progress detection and an explicit timeout (
platform/ev_mux.c); the low-level sinks report their real written byte counts, so a partial write aborts the frame deterministically and is counted, instead of silently truncating it.Preview frames are the single lossy class. A stalled sink is marked congested for a short cooldown; while congested,
preview.frameframes are dropped whole before a single byte is written (ev_mux_write_lossy), so the byte stream on the wire always stays parseable. On the USJ route the producer additionally caps the preview rate (one frame per 100 ms) because USB-Serial-JTAG throughput is far below OTG CDC. RPC and REPL frames are never dropped by this mechanism.All three ingress ring buffers are 2048 bytes.
debug.infoscopetransport.stats(pure C, answered even while the VM is busy) reports per-ingress RX byte counts and ring-full events, complete/malformed/rejected frame counters, and replRx/VM_BUSY/TX write-timeout/TX congestion-drop counters (txDrop). replRx overflow is currently counted only; there is no dedicated host overflow event yet.
Source Tree
Path |
Responsibility |
|---|---|
|
Board-aware |
|
Integration hub: registers user modules, platform and board sources, include paths, conditional |
|
Pinned third-party submodules (MicroPython, |
|
ESP-VISION MicroPython delta, using the MicroPython path layout. |
|
Per-board config, frozen manifests, and board peripheral backends. |
|
Shared runtime services (camera, preview, storage, display, USB, JPEG). |
|
MicroPython C/C++ bindings ( |
|
ESP-IDF components, including OpenMV |
|
Optional model assets loaded from board storage at runtime. |
|
MicroPython example scripts. |
|
|
Board Composition
A board is defined in a single tree, boards/<BOARD>/:
ESP-VISION side (top level):
boardconfig.h,imlib_config.h,manifest.py, and optionalcamera.c/display.c/sdcard.c.MicroPython port side (
boards/<BOARD>/port/):IDF_TARGETvalue, sdkconfig, partitions, and USB strings. The build projects this subdirectory ontoports/esp32/boards/<BOARD>/of the generated MicroPython copy.
See Add a New Board for the step-by-step procedure.
Chip-Dependent Sources
micropython.cmake selects modules from IDF_TARGET and the board profile. The ESP32-P4 build includes h264 and rtsp; the current P4 board profiles also enable the ZXing-C++ barcode backend. See Chip and Board Support for the resulting public API matrix.
MicroPython Overlay
ESP-VISION uses MicroPython v1.28.0 as a fixed upstream baseline. Project changes to the ESP32 port live under overlay/micropython/. The prepare-micropython build step applies that tree to a generated copy under build/micropython/idf<ESP_IDF_VERSION>/micropython/; the lib/micropython submodule remains a clean upstream reference.
For how ESP-VISION relates to its upstream projects, see Project Relationships; for the license of each component, see Licensing.