ESP32-S3 摄像头图传:固件 + Slint 客户端 + 共享协议

两块 ESP32-S3-WROOM-1-N16R8 图传板(DVP 接口接 OV2640/OV3660/OV5640),
向队伍客户端与裁判终端推 UDP 图传。固件是跑在 ESP-IDF 上的 Rust,用官方
esp32-camera 组件采集;客户端是 Rust + Slint 桌面程序。

三个 crate 各自独立构建:
  .            firmware,交叉编译到 Xtensa
  protocol/    线路格式与帧重组,两端共用,无 ESP-IDF 依赖,有单元测试
  client/      Slint 桌面客户端

视频走 UDP 而非 TCP:实测传感器产出 22 fps,TCP MJPEG 只送出 1.9 fps,
且只占用链路能力的 1%——链路是空的,帧堵在队头阻塞后面等一个实时画面
等不起的重传计时器。改为独立数据报、不重传、缺片丢整帧后达到 20.8 fps。

发现是双向的:板子每 2 秒广播 Hello,客户端广播 Subscribe,板子据此定向
推流。板子身份由 MAC 派生,重启不变,所以客户端可以把绑定存进配置。

裁判端同时收两块板子(每个发送方一个重组器),队伍端绑定其中一块并只显示
自己那一边的血量。装甲板接口已预留(ArmorSource trait + ARMOR_PORT),
当前无数据源,界面显示占位符。

vendor 了 esp32-camera 2.1.7:组件注册表的版本求解器找不到它,且需要打一处
补丁(见 components/esp32-camera/target/esp32s3/ll_cam.c)。

