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
+136 -7
View File
@@ -112,6 +112,14 @@ pub struct Frame {
}
impl Frame {
pub fn captured_at_us(&self) -> u64 {
self.fb.capture_started_us.max(0) as u64
}
pub fn skipped_before(&self) -> u32 {
self.fb.skipped_before
}
pub fn data(&self) -> &[u8] {
if self.fb.data.is_null() || self.fb.len == 0 {
return &[];
@@ -206,12 +214,17 @@ impl Camera {
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,
// Three buffers tolerate socket stalls. Two reduce capture queue
// depth, but may miss a VSYNC while the caller holds a buffer.
// Keep this selectable so latency and delivered FPS can be measured
// together instead of assuming the smallest pool always wins.
fb_count: {
let count: u32 = option_env!("CAM_FB_COUNT").unwrap_or("3").parse()?;
if !(2..=3).contains(&count) {
return Err(anyhow!("CAM_FB_COUNT must be 2 or 3"));
}
count as _
},
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
@@ -254,6 +267,122 @@ impl Camera {
}
}
pub fn set_grayscale(&self, enabled: bool) -> Result<()> {
let rc = unsafe { sys::camera::cam_wrapper_set_grayscale(i32::from(enabled)) };
if rc != 0 {
return Err(anyhow!("sensor grayscale failed (esp_err 0x{rc:x})"));
}
Ok(())
}
/// A complete PLL profile; preserve HTS/VTS and the active readout window.
/// 610 MHz VCO, 61 MHz system clock, 15.25 MHz DVP PCLK at XCLK 20 MHz.
pub fn set_clock_profile(&self, profile: &str) -> Result<()> {
if profile == "default" {
return Ok(());
}
if profile != "vga30" {
return Err(anyhow!("CAM_CLOCK_PROFILE must be default or vga30"));
}
unsafe {
let sensor = sys::camera::esp_camera_sensor_get();
if sensor.is_null() || (*sensor).id.PID != 0x5640 {
return Err(anyhow!("vga30 requires OV5640"));
}
let set = (*sensor)
.set_pll
.ok_or_else(|| anyhow!("sensor PLL control unavailable"))?;
// sensor_t callback order differs from the driver's private helper:
// bypass, multiplier, sys_div, root_2x, pre_div, pclk_root_div,
// manual_pclk, pclk_div.
if set(sensor, 0, 61, 1, 0, 2, 2, 1, 4) != 0 {
return Err(anyhow!("OV5640 clock profile failed"));
}
std::thread::sleep(std::time::Duration::from_millis(300));
info!("OV5640 vga30 clock profile: VCO 610 MHz, SYSCLK 61 MHz, PCLK 15.25 MHz; HTS/VTS unchanged");
}
Ok(())
}
/// Fine AE target adjustment without changing the exposure ceiling or AWB.
pub fn set_ae_level(&self, level: i32) -> Result<()> {
if !(-5..=5).contains(&level) {
return Err(anyhow!("CAM_AE_LEVEL must be between -5 and 5"));
}
unsafe {
let sensor = sys::camera::esp_camera_sensor_get();
if sensor.is_null() || (*sensor).id.PID != 0x5640 {
return Err(anyhow!("CAM_AE_LEVEL requires OV5640"));
}
let set = (*sensor)
.set_ae_level
.ok_or_else(|| anyhow!("sensor AE target control unavailable"))?;
if set(sensor, level) != 0 {
return Err(anyhow!("OV5640 AE target adjustment failed"));
}
}
info!(
"OV5640 AE target override: driver level {level}, target {}/55; AEC/AGC retained",
(level + 5) * 10 + 5
);
Ok(())
}
pub fn probe_frame_rate(&self) -> f32 {
unsafe { sys::camera::cam_wrapper_probe_frame_rate() }
}
pub fn set_quality(&self, quality: u8) -> Result<()> {
let rc = unsafe { sys::camera::cam_wrapper_set_quality(u32::from(quality)) };
if rc != 0 {
return Err(anyhow!("sensor JPEG quality failed (esp_err 0x{rc:x})"));
}
Ok(())
}
pub fn set_gain_ceiling(&self, gain: u8) -> Result<()> {
if gain == 0 {
return Ok(());
}
if gain > 63 {
return Err(anyhow!("CAM_GAIN_CEILING must be 0 or 1-63"));
}
// OV5640 gain is a 6.4 fixed-point value. Keep AEC/AGC automatic;
// change its limit, rather than forcing the live analogue gain.
unsafe {
let sensor = sys::camera::esp_camera_sensor_get();
if sensor.is_null() || (*sensor).id.PID != 0x5640 {
return Err(anyhow!("CAM_GAIN_CEILING requires OV5640"));
}
let write = (*sensor)
.set_reg
.ok_or_else(|| anyhow!("sensor register writes unavailable"))?;
let read = (*sensor)
.get_reg
.ok_or_else(|| anyhow!("sensor register reads unavailable"))?;
let raw = i32::from(gain) * 16 - 1;
if write(sensor, 0x3a18, 0x03, raw >> 8) != 0
|| write(sensor, 0x3a19, 0xff, raw & 0xff) != 0
|| read(sensor, 0x3a18, 0x03ff) != raw
{
return Err(anyhow!("could not set OV5640 gain ceiling"));
}
info!("OV5640 automatic gain ceiling: {:.2}x", raw as f32 / 16.0);
}
Ok(())
}
/// Keep capture memory at three buffers, but never send a FIFO backlog.
pub fn latest_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_latest(&mut fb, 100) };
if rc != 0 || fb.data.is_null() || fb.len == 0 {
unsafe { sys::camera::cam_wrapper_fb_release(&mut fb) };
return Err(FrameError);
}
Ok(Frame { fb })
}
/// Grabs the next frame.
///
/// This blocks until the sensor has one, which is what paces the stream.
@@ -297,4 +426,4 @@ mod pins {
/// D0..D7 == Y2..Y9, in the order `camera_config_t` expects.
pub const DATA: [i32; 8] = [11, 9, 8, 10, 12, 18, 17, 16];
}
}
+102
View File
@@ -0,0 +1,102 @@
//! Nonblocking Wi-Fi recovery. Association and DHCP have separate deadlines.
#[derive(Debug, PartialEq)]
pub enum Action {
Connect,
AbortAttempt,
}
pub struct Recovery {
next_attempt_ms: u64,
attempt_deadline_ms: Option<u64>,
lease_started_ms: Option<u64>,
backoff_ms: u64,
}
impl Recovery {
pub fn new(now_ms: u64) -> Self {
Self {
next_attempt_ms: now_ms,
attempt_deadline_ms: None,
lease_started_ms: None,
backoff_ms: 1000,
}
}
fn failed(&mut self, now_ms: u64) {
self.attempt_deadline_ms = None;
self.lease_started_ms = None;
self.next_attempt_ms = now_ms.saturating_add(self.backoff_ms);
self.backoff_ms = (self.backoff_ms * 2).min(30_000);
}
pub fn disconnected(&mut self, now_ms: u64) {
if self.attempt_deadline_ms.is_some() {
self.failed(now_ms);
}
}
pub fn poll(&mut self, now_ms: u64, associated: bool) -> Option<Action> {
if associated {
let start = *self.lease_started_ms.get_or_insert(now_ms);
if now_ms.saturating_sub(start) >= 15_000 {
self.failed(now_ms);
return Some(Action::AbortAttempt);
}
return None;
}
self.lease_started_ms = None;
if self
.attempt_deadline_ms
.is_some_and(|deadline| now_ms >= deadline)
{
self.failed(now_ms);
return Some(Action::AbortAttempt);
}
if self.attempt_deadline_ms.is_none() && now_ms >= self.next_attempt_ms {
self.attempt_deadline_ms = Some(now_ms.saturating_add(15_000));
return Some(Action::Connect);
}
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn first_attempt_is_immediate_but_does_not_restart_active_authentication() {
let mut r = Recovery::new(100);
assert_eq!(r.poll(100, false), Some(Action::Connect));
assert_eq!(r.poll(14_999, false), None);
assert_eq!(r.poll(15_100, false), Some(Action::AbortAttempt));
assert_eq!(r.poll(16_099, false), None);
assert_eq!(r.poll(16_100, false), Some(Action::Connect));
}
#[test]
fn failure_events_back_off_with_a_thirty_second_limit() {
let mut r = Recovery::new(0);
let mut now = 0;
for delay in [1000, 2000, 4000, 8000, 16000, 30000, 30000] {
assert_eq!(r.poll(now, false), Some(Action::Connect));
r.disconnected(now + 100);
assert_eq!(r.poll(now + 99 + delay, false), None);
now += 100 + delay;
}
}
#[test]
fn dhcp_is_allowed_time_but_cannot_hang_forever() {
let mut r = Recovery::new(0);
assert_eq!(r.poll(0, false), Some(Action::Connect));
assert_eq!(r.poll(500, true), None);
assert_eq!(r.poll(15_499, true), None);
assert_eq!(r.poll(15_500, true), Some(Action::AbortAttempt));
// The expected disconnect after abort must not double the backoff.
r.disconnected(15_501);
assert_eq!(r.poll(16_500, false), Some(Action::Connect));
}
#[test]
fn a_new_outage_resets_backoff_and_deadlines() {
let mut r = Recovery::new(0);
r.poll(0, false);
r.disconnected(100);
let mut next = Recovery::new(200);
assert_eq!(next.poll(200, false), Some(Action::Connect));
}
}
+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");
}
}
}
+287
View File
@@ -0,0 +1,287 @@
//! Frame pacing and a hysteretic, configurable thermal load limit.
//! Times are monotonic microseconds so the scheduling policy is host-testable.
pub struct Pacer {
period_us: u64,
next_due_us: u64,
}
impl Pacer {
pub fn new(fps: u32) -> Self {
Self::with_gap_us(period(fps))
}
pub fn with_gap_us(period_us: u64) -> Self {
Self {
period_us,
next_due_us: 0,
}
}
pub fn delay_us(&self, now_us: u64) -> u64 {
self.next_due_us.saturating_sub(now_us)
}
pub fn started(&mut self, now_us: u64) {
// An overrun starts a new period; never burst to repay missed slots.
self.next_due_us = now_us.saturating_add(self.period_us);
}
pub fn set_fps(&mut self, fps: u32, now_us: u64) {
self.period_us = period(fps);
self.started(now_us);
}
}
pub const MAX_SEND_HOLD_US: u64 = 25_000;
pub const MAX_CAPTURE_AGE_US: u64 = 100_000;
pub const DEFAULT_RETRY_BUDGET_US: u64 = 8_000;
/// One current frame, no pending-send FIFO. A short retry budget is shared by
/// all fragments/peers; success never renews the frame's absolute deadline.
pub struct FrameBudget {
deadline_us: u64,
retry_left_us: u64,
}
impl FrameBudget {
pub fn new(now_us: u64, captured_us: u64, retry_us: u64) -> Self {
Self {
deadline_us: now_us
.saturating_add(MAX_SEND_HOLD_US)
.min(captured_us.min(now_us).saturating_add(MAX_CAPTURE_AGE_US)),
retry_left_us: retry_us.min(MAX_SEND_HOLD_US),
}
}
pub fn expired(&self, now_us: u64) -> bool {
now_us >= self.deadline_us
}
pub fn allow_retry(&mut self, now_us: u64, wait_us: u64) -> bool {
if wait_us == 0
|| wait_us > self.retry_left_us
|| now_us.saturating_add(wait_us) >= self.deadline_us
{
return false;
}
self.retry_left_us -= wait_us;
true
}
}
fn period(fps: u32) -> u64 {
if fps == 0 {
0
} else {
1_000_000u64.div_ceil(fps as u64)
}
}
/// Adjust JPEG compression from five-second submission windows. The sensor's
/// quality number increases with compression; restore detail more slowly than
/// we shed load, and do not interpret absent subscribers as a healthy link.
pub struct QualityController {
base: u8,
ceiling: u8,
current: u8,
healthy_windows: u8,
}
impl QualityController {
pub fn new(base: u8, ceiling: u8) -> Self {
Self {
base,
ceiling,
current: base,
healthy_windows: 0,
}
}
pub fn observe(&mut self, peer_frames: u64, dropped: u32, send_ms: f32) -> Option<u8> {
if peer_frames < 5 || !send_ms.is_finite() {
self.healthy_windows = 0;
return None;
}
let previous = self.current;
if u64::from(dropped) * 100 >= peer_frames.saturating_mul(2) || send_ms >= 8.0 {
self.current = self.current.saturating_add(3).min(self.ceiling);
self.healthy_windows = 0;
} else if dropped == 0 && send_ms < 4.0 {
self.healthy_windows += 1;
if self.healthy_windows >= 3 {
self.current = self.current.saturating_sub(1).max(self.base);
self.healthy_windows = 0;
}
} else {
self.healthy_windows = 0;
}
(self.current != previous).then_some(self.current)
}
}
pub struct ThermalLimiter {
normal_fps: u32,
limit_c: f32,
limited: bool,
}
impl ThermalLimiter {
pub fn new(normal_fps: u32, limit_c: f32) -> Self {
Self {
normal_fps,
limit_c,
limited: false,
}
}
pub fn observe(&mut self, temperature: Option<f32>) -> u32 {
if self.limit_c > 0.0 {
if let Some(t) = temperature.filter(|t| t.is_finite()) {
if t >= self.limit_c {
self.limited = true;
} else if t <= self.limit_c - 5.0 {
self.limited = false;
}
}
}
self.fps()
}
pub fn fps(&self) -> u32 {
if !self.limited {
self.normal_fps
} else if self.normal_fps == 0 {
8
} else {
self.normal_fps.div_ceil(2)
}
}
pub fn limited(&self) -> bool {
self.limited
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pacing_caps_rate_without_catching_up_after_an_overrun() {
let mut pacer = Pacer::new(15);
assert_eq!(pacer.delay_us(100), 0);
pacer.started(100);
assert_eq!(pacer.delay_us(101), 66_666);
assert_eq!(pacer.delay_us(300_000), 0);
pacer.started(300_000);
assert_eq!(pacer.delay_us(300_001), 66_666);
}
#[test]
fn unlimited_and_runtime_rate_changes_do_not_burst() {
let mut pacer = Pacer::new(0);
pacer.started(1_000);
assert_eq!(pacer.delay_us(1_001), 0);
pacer.set_fps(10, 1_001);
assert_eq!(pacer.delay_us(1_002), 99_999);
}
#[test]
fn thermal_limit_has_five_degree_hysteresis() {
let mut limit = ThermalLimiter::new(15, 75.0);
assert_eq!(limit.observe(Some(74.9)), 15);
assert_eq!(limit.observe(Some(75.0)), 8);
assert_eq!(limit.observe(Some(72.0)), 8);
assert_eq!(limit.observe(None), 8);
assert_eq!(limit.observe(Some(f32::NAN)), 8);
assert_eq!(limit.observe(Some(70.0)), 15);
}
#[test]
fn thermal_guard_can_be_disabled_or_used_with_uncapped_streams() {
assert_eq!(ThermalLimiter::new(15, 0.0).observe(Some(90.0)), 15);
assert_eq!(ThermalLimiter::new(0, 75.0).observe(Some(80.0)), 8);
assert_eq!(ThermalLimiter::new(1, 75.0).observe(Some(80.0)), 1);
}
#[test]
fn packet_spacing_starts_from_actual_submission_without_catch_up() {
let mut pacer = Pacer::with_gap_us(1000);
pacer.started(100);
assert_eq!(pacer.delay_us(900), 200);
assert_eq!(pacer.delay_us(5000), 0);
pacer.started(5000);
assert_eq!(pacer.delay_us(5001), 999);
}
#[test]
fn frame_deadline_is_not_renewed_by_successful_fragments_or_other_peers() {
let frame = FrameBudget::new(50_000, 20_000, 8_000);
assert!(!frame.expired(74_999));
assert!(frame.expired(75_000));
}
#[test]
fn an_old_capture_cannot_gain_a_fresh_send_lifetime() {
let frame = FrameBudget::new(99_000, 0, 8_000);
assert!(!frame.expired(99_999));
assert!(frame.expired(100_000));
assert!(FrameBudget::new(100_001, 0, 8_000).expired(100_001));
}
#[test]
fn retry_allowance_is_shared_by_all_fragments_and_stops_before_deadline() {
let mut frame = FrameBudget::new(50_000, 20_000, 2_000);
assert!(frame.allow_retry(51_000, 1000));
assert!(frame.allow_retry(52_000, 1000));
assert!(!frame.allow_retry(53_000, 1000));
let mut late = FrameBudget::new(50_000, 20_000, 8_000);
assert!(!late.allow_retry(74_000, 1000));
}
#[test]
fn zero_retry_mode_still_allows_immediate_sends_and_abandons_backpressure() {
let mut frame = FrameBudget::new(50_000, 20_000, 0);
assert!(!frame.expired(50_001));
assert!(!frame.allow_retry(50_001, 1000));
}
#[test]
fn compression_sheds_congestion_load_quickly_and_stays_in_bounds() {
let mut control = QualityController::new(18, 24);
assert_eq!(control.observe(100, 2, 3.0), Some(21));
assert_eq!(control.observe(100, 0, 8.0), Some(24));
assert_eq!(control.observe(100, 20, 20.0), None);
let mut disabled = QualityController::new(18, 18);
assert_eq!(disabled.observe(100, 50, 20.0), None);
}
#[test]
fn compression_recovers_only_after_three_consecutive_healthy_windows() {
let mut control = QualityController::new(18, 24);
control.observe(100, 10, 10.0);
assert_eq!(control.observe(100, 0, 2.0), None);
assert_eq!(control.observe(100, 0, 2.0), None);
assert_eq!(control.observe(100, 1, 5.0), None);
assert_eq!(control.observe(100, 0, 2.0), None);
assert_eq!(control.observe(100, 0, 2.0), None);
assert_eq!(control.observe(100, 0, 2.0), Some(20));
for _ in 0..20 {
control.observe(100, 0, 2.0);
}
assert_eq!(control.current, 18);
}
#[test]
fn missing_traffic_or_invalid_timing_does_not_restore_quality() {
let mut control = QualityController::new(18, 24);
control.observe(100, 10, 10.0);
control.observe(100, 0, 2.0);
control.observe(100, 0, 2.0);
assert_eq!(control.observe(0, 0, 0.0), None);
assert_eq!(control.observe(100, 0, f32::NAN), None);
assert_eq!(control.observe(100, 0, 2.0), None);
assert_eq!(control.current, 21);
}
}
+224 -52
View File
@@ -32,6 +32,9 @@ use esp_idf_svc::sys as ffi;
use log::{debug, info, warn};
use crate::camera::{Camera, Frame};
use crate::stream_control::{
FrameBudget, Pacer, DEFAULT_RETRY_BUDGET_US, MAX_CAPTURE_AGE_US, MAX_SEND_HOLD_US,
};
/// 单个数据报的最大字节数。
///
@@ -51,6 +54,10 @@ const HELLO_INTERVAL_MS: i64 = 2_000;
/// 够用,且不让一个陈旧的地址条目拖慢每一帧。
const MAX_PEERS: usize = 4;
// A frame must not spend a capture period waiting for network buffers. Retry
// transient driver backpressure briefly, then abandon that peer's old frame.
const TX_RETRY_WAIT_US: i64 = 1_000_000 / ffi::CONFIG_FREERTOS_HZ as i64;
/// 把 Rust 的 socket 地址转成 lwIP 期望的 C 结构。
///
/// 必须逐字段构造而不是 transmute:Rust 的 `SocketAddrV4` 是紧凑的 6 字节(端口在前、
@@ -75,6 +82,7 @@ fn to_sockaddr(addr: SocketAddrV4) -> ffi::sockaddr_in {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct Peer {
addr: SocketAddrV4,
client_id: u32,
/// 最近一次收到它订阅的时刻。
last_seen_us: i64,
}
@@ -84,20 +92,32 @@ struct Peer {
/// 与"传感器产出了多少"分开计数:两者的差就是这条链路的全部故事。
#[derive(Debug, Clone, Copy, Default)]
pub struct SendStats {
/// 已交给网络的帧。
pub capture_errors: u32,
/// 所有分片均交给网络的帧(至少一个对端;不代表无线接收确认)。
pub frames_sent: u32,
pub frames_attempted: u32,
pub peer_frames_sent: u32,
pub peer_frames_dropped: u32,
/// 已发出的数据报。
pub fragments_sent: u32,
/// 已发出的字节,含头。
pub bytes_sent: u64,
/// `sendto` 失败次数。
pub errors: u32,
pub retries: u32,
pub last_errno: i32,
pub send_time_us: u64,
/// 因没有客户端而未发送的帧。
pub skipped_no_peer: u32,
/// 收到的订阅数。
pub subscriptions: u32,
/// 已广播的 Hello 数。
pub hellos_sent: u32,
pub capture_queue_skipped: u32,
pub stale_frames_dropped: u32,
pub deadline_peer_drops: u32,
pub max_submitted_age_us: u64,
pub max_send_hold_us: u64,
}
/// 向已订阅的客户端推流,并广播自身存在。
@@ -111,6 +131,9 @@ pub struct UdpSender {
/// 读客户端订阅的暂存。内容无关紧要——只有源地址有用——所以刻意很小。
announce_buf: [u8; 64],
last_hello_us: i64,
datagram: Vec<u8>,
packet_pacer: Pacer,
retry_budget_us: u64,
}
/// 本机标识:由 MAC 派生,重启后不变。
@@ -118,12 +141,7 @@ pub fn local_sender_id() -> u32 {
let mut mac = [0u8; 6];
// SAFETY: `mac` 有 6 字节空间,函数会把 6 字节写满。
let rc = unsafe {
ffi::esp_read_mac(
mac.as_mut_ptr(),
ffi::esp_mac_type_t_ESP_MAC_WIFI_STA,
)
};
let rc = unsafe { ffi::esp_read_mac(mac.as_mut_ptr(), ffi::esp_mac_type_t_ESP_MAC_WIFI_STA) };
if rc != 0 {
warn!("could not read the WiFi MAC (esp_err {rc}); using a fixed id");
@@ -142,6 +160,22 @@ impl UdpSender {
///
/// socket 既收也发:客户端广播订阅进来,板子往它看到的源地址推流。
pub fn new(sender_id: u32) -> Result<Self> {
let packet_gap_us: u64 = option_env!("UDP_PACKET_GAP_US").unwrap_or("0").parse()?;
if packet_gap_us > 5_000 || packet_gap_us % TX_RETRY_WAIT_US as u64 != 0 {
return Err(anyhow!(
"UDP_PACKET_GAP_US must be 0..5000 in scheduler-tick increments"
));
}
let retry_budget_us: u64 = option_env!("UDP_RETRY_BUDGET_US")
.map(str::parse)
.transpose()?
.unwrap_or(DEFAULT_RETRY_BUDGET_US);
if retry_budget_us > MAX_SEND_HOLD_US || retry_budget_us % TX_RETRY_WAIT_US as u64 != 0 {
return Err(anyhow!(
"UDP_RETRY_BUDGET_US must be 0..25000 in scheduler-tick increments"
));
}
info!("UDP packet gap: {packet_gap_us} us; retries {retry_budget_us} us/frame (0=fail fast), hold limit {MAX_SEND_HOLD_US} us, capture age limit {MAX_CAPTURE_AGE_US} us; newest completed frame only");
// SAFETY: 纯 socket 设置,没有指向 Rust 内存的指针。
let socket = unsafe {
let fd = ffi::lwip_socket(ffi::AF_INET as i32, ffi::SOCK_DGRAM as i32, 0);
@@ -163,6 +197,23 @@ impl UdpSender {
std::mem::size_of::<i32>() as u32,
);
// ESP-IDF maps IP precedence 4 to WMM AC_VI. Video should use the
// video queue, which retains AMPDU, rather than best-effort bursts.
let video_priority: i32 = 4 << 5;
if ffi::lwip_setsockopt(
fd,
ffi::IPPROTO_IP as i32,
ffi::IP_TOS as i32,
&video_priority as *const i32 as *const std::ffi::c_void,
std::mem::size_of::<i32>() as u32,
) < 0
{
warn!(
"could not set video Wi-Fi priority: errno {}",
*ffi::__errno()
);
}
// 绑定到视频端口的所有接口。地址留 0,lwIP 视作 INADDR_ANY。
let addr = ffi::sockaddr_in {
sin_len: std::mem::size_of::<ffi::sockaddr_in>() as u8,
@@ -188,8 +239,7 @@ impl UdpSender {
let flags = ffi::lwip_fcntl(fd, ffi::F_GETFL as i32, 0);
if flags < 0
|| ffi::lwip_fcntl(fd, ffi::F_SETFL as i32, flags | ffi::O_NONBLOCK as i32)
< 0
|| ffi::lwip_fcntl(fd, ffi::F_SETFL as i32, flags | ffi::O_NONBLOCK as i32) < 0
{
let err = *ffi::__errno();
ffi::lwip_close(fd);
@@ -224,6 +274,9 @@ impl UdpSender {
stats: SendStats::default(),
announce_buf: [0u8; 64],
last_hello_us: 0,
datagram: Vec::with_capacity(SEND_BUFFER_SIZE),
packet_pacer: Pacer::with_gap_us(packet_gap_us),
retry_budget_us,
})
}
@@ -231,6 +284,29 @@ impl UdpSender {
self.stats
}
pub fn network_lost(&mut self) {
if self.socket >= 0 {
unsafe {
ffi::lwip_close(self.socket);
}
self.socket = -1;
}
self.peers.clear();
}
pub fn restore_network(&mut self) -> Result<()> {
// Keep frame numbering and telemetry across a short radio outage.
let next_frame_id = self.next_frame_id;
let stats = self.stats;
let fresh = Self::new(self.sender_id)?;
*self = fresh;
self.next_frame_id = next_frame_id;
self.stats = stats;
self.broadcast_hello();
self.last_hello_us = now_us();
Ok(())
}
/// 广播一次自身存在。
///
/// 客户端靠它发现有谁在线,所以即使当前没有订阅者也照发——这正是"板子主动
@@ -244,7 +320,10 @@ impl UdpSender {
let buf = hello.encode(protocol::MsgType::Hello);
// 广播地址 255.255.255.255。
let sa = to_sockaddr(SocketAddrV4::new(protocol::BROADCAST_ADDR, protocol::DEFAULT_PORT));
let sa = to_sockaddr(SocketAddrV4::new(
protocol::BROADCAST_ADDR,
protocol::DEFAULT_PORT,
));
// SAFETY: 缓冲区与地址在调用期间都有效,sendto 会复制而不是持有指针。
let rc = unsafe {
@@ -261,7 +340,9 @@ impl UdpSender {
if rc >= 0 {
self.stats.hellos_sent += 1;
} else {
warn!("could not broadcast Hello: errno {}", unsafe { *ffi::__errno() });
warn!("could not broadcast Hello: errno {}", unsafe {
*ffi::__errno()
});
}
}
@@ -297,7 +378,9 @@ impl UdpSender {
// 没有待处理的数据,这是常态。
let err = unsafe { *ffi::__errno() };
if err != 11 /* EWOULDBLOCK */ {
if err != 11
/* EWOULDBLOCK */
{
warn!("recvfrom on the subscribe socket failed: errno {err}");
}
@@ -308,11 +391,10 @@ impl UdpSender {
continue;
}
let Some(_sub) = protocol::Subscribe::decode(&self.announce_buf[..rc as usize])
else {
let Some(sub) = protocol::Subscribe::decode(&self.announce_buf[..rc as usize]) else {
// 不是订阅:可能是另一块板子的 Hello,或者将来的其它消息。安静跳过,
// 每包都打日志没有意义。客户端的身份由它的源地址决定,订阅里带的
// id 只是给它自己用的,所以这里不需要读。
// id 用于识别新客户端的一次接收会话。
continue;
};
@@ -323,15 +405,29 @@ impl UdpSender {
);
self.stats.subscriptions += 1;
self.upsert_peer(addr);
self.upsert_peer(addr, sub.client_id);
}
}
/// 加入对端,或刷新已知地址的时间戳。
fn upsert_peer(&mut self, addr: SocketAddrV4) {
fn upsert_peer(&mut self, addr: SocketAddrV4, client_id: u32) {
let now = now_us();
if let Some(peer) = self.peers.iter_mut().find(|p| p.addr == addr) {
// New clients use a random session ID. NAT/TUN source-port changes must
// move that session, not create duplicate video destinations. Legacy
// clients all used 0x0C110001, so retain address-based behavior for them.
let session_id = client_id != 0 && client_id != 0x0C11_0001;
if let Some(peer) = self.peers.iter_mut().find(|p| {
p.addr == addr || (session_id && p.client_id == client_id && p.addr.ip() == addr.ip())
}) {
if peer.addr != addr {
info!(
"client session 0x{client_id:08X} moved from {} to {addr}",
peer.addr
);
}
peer.addr = addr;
peer.client_id = client_id;
peer.last_seen_us = now;
return;
}
@@ -344,6 +440,7 @@ impl UdpSender {
info!("client at {addr} subscribed; streaming to it");
self.peers.push(Peer {
addr,
client_id,
last_seen_us: now,
});
}
@@ -372,55 +469,125 @@ impl UdpSender {
///
/// 帧缓冲在返回时就交还驱动——多占着只会增加延迟。
pub fn send(&mut self, frame: &Frame) -> Result<()> {
if frame.data().is_empty() {
return Ok(());
}
if self.peers.is_empty() {
self.stats.skipped_no_peer += 1;
return Ok(());
}
let started = now_us() as u64;
let mut budget = FrameBudget::new(started, frame.captured_at_us(), self.retry_budget_us);
if budget.expired(started) {
self.stats.stale_frames_dropped += 1;
return Ok(());
}
let frame_id = self.next_frame_id;
self.next_frame_id = self.next_frame_id.wrapping_add(1);
self.stats.frames_attempted += 1;
let mut active = [true; MAX_PEERS];
let fragments = protocol::fragment_frame(
self.sender_id,
frame_id,
frame.data(),
frame.width() as u16,
frame.height() as u16,
);
// Borrow the PSRAM JPEG in place. fragment_frame() copies every payload
// into small internal-RAM allocations, competing with Wi-Fi TX buffers.
let jpeg = frame.data();
let frag_count = jpeg.len().div_ceil(protocol::MAX_FRAGMENT_PAYLOAD);
for (frag_index, payload) in jpeg.chunks(protocol::MAX_FRAGMENT_PAYLOAD).enumerate() {
if !active[..self.peers.len()].iter().any(|&peer| peer) {
break;
}
let header = protocol::Header {
sender_id: self.sender_id,
frame_id,
frag_index: frag_index as u16,
frag_count: frag_count as u16,
total_len: jpeg.len() as u32,
width: frame.width() as u16,
height: frame.height() as u16,
..Default::default()
};
self.datagram.clear();
protocol::encode_header_into(&header, &mut self.datagram);
self.datagram.extend_from_slice(payload);
// 每帧一个可复用缓冲:头每个分片重写,但分配每帧只发生一次。在 22fps 下这是
// 每秒 22 次与约 250 次分配的区别。
let mut datagram = Vec::with_capacity(SEND_BUFFER_SIZE);
for frag in &fragments {
datagram.clear();
frag.encode_into(&mut datagram);
for peer in &self.peers {
for (index, peer) in self.peers.iter().enumerate() {
if !active[index] {
continue;
}
let sa = to_sockaddr(peer.addr);
// SAFETY: 两个缓冲区在调用期间都有效,sendto 会复制。
let rc = unsafe {
ffi::lwip_sendto(
self.socket,
datagram.as_ptr() as *const std::ffi::c_void,
datagram.len(),
ffi::MSG_DONTWAIT as i32,
&sa as *const ffi::sockaddr_in as *const ffi::sockaddr,
std::mem::size_of::<ffi::sockaddr_in>() as ffi::socklen_t,
)
};
loop {
if budget.expired(now_us() as u64) {
active[index] = false;
self.stats.peer_frames_dropped += 1;
self.stats.deadline_peer_drops += 1;
break;
}
let wait_us = self.packet_pacer.delay_us(now_us() as u64);
if wait_us > 0 {
unsafe {
ffi::vTaskDelay(
wait_us.div_ceil(TX_RETRY_WAIT_US as u64) as ffi::TickType_t
)
};
// vTaskDelay wakes on a tick boundary and can return
// before this microsecond deadline; recheck it.
continue;
}
// SAFETY: buffers remain valid until sendto has copied them.
let rc = unsafe {
ffi::lwip_sendto(
self.socket,
self.datagram.as_ptr() as *const std::ffi::c_void,
self.datagram.len(),
ffi::MSG_DONTWAIT as i32,
&sa as *const ffi::sockaddr_in as *const ffi::sockaddr,
std::mem::size_of::<ffi::sockaddr_in>() as ffi::socklen_t,
)
};
if rc == self.datagram.len() as isize {
self.packet_pacer.started(now_us() as u64);
self.stats.fragments_sent += 1;
self.stats.bytes_sent += rc as u64;
break;
}
if rc < 0 {
self.stats.errors += 1;
} else {
self.stats.fragments_sent += 1;
self.stats.bytes_sent += rc as u64;
let errno = unsafe { *ffi::__errno() };
self.stats.last_errno = errno;
if matches!(errno, 11 | 12 | 105)
&& budget.allow_retry(now_us() as u64, TX_RETRY_WAIT_US as u64)
{
self.stats.retries += 1;
// Give the network tasks and TX completion callbacks a
// scheduler tick instead of repeatedly allocating pbufs.
unsafe { ffi::vTaskDelay(1) };
} else {
active[index] = false;
self.stats.peer_frames_dropped += 1;
break;
}
}
}
}
self.stats.frames_sent += 1;
let delivered = active[..self.peers.len()].iter().filter(|&&ok| ok).count();
self.stats.peer_frames_sent += delivered as u32;
self.stats.frames_sent += u32::from(delivered > 0);
let ended = now_us() as u64;
self.stats.send_time_us += ended.saturating_sub(started);
self.stats.max_send_hold_us = self
.stats
.max_send_hold_us
.max(ended.saturating_sub(started));
if delivered > 0 {
self.stats.max_submitted_age_us = self
.stats
.max_submitted_age_us
.max(ended.saturating_sub(frame.captured_at_us()));
}
Ok(())
}
@@ -452,7 +619,9 @@ impl Drop for UdpSender {
fn drop(&mut self) {
// SAFETY: socket 由本值拥有,且只关闭一次。
unsafe {
ffi::lwip_close(self.socket);
if self.socket >= 0 {
ffi::lwip_close(self.socket);
}
}
}
}
@@ -469,13 +638,16 @@ fn now_us() -> i64 {
pub fn pump(camera: &Camera, sender: &mut UdpSender) -> Result<()> {
sender.housekeeping();
let frame = match camera.next_frame() {
let frame = match camera.latest_frame() {
Ok(frame) => frame,
Err(_) => {
sender.stats.capture_errors += 1;
// 丢帧是可恢复的——驱动会重新同步——这里没什么可做的。
return Ok(());
}
};
sender.stats.capture_queue_skipped += frame.skipped_before();
sender.send(&frame)
}