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
+340
View File
@@ -0,0 +1,340 @@
//! Camera firmware for an ESP32-S3-N16R8 with an OV2640 / OV3660 / OV5640 sensor,
//! written in Rust on top of ESP-IDF.
//!
//! Captures JPEG frames and streams them as UDP datagrams to any number of
//! clients. The wire format lives in the `protocol` crate, shared with the desktop
//! client in `client/`; see that crate's documentation for why the transport is
//! UDP rather than TCP.
//!
//! WiFi credentials come from the `WIFI_SSID` / `WIFI_PASS` environment variables
//! at build time, so no secrets end up in the source.
use std::time::{Duration, Instant};
use anyhow::{anyhow, Result};
use embedded_svc::wifi::{
AccessPointInfo, AuthMethod, ClientConfiguration, Configuration, PmfConfiguration,
};
use esp_idf_svc::hal::peripherals::Peripherals;
use esp_idf_svc::wifi::{BlockingWifi, EspWifi};
use esp_idf_svc::{eventloop::EspSystemEventLoop, nvs::EspDefaultNvsPartition};
use log::{error, info, warn};
mod camera;
mod udp;
use camera::{Camera, Resolution, SensorHint};
use udp::UdpSender;
const SSID: &str = env!("WIFI_SSID");
const PASSWORD: &str = env!("WIFI_PASS");
/// Authentication mode to assume, overriding whatever the scan reports.
///
/// Usually left unset: the scan tells us the AP's real mode. Pin it with
/// `WIFI_AUTH=wpa2` (or `wpa3`, `wpa2wpa3`, `wpa`, `none`) when the scan cannot
/// see the AP - a hidden SSID, for instance.
fn auth_override() -> Option<AuthMethod> {
let name = option_env!("WIFI_AUTH")?;
Some(match name {
"none" => AuthMethod::None,
"wep" => AuthMethod::WEP,
"wpa" => AuthMethod::WPA,
"wpa2" => AuthMethod::WPA2Personal,
"wpawpa2" => AuthMethod::WPAWPA2Personal,
"wpa3" => AuthMethod::WPA3Personal,
"wpa2wpa3" => AuthMethod::WPA2WPA3Personal,
"wapi" => AuthMethod::WAPIPersonal,
other => panic!(
"WIFI_AUTH={other} is not one of: \
none, wep, wpa, wpa2, wpawpa2, wpa3, wpa2wpa3, wapi"
),
})
}
/// Capture resolution, overridable at build time: `CAM_RES=vga ./scripts/build.sh`
const RESOLUTION: &str = match option_env!("CAM_RES") {
Some(value) => value,
None => "vga",
};
/// JPEG quality 0-63 (lower is better), overridable: `CAM_QUALITY=15 ./scripts/build.sh`
const QUALITY: &str = match option_env!("CAM_QUALITY") {
Some(value) => value,
None => "12",
};
/// Whether the driver inverts VSYNC, overridable so polarity can be A/B tested
/// without editing code: `CAM_VSYNC_INVERT=0 ./scripts/build.sh`.
///
/// A function rather than a `const` because matching on a `str` is not yet
/// allowed in const context on this toolchain.
///
/// `true` is esp32-camera's default and correct for most DVP sensors. `false` is
/// what a board that inverts VSYNC in hardware needs.
fn vsync_invert() -> bool {
!matches!(option_env!("CAM_VSYNC_INVERT"), Some("0"))
}
fn main() -> Result<()> {
// Must run once so the esp-idf-sys runtime patches link correctly.
esp_idf_svc::sys::link_patches();
esp_idf_svc::log::EspLogger::initialize_default();
let resolution: Resolution = RESOLUTION.parse()?;
let quality: u8 = QUALITY.parse()?;
info!("vsync invert: {}", vsync_invert());
let camera = Camera::init(SensorHint::Auto, resolution, quality, vsync_invert())?;
let peripherals = Peripherals::take()?;
let sys_loop = EspSystemEventLoop::take()?;
let nvs = EspDefaultNvsPartition::take()?;
let mut wifi = BlockingWifi::wrap(
EspWifi::new(peripherals.modem, sys_loop.clone(), Some(nvs))?,
sys_loop,
)?;
// `connect` starts the driver, then scans and associates, so that an SSID on
// a 5 GHz network (which this radio cannot see at all) is reported as such
// instead of a bare association timeout.
if let Err(err) = connect(&mut wifi) {
error!("could not join \"{SSID}\": {err}");
error!("check that the SSID is a 2.4 GHz network and the password is right");
return Err(err);
}
let ip = wifi.wifi().sta_netif().get_ip_info()?.ip;
// Modem power save trades latency for current, and for a video stream that
// trade is backwards: with PS on, an idle beacon interval costs hundreds of
// milliseconds, which shows up as stutter and a ping of 200-300 ms on an
// otherwise good link. The board is mains-powered anyway.
//
// This one binds as a bare `esp_err_t` rather than a `Result`.
let ps_rc = unsafe {
esp_idf_svc::sys::esp_wifi_set_ps(esp_idf_svc::sys::wifi_ps_type_t_WIFI_PS_NONE)
};
if ps_rc == 0 {
info!("WiFi power save disabled");
} else {
warn!("could not disable WiFi power save (esp_err 0x{ps_rc:x})");
}
// The board identifies itself by an id derived from its MAC, not its IP: DHCP
// hands out a different address across reboots, and a client that saved its
// binding to one would have to be reconfigured every time.
let sender_id = udp::local_sender_id();
let mut sender = UdpSender::new(sender_id)?;
info!("ESP32 camera is live on {ip}");
info!("clients will find it by broadcast; no configuration needed on this side");
let mut last_report = Instant::now();
let mut last_stats = sender.stats();
loop {
udp::pump(&camera, &mut sender)?;
let now = Instant::now();
if now.duration_since(last_report) >= Duration::from_secs(5) {
// Deltas rather than totals: the totals alone cannot distinguish "the
// sensor is slow" from "the sender is not keeping up", which is the
// distinction that matters when the frame rate is wrong.
let s = sender.stats();
let elapsed = now.duration_since(last_report).as_secs_f32();
info!(
"{:.1} fps, {} datagrams/s, {:.1} kB/s, {} send errors, \
{} clients subscribed, {} frames skipped (no client)",
(s.frames_sent - last_stats.frames_sent) as f32 / elapsed,
(s.fragments_sent - last_stats.fragments_sent) as f32 / elapsed,
(s.bytes_sent - last_stats.bytes_sent) as f32 / elapsed / 1024.0,
s.errors - last_stats.errors,
sender.peer_count(),
s.skipped_no_peer - last_stats.skipped_no_peer,
);
last_stats = s;
last_report = now;
}
}
}
/// Joins `SSID`, scanning first and retrying the plausible authentication modes.
///
/// The scan is not decoration. This radio is 2.4 GHz only, so a 5 GHz SSID never
/// shows up at all and a bare "association timed out" tells you nothing.
///
/// The retries are because the AP's advertised mode is not the same thing as the
/// mode it will accept. Routers commonly run WPA/WPA2 mixed, WPA2/WPA3 mixed, or
/// WPA3 with PMF mandatory, and a client pinned to the wrong one fails the 4-way
/// handshake *after* it has already associated - the failure looks identical to
/// a wrong password, so it is worth walking the list instead of guessing.
fn connect(wifi: &mut BlockingWifi<EspWifi<'static>>) -> Result<()> {
let pinned_auth = auth_override();
// A configuration has to exist before start(), which is what decides
// station vs AP mode; the real one is picked after the scan.
wifi.set_configuration(&client_config(pinned_auth.unwrap_or_else(default_auth_method))?)?;
wifi.start()?;
info!("wifi started");
info!("scanning for 2.4 GHz networks...");
let advertised = match wifi.wifi_mut().scan() {
Ok(aps) => {
report_scan(&aps);
advertised_auth(&aps)
}
Err(err) => {
warn!("scan failed: {err}");
None
}
};
for auth in candidates(advertised, pinned_auth) {
info!("trying {auth:?}...");
wifi.set_configuration(&client_config(auth)?)?;
// Driver-level (non-blocking) connect: `BlockingWifi::connect()` waits
// out the driver's own ~20 s association timeout, which would make
// walking the list of modes take minutes.
wifi.wifi_mut().connect()?;
// Bounded, so one bad mode costs a few seconds instead of the timeout.
let associated = wifi
.wifi_wait_while(
|| wifi.wifi().is_connected().map(|c| !c),
Some(Duration::from_secs(5)),
)
.is_ok();
let got_ip = associated
&& wifi
.ip_wait_while(
|| wifi.wifi().is_up().map(|up| !up),
Some(Duration::from_secs(4)),
)
.is_ok();
if got_ip {
let ip_info = wifi.wifi().sta_netif().get_ip_info()?;
if !ip_info.ip.is_unspecified() {
info!("wifi connected with {auth:?}: {ip_info:?}");
return Ok(());
}
}
warn!(
"{auth:?} did not complete the handshake ({})",
if associated { "associated but no DHCP address" } else { "no association" }
);
wifi.wifi_mut().disconnect().ok();
}
Err(anyhow!(
"no authentication mode joined \"{SSID}\" (tried {:?}); \
association succeeded but the 4-way handshake did not, \
which almost always means the password is wrong",
candidates(advertised, pinned_auth)
))
}
/// Authentication modes to try, most likely first.
///
/// A mode pinned with `WIFI_AUTH` is the only one tried - if it does not work,
/// trying others would just be guessing at a value the builder already stated.
fn candidates(advertised: Option<AuthMethod>, pinned: Option<AuthMethod>) -> Vec<AuthMethod> {
if let Some(auth) = pinned {
return vec![auth];
}
// "WPA" proper is a pre-2004 mode no current router speaks, and "None" is
// only meaningful without a password, so neither is worth a 10 s attempt.
let mut modes = vec![advertised.unwrap_or_else(default_auth_method)];
modes.extend([
AuthMethod::WPA2Personal,
AuthMethod::WPA2WPA3Personal,
AuthMethod::WPA3Personal,
]);
if PASSWORD.is_empty() {
modes.push(AuthMethod::None);
}
let mut seen = Vec::new();
modes.retain(|m| {
let fresh = !seen.contains(m);
seen.push(*m);
fresh
});
modes
}
fn client_config(auth_method: AuthMethod) -> Result<Configuration> {
Ok(Configuration::Client(ClientConfiguration {
ssid: SSID
.try_into()
.map_err(|_| anyhow!("WIFI_SSID too long (max 32 bytes)"))?,
bssid: None,
auth_method,
password: PASSWORD
.try_into()
.map_err(|_| anyhow!("WIFI_PASS too long (max 64 bytes)"))?,
channel: None,
// Advertising PMF keeps routers that offer WPA2+WPA3 mixed mode from
// dropping the handshake, while `required: false` still lets a WPA2-only
// AP through. Leaving this at NotCapable fails on the former.
pmf_cfg: PmfConfiguration::Capable { required: false },
..Default::default()
}))
}
fn default_auth_method() -> AuthMethod {
if PASSWORD.is_empty() {
AuthMethod::None
} else {
AuthMethod::WPA2Personal
}
}
/// What `SSID` advertises, if the scan saw it.
///
/// `None` means the scan did not find this network at all, which on its own is
/// the most useful diagnostic there is: on a 2.4 GHz-only radio it almost always
/// means the SSID belongs to a 5 GHz network, or the AP is on a channel this
/// region's regulatory domain forbids.
fn advertised_auth(aps: &[AccessPointInfo]) -> Option<AuthMethod> {
aps.iter()
.find(|ap| ap.ssid.as_str() == SSID)
.and_then(|ap| ap.auth_method)
}
/// Logs the visible networks, marking the one we are trying to join.
fn report_scan(aps: &[AccessPointInfo]) {
if aps.is_empty() {
warn!("scan found no networks at all");
return;
}
for ap in aps {
info!(
" {:<32} ch {:>2} {:>4} dBm {:?}{}",
ap.ssid,
ap.channel,
ap.signal_strength,
ap.auth_method,
if ap.ssid.as_str() == SSID { " <- target" } else { "" }
);
}
if advertised_auth(aps).is_none() {
error!("\"{SSID}\" was not in the scan results");
error!("this radio is 2.4 GHz only, so the AP is likely 5 GHz or on a forbidden channel");
}
}