我最近被一个问题折腾得不轻:一台ESP32-CAM能出图、能跑RTSP流,拿到VLC里播放完全正常,可一旦拿到安防系统里,NVR就是死活不认。后来才把问题想明白,NVR不是普通播放器,它不会因为你给了一个rtsp://地址就老老实实取流。它必须先和设备完成发现、对时、拿能力列表、查Profile、找StreamUri这一整套ONVIF握手,确认你“是一个正经摄像头”,才会开始拉视频。而ESP-IDF环境里现成的ONVIF方案,要么是C++写成的重量级SOAP堆栈,要么是面向桌面Linux的库,想在ESP32这种资源紧张的单片机上裁剪出来用,非常别扭。所以最后我选择用ESP-IDF的plain C组件方式自己维护一套轻量ONVIF实现,也就是这篇要完整展开的onvif-c组件手册。如果你正在做ESP32相机、网关或者边缘节点,希望把设备真正接入NVR生态,这篇文章应该能帮你省掉我踩过的那些坑。
1. 项目拆解与方案选型
1.1 目标场景:不是做流媒体服务器,而是让摄像头“有身份”
一开始我犯过一个方向性错误,以为把RTSP推流做好就等于做出了摄像头。实际项目里,NVR侧看到的设备不是“一个可以播放的URL”,而是一个具备完整协议身份的网络视频发射器。ONVIF规范里的核心不是视频本身,而是让NVR可以通过一套统一的SOAP/XML接口,查询设备类型、能力范围、媒体配置、认证方式和取流入口。
所以目标场景可以拆成下面几点:
- 设备需要能被NVR自动发现,或者在手动添加IP后能正确响应。
- 设备需要提供一套HTTP服务,处理ONVIF的Device和Media命名空间请求。
- 设备需要给出可用的RTSP/取流地址,并保证视频流协议参数和描述一致。
- 设备需要支持最基本的用户名密码认证,否则大部分NVR不进入后续流程。
这样一来,ONVIF协议栈并不是“整个接入流程的主角”,它是定义设备和NVR之间身份关系的基础设施。视频推流能力只是这个基础设施的最后一段路。项目中最容易翻车的,往往不是RTSP,而是那些看起来不起眼的XML响应,比如GetCapabilities里少给了一个XAddr,或者GetStreamUri里返回了一个对NVR不可达的IP。
1.2 为什么不用C++、不用现成的SOAP代码生成工具
大家在搜索ONVIF解决方案时,大概率会看到两种主流路线:一是用专用SOAP框架生成代码,二是选用成熟的C++插件库。这两条路线在企业级产品中是合理的,但放到ESP32上就会遇到几个现实问题。
先说SOAP代码生成。ONVIF标准基于SOAP/XML,用代码生成工具可以把WSDL转换为C/C++源码,其中gSOAP是比较代表性的工具。但生成出来的代码体积庞大,带有大量同步缓冲、动态内存申请和异常处理逻辑。ESP32的RAM通常只有几百KB,一个最小可用的ONVIF Profile S实现还会叠加RTSP、JPEG抓帧、Wi-Fi协议栈,内存余量很快就会被消耗掉。而且这类工具生成的C代码风格偏老,依赖复杂的宏和全局状态,和ESP-IDF基于组件化的构建体系配合起来需要大量适配工作。
再说C++库。ESP-IDF本身支持C++,但很多安防设备和嵌入式团队的习惯是尽量用纯C,主要原因是:
- 纯C编译产物小,没有RTTI和异常表,更容易控制内存布局。
- 设备固件里可能同时存在多个轻量协议栈,C接口便于统一封装成事件驱动模型。
- 很多RTSP Server以及摄像头驱动在ESP-IDF生态里就是C写的,混用C++会破坏“所有模块都走同一套日志断言体系”的工程一致性。
所以onvif-c组件的技术路线就明确了:用plain C实现一个只覆盖Profile S最低要求的ONVIF服务端,配套WS-Discovery设备发现、SOAP/XML解析、摘要认证和RTSP链路桥接。不需要追求完整规范覆盖,目标是让常见NVR能加、能出图、能稳定运行。
1.3 功能边界:能进NVR的最小协议集
很多初次实现的开发者会想去覆盖整个ONVIF规范,这是没必要的。NVR添加第三方设备时,遵循的是Profile S中的一小部分操作。我把实际抓包过程中会看到的接口整理了一下,这就是我们要先锁定的功能边界。
| 模块 | 关键操作 | 作用 |
|---|---|---|
| 设备发现 | WS-Discovery Probe / ProbeResponse | NVR搜索局域网中的摄像头 |
| 设备信息 | GetDeviceInformation | 获取厂商、型号、固件版本、序列号 |
| 能力查询 | GetCapabilities | 获取Device、Media等服务的地址 |
| 系统时间 | GetSystemDateAndTime / SetSystemDateAndTime | 对时,为摘要认证做准备 |
| 媒体配置 | GetProfiles / GetVideoEncoderConfiguration | 获取Profile Token和编码参数 |
| 取流信息 | GetStreamUri | 返回RTSP地址 |
| 用户管理 | GetUsers | 返回可用的登录账号信息 |
这个集合非常小,但恰好是NVR添加摄像头时“必须打通”的最小集合。其余接口,比如PTZ、事件订阅、录像配置,可以先不实现。如果NVR尝试调用未实现接口,返回一个规范的SOAP Fault就行,并不会阻塞设备添加。如果强迫自己把规范里所有接口都写完,项目周期会被严重拖长,而且大部分代码根本不会被调用。
2. NVR添加摄像头背后的协议链路
2.1 两种添加方式走的是两条不同的握手路径
NVR添加摄像头通常有两种方式:自动发现和手动添加。前者靠WS-Discovery在局域网内找设备,后者用户直接输入IP地址和用户名密码。NVR厂商在“一键添加”时几乎都会先发WS-Discovery Probe,然后根据设备返回的XAddr发起后续请求;如果是手动添加,NVR会跳过发现环节,直接向http://设备IP/onvif/device_service发送SOAP请求。
这两条路径的区别对实现影响很大。自动发现要求设备必须监听UDP 3702端口,并且正确应答单播回复;手动添加则要求HTTP端点必须在80或其他业务端口稳定监听。最稳妥的做法是两个都做,因为有些NVR虽然支持手动添加IP,但添加之后仍然会用WS-Discovery再确认一次设备能力。
我在实际项目中见过一个典型错误:设备HTTP服务绑定在8000端口,WS-Discovery返回的XAddr也写的是http://192.168.1.xx:8000/onvif/device_service,但发现响应里的Scopes包含了不匹配的onvif://www.onvif.org/type/audio_encoder,NVR收到后发现这不是它要找的视频设备,直接忽略。Scopes这个字段非常关键,后面章节会重点说。
2.2 设备发现:被忽略的3702端口
WS-Discovery用的是UDP多播,默认组播地址是239.255.255.250,端口是3702。NVR会向这个组播地址发送一条Probe报文,设备如果认为自己符合条件,就返回单播ProbeResponse。NVR则通过ProbeResponse里的XAddrs列表确定后续要访问的HTTP地址。
完整的Probe消息很长,核心内容就是询问:你们当中有没有符合特定Types和Scopes的设备。我们至少需要从报文里判断两个要素,一是存在Probe关键字,二是请求中要求的Types包含类似NetworkVideoTransmitter或者Scopes匹配我们的视频类型。
应答时要注意两个细节。第一,回复目标地址必须是从recvfrom得到的源地址和源端口,不要傻乎乎地往组播地址回,那会导致大部分NVR收不到回复。第二,XAddr必须填充设备当前局域网IP,不能是127.0.0.1,也不能是编译时写死的旧地址。
还有个很容易踩的坑是路由器或者Wi-Fi接入点的组播隔离。如果ESP32连接的AP启用了“多播过滤”或“无线隔离”,可能根本收不到多播Probe。排查这个问题时可以先让设备监听3702端口并打印所有收到UDP包的长度,哪怕收到一条字节数明显异常的消息,都说明链路是通的。
2.3 从GetCapabilities到GetStreamUri的递进关系
一旦NVR找到了设备,就会用HTTP POST方式调ONVIF接口。第一步通常是GetCapabilities,拿到Capabilities下的Device和Media两个XAddr。注意,这里NVR完全以你返回的XAddr为准,它不会猜测你的媒体服务地址。所以如果GetCapabilities中Media的XAddr为空或者拼错路径,后面所有GetProfiles、GetStreamUri调用都会失败。
拿到媒体服务地址后,NVR会连续调用GetProfiles。返回结果里的Profiles token是后续取流的“身份证”。NVR会先遍历Profiles,确认Profile里包含VideoEncoderConfiguration,再调用GetStreamUri,把Profile token放在请求参数里。设备要做的事情是返回对应的RTSP地址,比如rtsp://192.168.1.100:8554/stream1。
很多开发者以为GetStreamUri的返回值可以随便写一个URL,NVR会根据设备IP自动替换,这是错的。NVR基本会直接使用返回的URI去拉流。如果URI里的IP是板卡内部地址,或者端口写错,NVR就会卡在添加设备最后一步,反复提示失败。
2.4 时间、认证、保活:NVR添加设备的隐含要求
NVR添加设备的流程中,还隐含着一个顺序要求:先对时,再认证,最后取流。原因是ONVIF标准里常见的WS-Security用户名令牌摘要认证和客户端创建时间有关,如果设备本地时间离真实时间差距太大,服务端算出来的加密码无法校验通过。所以NVR在拿到设备信息之后,通常会先请求GetSystemDateAndTime,如果发现设备时间不对,会直接调用SetSystemDateAndTime把设备时钟校准到NVR认为正确的时间。
处理SetSystemDateAndTime时,一定要真正把传入时间写入系统时钟,而不是简单回复一个空OK。在ESP-IDF里,最直接的做法就是解析出年月日时分秒后调用标准C的time函数设置系统时间。部分NVR还会在获取时间后马上发起带认证的请求,如果解析逻辑有问题,用户名密码就永远校验失败。
另外一个隐含要求是保活。有些NVR添加成功后并不是一直主动拉流,它会每隔一段时间重新查询GetProfiles或者GetStreamUri。如果HTTP服务在长时间空闲后连接数占满,或者处理请求的task被优先级更高的摄像头采集任务饿死,NVR就会把设备标记为离线。这个问题在联调阶段看不出来,但连续跑几天后就会暴露,后面章节会专门讲稳定性调优。
3. 工程搭建与onvif-c组件目录设计
3.1 创建工程:先让IDF跑起来
我默认你已经有ESP-IDF开发环境,版本建议使用v4.4及以上,因为后续用到的esp_http_server、esp_netif接口在低版本上有一些行为差异。新建工程用IDF自带命令就行:
idf.py create-project onvif_camera cd onvif_camera mkdir -p components/onvif_c/include mkdir -p components/onvif_c/src接着做一次最小构建。不要急着往主文件里写功能,先把工具链跑通:
idf.py set-target esp32s3 idf.py build这里我强烈建议用ESP32-S3而不是经典ESP32来做,原因会在视频衔接章节说明。目标芯片确定后,就能用下面命令查看内存和Flash:
idf.py size这只是验证环境,什么都还没写,但至少保证后面所有编译错误不会被环境问题干扰。我在项目里会顺手打开CONFIG_SPIRAM选项,因为JPEG帧缓存用外部PSRAM会从容很多。
3.2 组件目录与CMake组织
onvif-c组件的目录结构可以这样设计:
components/onvif_c/ ├── CMakeLists.txt ├── Kconfig ├── include/ │ └── onvif_server.h ├── src/ │ ├── onvif_discovery.c │ ├── onvif_device.c │ ├── onvif_media.c │ ├── onvif_auth.c │ ├── onvif_xml.c │ └── onvif_server.c组件的CMakeLists.txt这么写:
idf_component_register( SRCS "src/onvif_discovery.c" "src/onvif_device.c" "src/onvif_media.c" "src/onvif_auth.c" "src/onvif_xml.c" "src/onvif_server.c" INCLUDE_DIRS "include" REQUIRES esp_wifi esp_netif lwip esp_http_server mbedtls )REQUIRES里必须把esp_http_server和mbedtls加上,前者提供HTTP端点,后者提供SHA1和Base64计算。WS-Discovery要直接操作套接字,所以lwip也必须依赖。主工程的CMakeLists不用改,只要组件在components/目录下,ESP-IDF会自动扫描。
需要注意的是,不要在onvif-c组件内部直接依赖摄像头或者RTSP Server组件。ONVIF模块应该只负责协议逻辑,通过注册回调函数让上层填充设备名、序列号、取流URL等动态信息。这样可以保持组件在不同摄像头方案之间可复用。
3.3 Kconfig可裁剪项与内存预算
嵌入式开发最忌讳把功能和配置全写死在代码里。onvif-c组件可以预留以下几个Kconfig项:
menu "ONVIF Camera Options" config ONVIF_SERVER_PORT int "ONVIF HTTP server port" default 80 config ONVIF_RTSP_URL string "Default RTSP URL" default "rtsp://192.168.1.100:8554/stream1" config ONVIF_MAX_XML_LEN int "Max XML response length" default 4096 endmenu端口默认用80,原因是绝大多数NVR对ONVIF设备的默认HTTP端口就是80。如果80端口被摄像头Web管理页面占用,建议把Web管理页面换到其他端口,而不是让ONVIF让路。
内存预算方面,onvif-c组件需要分配:
- UDP发现接收缓冲:2KB
- HTTP请求接收缓冲:4KB左右
- XML响应拼装缓冲:4KB到8KB
- 每个连接的临时解析缓冲:2KB到4KB
如果同时支持4个HTTP连接,粗略估算组件运行期需要20KB到30KB RAM,在ESP32-S3上是可以接受的。如果内存紧张,可以把HTTPD_CONFIG里的max_open_sockets调小,或者改用单连接串行处理模式。
4. 核心C代码实现:四个必须打通的关卡
4.1 SOAP/XML解析与响应拼装
ONVIF协议本质是SOAP,也就是HTTP POST请求体里装着一坨XML。实现时可以不用引入重型XML解析器,因为我们需要处理的报文结构相对固定,只需要做两件事:从请求体里抽取Action名、从Action的XML片段里抽取关键参数。
最灵活的方案是引入一个轻量级纯C XML库。ONVIF报文嵌套层级深,如果手写字符串查找函数去解析,遇到属性顺序变化就容易翻车。轻量级XML库能保留DOM结构,让我们直接定位Envelope -> Body -> GetProfile这样的节点路径。需要注意的是,解析库要设置一个合理的最大节点数上限,比如256个节点,避免恶意XML撑爆内存。
响应拼装则相反,XML结构是固定的,可以直接用snprintf往缓冲区里写。以GetDeviceInformation为例:
static int build_device_info(char *out, size_t out_size) { return snprintf(out, out_size, "<?xml version=\"1.0\" encoding=\"UTF-8\"?>" "<s:Envelope xmlns:s=\"http://www.w3.org/2003/05/soap-envelope\">" "<s:Body>" "<GetDeviceInformationResponse>" "<Manufacturer>Espressif</Manufacturer>" "<Model>ESP32-CAM</Model>" "<FirmwareVersion>1.0.0</FirmwareVersion>" "<SerialNumber>%s</SerialNumber>" "<HardwareId>ESP32</HardwareId>" "</GetDeviceInformationResponse>" "</s:Body>" "</s:Envelope>", serial); }这里的序列号建议直接用Wi-Fi MAC拼接,保证每个设备唯一。如果多个摄像头使用同一个固件并且序列号写死,NVR就会出现设备ID冲突。
拼装响应时最容易漏掉的是命名空间。ONVIF规范对命名空间非常敏感,如果漏掉xmlns:tt或者错误使用SOAP版本,NVR可能直接判定为协议错误。我在开发时每个响应模板都对照抓包结果核验过,建议你实现时也准备一个标准客户端工具做回归测试。
4.2 设备服务与媒体服务:HTTP双端点
在ESP-IDF里用esp_http_server实现ONVIF HTTP服务端是非常自然的选择。初始化时可以这样写:
httpd_handle_t onvif_server = NULL; httpd_config_t cfg = HTTPD_DEFAULT_CONFIG; cfg.server_port = CONFIG_ONVIF_SERVER_PORT; cfg.max_uri_handlers = 4; cfg.max_open_sockets = 6; cfg.stack_size = 16384; cfg.recv_wait_timeout = 10; httpd_start(&onvif_server, &cfg); httpd_uri_t dev_uri = { .uri = "/onvif/device_service", .method = HTTP_POST, .handler = onvif_device_handler, .user_ctx = NULL, }; httpd_register_uri_handler(onvif_server, &dev_uri); httpd_uri_t media_uri = { .uri = "/onvif/media_service", .method = HTTP_POST, .handler = onvif_media_handler, .user_ctx = NULL, }; httpd_register_uri_handler(onvif_server, &media_uri);stack_size一定要调大。虽然esp_http_server创建了自己的任务,但XML解析和snprintf拼长字符串都需要调用栈,默认值常常会造成栈溢出。
两个handler的核心逻辑是收到POST后读取完整body,然后分发给统一的ONVIF分发函数。设备的媒体服务路径虽然不同,但很多请求名可以从XML里提取,比如GetProfiles和GetStreamUri。
这里有个并发注意事项:同一时间NVR可能发起多个请求,比如一个线程取GetDeviceInformation,另一个线程取GetProfiles。如果XML解析器和响应模板都用了全局缓冲区,必须加互斥锁,否则会偶发响应错乱。我踩过一次,表现为NVR偶尔拿到另一个请求的XML,排查了很久才发现是全局缓冲竞争。
4.3 WS-Security摘要认证
ONVIF常用的认证方式是WS-Security中的UsernameToken摘要认证。请求头里会带有Security节点,类似这样:
<Security xmlns="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd"> <UsernameToken> <Username>admin</Username> <Password Type="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-username-token-profile-1.0#PasswordDigest">base64字符串</Password> <Nonce>base64字符串</Nonce> <Created>2024-01-01T00:00:00.000Z</Created> </UsernameToken> </Security>服务端校验逻辑是这样:取出Nonce、Created、PasswordDigest,然后用SHA1计算SHA1(Nonce原值 + Created字符串 + 设备密码),最后Base64编码,与PasswordDigest做字符串比较,相等则通过。
下图是完整的校验流程说明。在onvif-auth.c里可以直接用ESP-IDF自带的mbedtls库完成计算:
#include "mbedtls/sha1.h" #include "mbedtls/base64.h" int onvif_verify_digest( const char *nonce_b64, const char *created, const char *digest_b64, const char *password) { unsigned char nonce[64] = {0}; size_t nonce_len = 0; mbedtls_base64_decode(nonce, sizeof(nonce), &nonce_len, (const unsigned char *)nonce_b64, strlen(nonce_b64)); unsigned char input[160] = {0}; size_t input_len = 0; memcpy(input + input_len, nonce, nonce_len); input_len += nonce_len; memcpy(input + input_len, created, strlen(created)); input_len += strlen(created); memcpy(input + input_len, password, strlen(password)); input_len += strlen(password); unsigned char sha1_out[20] = {0}; mbedtls_sha1_ret(input, input_len, sha1_out); char calc_b64[64] = {0}; size_t calc_len = 0; mbedtls_base64_encode((unsigned char *)calc_b64, sizeof(calc_b64), &calc_len, sha1_out, sizeof(sha1_out)); return strcmp(calc_b64, digest_b64) == 0 ? 0 : -1; }注意不要把Nonce和Created搞反。我第一次实现时误以为Nonce是设备下发的一次性随机数,实际上ONVIF的UsernameToken机制里,Nonce和Created都由客户端生成,服务端只需要拿它们和密码一起做哈希。校验前也建议先做缓存检查,防止重放攻击,但最小实现可以省略。
还有一点,很多NVR在时间不同步时会先匿名调用GetSystemDateAndTime和SetSystemDateAndTime,然后才带认证请求设备信息。所以设备端必须支持“匿名访问这几个时间接口”,否则NVR没法完成对时。
4.4 WS-Discovery多播监听与应答
WS-Discovery的实现需要单独开一个任务,负责接收UDP 3702端口的多播消息。核心代码如下:
static void discovery_task(void *arg) { int fd = socket(AF_INET, SOCK_DGRAM, 0); int reuse = 1; setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse)); struct sockaddr_in bind_addr = {0}; bind_addr.sin_family = AF_INET; bind_addr.sin_port = htons(3702); bind_addr.sin_addr.s_addr = htonl(INADDR_ANY); bind(fd, (struct sockaddr *)&bind_addr, sizeof(bind_addr)); struct ip_mreq mreq; mreq.imr_multiaddr.s_addr = inet_addr("239.255.255.250"); mreq.imr_interface.s_addr = htonl(INADDR_ANY); setsockopt(fd, IPPROTO_IP, IP_ADD_MEMBERSHIP, &mreq, sizeof(mreq)); char buf[2048]; while (1) { struct sockaddr_in from; socklen_t from_len = sizeof(from); int len = recvfrom(fd, buf, sizeof(buf) - 1, 0, (struct sockaddr *)&from, &from_len); if (len <= 0) continue; buf[len] = 0; if (strstr(buf, "Probe") && strstr(buf, "NetworkVideoTransmitter")) { char xaddr[64]; get_station_ip(xaddr, sizeof(xaddr)); char resp[1024]; snprintf(resp, sizeof(resp), "<?xml version=\"1.0\" encoding=\"UTF-8\"?>" "<e:Envelope xmlns:e=\"http://www.w3.org/2003/05/soap-envelope\"" " xmlns:d=\"http://schemas.xmlsoap.org/ws/2005/04/discovery\">" "<e:Body>" "<d:ProbeResponse>" "<d:XAddrs>http://%s/onvif/device_service</d:XAddrs>" "<d:Scopes>onvif://www.onvif.org/type/video_encoder</d:Scopes>" "<d:Types>dn:NetworkVideoTransmitter</d:Types>" "</d:ProbeResponse>" "</e:Body>" "</e:Envelope>", xaddr); sendto(fd, resp, strlen(resp), 0, (struct sockaddr *)&from, from_len); } } }get_station_ip这一步非常容易出错。设备如果有多个网络接口,必须确保取的是实际能到达NVR的那个接口IP。最简单的方法是用esp_netif_get_ip_info获取STA接口地址,再格式化为字符串。在Wi-Fi连接反复重连的情况下,IP可能变化,所以每次收到Probe都动态获取一次,不要缓存。
无论收到单播还是多播发送的Probe,回复都用单播发回源地址。另外很多系统默认会忽略多播包,需要检查LWIP配置里是否打开了对应选项,否则IP_ADD_MEMBERSHIP可能无效。我在ESP-IDF v4.4上验证过,如下配置是稳妥的。
5. 视频流衔接与运行调优
5.1 摄像头采集与帧生命周期
ONVIF只负责“告诉NVR去哪里取流”,真正出图靠的是RTSP服务。在ESP32平台上,摄像头采集一般直接用官方组件,初始化代码基本都是设置引脚和分辨率。要特别注意帧的生命周期,因为JPEG帧缓冲是从摄像头驱动内部申请的,每次只能同时存在有限几帧。取帧后必须尽快交给RTSP任务,发送完毕后立刻归还。
伪代码逻辑:
while (rtsp_is_connected) { camera_fb_t *fb = esp_camera_fb_get(); if (!fb) continue; rtsp_send_jpeg_frame(fb->buf, fb->len); esp_camera_fb_return(fb); }不要在这条循环里做耗时操作,比如打印日志、计算复杂统计,否则摄像头缓冲区会迅速被占满导致丢帧。
5.2 RTSP Server与MJPEG RTP载荷
RTSP Server组件负责回应NVR的OPTIONS、DESCRIBE、SETUP、PLAY请求,并把JPEG帧封装成RTP包发送。在SDP描述里要声明视频格式。标准JPEG的RTP payload type是26,但如果RTSP Server实现用的是动态类型,也需要在SDP里明确写:
m=video 0 RTP/AVP 96 a=rtpmap:96 JPEG/90000ONVIF的GetStreamUri只需要返回RTSP Server监听的那个地址,例如rtsp://192.168.1.100:8554/stream1。端口可以自定义,NVR会尊重URI中给出的端口。开发时建议把RTSP端口固定下来,接入路由器上的端口映射或者防火墙规则时会更方便。
RTSP和ONVIF是两个独立模块,但要注意它们之间的联动。NVR首先通过ONVIF拿到RTSP地址,然后立刻去访问这个地址。如果设备里同时存在多个Profile,每个Profile都对应一条流,GetStreamUri返回的地址要和RTSP Server实际存在的路径严格对应。一个常见错误是返回了/video1,但RTSP Server只注册了/cam/real1,NVR就会在最后一步取流时失败。
5.3 性能指标和长时间稳定性
从实际运行看,老ESP32在处理200万像素JPEG时,CPU占用会接近满载,尤其在做RTP打包和网络发送时。现在ESP32-S3配合PSRAM,720P分辨率、15帧左右是可以稳定跑的。如果NVR配置里要求连续在线,我的建议参数如下:
| 项目 | 推荐配置 | 说明 |
|---|---|---|
| 分辨率 | 800x600 或 1280x720 | 太高会撑爆带宽和CPU |
| 帧率 | 10到15 fps | 安防监控场景够用 |
| JPEG质量 | 70到80 | 过低画面模糊,过高体积大 |
| RTSP端口 | 8554 | 避开常见端口 |
| ONVIF HTTP端口 | 80 | 是NVR默认认知 |
内存方面,建议把RTSP发送任务栈设置到4096以上,否则会出现莫名其妙的断流。HTTP Server任务栈设置到16384已经说过,不要再低。长时间运行还需要关注Wi-Fi重连后IP变化的问题。如果STA地址变了,ONVIF组件里的XAddr必须更新。比较好的做法是订阅IP_EVENT_STA_GOT_IP事件,在该事件触发时重新配置GetStreamUri的地址和WS-Discovery回复地址,而不是在固件里固定一个IP。
6. 真机联调、问题速查与抓包建议
6.1 标准过程:先用ONVIF客户端工具过一遍
除非你已经对协议链路的每一步都有把握,否则不要一开始就直接捅给NVR。开发过程中先用一个ONVIF客户端工具逐项测试,效果会好很多。推荐的测试顺序是:
- 扫描局域网,确认能发现设备。
- 输入用户名密码,测试GetSystemDateAndTime,看设备时间是否准确。
- 依次调用GetDeviceInformation、GetCapabilities、GetProfiles。
- 调用GetStreamUri,拿到RTSP地址。
- 用普通播放器测试RTSP地址能否出图。
- 最后再接入NVR。
这样做的好处是能快速把问题隔离在ONVIF层或者视频流层。如果客户端工具里能正常拿到StreamUri并播放,但NVR添加后无法观看,那问题就在NVR对协议细节的特殊要求上,排查范围会大大缩小。
6.2 抓包看什么
协议联调离不开抓包。抓包过滤方式根据阶段不同可以切换:
- 发现阶段:过滤
udp.port == 3702,检查Probe和ProbeResponse。 - 能力获取阶段:过滤HTTP流量,看POST路径和响应XML。
- 取流阶段:过滤
tcp.port == 8554,看RTSP交互。
抓包时不要只看有没有请求和响应,还要检查响应时间。NVR通常有一个超时阈值,比如5秒。如果设备处理GetCapabilities需要很久,建议优化拼包逻辑,不要在HTTP Handler里做耗时操作。一次我看到某个实现里每次响应都重新计算Wi-Fi MAC和序列号,还加了两层锁,耗时接近2秒,NVR几乎要判超时。
6.3 问题速查表与我的避坑笔记
下面这张表是我实际调试时整理出来的,不敢说覆盖所有情况,但至少能把常见问题对上号。
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| NVR搜不到设备 | 多播被路由丢弃、3702未监听、应答地址错误 | 抓包看Probe是否到达,确认应答单播 |
| 能发现但添加失败 | GetCapabilities返回的XAddr错误或为空 | 检查Media服务路径和IP是否真实有效 |
| 一直提示用户名密码错误 | 时间没有对时、摘要算法算错、密码不匹配 | 查看SetSystemDateAndTime是否生效,抓包对比Digest |
| 添加成功但没有画面 | GetStreamUri和RTSP实际路径不一致 | 用播放器验证RTSP地址,看SDP内容 |
| 画面频繁卡顿 | 分辨率太高、带宽不足、内存不足 | 降帧率、降分辨率、开启PSRAM |
| 长时间运行后离线 | HTTP任务栈溢出、Wi-Fi重连IP变化 | 看日志,检查栈水位,订阅IP事件更新地址 |
还有一个非常容易被人忽略的问题:设备名称和序列号不要用固定字符串。大量设备用同一个固件时,如果SerialNumber相同,NVR会把它们当作同一台设备,甚至出现后来设备踢掉前一台的情况。用Wi-Fi MAC做后缀是最简单的方案。
抓包时如果发现NVR请求了某个我们没实现的接口,比如GetEventProperties,不用慌。返回一个标准SOAP Fault即可。很多NVR并不会因为可选接口失败而中止添加流程。如果你想让兼容性更好,可以在Fault里带上ter:NotSupported错误码,客户端一般就知道这是能力范围之外了。
最后留一个小技巧
调试ONVIF设备有一个心态要摆正:NVR不是“人类用户”,它不会自动容忍你减小误差。它要求的时间格式、命名空间、XML节点顺序,都必须严格按照标准来。我自己习惯在所有响应模板里用一次strstr做自检,确保关键的XAddrs和Profile token没有被截断。另外可以给固件加一条调试日志,把每次ONVIF请求的Action名和响应状态打出来,配合抓包工具能极大缩短排查周期。这个组件我后续打算继续补充SnapshotURI和RTSP over TLS的能力,但如果你只是想快速让ESP32相机被NVR添加,先把手册里的这个最小闭环跑通,已经能解决绝大多数项目的实际需求了。