feat: 链路控制、延迟校准与长测工具,补全文档
构建客户端 / 检查(类型 + 单元测试) (push) Canceled after 0s
构建客户端 / Linux x64(deb + AppImage) (push) Canceled after 0s
构建客户端 / Windows x64(NSIS 安装包) (push) Canceled after 0s
构建客户端 / macOS 通用(.app + .dmg) (push) Canceled after 0s

- 固件:新增 link_control / stream_control,覆盖运行中 Wi-Fi 断线恢复、取帧节拍、
  温控与质量/帧率减载;UDP 发送增加有界重试预算与帧期限,PSRAM JPEG 直发减少拷贝。
- 协议:重组器严格校验分片布局,统计未收齐、超时与完全缺失的帧,处理帧号回绕与设备重启。
- 桌面端:视频改 latest-value 通道并前置帧时间标记,拒绝积压和慢解码的过期画面;新增
  延迟标记页与 --latency-report 校准,完善录制、服务端与退出流程。
- 文档与脚本:新增硬件、开发、延迟、功耗、实时性、稳定性文档和改动记录,重写根 README;
  新增构建、性能、延迟与串口长测脚本,以及长测客户端脚本。
- 构建:固定 esp-1.97.0.0 工具链,分区表随构建复制,更新 sdkconfig 与忽略规则。
This commit is contained in:
2026-10-19 00:19:07 +08:00
parent 4794bed2cf
commit cc6b82d7ec
60 changed files with 4347 additions and 655 deletions
+329 -22
View File
@@ -9,6 +9,10 @@
//! WiFi credentials come from the `WIFI_SSID` / `WIFI_PASS` environment variables
//! at build time, so no secrets end up in the source.
use std::sync::{
atomic::{AtomicU32, Ordering},
Arc,
};
use std::time::{Duration, Instant};
use anyhow::{anyhow, Result};
@@ -16,14 +20,17 @@ 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::wifi::{BlockingWifi, EspWifi, WifiEvent};
use esp_idf_svc::{eventloop::EspSystemEventLoop, nvs::EspDefaultNvsPartition};
use log::{error, info, warn};
mod camera;
mod link_control;
mod stream_control;
mod udp;
use camera::{Camera, Resolution, SensorHint};
use stream_control::{Pacer, QualityController, ThermalLimiter};
use udp::UdpSender;
const SSID: &str = env!("WIFI_SSID");
@@ -84,14 +91,88 @@ fn main() -> Result<()> {
let resolution: Resolution = RESOLUTION.parse()?;
let quality: u8 = QUALITY.parse()?;
let quality_ceiling: u8 = option_env!("CAM_QUALITY_MAX").unwrap_or("0").parse()?;
if quality_ceiling != 0 && !(quality..=63).contains(&quality_ceiling) {
return Err(anyhow!(
"CAM_QUALITY_MAX must be 0 (fixed quality) or CAM_QUALITY..63"
));
}
let max_fps: u32 = option_env!("CAM_MAX_FPS").unwrap_or("0").parse()?;
if max_fps > 60 {
return Err(anyhow!("CAM_MAX_FPS must be 0 (uncapped) or 1-60"));
}
let grayscale = match option_env!("CAM_GRAYSCALE").unwrap_or("0") {
"0" => false,
"1" => true,
_ => return Err(anyhow!("CAM_GRAYSCALE must be 0 or 1")),
};
let thermal_limit: f32 = option_env!("CAM_THERMAL_LIMIT_C").unwrap_or("75").parse()?;
if !thermal_limit.is_finite()
|| (thermal_limit != 0.0 && !(40.0..=95.0).contains(&thermal_limit))
{
return Err(anyhow!("CAM_THERMAL_LIMIT_C must be 0 (disabled) or 40-95"));
}
info!("reset reason: {}", unsafe {
esp_idf_svc::sys::esp_reset_reason()
});
info!("vsync invert: {}", vsync_invert());
let camera = Camera::init(SensorHint::Auto, resolution, quality, vsync_invert())?;
camera.set_clock_profile(option_env!("CAM_CLOCK_PROFILE").unwrap_or("default"))?;
camera.set_grayscale(grayscale)?;
let exposure_ev: i32 = option_env!("CAM_EXPOSURE_EV").unwrap_or("0").parse()?;
esp_idf_svc::sys::esp!(unsafe {
esp_idf_svc::sys::camera::cam_wrapper_set_exposure_bias(exposure_ev)
})?;
if let Some(level) = option_env!("CAM_AE_LEVEL") {
if exposure_ev != 0 {
return Err(anyhow!(
"Use CAM_AE_LEVEL or a nonzero CAM_EXPOSURE_EV, not both"
));
}
camera.set_ae_level(level.parse()?)?;
}
let horizontal_total: u32 = option_env!("CAM_HTS").unwrap_or("0").parse()?;
esp_idf_svc::sys::esp!(unsafe {
esp_idf_svc::sys::camera::cam_wrapper_set_horizontal_timing(horizontal_total)
})?;
let shutter_lines: u32 = option_env!("CAM_SHUTTER_LINES").unwrap_or("0").parse()?;
let exposure_limit: u32 = option_env!("CAM_EXPOSURE_MAX_LINES")
.unwrap_or("0")
.parse()?;
if shutter_lines != 0 && exposure_limit != 0 {
return Err(anyhow!(
"Use CAM_SHUTTER_LINES or CAM_EXPOSURE_MAX_LINES, not both"
));
}
esp_idf_svc::sys::esp!(unsafe {
esp_idf_svc::sys::camera::cam_wrapper_set_shutter_lines(shutter_lines)
})?;
esp_idf_svc::sys::esp!(unsafe {
esp_idf_svc::sys::camera::cam_wrapper_set_exposure_limit(exposure_limit)
})?;
camera.set_gain_ceiling(option_env!("CAM_GAIN_CEILING").unwrap_or("0").parse()?)?;
info!("stream profile: target {max_fps} fps, grayscale {grayscale}, quality {quality}, thermal limit {thermal_limit:.1} C");
info!(
"configured capture probe: {:.2} fps",
camera.probe_frame_rate()
);
let temperature_available = unsafe { esp_idf_svc::sys::camera::cam_wrapper_temperature_init() };
if temperature_available != 0 {
warn!("chip temperature unavailable (esp_err 0x{temperature_available:x}); fixed frame cap remains active");
}
let peripherals = Peripherals::take()?;
let sys_loop = EspSystemEventLoop::take()?;
let nvs = EspDefaultNvsPartition::take()?;
let network_epoch = Arc::new(AtomicU32::new(0));
let event_epoch = network_epoch.clone();
let _wifi_events = sys_loop.subscribe::<WifiEvent, _>(move |event| {
if matches!(event, WifiEvent::StaDisconnected(_)) {
event_epoch.fetch_add(1, Ordering::Relaxed);
}
})?;
let mut wifi = BlockingWifi::wrap(
EspWifi::new(peripherals.modem, sys_loop.clone(), Some(nvs))?,
@@ -109,21 +190,7 @@ fn main() -> Result<()> {
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})");
}
configure_radio()?;
// 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
@@ -136,8 +203,114 @@ fn main() -> Result<()> {
let mut last_report = Instant::now();
let mut last_stats = sender.stats();
let mut pacer = Pacer::new(max_fps);
let mut thermal = ThermalLimiter::new(max_fps, thermal_limit);
let mut current_quality = quality;
let mut quality_control = QualityController::new(
quality,
if quality_ceiling == 0 {
quality
} else {
quality_ceiling
},
);
let loop_start = Instant::now();
let mut recovery = link_control::Recovery::new(0);
let mut seen_epoch = network_epoch.load(Ordering::Relaxed);
let mut link_up = true;
let mut active_ip = ip;
let mut next_link_check = 0u64;
let mut outage_started = 0u64;
let test_drop_ms = option_env!("WIFI_TEST_DROP_AFTER_S")
.unwrap_or("0")
.parse::<u64>()?
.saturating_mul(1000);
let mut test_injected = false;
let mut test_hold_until = 0u64;
loop {
let now_ms = loop_start.elapsed().as_millis() as u64;
if test_drop_ms > 0 && !test_injected && now_ms >= test_drop_ms {
test_injected = true;
test_hold_until = now_ms + 5000;
warn!("Wi-Fi recovery self-test: intentional disconnect for 5000 ms");
wifi.wifi_mut().disconnect().ok();
next_link_check = 0;
}
if now_ms >= next_link_check {
next_link_check = now_ms + 100;
let epoch = network_epoch.load(Ordering::Relaxed);
let associated = wifi.wifi().is_connected().unwrap_or(false);
let lease = wifi
.wifi()
.sta_netif()
.get_ip_info()
.ok()
.map(|v| v.ip)
.filter(|ip| !ip.is_unspecified());
let ready = associated
&& wifi.wifi().is_up().unwrap_or(false)
&& lease.is_some()
&& now_ms >= test_hold_until;
let address_changed = ready && lease.is_some_and(|address| address != active_ip);
if link_up && (!ready || epoch != seen_epoch || address_changed) {
link_up = false;
outage_started = now_ms;
recovery = link_control::Recovery::new(now_ms);
sender.network_lost();
warn!("Wi-Fi link unavailable or changed; UDP suspended, waiting for association and DHCP");
} else if epoch != seen_epoch {
recovery.disconnected(now_ms);
}
seen_epoch = epoch;
if !link_up && ready {
match sender.restore_network() {
Ok(()) => {
active_ip = lease.unwrap();
if let Err(err) = configure_radio() {
warn!("radio profile restore: {err}");
}
// Discard any queued image from before recovery.
for _ in 0..2 {
if let Ok(frame) = camera.next_frame() {
drop(frame);
}
}
link_up = true;
last_report = Instant::now();
last_stats = sender.stats();
info!("Wi-Fi recovery complete after {} ms; IP {active_ip}; UDP recreated, camera state preserved",now_ms-outage_started);
}
Err(err) => warn!("UDP restore failed, will retry: {err}"),
}
} else if !link_up && now_ms >= test_hold_until {
match recovery.poll(now_ms, associated) {
Some(link_control::Action::Connect) => {
info!("Wi-Fi reconnect attempt");
if let Err(err) = wifi.wifi_mut().connect() {
warn!("Wi-Fi reconnect rejected: {err}");
recovery.disconnected(now_ms);
}
}
Some(link_control::Action::AbortAttempt) => {
warn!("Wi-Fi association/DHCP deadline elapsed; cancelling attempt before backoff");
wifi.wifi_mut().disconnect().ok();
}
None => {}
}
}
}
if !link_up {
std::thread::sleep(Duration::from_millis(50));
continue;
}
sender.housekeeping();
let now_us = unsafe { esp_idf_svc::sys::esp_timer_get_time() } as u64;
let wait_us = pacer.delay_us(now_us);
if wait_us > 0 {
std::thread::sleep(Duration::from_micros(wait_us));
}
pacer.started(unsafe { esp_idf_svc::sys::esp_timer_get_time() } as u64);
udp::pump(&camera, &mut sender)?;
let now = Instant::now();
@@ -148,16 +321,90 @@ fn main() -> Result<()> {
// distinction that matters when the frame rate is wrong.
let s = sender.stats();
let elapsed = now.duration_since(last_report).as_secs_f32();
let attempted = s.frames_attempted - last_stats.frames_attempted;
let dropped = s.peer_frames_dropped - last_stats.peer_frames_dropped;
let send_ms = (s.send_time_us - last_stats.send_time_us) as f32
/ attempted.max(1) as f32
/ 1000.0;
if let Some(next) = quality_control.observe(
u64::from(attempted) * sender.peer_count() as u64,
dropped,
send_ms,
) {
match camera.set_quality(next) {
Ok(()) => {
info!("JPEG load control: quality {current_quality} -> {next}, {dropped} peer drops / {attempted} attempts, {send_ms:.2} ms/send");
current_quality = next;
}
Err(err) => {
warn!("JPEG load control disabled after sensor error: {err}");
quality_control = QualityController::new(current_quality, current_quality);
}
}
}
let mut ap: esp_idf_svc::sys::wifi_ap_record_t = unsafe { std::mem::zeroed() };
let ap_ok = unsafe { esp_idf_svc::sys::esp_wifi_sta_get_ap_info(&mut ap) } == 0;
let mut tx_power = 0;
let tx_power_ok = unsafe {
esp_idf_svc::sys::esp_wifi_get_max_tx_power(&mut tx_power)
} == 0;
let mut celsius = f32::NAN;
let temp_ok = temperature_available == 0
&& unsafe { esp_idf_svc::sys::camera::cam_wrapper_temperature_read(&mut celsius) }
== 0;
let previous_fps = thermal.fps();
let target_fps = thermal.observe(if temp_ok { Some(celsius) } else { None });
if previous_fps != target_fps {
pacer.set_fps(
target_fps,
unsafe { esp_idf_svc::sys::esp_timer_get_time() } as u64,
);
info!("thermal load limit changed: {previous_fps} -> {target_fps} fps at {celsius:.1} C");
}
info!(
"{:.1} fps, {} datagrams/s, {:.1} kB/s, {} send errors, \
{} clients subscribed, {} frames skipped (no client)",
"{:.1} fps submitted, {:.1} fps attempted, {:.1} datagrams/s, {:.1} kB/s, \
{} send failures, {} retries, errno {}, {} peer frames dropped, \
{:.2} ms/send, {} clients subscribed, {} frames skipped (no client), \
internal heap {} free / {} largest bytes, RSSI {} dBm channel {}, \
chip temp {:.1} C, target {} fps, thermal_limited {}, capture errors {}, exposure {} lines, jpeg quality {}, gain {:.2}x, \
capture queue skipped {}, stale frames dropped {}, deadline peer drops {}, max submitted capture age {:.2} ms, max send hold {:.2} ms, max TX power {:.2} dBm",
(s.frames_sent - last_stats.frames_sent) as f32 / elapsed,
(s.frames_attempted - last_stats.frames_attempted) 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,
s.retries - last_stats.retries,
s.last_errno,
s.peer_frames_dropped - last_stats.peer_frames_dropped,
(s.send_time_us - last_stats.send_time_us) as f32
/ (s.frames_attempted - last_stats.frames_attempted).max(1) as f32
/ 1000.0,
sender.peer_count(),
s.skipped_no_peer - last_stats.skipped_no_peer,
unsafe {
esp_idf_svc::sys::heap_caps_get_free_size(esp_idf_svc::sys::MALLOC_CAP_INTERNAL)
},
unsafe {
esp_idf_svc::sys::heap_caps_get_largest_free_block(
esp_idf_svc::sys::MALLOC_CAP_INTERNAL,
)
},
if ap_ok { ap.rssi } else { 0 },
if ap_ok { ap.primary } else { 0 },
if temp_ok { celsius } else { f32::NAN },
target_fps,
thermal.limited(),
s.capture_errors - last_stats.capture_errors,
unsafe { esp_idf_svc::sys::camera::cam_wrapper_exposure_lines() },
current_quality,
unsafe { esp_idf_svc::sys::camera::cam_wrapper_gain_raw() } as f32 / 16.0,
s.capture_queue_skipped - last_stats.capture_queue_skipped,
s.stale_frames_dropped - last_stats.stale_frames_dropped,
s.deadline_peer_drops - last_stats.deadline_peer_drops,
s.max_submitted_age_us as f32 / 1000.0,
s.max_send_hold_us as f32 / 1000.0,
if tx_power_ok { tx_power as f32 / 4.0 } else { f32::NAN },
);
last_stats = s;
@@ -166,6 +413,56 @@ fn main() -> Result<()> {
}
}
fn configure_radio() -> Result<()> {
// 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})");
}
// Apply the selected power cap both at startup and after reconnecting.
// Congestion/thermal controllers must not lower it: they adjust JPEG
// quality or frame delivery instead. The SDK quantizes this setting.
if let Some(value) = option_env!("WIFI_TX_POWER_DBM") {
let dbm: u8 = value
.parse()
.map_err(|_| anyhow!("WIFI_TX_POWER_DBM must be an integer from 2 to 20"))?;
if !(2..=20).contains(&dbm) {
return Err(anyhow!("WIFI_TX_POWER_DBM must be an integer from 2 to 20"));
}
esp_idf_svc::sys::esp!(unsafe {
esp_idf_svc::sys::esp_wifi_set_max_tx_power((dbm * 4) as i8)
})?;
info!("WiFi requested max TX power: {dbm} dBm");
}
let mut ap: esp_idf_svc::sys::wifi_ap_record_t = unsafe { std::mem::zeroed() };
let mut phy = 0;
let mut tx_power = 0;
unsafe {
esp_idf_svc::sys::esp_wifi_sta_get_ap_info(&mut ap);
esp_idf_svc::sys::esp_wifi_sta_get_negotiated_phymode(&mut phy);
}
esp_idf_svc::sys::esp!(unsafe {
esp_idf_svc::sys::esp_wifi_get_max_tx_power(&mut tx_power)
})?;
info!(
"WiFi link: negotiated PHY {phy}, pairwise cipher {}, max TX power {:.2} dBm",
ap.pairwise_cipher,
tx_power as f32 / 4.0
);
Ok(())
}
/// 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
@@ -181,7 +478,9 @@ fn connect(wifi: &mut BlockingWifi<EspWifi<'static>>) -> Result<()> {
// 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.set_configuration(&client_config(
pinned_auth.unwrap_or_else(default_auth_method),
)?)?;
wifi.start()?;
info!("wifi started");
@@ -232,7 +531,11 @@ fn connect(wifi: &mut BlockingWifi<EspWifi<'static>>) -> Result<()> {
warn!(
"{auth:?} did not complete the handshake ({})",
if associated { "associated but no DHCP address" } else { "no association" }
if associated {
"associated but no DHCP address"
} else {
"no association"
}
);
wifi.wifi_mut().disconnect().ok();
}
@@ -329,7 +632,11 @@ fn report_scan(aps: &[AccessPointInfo]) {
ap.channel,
ap.signal_strength,
ap.auth_method,
if ap.ssid.as_str() == SSID { " <- target" } else { "" }
if ap.ssid.as_str() == SSID {
" <- target"
} else {
""
}
);
}
@@ -337,4 +644,4 @@ fn report_scan(aps: &[AccessPointInfo]) {
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");
}
}
}