onvif-bridge:标准协议桥接
案例背景
onvif-bridge 是 NeoMind 生态中的标准协议桥接案例。ONVIF(Open Network Video Interface Forum)是网络视频设备的开放标准,定义了设备发现(WS-Discovery)、媒体流协商(RTSP URL 获取)、PTZ 控制、事件订阅等接口规范。覆盖 Profile S(流媒体)、Profile T(高级流媒体)、Profile G(视频存储)等多个 profile。
任何符合 ONVIF Profile S 的 IP 摄像头——海康、大华、安讯威、Tiandy——都可以通过 onvif-bridge 接入 NeoMind,无需厂商私有 SDK,无需适配层。
当前版本 2.7.6,核心代码分布在 5 个 Rust 源文件中约 2700 行:lib.rs(1646 行,Extension trait + 命令分发)、soap_client.rs(516 行,SOAP envelope + WS-Security)、discovery.rs(211 行,WS-Discovery UDP 多播)、ptz.rs(214 行,PTZ 命令)、types.rs(78 行,数据结构)。
它解决了什么问题? NeoMind 的前端需要统一管理异构 IP 摄像头。如果每个厂商都用自己的 SDK(海康 SDK、大华 SDK、Tiandy SDK),代码量爆炸、维护成本高、新厂商接入周期长。
onvif-bridge 把 ONVIF 标准协议封装为 NeoMind 的命令和指标,前端只需调用 discover / get_stream_uri / ptz_move 等统一命令,就能操作任何 ONVIF 兼容摄像头。这是开放标准驱动的集成策略——不是适配厂商,而是适配协议。
与 5 uink-rms-bridge 的对比预告:5 uink-rms-bridge 是厂商专有协议(封闭 SDK + 私有二进制协议),onvif-bridge 是标准协议(开放规范 + SOAP/WS-Discovery)。
两者代表 NeoMind 生态中两种截然不同的集成策略——「标准协议桥接」vs「专有协议桥接」——各自的适用场景、工程复杂度、维护成本差异巨大。本系列 5 会专门对比这两种策略。
与 NeoEyes 摄像头产品线的关系:NeoEyes NE101 / NE301 等硬件设备部分支持 ONVIF 协议栈,onvif-bridge 也可以作为这些自研设备的通用接入路径。当客户混部 NeoEyes 摄像头和第三方 ONVIF 摄像头时,onvif-bridge 提供统一的管理面板。
两大痛点驱动了手写而非依赖现成 crate:
- ONVIF 协议栈复杂——SOAP 1.2 envelope + WS-Security UsernameToken Profile + WS-Discovery UDP 多播,现成的 Rust crate(如
onvif-rs)维护滞后且不覆盖 PTZ/事件订阅,缺失的功能只能自己补 - 厂商实现差异大——某些设备 Probe 响应的 XML 命名空间前缀不规范(
SOAP-ENV:vss:vssoap:),某些设备 SOAP Fault 格式不标准,解析逻辑必须容忍这些差异
目标读者:
- 要接入第三方 IP 摄像头的集成商——你会看到从设备发现到 PTZ 控制的完整命令链路
- 想理解 SOAP / WS-Discovery / WS-Security 在 Rust 中如何手写的协议开发者——本案例没有依赖任何 ONVIF/SOAP crate,全部手写,是纯协议工程的极佳参考。
你将学到:
- WS-Discovery 多播发现的工程实现——UDP 多播 socket 绑定、TTL 控制、Probe/ProbeMatch 消息格式、macOS 多播陷阱
- SOAP 1.2 + WS-Security UsernameToken Profile 的 PasswordDigest 算法——SHA1(nonce+created+password) 的 Rust 实现和为什么选择 PasswordDigest 而非 PasswordText
- ONVIF 设备能力协商链路——GetDeviceInformation → GetProfiles → GetStreamUri → 可选 PTZ
- 纯后端桥接扩展的架构模式——无 frontend 组件、无 ONNX 模型 、同步 HTTP、如何通过命令系统和虚拟指标与 NeoMind 主体集成
架构总览
onvif-bridge 是一个纯后端协议桥接扩展——没有 frontend 组件、没有 ONNX 模型、没有视频解码逻辑。它的职责是:用标准协议(WS-Discovery + SOAP)与 ONVIF 摄像头通信,把结果转化为 NeoMind 的命令返回值和虚拟指标。
扩展进程内通过 parking_lot::RwLock 管理 HashMap<String, OnvifDevice> 设备注册表,所有命令操作都围绕这个注册表展开。
模块职责拆分
| 模块 | 文件 | 行数 | 职责 |
|---|---|---|---|
| 入口 + 分发 | src/lib.rs | 1646 | Extension trait 实现(metadata / metrics / commands / execute_command)、设备注册表(RwLock HashMap)、14 个命令处理函数、FFI 导出 |
| WS-Discovery | src/discovery.rs | 211 | UDP 多播 socket 构建、Probe 消息模板、ProbeMatch 解析(多命名空间容忍)、find_local_ipv4 本地 IP 检测 |
| SOAP 客户端 | src/soap_client.rs | 516 | SOAP envelope 构建、WS-Security UsernameToken(PasswordDigest)、ureq 同步 HTTP 发送、SOAP Fault 解析、设备能力协商函数群 |
| PTZ 控制 | src/ptz.rs | 214 | PTZ RelativeMove / AbsoluteMove / Stop / GotoHomePosition / GetPresets / GotoPreset 六个命令封装 |
| 数据结构 | src/types.rs | 78 | OnvifConfig / OnvifDevice / OnvifProfile / VideoEncoderConfig / PtzParams / DiscoveryMatch |
与 AI 推理扩展的架构对比
| 架构维度 | 2 yolo-device-inference | 3 yolo-video-v2 | 4 onvif-bridge |
|---|---|---|---|
| 核心职责 | 单帧 YOLO 推理 | 实时视频流推理 + 检测 | 标准协议桥接(发现 + 取流 URL + PTZ) |
| ONNX 模型 | 有 | 有 | 无 |
| Frontend 组件 | 无 | YoloVideoDisplay | 无(纯后端) |
| 视频解码 | 无(直接消费 image) | ffmpeg-next / nokhwa | 无(只返回 RTSP URL,不解码流) |
| HTTP 客户端 | 无 | 无 | ureq(同步) |
| 网络协议 | 无 | 无 | UDP 多播 + SOAP/HTTP |
| 线程模型 | runtime 主线程 | 专用 OS 线程 | 同步阻塞,无额外线程 |
| 代码规模 | ~1200 行 | ~4000 行 | ~2700 行 |
这张对比表揭示了一个关键事实:onvif-bridge 与 AI 推理扩展在架构上几乎完全正交。它不碰模型、不碰视频解码、不碰前端渲染。它只做一件事——用标准协议与设备对话,把结果返回给 NeoMind。
后续的推理、渲染由其他扩展或前端组件完成。这种职责分离正是标准协议桥接的核心设计哲学。
核心实现剖析
WS-Discovery 多播发现(discovery.rs)
WS-Discovery 是 OASIS 标准的局域网设备发现协议,ONVIF 使用其 UDP 多播模式。onvif-bridge 在 src/discovery.rs 手写了完整的多播发现逻辑。
多播地址和端口是 WS-Discovery 标准固定的(src/discovery.rs L6-L7):
const MULTICAST_ADDR: &str = "239.255.255.250";
const MULTICAST_PORT: u16 = 3702;
Probe 消息(src/discovery.rs L10-L31)是一个 SOAP envelope,告知网络中的 ONVIF 设备「我在找 NetworkVideoTransmitter 类型的设备」。每次探测会生成一个随机 UUID 作为 MessageID,确保不会与历史探测混淆。
fn build_probe_message() -> String {
let message_id = uuid::Uuid::new_v4();
format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope"
xmlns:a="http://www.w3.org/2005/08/addressing">
<s:Header>
<a:Action s:mustUnderstand="1">http://schemas.xmlsoap.org/ws/2005/04/discovery/Probe</a:Action>
<a:MessageID>urn:uuid:{message_id}</a:MessageID>
<a:ReplyTo><a:Address>http://schemas.xmlsoap.org/ws/2004/08/addressing/role/anonymous</a:Address></a:ReplyTo>
<a:To s:mustUnderstand="1">urn:schemas-xmlsoap-org:ws:2005:04:discovery</a:To>
</s:Header>
<s:Body>
<Probe xmlns="http://schemas.xmlsoap.org/ws/2005/04/discovery">
<d:Types xmlns:d="http://schemas.xmlsoap.org/ws/2005/04/discovery"
xmlns:dp0="http://www.onvif.org/ver10/network/wsdl">dp0:NetworkVideoTransmitter</d:Types>
</Probe>
</s:Body>
</s:Envelope>"#,
message_id = message_id,
)
}
Source: src/discovery.rs L10-L31
discover_devices 主循环(src/discovery.rs L134-L211)的核心步骤:
- 调用
find_local_ipv4()检测本机真实 IP(不能绑0.0.0.0,macOS 会失败) - 绑定 UDP socket 到该 IP 的任意端口
- 设置
set_broadcast(true)+set_multicast_ttl_v4(1)(TTL=1 确保多播包不穿越路由器) join_multicast_v4加入多播组- 发送 Probe 到
239.255.255.250:3702 - 在 deadline 循环内
recv_from收集 ProbeMatch 响应 - 按 endpoint 去重后返回
pub fn discover_devices(timeout_ms: u64) -> Result<Vec<DiscoveryMatch>, String> {
let timeout_ms = timeout_ms.clamp(500, 30_000);
let bind_addr = match find_local_ipv4() {
Some(ip) => {
eprintln!("[onvif-bridge] Binding multicast socket to {}", ip);
SocketAddrV4::new(ip, 0)
}
None => {
eprintln!("[onvif-bridge] Could not detect local IP, binding to 0.0.0.0");
SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, 0)
}
};
let socket = UdpSocket::bind(bind_addr)
.map_err(|e| format!("Failed to bind UDP socket: {}", e))?;
socket.set_broadcast(true).map_err(|e| format!("Failed to enable broadcast: {}", e))?;
socket.set_multicast_ttl_v4(1).map_err(|e| format!("Failed to set multicast TTL: {}", e))?;
socket.set_read_timeout(Some(Duration::from_millis(timeout_ms)))
.map_err(|e| format!("Failed to set read timeout: {}", e))?;
// ... (30 lines omitted: join multicast group, send probe, deadline recv loop)
let mut seen = std::collections::HashSet::new();
discovered.retain(|m| seen.insert(m.endpoint.clone()));
Ok(discovered)
}
Source: src/discovery.rs L134-L211
find_local_ipv4 的必要性(src/discovery.rs L119-L131):macOS 的网络栈在多播绑定时,如果绑定到 0.0.0.0(INADDR_ANY),内核不知道用哪个网卡接口发送多播包,会报 No route to host。
解决方案是先创建一个临时 UDP socket 连接到 8.8.8.8:80(不实际发包,只是让内核选择默认路由的网卡),然后读取该 socket 的 local_addr() 获取本机真实 IP。这个修复在 commit 59d3490 中引入。
fn find_local_ipv4() -> Option<Ipv4Addr> {
// On macOS, multicast from 0.0.0.0 can fail with "No route to host"
// Binding to a specific interface address fixes this
let socket = UdpSocket::bind("0.0.0.0:0").ok()?;
// Try connecting to a public address (doesn't actually send packets)
socket.connect("8.8.8.8:80").ok()?;
let local = socket.local_addr().ok()?;
match local {
std::net::SocketAddr::V4(v4) => Some(*v4.ip()),
_ => None,
}
}
Source: src/discovery.rs L119-L131
SOAP 客户端与 WS-Security(soap_client.rs)
ONVIF 的所有控制接口都走 SOAP 1.2 over HTTP。onvif-bridge 在 src/soap_client.rs 手写了完整的 SOAP 客户端,没有依赖任何 SOAP/ONVIF crate。
WS-Security UsernameToken Profile 的 PasswordDigest 算法(src/soap_client.rs L23-L45)是本扩展安全性的核心:
fn compute_password_digest(password: &str) -> (String, String, String) {
use base64::Engine;
let engine = base64::engine::general_purpose::STANDARD;
// 16-byte random nonce (from UUID v4)
let nonce_bytes = uuid::Uuid::new_v4();
let nonce = nonce_bytes.as_bytes()[..16].to_vec();
let nonce_b64 = engine.encode(&nonce);
// ISO 8601 UTC timestamp
let created = chrono::Utc::now()
.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
// SHA-1(nonce + created + password)
use sha1::Digest;
let mut hasher = sha1::Sha1::new();
hasher.update(&nonce);
hasher.update(created.as_bytes());
hasher.update(password.as_bytes());
let digest = hasher.finalize();
let digest_b64 = engine.encode(digest);
(nonce_b64, created, digest_b64)
}
算法公式:Digest = Base64(SHA-1(Nonce + Created + Password))。这意味着密码本身从不在网络上明文传输——即使设备使用 HTTP(而非 HTTPS),抓包也只能看到 nonce、timestamp 和 SHA-1 digest,无法逆推密码。
soap_request_raw(src/soap_client.rs L68-L124)构建完整的 SOAP envelope,包含 WS-Security header(如果提供了凭据)和 SOAP Body,然后通过 ureq::post(url) 同步发送。关键安全措施包括:响应体大小限制 10MB(防内存耗尽)、SOAP Fault 自动检测和格式化错误消息。
pub fn soap_request_raw(url: &str, action: &str, body: &str,
username: Option<&str>, password: Option<&str>) -> Result<String, String> {
let security_header = match (username, password) {
(Some(user), Some(pass)) if !user.is_empty() && !pass.is_empty() => {
Some(build_security_header(user, pass))
}
_ => None,
};
let header_content = match &security_header {
Some(sec) => format!(" <s:Header>\n {}\n </s:Header>", sec),
None => " <s:Header/>".to_string(),
};
let envelope = format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope" ...>
{header} <s:Body> {body} </s:Body>
</s:Envelope>"#, header = header_content, body = body);
let content_type = format!("application/soap+xml; charset=utf-8; action=\"{}\"", action);
let response = ureq::post(url).set("Content-Type", &content_type)
.send_string(&envelope).map_err(|e| format!("SOAP request failed: {}", e))?;
let response_text = response.into_string()
.map_err(|e| format!("Failed to read response: {}", e))?;
if response_text.len() > 10 * 1024 * 1024 {
return Err("SOAP response too large (exceeds 10MB)".to_string());
}
if let Some(fault) = extract_soap_fault(&response_text) { return Err(fault); }
Ok(response_text)
}
Source: src/soap_client.rs L68-L124
设备能力协商
onvif-bridge 实现了 ONVIF Core 和 Media 规范中的核心协商函数:
| 函数 | 文件位置 | ONVIF 操作 | 返回值 |
|---|---|---|---|
get_device_info | L214-L233 | GetDeviceInformation | 制造商 / 型号 / 固件版本 / 序列号 |
get_profiles | L236-L313 | GetProfiles | 媒体 profile 列表(编码 / 分辨率 / 帧率) |
get_stream_uri | L316-L343 | GetStreamUri | RTSP 流地址(rtsp://...) |
get_snapshot_uri | L346-L365 | GetSnapshotUri | JPEG 抓拍地址 |
is_ptz_supported | L368-L385 | 检查 GetProfiles 响应 | 布尔值 |
resolve_service_url(src/soap_client.rs L390-L407)是一个容易被忽略但很关键的函数——ONVIF 设备的不同服务(device/media/ptz)有不同的 URL 路径(/onvif/device_service、/onvif/media_service、/onvif/ptz_service)。
WS-Discovery 返回的 device URL 通常已经是 /onvif/device_service 结尾,调用 media 服务时需要替换路径后缀而非追加。
pub fn get_device_info(device: &OnvifDevice) -> Result<serde_json::Value, String> {
let service_url = resolve_service_url(&device.device_url, "device");
let body = r#"<tds:GetDeviceInformation/>"#;
let response = soap_request_raw(&service_url,
"http://www.onvif.org/ver10/device/wsdl/GetDeviceInformation", body,
device.username.as_deref(), device.password.as_deref())?;
Ok(serde_json::json!({
"manufacturer": extract_tag(&response, "tt:Manufacturer").unwrap_or_default(),
"model": extract_tag(&response, "tt:Model").unwrap_or_default(),
"firmware_version": extract_tag(&response, "tt:FirmwareVersion").unwrap_or_default(),
"serial_number": extract_tag(&response, "tt:SerialNumber").unwrap_or_default(),
"hardware_id": extract_tag(&response, "tt:HardwareId").unwrap_or_default(),
}))
}
Source: src/soap_client.rs L214-L233
pub fn resolve_service_url(device_url: &str, service: &str) -> String {
let base = device_url.trim_end_matches('/');
let suffix = match service {
"device" => "/onvif/device_service",
"media" => "/onvif/media_service",
"ptz" => "/onvif/ptz_service",
_ => "/onvif/device_service",
};
if let Some(slash_pos) = base.find("/onvif/") {
return format!("{}{}", &base[..slash_pos], suffix);
}
format!("{}{}", base, suffix)
}
Source: src/soap_client.rs L390-L407
PTZ 控制(ptz.rs)
src/ptz.rs 封装了六个 PTZ 命令,全部基于 soap_client::soap_request_raw 构建 SOAP body:
- ptz_relative_move(L5-L42):相对移动(指定 Pan/Tilt/Zoom 偏移量 + 速度)
- ptz_absolute_move(L45-L82):绝对定位(移动到指定 Pan/Tilt/Zoom 坐标)
- ptz_stop(L85-L105):停止所有运动
- ptz_go_home(L108-L130):回到 home position
- list_presets(L133-L183):列出预设位
- goto_preset(L186-L210):移动到指定预设位
每个命令都通过 resolve_ptz_url 确定服务的 URL,然后在 SOAP body 中嵌入对应的 ONVIF PTZ WSDL 操作名和参数(PanTilt 空间、Zoom 空间、Speed 向量等)。
pub fn ptz_relative_move(device: &OnvifDevice, profile_token: &str,
pan: f64, tilt: f64, zoom: f64, speed: f64) -> Result<(), String> {
let service_url = resolve_ptz_url(&device.device_url);
let body = format!(
r#"<tptz:RelativeMove>
<tptz:ProfileToken>{profile_token}</tptz:ProfileToken>
<tptz:Translation>
<tt:PanTilt x="{pan}" y="{tilt}" space="http://www.onvif.org/ver10/tptz/PanTiltSpaces/TranslationGenericSpace"/>
<tt:Zoom x="{zoom}" space="http://www.onvif.org/ver10/tptz/ZoomSpaces/TranslationGenericSpace"/>
</tptz:Translation>
<tptz:Speed>
<tt:PanTilt x="{speed}" y="{speed}" space="http://www.onvif.org/ver10/tptz/PanTiltSpaces/GenericSpeedSpace"/>
<tt:Zoom x="{speed}" space="http://www.onvif.org/ver10/tptz/ZoomSpaces/ZoomGenericSpeedSpace"/>
</tptz:Speed>
</tptz:RelativeMove>"#,
profile_token = xml_escape(profile_token),
pan = pan, tilt = tilt, zoom = zoom, speed = speed,
);
crate::soap_client::soap_request_raw(&service_url,
"http://www.onvif.org/ver20/ptz/wsdl/RelativeMove", &body,
device.username.as_deref(), device.password.as_deref())?;
Ok(())
}
Source: src/ptz.rs L5-L42
命令分发(lib.rs execute_command)
src/lib.rs L696-L717 是扩展的核心入口——一个 match 把字符串命令路由到对应的处理函数:
async fn execute_command(&self, command: &str, args: &serde_json::Value) -> Result<serde_json::Value> {
self.total_commands.fetch_add(1, Ordering::SeqCst);
match command {
"discover" => self.cmd_discover(args),
"add_device" => self.cmd_add_device(args),
"remove_device" => self.cmd_remove_device(args),
"list_devices" => self.cmd_list_devices(),
"get_device" => self.cmd_get_device(args),
"get_stream_uri" => self.cmd_get_stream_uri(args),
"get_snapshot" => self.cmd_get_snapshot(args),
"ptz_move" => self.cmd_ptz_move(args),
"ptz_absolute" => self.cmd_ptz_absolute(args),
"ptz_stop" => self.cmd_ptz_stop(args),
"ptz_home" => self.cmd_ptz_home(args),
"list_presets" => self.cmd_list_presets(args),
"goto_preset" => self.cmd_goto_preset(args),
"get_status" => self.cmd_get_status(args),
"configure" => Ok(json!({"status": "ok"})),
_ => Err(ExtensionError::CommandNotFound(command.to_string())),
}
}
共 14 个命令覆盖了 ONVIF 设备管理的完整生命周期:发现(discover)、手动添加(add_device)、列表(list_devices)、详情(get_device)、取流(get_stream_uri)、抓拍(get_snapshot)、PTZ 控制(6 个命令)、状态查询(get_status)、移除(remove_device)、配置(configure)。
发现到取流的完整时序
下图展示前端发起 discover 命令后,到拿到 RTSP 流地址的完整协议交互过程(可选 PTZ 控制在最后):
这个时序图揭示了一个重要事实:onvif-bridge 在整个链路中不触碰任何视频帧。它的终点是返回一个 RTSP URL——后续的拉流、解码、推理、 渲染全部由其他组件完成(例如 案例 3 yolo-video-v2 可以消费这个 RTSP URL 跑实时检测)。
这种设计保证了协议桥接和流处理可以独立演进。
关键设计决策(含权衡与替代方案)
决策 1:手写 SOAP 客户端而非使用 onvif-rs crate
我们的选择:手写约 500 行 SOAP envelope 构建 + WS-Security + XML 解析代码(soap_client.rs)。
替代方案:使用社区 crate onvif-rs(Rust 生态中最知名的 ONVIF 客户端库)。
理由:
onvif-rs维护滞后——最后一次实质性更新距离我们调研已超过一年,且不覆盖 PTZ ContinuousMove / GotoPreset / GetPresets 等关键操作onvif-rs依赖hyper+tokio异步栈,而 onvif-bridge 作为.dylib/.so动态库加载到 NeoMind 主进程时,嵌套 tokio runtime 会引发 panic,手写客户端用同步ureq彻底规避了这个问题- 手写的约 500 行代码完全可控——遇到厂商非标准实现可以立即修改解析逻辑,而修改第三方 crate 需要发 PR 等待合并
权衡的代价是失去了 onvif-rs 提供的类型安全 WSDL 绑定,但通过严格的单元测试(参见 6)弥补。
决策 2:WS-Security PasswordDigest 而非 PasswordText
我们的选择:使用 PasswordDigest 模式——Digest = Base64(SHA-1(Nonce + Created + Password)),密码从不在网络上明文传输(src/soap_client.rs L23-L45)。
替代方案:使用 PasswordText 模式——直接在 SOAP header 中放明文密码(<wsse:Password>明文密码</wsse:Password>)。
理由:
- ONVIF 设备的 SOAP 通信经常走 HTTP 而非 HTTPS——大量摄像头出厂配置不启用 TLS,PasswordText 模式下抓包即可获取管理员密码
- PasswordDigest 的 SHA-1 哈希虽然不是密码学安全的(SHA-1 已被证明有碰撞攻击),但在 ONVIF 场景下它配合 nonce + timestamp 提供了重放攻击防护——攻击者即使抓到 digest,也无法在 created timestamp 过期后重放
- 某些厂商设备强制要求 PasswordDigest 模式(如海康固件默认配置),PasswordText 直接被拒绝并返回
ter:NotAuthorizedSOAP Fault。
权衡的代价是每次 SOAP 请求都要计算 SHA-1 哈希,但这个开销在微秒级别,完全可以忽略。
决策 3:ureq 同步 HTTP 而非 reqwest 异步
我们的选择:使用 ureq(同步 HTTP 客户端),所有 SOAP 请求都是阻塞调用。
替代方案:使用 reqwest + async/await,利用 tokio 异步 IO 并发处理多个设备。
理由:
- ONVIF 设备响应通常在 50ms~500ms,并发量极低(一个安装现场通常不超过 20 台摄像头),同步阻塞的开销远小于异步运行时的复杂度
- onvif-bridge 作为
.dylib/.so动态库加载到 NeoMind 主进程中,主进程已有自己的 tokio runtime——如果扩展内部再创建嵌套的 tokio runtime(reqwest需要),会引发panicked at 'Cannot start a runtime from within a runtime',手写 SOAP 客户端用同步ureq彻底规避了这个问题(参见lib.rsL18-L23 的架构注释) ureq依赖树极小(不含hyper、mio、tokio),编译产物体积比reqwest小约 2MB,对.nep分发包有意义
权衡的代价是无法并行请求多台设备,但 execute_command 本身是 async 的,NeoMind 主进程可以在多设备场景下并行调用不同命令。
决策 4:find_local_ipv4 而非 bind 0.0.0.0
我们的选择:发送 WS-Discovery Probe 前,先通过「连接 8.8.8.8:80 读取 local_addr」的方式检测本机真实出口 IP,然后绑定 UDP socket 到该 IP(src/discovery.rs L119-L131)。
替代方案:直接 UdpSocket::bind("0.0.0.0:0") 绑定到所有接口。
理由:macOS 的网络栈在多播绑定时,如果 socket 绑定到 0.0.0.0(INADDR_ANY),内核无法确定用哪个网卡接口发送多播包,会直接返回 No route to host(errno 65)。这个问题在 commit 59d3490 中被修复——该 commit 的标题就是 fix(onvif): improve WS-Discovery multicast reliability。
Linux 和 Windows 对 0.0.0.0 的多播处理更宽容,但为了跨平台一致性,onvif-bridge 统一采用 find_local_ipv4。权衡的代价是多机网卡场景下可能选中非期望的接口(例如同时有 Wi-Fi 和以太网),但这可以通过用户手动 add_device 绕过。
决策 5:扩展内不解析 RTSP 流
我们的选择:onvif-bridge 只返回 RTSP URL 字符串(get_stream_uri 命令的返回值),不在扩展内部做任何视频拉流 / 解码 / 渲染。
替代方案:在扩展内集成 ffmpeg-next,直接拉取 RTSP 流并解码为 JPEG 帧推送给前端。
理由:
- 职责分离——协议桥接(SOAP/WS-Discovery)和视频处理(RTSP 拉流 / H.264 解码)是完全不同的工程领域,混在一个扩展里会导致代码量翻倍且难以独立测试
- 可组合性——RTSP URL 返回给前端后,可以交给 案例 3 yolo-video-v2 跑实时 AI 检测,也可以交给前端
<video>标签直接播放,或者交给第三方 NVR 录像——onvif-bridge 不应该限制流的消费方式 - 编译产物体积——不引入
ffmpeg-next/nokhwa,.nep包从约 15MB 降到约 3MB,对边缘部署(带宽受限场景)意义重大。
权衡的代价是用户需要自行组合 onvif-bridge + yolo-video-v2 才能实现「摄像头发现 + AI 检测」端到端链路,但 NeoMind 的扩展组合机制正是为此设计的。
与 NeoMind 主体的集成
onvif-bridge 通过 NeoMind Extension SDK 的标准接口与主体集成,不依赖任何私有 API 或 hack。集成体现在三个层面:命令系统、指标产出、跨扩展协作。
命令系统
扩展通过 commands() 方法(src/lib.rs L145-L694)声明 14 个可用命令,每个命令都有 name