ESP32-S3 companion and camera
An ESP32-S3 on the native USB port is OmniAccelerANT's physical companion: a USB peripheral, not a second app. Two firmware variants speak the same framed USB protocol:
| Variant | On the board | Source |
|---|---|---|
| Companion | Rust (esp-hal): USB, LED/button, telemetry, host-detection notifications |
firmware/esp32-s3 |
| Camera (ESP32-S3-EYE) | ESP-IDF + ESP-WHO/ESP-DL cat detection (C/C++) plus a Rust protocol staticlib |
firmware/esp32-s3-eye |
The companion was the original bring-up; the camera firmware and its on-device AI are in Camera and on-device AI.
What runs where, in plain terms
The Flutter application does not run on the ESP32: it stays on the host (a Windows/Linux PC or a Raspberry Pi), and the board runs only a firmware. The USB cable is the whole connection: no Wi-Fi, no driver installation, no web server on the board.
ESP32-S3 companion (Rust) Host (OmniAccelerANT)
┌───────────────────────────┐ USB CDC ┌──────────────────────────────┐
│ GPIO0 BOOT button │ /dev/tty │ Rust link → frb → Device │
│ GPIO2 status LED │ ACM0/COMx │ page (Flutter) │
│ Hello, telemetry, pong │ ◄────────► │ webcam engine ──► detections │
└───────────────────────────┘ frames └──────────────────────────────┘
ESP32-S3-EYE camera (ESP-IDF) Host (same link)
┌───────────────────────────┐ USB CDC ┌──────────────────────────────┐
│ OV2640 → ESP-DL cat model │ /dev/tty │ CameraDetection → Device page│
│ Rust protocol staticlib │ ACM0/COMx │ (count, best score) │
└───────────────────────────┘ frames └──────────────────────────────┘
- Plug in. The chip's built-in USB peripheral is a plain serial port (
/dev/ttyACM0,COMx); nothing is installed on the PC. - Select and connect on the Device page. The Rust host opens the port and sends
HostHello; the board answersHello(firmware version, chip, MAC). That is the handshake. - Stay in touch. The board sends telemetry about once a second (uptime, button state, last
detection count) and answers
PingwithPong. If the host hears nothing for 6 s, it reports a disconnect. - Control it. The LED sliders send
SetLed; the firmware acknowledges. The BOOT button sends aButtonevent back (companion). - Detections. Companion: the host's Rust webcam detector sends
Detection, and the board pulses its LED. Camera firmware: the board's ESP-DL detector sendsCameraDetection, and the Device page shows the count and best score. Dart is in neither path. - Unplug. The link reports the failure to the Device page; plug back in, rescan, connect.
One frame of the small, versioned protocol:
A5 5A | version u8 | seq u16 LE | len u16 LE | postcard payload | CRC-16/CCITT LE
The CRC covers version, seq, len and the payload. crates/esp32-protocol is no_std and
compiles for both the host and the Xtensa target, so a message cannot drift between the ends.
Why this shape
Three integration routes were considered:
| Route | Host side | Firmware | Verdict |
|---|---|---|---|
| USB CDC + framed protocol | Rust (serialport + shared protocol crate) |
Rust (esp-hal) companion; ESP-IDF/ESP-DL (C/C++) + Rust staticlib camera |
Chosen. Plug-and-play, no network or drivers, both ends share one protocol crate. |
| USB UVC camera | existing GStreamer path | C (ESP-IDF UVC) | The board is a companion, not a UVC camera; the useful Rust surface shrinks to "the existing pipeline". |
| Wi-Fi / WebRTC producer | existing ObservANT path | C or Rust | Needs credentials, DHCP, signalling and a camera; the board is plugged in by USB and has none of those. |
| Direction | Message | Purpose |
|---|---|---|
| host → device | HostHello |
version handshake, retried every 500 ms until Hello |
| device → host | Hello |
protocol, firmware, chip, MAC, uptime |
| both | Ping / Pong |
liveness and a UI-visible round trip |
| host → device | SetLed |
RGB collapsed to on/off, optional blink |
| host → device | Detection |
host webcam's latest count and score, throttled |
| device → host | CameraDetection |
the board's own ESP-DL camera count and best score |
| host → device | SetTelemetryPeriod |
0 stops periodic telemetry |
| device → host | Telemetry |
uptime, heap, temperature, button, last count |
| device → host | Button |
BOOT button edge and press count |
| both | Ack / Error |
command outcome |
Hardware
- Companion: ESP32-S3 DevKitC-1, or any board exposing the native USB peripheral; 8 MB flash
is the tested size. BOOT button GPIO0 (active low), status LED GPIO2. The DevKitC-1's onboard
WS2812 is not driven: it needs a data protocol, and a plain GPIO is portable. Wire an LED
to GPIO2 (with a resistor) or change the two pin numbers in
firmware/esp32-s3/src/main.rs. - Camera: ESP32-S3-EYE: 8 MB flash, 8 MB octal PSRAM, OV2640 on the DVP bus, LCD,
ES8311 codec; camera/SCCB/DVP pins in Camera and on-device AI.
The Rust protocol's
SetLedtargets the EYE's green LED (GPIO3), but the camera app disables the BSP LED helper, so thereSetLedonly tests the Ack/host→device path; the companion's GPIO2 is the controllable LED. - The cable goes to the native USB port, not the UART bridge. The board enumerates as
303a:1001,USB JTAG/serial debug unit, usually/dev/ttyACM0(COMxon Windows); its USB serial number is the MAC. esp-hal1.2.2 does not expose the internal temperature sensor, so the companion's telemetry reports0and the app shows—. The camera firmware reports0too.
Build and flash the companion firmware
The toolchain is Espressif's Xtensa Rust fork (1.97), installed by espup, on aarch64 and x86_64
Linux. Keep it under $HOME: /tmp is often a small tmpfs (§ Troubleshooting). This section is
the companion; the camera firmware's build is in Camera and Rust protocol
firmware.
# On the host, once:
# espup install --targets esp32s3
# install the prebuilt espflash binary
# Then:
firmware/esp32-s3/flash.sh # /dev/ttyACM0, native USB
ESP32_PORT=/dev/ttyUSB0 firmware/esp32-s3/flash.sh # UART bridge, default reset
The same build runs in the family image, with the toolchain in a named volume:
scripts/linux/esp32/build-firmware.sh # build only
scripts/linux/esp32/build-firmware.sh --flash # build + flash /dev/ttyACM0
scripts/linux/esp32/build-firmware.sh --flash --port /dev/ttyUSB0 --uart
- The volume keeps
espup, the Xtensa toolchain,espflash, the cargo registry andtarget/between runs; sources stay on the host mount. The first run needs network, later runs are offline. - Commit the OxidANT submodule before moving this repository's gitlink. The firmware's only
path dependency is
third_party/OxidANT/crates/esp32-protocol; a clean checkout whose gitlink predates that crate cannot resolve the firmware build. - The build runs as the image user. Only
espflashruns as root, because rootless nerdctl remaps/dev/ttyACM0tonobody:nogroup, which the image user cannot open; the build artifacts stay owned by the image user. firmware/esp32-s3/Cargo.lockis committed:--lockedmakes the volume build reproducible. The firmware is a separate crate with its ownrust-toolchain.toml(channel = "esp"), not a member of OxidANT's workspace and not built by the app's Cargokit path.
App side
kataglyphis_esp32 (OxidANT crates/esp32) owns discovery, framing and two threads:
- the reader parses frames, answers
Ping, sendsHostHellountil the handshake and reports a 6 s silence as a disconnect; - the writer owns the port and drains a channel, so the inference thread never blocks on USB.
It is a desktop-only target dependency of oxidant:
[target.'cfg(any(target_os = "linux", target_os = "windows", target_os = "macos"))'.dependencies]
kataglyphis_esp32 = { path = "crates/esp32" }
Android and wasm builds do not compile serialport; esp32_available() returns false there.
On Linux serialport is built without its optional libudev feature: port discovery uses its
/sys/class/tty fallback, so no new native library enters the packaged bundle and the riscv64
cross lane is unaffected.
The frb surface is src/api/esp32.rs:
| Dart | Rust |
|---|---|
esp32Available() |
compile-time desktop answer |
esp32ListDevices() |
Espressif-first USB port list |
esp32Connect(path) |
opens the link, returns an event Stream |
esp32Disconnect() |
stops both threads |
esp32Connected() |
handshake state |
esp32SetLed(...) |
SetLed |
esp32Ping() |
Ping with a fresh id |
esp32SetTelemetryPeriod(ms) |
telemetry cadence |
esp32NotifyDetection(count, score) |
external/manual detection forwarding |
The Device page (lib/Pages/DevicePage/) shows the port, firmware, MAC, uptime, heap,
temperature, button, the latest host-webcam detection count, the LED sliders and a ping; with
the camera firmware, also On-device camera (count and best score) once the first camera
event arrives. It is a view over Esp32Controller; Esp32Api is the seam the tests fake, so
flutter test needs no Rust library.
Esp32Event.kind == "camera" carries the board's own detector: value is the hit count,
scorePermille the best score (0–1000). The companion never sends it, so the field stays hidden
there.
Detections without Dart
webcam_engine's EngineConfig has on_detections; api/webcam.rs installs
api::esp32::notify_detections, so every inference result is offered to the link on the
inference thread. Link::notify_detection throttles: a change at most every 100 ms, a steady
count every 250 ms, zero every second. The firmware pulses GPIO2 for 120–300 ms depending on the
score. No Dart code runs on this path.
esp32NotifyDetection is for sources outside the Rust webcam engine, such as the ObservANT web
page.
The camera firmware reports the other direction: its ESP-DL detector calls
omni_proto_on_detections, and the Rust task sends CameraDetection to the host. The Device
page renders the count and best score, not the boxes: the ESP-WHO LCD app can draw them, but
the integrated firmware uses the terminal variant and the message carries count/score only. The
host webcam path above is unchanged.
Camera and on-device AI
Status 2026-10-18: the integrated camera firmware is built and flashed. Variant A (true
on-device AI) runs on the ESP32-S3-EYE; the companion (firmware/esp32-s3) remains the
fallback. The camera firmware is a separate ESP-IDF project because it needs
esp32-camera/ESP-DL; the Rust protocol is a staticlib inside it.
Probe result
firmware/esp32-s3-probe replaces the companion for one boot, reports PSRAM and scans the
camera's SCCB bus. On this board:
| Item | Result |
|---|---|
| Board | ESP32-S3-EYE (8 MB flash, 8 MB octal PSRAM) |
| Camera | OV2640, SCCB address 0x30, PID 0x26, VER 0x42 |
| SCCB | SDA GPIO4, SCL GPIO5 (the ES8311 codec also answers at 0x12 on this bus) |
| XCLK | GPIO15; the sensor answered only after a clean 10 MHz LEDC clock |
| DVP | VSYNC 6, HREF 7, PCLK 13, Y2…Y9 = 11, 9, 8, 10, 12, 18, 17, 16 |
The first scan, without XCLK, found only the codec; a busy-loop clock also missed the sensor, and the LEDC's 10 MHz made it answer. So the probe starts the clock before scanning. The integrated camera firmware was flashed after it.
Camera and Rust protocol firmware (integrated)
The script builds the integrated firmware: Espressif's ESP-WHO object_detect example with
the cat_detect model (C++ camera + ESP-DL) plus firmware/esp32-s3-eye/rust_protocol, a
no_std staticlib that owns the native USB Serial/JTAG protocol. The C++ detection callback
calls omni_proto_on_detections; the Rust task sends Hello, Telemetry and
CameraDetection, and handles HostHello, Ping, SetLed and SetTelemetryPeriod. The C++
app console is UART0, so the USB port belongs to the protocol: idf.py monitor is not the
view, it competes for the protocol port. Use the host probe or the app's Device page.
scripts/linux/esp32/build-eye-demo.sh # build only
scripts/linux/esp32/build-eye-demo.sh --flash # build and flash /dev/ttyACM0
The script clones ESP-WHO into ~/.cache/esp-who, patches the example to the terminal app and
the esp-dl 3.3.13 API, builds the Rust staticlib on the host (espup's esp toolchain), copies it
into the ESP-IDF project, and builds in the official espressif/idf:release-v5.5 image; the
family :latest image carries no ESP-IDF. The first build downloads the component-managed BSP,
ESP-DL and the cat model.
Model espressif/cat_detect (packed: espdet_pico_224_224_cat, 498 KB); BSP
esp32_s3_eye_noglib with OV2640 DVP RGB565 at 240×240, 8 MB flash and octal PSRAM; factory
app partition 8000K.
Verified 2026-10-18 after building and flashing the integrated script: the boot log reports
Found 8MB PSRAM device, Camera PID=0x26 VER=0x42, Detected OV2640 camera, five PSRAM frame
buffers and the cat model loaded. The app binary is 0x2873d0 bytes (2.64 MB), leaving 68 % of
the 8000K partition free. The host probe saw firmware 0.2.0, chip Esp32S3, MAC
E8:06:90:9D:3E:78, and the events in § Verify.
The script pins ESP-WHO b6ac2be, BSP 5.0.2 and cat_detect 0.3.0. Master's example code
uses the BSP 6.0.0 bsp_camera_cfg_t API while its lock and sdkconfig defaults are still 5.0.2;
pinning the commit before the BSP 6.0.0/esp_video migration avoids that mismatch. The newer
esp-dl also needs its RGB565BE enum, img.bytes(), two-argument sw_decode_jpeg and
non-caps cvt_pix call sites patched; the script does that. The Rust archive sits at the end
of the IDF link line, so the main component forces esp_efuse_mac_get_default,
xTaskCreatePinnedToCore and usb_serial_jtag_driver_install with -Wl,--undefined.
What A means here
- On the board: ESP-IDF + the
esp32-cameradriver + ESP-DL/ESP-WHO (C/C++), because the optimised camera and inference code is C/C++. Rust owns the USB protocol, control and telemetry as astaticliblinked into that app: as much Rust as the vendor components allow. - On the host:
kataglyphis_esp32has theCameraDetectionlink event, shown on the Device page (§ App side). A JPEG preview is the next optional step. - Model: the ESP-DL
espdet_pico_224_224_catdetector. The 8 MB flash is tight: a 320×320 YOLO11n model plus ESP-IDF and the Rust app does not fit comfortably; a 16 MB flash board would allow a larger model. - Expectation: a few frames per second, not 30 fps video. Good for "a cat arrived" events, not for replacing the host's ONNX model.
Seeing it
# Host probe (same events the app receives):
cd third_party/OxidANT
cargo run -p kataglyphis_esp32 --example probe
# Or rebuild the desktop app and open the Device page, then Connect.
flutter run -d linux
Point the camera at a cat: the probe's camera: line and the Device page's On-device camera
field change together.
Running the probe firmware
cd firmware/esp32-s3-probe
cargo +esp build --release
espflash flash --monitor --chip esp32s3 --port /dev/ttyACM0 --before usb-reset \
target/xtensa-esp32s3-none-elf/release/omni-esp32s3-probe
Then reflash the companion with firmware/esp32-s3/flash.sh.
Verify
# Rust protocol + bridge (host, no board):
cargo test -p kataglyphis_esp32_proto -p kataglyphis_esp32
# End to end against a flashed board:
cargo run -p kataglyphis_esp32 --example probe
The probe lists the ports, opens the first Espressif board, prints hello, telemetry,
camera, ack and pong, pulses the LED and sends one detection. A healthy run against the
camera firmware:
hello: DeviceInfo { protocol: 1, firmware_major: 0, firmware_minor: 2,
firmware_patch: 0, chip: Esp32S3, mac: [...] }
telemetry: Telemetry { uptime_ms: 1025, free_heap: 4967415, ... }
camera: 0 hit(s), best 0 per mille
ack: command 3 # SetLed
pong: 42
firmware_patch is the camera firmware's USB Serial/JTAG driver install result (0 =
success). The camera line is the on-device detector; it stays at 0 until a cat is in view.
Against the companion the line is absent and firmware_minor is 1.
Troubleshooting
Permission deniedon/dev/ttyACM0: on the host, add your user to the device's group (usuallyplugdev/dialout). The container script already flashes as root (§ Build and flash the companion firmware).espflashcannot connect on/dev/ttyACM0: the native USB port needs--before usb-reset, a UART bridge (/dev/ttyUSB0)default-reset. The scripts pick it from the port name, or pass--uart.- The board reboots when the port opens: do not toggle DTR/RTS on the native USB port; the probe does not.
- The app shows "desktop builds only": that is the web/Android build. On Linux or Windows, check that the app was rebuilt with the new Rust core and that the port is in the dropdown.
- The device is silent after flashing: check the baud-independent USB CDC link with
espflash board-info, then run the probe. The companion sendsHelloevery 500 ms until it seesHostHello, the camera firmware every 2 s; the host may be the silent side. - The camera is not found: run
firmware/esp32-s3-probeand check the camera ribbon and the SCCB pair (SDA4/SCL5). The OV2640's SCCB answers only with XCLK 10 MHz on GPIO15; a bare I2C scan without XCLK finds only the ES8311 codec (§ Probe result). camera:stays at 0: the on-device model reports only a cat it is sure about; check the lighting and point the camera at the cat. Events are throttled to one per 100 ms, not one per camera frame.- The app connects but has no "On-device camera" field: the board runs the companion,
which sends no
CameraDetection. Flash the camera firmware withscripts/linux/esp32/build-eye-demo.sh --flash;firmware/esp32-s3/flash.shrestores the companion. - No
Hello/ the app stays disconnected: the camera firmware's C++ console must be UART0 (CONFIG_ESP_CONSOLE_UART_DEFAULT); a USB console consumes the host'sHostHellobefore the Rust read, andidf.py monitorcompetes for the protocol port.firmware_patchinHellois the USB driver install result (§ Verify). - The ESP-WHO build fails: use the script, which carries the pins and the esp-dl and
-Wl,--undefinedpatches (§ Camera and Rust protocol firmware (integrated)). Bumping the pin needs the code, defaults and patches moved together; the first build downloads the BSP and model. - Toolchain install fails with "No space left on device":
espupwas pointed at/tmp(a 4 GB tmpfs on the Pi). Use$HOME/.cache/kataglyphis-esp.