.gitignore 三条 target/ 都锚定在根目录,因为 protocol/ 与 client/ 各自也是
独立 crate;而 components/esp32-camera/target/ 是上游的按芯片分目录,里面是
源码,必须保留。
This commit is contained in:
Horldsence
2026-10-05 18:22:19 +08:00
commit 4f893ce140
134 changed files with 41128 additions and 0 deletions
+300
View File
@@ -0,0 +1,300 @@
//! Thin safe wrapper over the `cam_wrapper` C component (see
//! `components/cam_wrapper`).
//!
//! The pin map matches the XIAO ESP32S3 Sense camera connector, which is the
//! common OV2640/OV3660 wiring on an ESP32-S3-N16R8. Adjust `PINS` at the
//! bottom of this file if your board wires the DVP bus differently.
use std::ffi::CStr;
use anyhow::{anyhow, Result};
use esp_idf_svc::sys;
// bindgen names C enum variants `<enum type>_<VARIANT>`, so alias them to
// something readable here instead of scattering those prefixes around.
use esp_idf_svc::sys::camera::{
cam_wrapper_fb_location_t_CAM_WRAPPER_FB_IN_PSRAM as FB_IN_PSRAM,
cam_wrapper_framesize_t_CAM_WRAPPER_FRAMESIZE_CIF as FRAMESIZE_CIF,
cam_wrapper_framesize_t_CAM_WRAPPER_FRAMESIZE_HVGA as FRAMESIZE_HVGA,
cam_wrapper_framesize_t_CAM_WRAPPER_FRAMESIZE_QQVGA as FRAMESIZE_QQVGA,
cam_wrapper_framesize_t_CAM_WRAPPER_FRAMESIZE_QVGA as FRAMESIZE_QVGA,
cam_wrapper_framesize_t_CAM_WRAPPER_FRAMESIZE_SVGA as FRAMESIZE_SVGA,
cam_wrapper_framesize_t_CAM_WRAPPER_FRAMESIZE_SXGA as FRAMESIZE_SXGA,
cam_wrapper_framesize_t_CAM_WRAPPER_FRAMESIZE_UXGA as FRAMESIZE_UXGA,
cam_wrapper_framesize_t_CAM_WRAPPER_FRAMESIZE_VGA as FRAMESIZE_VGA,
cam_wrapper_framesize_t_CAM_WRAPPER_FRAMESIZE_XGA as FRAMESIZE_XGA,
cam_wrapper_grab_mode_t_CAM_WRAPPER_GRAB_LATEST as GRAB_LATEST,
cam_wrapper_pixel_format_t_CAM_WRAPPER_PIXFORMAT_JPEG as PIXFORMAT_JPEG,
cam_wrapper_sensor_t_CAM_WRAPPER_SENSOR_AUTO as SENSOR_AUTO,
cam_wrapper_sensor_t_CAM_WRAPPER_SENSOR_OV2640 as SENSOR_OV2640,
cam_wrapper_sensor_t_CAM_WRAPPER_SENSOR_OV3660 as SENSOR_OV3660,
cam_wrapper_sensor_t_CAM_WRAPPER_SENSOR_OV5640 as SENSOR_OV5640,
};
/// Capture resolution.
///
/// Anything above CIF needs PSRAM, which the N16R8 has; the bandwidth cost of
/// the bigger sizes is real, so start at VGA and only go higher if the link
/// keeps up.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Resolution {
Qqvga,
Qvga,
Cif,
Hvga,
Vga,
Svga,
Xga,
Sxga,
Uxga,
}
impl Resolution {
fn framesize(self) -> sys::camera::cam_wrapper_framesize_t {
match self {
Resolution::Qqvga => FRAMESIZE_QQVGA,
Resolution::Qvga => FRAMESIZE_QVGA,
Resolution::Cif => FRAMESIZE_CIF,
Resolution::Hvga => FRAMESIZE_HVGA,
Resolution::Vga => FRAMESIZE_VGA,
Resolution::Svga => FRAMESIZE_SVGA,
Resolution::Xga => FRAMESIZE_XGA,
Resolution::Sxga => FRAMESIZE_SXGA,
Resolution::Uxga => FRAMESIZE_UXGA,
}
}
}
impl std::str::FromStr for Resolution {
type Err = anyhow::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_ascii_lowercase().as_str() {
"qqvga" => Ok(Resolution::Qqvga),
"qvga" => Ok(Resolution::Qvga),
"cif" => Ok(Resolution::Cif),
"hvga" => Ok(Resolution::Hvga),
"vga" => Ok(Resolution::Vga),
"svga" => Ok(Resolution::Svga),
"xga" => Ok(Resolution::Xga),
"sxga" => Ok(Resolution::Sxga),
"uxga" => Ok(Resolution::Uxga),
other => Err(anyhow!("unknown resolution {other:?}")),
}
}
}
use log::info;
/// Sensor override. `Auto` lets esp32-camera probe the SCCB bus, which is what
/// you want unless the probe is ambiguous.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SensorHint {
Auto,
Ov2640,
Ov3660,
Ov5640,
}
impl From<SensorHint> for sys::camera::cam_wrapper_sensor_t {
fn from(hint: SensorHint) -> Self {
match hint {
SensorHint::Auto => SENSOR_AUTO,
SensorHint::Ov2640 => SENSOR_OV2640,
SensorHint::Ov3660 => SENSOR_OV3660,
SensorHint::Ov5640 => SENSOR_OV5640,
}
}
}
/// One captured JPEG frame. Holds the driver frame buffer, so the data is only
/// valid until the `Frame` is dropped.
pub struct Frame {
fb: sys::camera::cam_wrapper_fb_t,
}
impl Frame {
pub fn data(&self) -> &[u8] {
if self.fb.data.is_null() || self.fb.len == 0 {
return &[];
}
// SAFETY: `data` points at `len` bytes owned by esp32-camera, which
// stays valid until this Frame is dropped (cam_wrapper_fb_release).
unsafe { std::slice::from_raw_parts(self.fb.data, self.fb.len) }
}
/// Size of the JPEG in bytes.
///
/// Reported in the sender's statistics, which is how a frame rate problem gets
/// attributed to the capture side or the transport side.
#[allow(dead_code)]
pub fn len(&self) -> usize {
self.fb.len
}
/// Capture width in pixels, as the sensor actually produced it (which can be
/// smaller than the requested frame size).
pub fn width(&self) -> usize {
self.fb.width
}
/// Capture height in pixels.
pub fn height(&self) -> usize {
self.fb.height
}
}
impl Drop for Frame {
fn drop(&mut self) {
unsafe { sys::camera::cam_wrapper_fb_release(&mut self.fb) }
}
}
/// The driver could not hand out a frame.
#[derive(Debug, Clone, Copy)]
pub struct FrameError;
impl std::fmt::Display for FrameError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("camera returned no frame")
}
}
impl std::error::Error for FrameError {}
/// An initialized camera.
///
/// Held for the lifetime of the program: esp32-camera keeps a task and the
/// frame buffers running, and tearing them down would only be needed to power
/// the sensor down, which this program never does.
pub struct Camera {
sensor: SensorHint,
}
impl Camera {
/// Brings up the sensor and blocks until it has produced frames.
///
/// `vsync_invert` should be true for a directly connected DVP VSYNC (the
/// esp32-camera default) and false if the board inverts the line in hardware.
pub fn init(
sensor: SensorHint,
resolution: Resolution,
jpeg_quality: u8,
vsync_invert: bool,
) -> Result<Self> {
if jpeg_quality > 63 {
return Err(anyhow!("jpeg_quality must be 0-63, got {jpeg_quality}"));
}
let config = sys::camera::cam_wrapper_config_t {
pin_pwdn: -1,
pin_reset: pins::RESET,
pin_xclk: pins::XCLK,
pin_sccb_sda: pins::SIOD,
pin_sccb_scl: pins::SIOC,
pin_d: pins::DATA,
pin_vsync: pins::VSYNC,
pin_href: pins::HREF,
pin_pclk: pins::PCLK,
// 20 MHz is what the OV5640 datasheet calls for, and esp32-camera's
// OV5640 driver is only PLL-tuned for it; raising this without
// retuning the sensor's own PLL just corrupts frames.
xclk_freq_hz: 20_000_000,
ledc_timer: 0,
ledc_channel: 0,
pixel_format: PIXFORMAT_JPEG,
frame_size: resolution.framesize(),
jpeg_quality: jpeg_quality as i32,
// Three buffers, not two: esp32-camera needs a free buffer at every
// VSYNC, and with two a client that is still draining the previous
// frame costs a whole capture period. One is spent filling, one is
// being written to the socket, and one is spare for the moment the
// write blocks.
fb_count: 3,
fb_location: FB_IN_PSRAM,
// LATEST keeps the newest frame in the queue instead of handing out
// whatever was captured first, which matters once a client is slow
// enough for frames to pile up.
grab_mode: GRAB_LATEST,
sccb_i2c_port: 0,
// esp32-camera inverts VSYNC by default, which is right for the
// large majority of DVP sensors. A board that inverts the line
// itself needs this cleared: a mismatch here does not fail, it
// makes the driver latch frames on the wrong edge and the frame rate
// collapses while the pictures still look plausible.
vsync_invert: if vsync_invert { 1 } else { 0 },
};
let rc = unsafe { sys::camera::cam_wrapper_init(&config, sensor.into()) };
if rc != 0 {
return Err(anyhow!(
"camera init failed (esp_err 0x{:x}) - check the DVP/SCCB wiring and that \
octal PSRAM is enabled",
rc
));
}
info!("camera ready: {}", Self::sensor_name());
Ok(Self { sensor })
}
pub fn sensor_name() -> String {
unsafe {
let ptr = sys::camera::cam_wrapper_sensor_name();
if ptr.is_null() {
String::from("unknown")
} else {
CStr::from_ptr(ptr).to_string_lossy().into_owned()
}
}
}
/// Grabs the next frame.
///
/// This blocks until the sensor has one, which is what paces the stream.
/// `Err` means the driver could not hand out a frame at all; the camera is
/// still usable afterwards, so callers should keep going rather than treat
/// it as fatal.
pub fn next_frame(&self) -> Result<Frame, FrameError> {
let mut fb: sys::camera::cam_wrapper_fb_t = unsafe { std::mem::zeroed() };
let rc = unsafe { sys::camera::cam_wrapper_fb_get(&mut fb, self.sensor.into()) };
if rc != 0 || fb.data.is_null() || fb.len == 0 {
// Nothing was handed out, but be defensive in case a future
// driver version sets a partial frame.
unsafe { sys::camera::cam_wrapper_fb_release(&mut fb) };
return Err(FrameError);
}
Ok(Frame { fb })
}
}
/// Camera connector of the Freenove ESP32-S3-WROOM CAM board.
///
/// Every pin here is one the ESP32-S3 leaves free: 26..37 are bonded to the
/// module's SPI flash and octal PSRAM, 19/20 are the native USB pair this
/// firmware logs over, and 43/44 are the console UART. Touching IO_MUX on any
/// of the flash/PSRAM pins hangs the CPU mid-transfer, which is what happens if
/// you reuse a pin map written for a different board.
///
/// The data bus is the sensor's Y2..Y9, i.e. the low byte of the 10-bit pixel
/// word, so Y2 is D0 and Y9 is D7.
mod pins {
pub const RESET: i32 = 0;
pub const XCLK: i32 = 15;
pub const PCLK: i32 = 13;
pub const VSYNC: i32 = 6;
pub const HREF: i32 = 7;
pub const SIOD: i32 = 4;
pub const SIOC: i32 = 5;
/// D0..D7 == Y2..Y9, in the order `camera_config_t` expects.
pub const DATA: [i32; 8] = [11, 9, 8, 10, 12, 18, 17, 16];
}