先完成V01,再增加自己的局域网
V01先排除了板级端点、PSRAM与单张文件问题。本篇沿用官方S3-EYE MB_V2.2/OV2640、USB单独供电和8MiB OPI,增加2.4GHz STA与HTTP预览。新工程不初始化LCD/SD/麦克风,也不外接LED或按钮。原生USB19/20没有UART桥,恢复下载步骤与V01相同。
目标是可调QVGA/VGA的预览、可读状态和可复现对照实验。服务是明文HTTP、没有登录/鉴权,仅在自己控制的隔离LAN使用,不做端口映射或公网摄像头,不用于真实人员监控。本站尚未联网拍画面,距离、FPS、画质和内存回收都保留pending。
参考:[1] S3-EYE v2.2官方指南:§3引脚/供电,§4修订与USB恢复[2] MB_V2.2原理图:p2电源/USB、p3模组、p4摄像头[3] 固定BSP板级端点定义;不能混用其esp_video实现
公开工程默认空SSID,私有构建另放
默认构建启动打印V02_DISABLED就返回,不初始化无线或相机,真实app438672B。为了验证未被编译器裁掉的完整网络分支,另用YOUR_OWN_LAN_SSID/PASSWORD占位构建,通过app910992B;这是链接/配置证据,不是已连接AP。
读者复制sdkconfig.private.example为已忽略的sdkconfig.private,填自己LAN信息,使用独立build-private目录和SDKCONFIG_DEFAULTS组合。生成sdkconfig、Flash镜像和私有文件含凭据,不发布。WIFI_STORAGE_RAM减少持久写入,但不能让固件中字符串消失。
启动初次等IP30秒,未获IP停止无线并要求检查配置后复位;NVS错误不自动erase。后续断开会清网络标志并有限次重连,获IP重置计数。AP客户端隔离/防火墙可能阻止80/81端口,要核自己的测试配置,不因打不开图片关闭整个网络防护。
cp sdkconfig.private.example sdkconfig.private
# 编辑sdkconfig.private填自己的值;不要提交
idf.py -D IDF_TARGET=esp32s3 -B build-private -D SDKCONFIG=build-private/sdkconfig -D 'SDKCONFIG_DEFAULTS=sdkconfig.defaults;sdkconfig.private' build
idf.py -B build-private -D SDKCONFIG=build-private/sdkconfig -p PORT flash monitor两个HTTP端口,一个相机所有者
80端口给控制页与/status,81端口收/stream?size=qvga或size=vga。GET参数严格白名单,重复/附加键拒绝。长流不能直接占住81的HTTP任务;handler取得二进制信号量后,用httpd_req_async_handler_begin复制请求交给单worker,原HTTP任务可继续响应第二个请求。
同一时间只有一个owner,第二个流立即503;不排队创建另一个相机任务。信号量是binary semaphore,因为handler取得后由worker归还,不能换成要求同一任务释放的mutex。取得slot后才读cameraFault,避免旧流deinit失败和新请求入场之间读到旧状态的竞态。worker完成或报错都complete异步请求,否则官方实现会逐渐耗尽可用请求/连接。
状态读取只在短临界区复制计数,HTTP/堆查询在锁外;相机只由worker操作。80仍可被请求不是响应时延保证。若worker阻塞于真实驱动/发送,控制页不能替它安全抢走帧。
参考:[7] IDF5.5.5 HTTP server API与异步请求清理要求[8] IDF5.5.5完整异步请求示例[9] 固定HTTP异步复制与complete真实实现
分辨率切换时重建相机,明确槽容量
两档都用JPEG quality16、20MHz XCLK、WHEN_EMPTY、一个PSRAMfb、PSRAM DMA关闭。每次开始流才按选定QVGA320×240或VGA640×480初始化;结束先归还帧再deinit。没有先用小缓冲初始化,再只改传感器framesize的容量错误。
V02显式jpeg_buffer_size=262144B,两档使用同样大小JPEG槽,比V01的AUTO15360B大。VGA像素数是QVGA的4倍,不等于JPEG文件、实际内存或传输时间恰好4倍;槽固定时PSRAM分配本身可能相近,其他DMA/任务/网络占用仍要测。获取帧后严格检查其元数据与JPEG标记,主机再次真实解码尺寸。
初始化会丢一个待取帧再进入流,这不保证精确曝光新鲜度。若deinit报错,锁cameraFault要求RESET,避免误以为旧驱动已释放。先保原日志,复位后重新建内存基线,不能把返回码0当成长期无泄漏。
参考:[4] esp32-camera2.1.8公开结构与帧所有权API[5] 固定取帧实现:4s SDK等待和帧元数据[6] cam_hal缓冲配置:显式jpeg_buffer_size优先于AUTO
完整生产单帧函数:取得后只归还一次
stream_one是固件真正调用的函数:取得帧后,无论格式/尺寸/标记错误,还是multipart头、JPEG正文、CRLF哪一步发送失败,最终都release;空get没有可归还对象。主机C adapter直接调用它覆盖这些路径,不另写一份看似相同的算法。
每轮间检查network、30秒预算和120帧上限,循环延时100ms。真正fb_get有SDK等待,每次发送等待配置3秒;这些值不能组成严格整段30秒截止,也不保证Stop后某个固定毫秒内结束。只要阻塞尚未结束,退出检查就还没执行。
// ---- main/stream_policy.h ----
/* SPDX-License-Identifier: MIT */
#pragma once
#include <stdint.h>
#include <stdbool.h>
#include <stddef.h>
#include <stdio.h>
#include <string.h>
#include <inttypes.h>
#include "frame_policy.h"
#define STREAM_FRAMES 120U
#define STREAM_US INT64_C(30000000)
static inline bool stream_size(const char *q,size_t *w,size_t *h){
if(!strcmp(q,"size=qvga")){*w=320;*h=240;return true;}
if(!strcmp(q,"size=vga")){*w=640;*h=480;return true;}
return false;
}
static inline bool stream_continue(int64_t start,int64_t now,unsigned sent,bool network){return network&&now>=start&&now-start<STREAM_US&&sent<STREAM_FRAMES;}
typedef struct {const uint8_t *data;size_t bytes,width,height;bool jpeg;void *handle;} StreamPacket;
typedef struct {void *context;bool (*get)(void*,StreamPacket*);void (*release)(void*,StreamPacket*);bool (*send)(void*,const void*,size_t);} StreamOps;
typedef enum {STREAM_SENT,STREAM_GET_FAILED,STREAM_FRAME_REJECTED,STREAM_SEND_FAILED} StreamResult;
/* Actual firmware calls this function. Adapter tests prove these software paths,
* not camera, HTTP socket, DMA, RF or image quality. Always release acquired fb. */
static inline StreamResult stream_one(StreamOps *ops,size_t width,size_t height,size_t *bytes){
StreamPacket f={0};if(!ops->get(ops->context,&f))return STREAM_GET_FAILED;
StreamResult result=STREAM_FRAME_REJECTED;
if(f.jpeg&&f.width==width&&f.height==height&&frame_markers(f.data,f.bytes)){
char header[176];int n=snprintf(header,sizeof header,"--v02frame\r\nContent-Type: image/jpeg\r\nContent-Length: %zu\r\n\r\n",f.bytes);
result=STREAM_SEND_FAILED;
if(n>0&&(size_t)n<sizeof header&&ops->send(ops->context,header,(size_t)n)&&ops->send(ops->context,f.data,f.bytes)&&ops->send(ops->context,"\r\n",2)){result=STREAM_SENT;*bytes=f.bytes;}
}
ops->release(ops->context,&f);return result;
}
参考:[4] esp32-camera2.1.8公开结构与帧所有权API[5] 固定取帧实现:4s SDK等待和帧元数据[7] IDF5.5.5 HTTP server API与异步请求清理要求
完整主程序:初始化、异步入场、流与状态
下载包还含CMake、精确manifest/lock、板定义、frame_policy、sdkconfig.defaults、私有配置示例和分区,不能只复制这一个文件。主程序把相机入场、异步复制失败、队列失败和结束清理分别处理;请求complete之后不再访问req。默认SSID为空的早返回保留安全的公开示例。
页面Start才设置img流地址,Stop移除src;切换档位先Stop并等active=false。浏览器关闭/网络中断最终由发送错误或下一轮网络检查收尾,可能先等真实阻塞调用。会话结束打印原因、帧数、elapsed_us和堆状态,供实板记录,不假造稳定帧率。
// ---- main/main.c ----
/* SPDX-License-Identifier: MIT */
#include <stdio.h>
#include <inttypes.h>
#include <string.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/semphr.h"
#include "freertos/queue.h"
#include "freertos/event_groups.h"
#include "esp_event.h"
#include "esp_wifi.h"
#include "esp_netif.h"
#include "nvs_flash.h"
#include "esp_timer.h"
#include "esp_heap_caps.h"
#include "esp_http_server.h"
#include "camera_board.h"
#include "stream_policy.h"
/* Binary semaphore, not a mutex: handler acquires, worker releases. */
static SemaphoreHandle_t camera_slot;
static QueueHandle_t requests;
static EventGroupHandle_t network_events;
static portMUX_TYPE status_mux=portMUX_INITIALIZER_UNLOCKED;
static bool net_up,active,camera_fault;
static unsigned reconnects,sessions,refused,frames,frame_errors,send_errors;
static size_t last_bytes;
static int64_t session_started,last_grab_us;
static unsigned current_width=320,current_height=240;
typedef struct {httpd_req_t *req;size_t width,height;} Request;
static bool network_ready(void){bool value;portENTER_CRITICAL(&status_mux);value=net_up;portEXIT_CRITICAL(&status_mux);return value;}
static void count_refused(void){portENTER_CRITICAL(&status_mux);refused++;portEXIT_CRITICAL(&status_mux);}
static void wifi_event(void *arg,esp_event_base_t base,int32_t id,void *data){
(void)arg;
if(base==WIFI_EVENT&&id==WIFI_EVENT_STA_START){(void)esp_wifi_connect();return;}
if(base==WIFI_EVENT&&id==WIFI_EVENT_STA_DISCONNECTED){
portENTER_CRITICAL(&status_mux);net_up=false;portEXIT_CRITICAL(&status_mux);xEventGroupClearBits(network_events,BIT0);
if(reconnects++<5)(void)esp_wifi_connect();else puts("V02_WIFI_RETRY_LIMIT reset after checking own LAN; no credentials logged");
}
if(base==IP_EVENT&&id==IP_EVENT_STA_GOT_IP){
ip_event_got_ip_t *e=data;portENTER_CRITICAL(&status_mux);net_up=true;portEXIT_CRITICAL(&status_mux);reconnects=0;xEventGroupSetBits(network_events,BIT0);
printf("V02_LAN http://"IPSTR"/ ; stream port81; plainHTTP no authentication\n",IP2STR(&e->ip_info.ip));
}
}
static esp_err_t wifi_start_private(void){
if(!strlen(CONFIG_V02_WIFI_SSID)){puts("V02_DISABLED empty private SSID; no radio or camera initialized. Add private defaults to test own LAN.");return ESP_ERR_INVALID_STATE;}
esp_err_t e=nvs_flash_init();if(e!=ESP_OK){printf("V02_NVS %s; no automatic erase\n",esp_err_to_name(e));return e;}
if((e=esp_netif_init())!=ESP_OK)return e;
if((e=esp_event_loop_create_default())!=ESP_OK)return e;
if(!esp_netif_create_default_wifi_sta())return ESP_ERR_NO_MEM;
network_events=xEventGroupCreate();if(!network_events)return ESP_ERR_NO_MEM;
if((e=esp_event_handler_register(WIFI_EVENT,ESP_EVENT_ANY_ID,wifi_event,NULL))!=ESP_OK)return e;
if((e=esp_event_handler_register(IP_EVENT,IP_EVENT_STA_GOT_IP,wifi_event,NULL))!=ESP_OK)return e;
wifi_init_config_t init=WIFI_INIT_CONFIG_DEFAULT();if((e=esp_wifi_init(&init))!=ESP_OK)return e;
if((e=esp_wifi_set_storage(WIFI_STORAGE_RAM))!=ESP_OK)return e;
wifi_config_t c={0};
if(strlen(CONFIG_V02_WIFI_SSID)>=sizeof c.sta.ssid||strlen(CONFIG_V02_WIFI_PASSWORD)>=sizeof c.sta.password)return ESP_ERR_INVALID_SIZE;
memcpy(c.sta.ssid,CONFIG_V02_WIFI_SSID,strlen(CONFIG_V02_WIFI_SSID));memcpy(c.sta.password,CONFIG_V02_WIFI_PASSWORD,strlen(CONFIG_V02_WIFI_PASSWORD));
c.sta.threshold.authmode=WIFI_AUTH_WPA2_PSK;c.sta.pmf_cfg.capable=true;c.sta.pmf_cfg.required=false;
if((e=esp_wifi_set_mode(WIFI_MODE_STA))!=ESP_OK)return e;
if((e=esp_wifi_set_config(WIFI_IF_STA,&c))!=ESP_OK)return e;
if((e=esp_wifi_start())!=ESP_OK)return e;
EventBits_t bits=xEventGroupWaitBits(network_events,BIT0,pdFALSE,pdTRUE,pdMS_TO_TICKS(30000));
if(!(bits&BIT0)){puts("V02_WIFI_WAIT_EXPIRED no server; stop radio and reset after fixing private config");(void)esp_wifi_stop();return ESP_ERR_TIMEOUT;}return ESP_OK;
}
typedef struct {httpd_req_t *req;int64_t grab_us;} Transport;
static bool packet_get(void *context,StreamPacket *out){
Transport *t=context;int64_t start=esp_timer_get_time();camera_fb_t *f=esp_camera_fb_get();t->grab_us=esp_timer_get_time()-start;
if(!f)return false;
*out=(StreamPacket){.data=f->buf,.bytes=f->len,.width=f->width,.height=f->height,.jpeg=f->format==PIXFORMAT_JPEG,.handle=f};return true;
}
static void packet_release(void *context,StreamPacket *f){(void)context;esp_camera_fb_return(f->handle);f->handle=NULL;}
static bool packet_send(void *context,const void *data,size_t n){Transport *t=context;return httpd_resp_send_chunk(t->req,data,n)==ESP_OK;}
static esp_err_t unavailable(httpd_req_t *req,const char *message){httpd_resp_set_status(req,"503 Service Unavailable");httpd_resp_set_type(req,"text/plain");return httpd_resp_sendstr(req,message);}
static void worker(void *arg){
(void)arg;Request job;
for(;;){
if(!xQueueReceive(requests,&job,portMAX_DELAY))continue;
httpd_req_t *req=job.req;bool initialized=false;unsigned sent=0;int64_t started=esp_timer_get_time();const char *reason="budget";
portENTER_CRITICAL(&status_mux);active=true;frames=0;session_started=started;current_width=(unsigned)job.width;current_height=(unsigned)job.height;portEXIT_CRITICAL(&status_mux);
esp_err_t e=eye_camera_init(job.width==320?FRAMESIZE_QVGA:FRAMESIZE_VGA);
if(e!=ESP_OK){(void)unavailable(req,"Camera init failed; retain boot log and check exact board/power.\n");reason="init_failed";goto cleanup;}
initialized=true;
/* Exactly one pending-frame discard; does not establish exposure freshness. */
camera_fb_t *old=esp_camera_fb_get();if(!old){(void)unavailable(req,"No frame; check camera log.\n");reason="discard_null";goto cleanup;}esp_camera_fb_return(old);
httpd_resp_set_type(req,"multipart/x-mixed-replace;boundary=v02frame");httpd_resp_set_hdr(req,"Cache-Control","no-store");
{Transport t={.req=req};StreamOps ops={.context=&t,.get=packet_get,.release=packet_release,.send=packet_send};
while(stream_continue(started,esp_timer_get_time(),sent,network_ready())){
size_t bytes=0;StreamResult result=stream_one(&ops,job.width,job.height,&bytes);
portENTER_CRITICAL(&status_mux);last_grab_us=t.grab_us;
if(result==STREAM_SENT){sent++;frames=sent;last_bytes=bytes;}
else if(result==STREAM_SEND_FAILED)send_errors++;else frame_errors++;
portEXIT_CRITICAL(&status_mux);
if(result!=STREAM_SENT){reason=result==STREAM_SEND_FAILED?"send_failed":"frame_failed";break;}
vTaskDelay(pdMS_TO_TICKS(100));
}
}
if(!network_ready())reason="network_lost";
/* Bounded between calls; driver get/send can extend the wall-clock session. */
(void)httpd_resp_send_chunk(req,"--v02frame--\r\n",14);(void)httpd_resp_send_chunk(req,NULL,0);
cleanup:
if(initialized&&esp_camera_deinit()!=ESP_OK){portENTER_CRITICAL(&status_mux);camera_fault=true;portEXIT_CRITICAL(&status_mux);reason="deinit_failed_reset_required";}
/* No request access after complete. Complete releases async request ownership. */
e=httpd_req_async_handler_complete(req);if(e!=ESP_OK)printf("V02_ASYNC_COMPLETE_FAILED %s\n",esp_err_to_name(e));
portENTER_CRITICAL(&status_mux);active=false;sessions++;portEXIT_CRITICAL(&status_mux);
xSemaphoreGive(camera_slot);
printf("V02_SESSION reason=%s frames=%u elapsed_us=%"PRId64" internal=%zu largest=%zu psram_free=%zu; no FPS guarantee\n",reason,sent,esp_timer_get_time()-started,heap_caps_get_free_size(MALLOC_CAP_INTERNAL|MALLOC_CAP_8BIT),heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL|MALLOC_CAP_8BIT),heap_caps_get_free_size(MALLOC_CAP_SPIRAM));
}
}
static esp_err_t stream_handler(httpd_req_t *req){
char query[20];size_t w,h;
if(httpd_req_get_url_query_str(req,query,sizeof query)!=ESP_OK||!stream_size(query,&w,&h)){httpd_resp_send_err(req,HTTPD_400_BAD_REQUEST,"Use exactly ?size=qvga or ?size=vga");return ESP_OK;}
if(!xSemaphoreTake(camera_slot,0)){count_refused();return unavailable(req,"One preview owns the camera; stop it and wait for active=false before retry.\n");}
/* Read the fault latch AFTER ownership acquisition. A pre-take read races cleanup. */
bool fault,up;portENTER_CRITICAL(&status_mux);fault=camera_fault;up=net_up;portEXIT_CRITICAL(&status_mux);
if(fault||!up){xSemaphoreGive(camera_slot);return unavailable(req,"Unavailable; check network or reset after camera cleanup fault.\n");}
httpd_req_t *copy=NULL;esp_err_t e=httpd_req_async_handler_begin(req,©);
if(e!=ESP_OK){xSemaphoreGive(camera_slot);return unavailable(req,"Async request allocation failed.\n");}
Request job={.req=copy,.width=w,.height=h};
if(!xQueueSend(requests,&job,0)){(void)unavailable(copy,"Worker queue unavailable.\n");(void)httpd_req_async_handler_complete(copy);xSemaphoreGive(camera_slot);}
return ESP_OK;
}
static esp_err_t status_handler(httpd_req_t *req){
bool up,busy,fault;unsigned s,r,f,fe,se,w,h;size_t bytes;int64_t start,grab;
portENTER_CRITICAL(&status_mux);up=net_up;busy=active;fault=camera_fault;s=sessions;r=refused;f=frames;fe=frame_errors;se=send_errors;w=current_width;h=current_height;bytes=last_bytes;start=session_started;grab=last_grab_us;portEXIT_CRITICAL(&status_mux);
char out[640];int n=snprintf(out,sizeof out,"{\"network\":%s,\"active\":%s,\"cameraFault\":%s,\"sessions\":%u,\"refused\":%u,\"frames\":%u,\"frameErrors\":%u,\"sendErrors\":%u,\"width\":%u,\"height\":%u,\"lastBytes\":%zu,\"lastGetUs\":%"PRId64",\"sessionObservedUs\":%"PRId64",\"freeInternal\":%zu,\"largestInternal\":%zu,\"freePsram\":%zu}",up?"true":"false",busy?"true":"false",fault?"true":"false",s,r,f,fe,se,w,h,bytes,grab,busy?esp_timer_get_time()-start:INT64_C(0),heap_caps_get_free_size(MALLOC_CAP_INTERNAL|MALLOC_CAP_8BIT),heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL|MALLOC_CAP_8BIT),heap_caps_get_free_size(MALLOC_CAP_SPIRAM));
if(n<0||(size_t)n>=sizeof out)return ESP_FAIL;
httpd_resp_set_type(req,"application/json");httpd_resp_set_hdr(req,"Cache-Control","no-store");return httpd_resp_send(req,out,n);
}
static const char PAGE[]= "<!doctype html><meta name=viewport content='width=device-width,initial-scale=1'><title>V02 own LAN preview</title><style>body{font:16px system-ui;max-width:54rem;margin:2rem auto;padding:0 1rem}button,select{font:inherit;padding:.5rem}img{max-width:100%;display:block;margin:1rem 0}pre{white-space:pre-wrap;overflow-wrap:anywhere}</style><h1>V02 own LAN preview</h1><p>Plain HTTP, no authentication. Use only an isolated LAN you control. One viewer; 120 frames or 30s checked between calls. Stop, wait for active=false, then change resolution.</p><select id=size><option value=qvga>QVGA 320 x 240</option><option value=vga>VGA 640 x 480</option></select> <button id=start>Start</button> <button id=stop>Stop</button><img id=view alt='Preview starts only after Start'><pre id=status>Waiting</pre><script>const view=document.querySelector('#view'),state=document.querySelector('#status');document.querySelector('#start').onclick=()=>{view.src='http://'+location.hostname+':81/stream?size='+document.querySelector('#size').value};document.querySelector('#stop').onclick=()=>view.removeAttribute('src');setInterval(async()=>{try{const r=await fetch('/status',{cache:'no-store'});state.textContent=JSON.stringify(await r.json(),null,2)}catch(e){state.textContent='Status unavailable: '+e.message}},1000)</script>";
static esp_err_t root_handler(httpd_req_t *req){httpd_resp_set_type(req,"text/html; charset=utf-8");httpd_resp_set_hdr(req,"Cache-Control","no-store");return httpd_resp_send(req,PAGE,HTTPD_RESP_USE_STRLEN);}
void app_main(void){
if(wifi_start_private()!=ESP_OK)return;
camera_slot=xSemaphoreCreateBinary();requests=xQueueCreate(1,sizeof(Request));if(!camera_slot||!requests){puts("V02_ALLOC_FAILED reset; server not started");return;}xSemaphoreGive(camera_slot);
if(xTaskCreate(worker,"camera_owner",6144,NULL,5,NULL)!=pdPASS){puts("V02_WORKER_FAILED reset; server not started");return;}
httpd_config_t c=HTTPD_DEFAULT_CONFIG();c.server_port=80;c.ctrl_port=32768;c.stack_size=6144;c.max_uri_handlers=3;c.max_open_sockets=4;c.lru_purge_enable=false;c.send_wait_timeout=3;c.recv_wait_timeout=3;
httpd_handle_t control=NULL,stream=NULL;esp_err_t e=httpd_start(&control,&c);if(e!=ESP_OK){printf("V02_CONTROL_START %s reset\n",esp_err_to_name(e));return;}
httpd_uri_t root={.uri="/",.method=HTTP_GET,.handler=root_handler},status={.uri="/status",.method=HTTP_GET,.handler=status_handler};
if(httpd_register_uri_handler(control,&root)!=ESP_OK||httpd_register_uri_handler(control,&status)!=ESP_OK){puts("V02_CONTROL_REGISTER reset");return;}
c.server_port=81;c.ctrl_port=32769;c.max_uri_handlers=1;c.max_open_sockets=3;
if((e=httpd_start(&stream,&c))!=ESP_OK){printf("V02_STREAM_START %s reset\n",esp_err_to_name(e));return;}
httpd_uri_t uri={.uri="/stream",.method=HTTP_GET,.handler=stream_handler};if(httpd_register_uri_handler(stream,&uri)!=ESP_OK){puts("V02_STREAM_REGISTER reset");return;}
puts("V02_READY control80/status and async stream81; binary camera owner; default credentials empty; no public exposure/authentication guarantee");
}
参考:[7] IDF5.5.5 HTTP server API与异步请求清理要求[8] IDF5.5.5完整异步请求示例[9] 固定HTTP异步复制与complete真实实现
运行页面与第二客户端验收
monitor出现V02_LAN后,在同一个自有LAN浏览器打开http://设备地址/。控制页在80,图片来自81;先用QVGA,看到实际JPEG后再停流换VGA。没有输出地址先查网络配置/AP,不先改摄像头端点。
第一个流运行时,第二个终端/浏览器请求http://设备地址:81/stream?size=qvga,应得到503并增加refused。记录首个流是否继续,停止后等active=false再重连;要把断网、主动关闭、第二viewer、切换分辨率分别测试。本文没有实际执行这些无线步骤。
不要把空白img或一次缓慢状态刷新直接归因“ESP32带不动”。状态能开而流失败先查81端口、另一个viewer、HTTP返回码,再看驱动/发送日志;完整JPEG拿不到时,FPS统计没有意义。
完整桌面接收器:记录收到并解码的帧
安装固定Pillow12.3.0后,先停浏览器流,运行python tools/preview_probe.py --host OWN_LAN_IP --size qvga --frames 20 --out run-qvga --provenance hardware。VGA另用全新目录。host只接收明确RFC1918 IPv4,loopback只允许synthetic;已有目录拒绝覆盖。
工具先解HTTP chunked,再严格读multipart边界、Content-Length、CRLF,JPEG最多262144B,Pillow实际load并核尺寸。保存首/末JPEG与逐帧SHA、长度和主机解码完成时间;中途失败可能留下前面JPEG,但没有成功result.json,重试保留旧目录并换新名字。
hostDecodedFPS按多帧解码完成时刻算,是电脑收到且解码的节奏,包含链路与主机解码代价,不是传感器曝光FPS、RF吞吐或端到端时延。只有一帧时FPS为null。来源标签由操作人声明,SYNTHETIC服务/图像不能改标hardware;IO timeout与预算也不是硬实时截止。
# ---- tools/preview_probe.py ----
#!/usr/bin/env python3
# SPDX-License-Identifier: MIT
"""Receive bounded MJPEG from an explicitly provided own-LAN IPv4 address.
Host receipt/decode statistics are not sensor exposure FPS or authenticated origin.
"""
import argparse,hashlib,http.client,io,ipaddress,json,pathlib,socket,time
from PIL import Image
CAP=262144
class Reader:
def __init__(self,response,sock,deadline):self.response=response;self.sock=sock;self.deadline=deadline
def read(self,n):
left=self.deadline-time.monotonic()
if left<=0:raise ValueError('host budget reached between reads')
self.sock.settimeout(min(3,left));return self.response.read1(n)
def exact(self,n):
data=bytearray()
while len(data)<n:
chunk=self.read(n-len(data))
if not chunk:raise ValueError('truncated multipart payload')
data.extend(chunk)
return bytes(data)
def line(self):
line=bytearray()
while not line.endswith(b'\n'):
if len(line)>=256:raise ValueError('multipart line exceeds256 bytes')
chunk=self.read(1)
if not chunk:raise ValueError('multipart EOF before line end')
line.extend(chunk)
if not line.endswith(b'\r\n'):raise ValueError('multipart requires CRLF')
return bytes(line)
def jpeg_frame(reader,width,height):
boundary=reader.line()
if boundary==b'--v02frame--\r\n':return None
if boundary!=b'--v02frame\r\n':raise ValueError('multipart boundary mismatch')
headers={}
for unused in range(4):
line=reader.line()
if line==b'\r\n':break
key,sep,value=line[:-2].partition(b': ')
if not sep or key in headers:raise ValueError('malformed/duplicate part header')
headers[key]=value
else:raise ValueError('too many part headers')
if set(headers)!={b'Content-Type',b'Content-Length'} or headers[b'Content-Type']!=b'image/jpeg':raise ValueError('unexpected part headers/type')
value=headers[b'Content-Length']
if not value.isdigit() or len(value)>6:raise ValueError('invalid part length')
size=int(value)
if not 4<=size<=CAP:raise ValueError('JPEG length outside4..262144')
data=reader.exact(size)
if reader.exact(2)!=b'\r\n':raise ValueError('missing JPEG trailing CRLF')
if data[:2]!=b'\xff\xd8' or data[-2:]!=b'\xff\xd9':raise ValueError('missing JPEG markers')
with Image.open(io.BytesIO(data)) as image:
if image.format!='JPEG' or image.size!=(width,height):raise ValueError('decoded JPEG resolution/type mismatch')
image.load()
return data
def main():
p=argparse.ArgumentParser();p.add_argument('--host',required=True);p.add_argument('--port',type=int,default=81);p.add_argument('--size',choices=['qvga','vga'],required=True);p.add_argument('--frames',type=int,default=20);p.add_argument('--timeout',type=float,default=30);p.add_argument('--out',type=pathlib.Path,required=True);p.add_argument('--provenance',choices=['hardware','synthetic'],required=True);a=p.parse_args()
try:address=ipaddress.IPv4Address(a.host)
except ipaddress.AddressValueError:p.error('host must be literal own-LAN IPv4, not a URL/domain')
private=any(address in ipaddress.IPv4Network(n) for n in ['10.0.0.0/8','172.16.0.0/12','192.168.0.0/16'])
if not (private or (address.is_loopback and a.provenance=='synthetic')):p.error('use own RFC1918 LAN; loopback is allowed only with synthetic provenance')
if not 1<=a.port<=65535 or not 1<=a.frames<=120 or not 0<a.timeout<=120:p.error('port/frames/timeout outside bounds')
if a.out.exists():p.error('output exists; no overwrite')
connection=None;frames=[];synthetic=False;start=time.monotonic();deadline=start+a.timeout;width,height=(320,240) if a.size=='qvga' else (640,480)
try:
connection=http.client.HTTPConnection(str(address),a.port,timeout=min(3,a.timeout));connection.connect();sock=connection.sock
connection.request('GET','/stream?size='+a.size,headers={'Connection':'close'});response=connection.getresponse()
if response.status!=200:raise ValueError('HTTP status '+str(response.status))
if response.getheader('Content-Type')!='multipart/x-mixed-replace;boundary=v02frame':raise ValueError('outer multipart type/boundary mismatch')
synthetic=response.getheader('X-V02-Provenance')=='SYNTHETIC'
if synthetic and a.provenance!='synthetic':raise ValueError('synthetic server cannot be labeled hardware')
reader=Reader(response,sock,deadline)
a.out.mkdir(parents=True,exist_ok=False)
for index in range(a.frames):
data=jpeg_frame(reader,width,height)
if data is None:raise ValueError('stream ended before requested frame count')
if b'SYNTHETIC_V02' in data:
synthetic=True
if a.provenance!='synthetic':raise ValueError('synthetic JPEG cannot be labeled hardware')
now=time.monotonic();frames.append({'index':index,'hostDecodedAtSeconds':now-start,'bytes':len(data),'sha256':hashlib.sha256(data).hexdigest()})
if index==0 or index==a.frames-1:
with (a.out/('frame-'+str(index)+'.jpg')).open('xb') as output:output.write(data)
interval=frames[-1]['hostDecodedAtSeconds']-frames[0]['hostDecodedAtSeconds'];fps=(len(frames)-1)/interval if len(frames)>1 and interval>0 else None
report={'provenanceProvidedByOperator':a.provenance,'containsSyntheticMarker':synthetic,'scope':'Host receive+JPEG decode timing only. Not authenticated sensor origin, exposure FPS, RF throughput, end-to-end latency, power or ESP32 memory recovery. Timeout/budget checked between IO calls; no hard real-time deadline.','resolution':[width,height],'frames':frames,'hostDecodedFPS':fps,'jpegBytesMean':sum(f['bytes'] for f in frames)/len(frames),'elapsedSeconds':time.monotonic()-start}
with (a.out/'result.json').open('x') as output:json.dump(report,output,indent=2);output.write('\n')
print(json.dumps(report,indent=2));return 0
except (ValueError,OSError,http.client.HTTPException,UnicodeError,Image.DecompressionBombError) as error:print(str(error));return 2
finally:
if connection:connection.close()
if __name__=='__main__':raise SystemExit(main())
固定条件,再比较两档和断连恢复
固定同镜头/被摄物/光照、设备到AP距离、信道、电脑/浏览器、quality16和一个viewer。按QVGA→VGA→QVGA至少三轮,记录真实持续时间、有效解码帧数、平均JPEG字节和主机FPS,保留原图。JPEG对细节敏感,不同画面不能直接用于“分辨率导致”结论。
再独立做至少10轮断开/重连:每轮记录同次启动空闲基线、流中、结束后同一稳定窗口(例如5秒后连续两次)的freeInternal/largestInternal/freePsram。TCP栈可能延后释放,比较稳定值和趋势;一次deinit或free没变化不足以证明没有泄漏。
两档都是单262144B槽,内存比较可能揭示网络/DMA占用而不是四倍PSRAM变化。observations.csv只有表头,留真实appSHA、client_count、时长、帧数、JPEG均值、FPS、内存前后和原日志;本站不会把localhost合成结果填进去。
参考:[4] esp32-camera2.1.8公开结构与帧所有权API[6] cam_hal缓冲配置:显式jpeg_buffer_size优先于AUTO[7] IDF5.5.5 HTTP server API与异步请求清理要求
用字段回答具体问题
/status的frames是当前/最近一次会话成功发送完整multipart帧计数;lastBytes与lastGetUs是最近成功帧长度和最近取帧调用耗时,lastGetUs不是曝光。sessionObservedUs仅active时有意义,空闲为0;整段elapsed_us在串口V02_SESSION里。
frameErrors/sendErrors/refused/sessions是运行中累计计数,重启会重新开始;freeInternal和largestInternal分别是内部8位可分配总量与最大连续块,freePsram来自SPIRAM堆。状态字段描述的是调用时软件可见值,不能代替电气/CPU调度或长期稳定性证据。
| 字段/日志 | 诊断问题 | 不能证明 |
|---|---|---|
| network / active / cameraFault | 联网、owner和清理故障状态 | AP实际覆盖/停止时限 |
| frames / lastBytes | 本流完整发送与JPEG长度 | 主机全部收到了 |
| frameErrors / sendErrors | 相机帧拒绝还是发送失败 | 单凭计数确定硬件根因 |
| lastGetUs / elapsed_us | 取帧调用/会话耗时 | 曝光时刻与端到端延迟 |
| freeInternal / largestInternal / freePsram | 堆量与连续块趋势 | 一次样本无泄漏 |
参考:[5] 固定取帧实现:4s SDK等待和帧元数据[7] IDF5.5.5 HTTP server API与异步请求清理要求
已跑的软件检查与仍空的物理表
同源C ASan/UBSan检查严格query/预算和stream_one所有成功/失败release路径。真实localhost HTTP chunked服务以37字节片段发送数学JPEG,实际19个CLI预期结果通过:两分辨率成功、503、错误头/边界/长度/类型/尺寸、截断、假JPEG、覆盖与错误来源拒绝。
最初坏标记夹具用了生命周期错误的临时数组,原夹具/日志已保留,改静态数据后复跑;固件未因此改动。软件检查没有执行真实camera HAL、IDF异步调度/RF/socket超时或堆回收。当前两配置修正后已真实重编;最初在取得slot前读fault的竞态有完整处理函数反例,现先取slot再读fault/network,七种完整处理函数adapter时序检查通过。当前39份产品(含V01)和此前26份成功构建的历史产品分开保留,均不替代硬件维度。
clang -std=c11 -Wall -Wextra -Werror -g -fsanitize=address,undefined -I main host/policy_test.c -o /tmp/v02-policy
/tmp/v02-policy
python host/check_admission.py --out /tmp/v02-SYNTHETIC-admission
python host/check_probe.py /tmp/v02-SYNTHETIC-http参考:[7] IDF5.5.5 HTTP server API与异步请求清理要求[10] Pillow12.3.0:Image.open/load及解压尺寸异常
按层恢复,再进入端侧视觉
无地址查私有配置与AP;有/status无流先查81/503/单viewer;frameErrors查固定尺寸、完整JPEG、板配置和相机电源;sendErrors先看客户端关闭/无线与原日志。cameraFault必须记录后RESET,不能继续把旧相机当已释放。
只有真实图、受控条件和可复核恢复记录到位,才讨论V03模型输入。HTTP预览里的JPEG还不是模型张量,分辨率和可见画面不等于识别精度。保持原V01/V02 ID和独立结果,不把缺少摄像头硬件改写成已经完成端侧视觉实验。
资料来源与版本说明
实际读固定S3-EYE指南、原理图p2/p3/p4与BSP端点、camera/HTTP完整所有权和缓冲实现;鲜取与实际编译SDK/组件字节比较,Registry manifest重排并增加发布元数据的差异另记,未假称全部字节相同。资料核对日期:。资料核对、程序构建与实板测试分别记录,不互相替代。
- S3-EYE v2.2官方指南:§3引脚/供电,§4修订与USB恢复
- MB_V2.2原理图:p2电源/USB、p3模组、p4摄像头
- 固定BSP板级端点定义;不能混用其esp_video实现
- esp32-camera2.1.8公开结构与帧所有权API
- 固定取帧实现:4s SDK等待和帧元数据
- cam_hal缓冲配置:显式jpeg_buffer_size优先于AUTO
- IDF5.5.5 HTTP server API与异步请求清理要求
- IDF5.5.5完整异步请求示例
- 固定HTTP异步复制与complete真实实现
- Pillow12.3.0:Image.open/load及解压尺寸异常
- pyserial3.5:端口、timeout与逐行读取接口