feat: 链路控制、延迟校准与长测工具,补全文档
- 固件:新增 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:
+136
-7
@@ -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];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user