feat: add QUIC media transport and integrate Hikvision updates

This commit is contained in:
xtkuang 2026-07-23 12:52:30 +08:00
parent f8961c4e7a
commit d40c92b9bf
71 changed files with 12655 additions and 725 deletions

View File

@ -2,6 +2,8 @@ cmake_minimum_required(VERSION 3.22.0)
project(cmvr_es) project(cmvr_es)
include(CTest)
# C++ # C++
set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_STANDARD_REQUIRED ON)
@ -18,6 +20,34 @@ set(CMAKE_INSTALL_PREFIX "${CMAKE_SOURCE_DIR}/output" CACHE PATH "" FORCE)
set(CMAKE_BUILD_RPATH "\$ORIGIN:\$ORIGIN/../lib") set(CMAKE_BUILD_RPATH "\$ORIGIN:\$ORIGIN/../lib")
set(CMAKE_INSTALL_RPATH "\$ORIGIN:\$ORIGIN/../lib") set(CMAKE_INSTALL_RPATH "\$ORIGIN:\$ORIGIN/../lib")
# Some vendor SDK directories contain an obsolete private libstdc++.so.6. They
# are still needed in the build RUNPATH for their own shared objects, but must
# not shadow the C++ runtime selected by the active compiler.
execute_process(
COMMAND ${CMAKE_CXX_COMPILER} -print-file-name=libstdc++.so.6
OUTPUT_VARIABLE CMVR_COMPILER_LIBSTDCXX
OUTPUT_STRIP_TRAILING_WHITESPACE
)
if(IS_ABSOLUTE "${CMVR_COMPILER_LIBSTDCXX}" AND EXISTS "${CMVR_COMPILER_LIBSTDCXX}")
# Do not put the whole system library directory first: that can mix the
# system FFmpeg with the repository's bundled FFmpeg. A one-library shim
# fixes only the vendor libstdc++ collision.
set(CMVR_COMPILER_RUNTIME_DIR "${CMAKE_BINARY_DIR}/cmvr_compiler_runtime")
file(MAKE_DIRECTORY "${CMVR_COMPILER_RUNTIME_DIR}")
file(CREATE_LINK
"${CMVR_COMPILER_LIBSTDCXX}"
"${CMVR_COMPILER_RUNTIME_DIR}/libstdc++.so.6"
SYMBOLIC COPY_ON_ERROR
RESULT CMVR_COMPILER_RUNTIME_LINK_RESULT)
if(CMVR_COMPILER_RUNTIME_LINK_RESULT STREQUAL "0")
list(PREPEND CMAKE_BUILD_RPATH "${CMVR_COMPILER_RUNTIME_DIR}")
else()
message(WARNING
"Could not prepare compiler libstdc++ build shim: "
"${CMVR_COMPILER_RUNTIME_LINK_RESULT}")
endif()
endif()
# Use RUNPATH (new dtags) generally preferable # Use RUNPATH (new dtags) generally preferable
set(CMAKE_BUILD_WITH_INSTALL_RPATH OFF) set(CMAKE_BUILD_WITH_INSTALL_RPATH OFF)
set(CMAKE_INSTALL_RPATH_USE_LINK_PATH TRUE) set(CMAKE_INSTALL_RPATH_USE_LINK_PATH TRUE)
@ -27,7 +57,20 @@ set(CMAKE_INSTALL_RPATH_USE_LINK_PATH TRUE)
############################################################# #############################################################
list(APPEND CMAKE_MODULE_PATH "${CMAKE_SOURCE_DIR}/cmake") list(APPEND CMAKE_MODULE_PATH "${CMAKE_SOURCE_DIR}/cmake")
include(FindExternalLib) include(FindExternalLib)
set(ARCH "x86") set(CMVR_ARCH "" CACHE STRING "Dependency architecture directory (x86 or arm)")
if(NOT CMVR_ARCH)
string(TOLOWER "${CMAKE_SYSTEM_PROCESSOR}" _cmvr_system_processor)
if(_cmvr_system_processor MATCHES "^(aarch64|arm64|arm)")
set(CMVR_ARCH "arm")
else()
set(CMVR_ARCH "x86")
endif()
endif()
set_property(CACHE CMVR_ARCH PROPERTY STRINGS x86 arm)
if(NOT CMVR_ARCH STREQUAL "x86" AND NOT CMVR_ARCH STREQUAL "arm")
message(FATAL_ERROR "CMVR_ARCH must be either 'x86' or 'arm', got: ${CMVR_ARCH}")
endif()
set(ARCH "${CMVR_ARCH}")
setup_external_libs(${ARCH}) setup_external_libs(${ARCH})
# setup_external_libs # setup_external_libs
message(STATUS "CMAKE_EXE_LINKER_FLAGS: ${CMAKE_EXE_LINKER_FLAGS}") message(STATUS "CMAKE_EXE_LINKER_FLAGS: ${CMAKE_EXE_LINKER_FLAGS}")
@ -35,6 +78,20 @@ message(STATUS "CMAKE_SHARED_LINKER_FLAGS: ${CMAKE_SHARED_LINKER_FLAGS}")
find_package(protobuf REQUIRED) find_package(protobuf REQUIRED)
find_package(gRPC REQUIRED) find_package(gRPC REQUIRED)
option(CMVR_ENABLE_MSQUIC_BACKEND
"Enable the MsQuic backend for the QUIC edge service when available"
ON)
set(CMVR_HAS_MSQUIC OFF)
if(CMVR_ENABLE_MSQUIC_BACKEND)
find_package(MsQuic QUIET)
if(MsQuic_FOUND)
set(CMVR_HAS_MSQUIC ON)
message(STATUS "MsQuic found: ${MsQuic_LIBRARY}")
else()
message(STATUS "MsQuic not found; building QUIC edge in unavailable/stub mode")
endif()
endif()
############################################################ ############################################################
# PROTO , proto build cmake --install . --verbose # PROTO , proto build cmake --install . --verbose
############################################################ ############################################################
@ -109,7 +166,7 @@ target_include_directories(cmvr_es PRIVATE ${GLOG_INCLUDE_DIRS})
target_link_libraries(cmvr_es PRIVATE target_link_libraries(cmvr_es PRIVATE
cmvr_es::proto cmvr_es::proto
cmvr_es::logging cmvr_es::logging
service cmvr_es::quic_edge_task
${GLOG_LIBRARIES} ${GLOG_LIBRARIES}
jsoncpp jsoncpp
cmvr_es::service cmvr_es::service

1110
README.md

File diff suppressed because it is too large Load Diff

50
cmake/FindMsQuic.cmake Normal file
View File

@ -0,0 +1,50 @@
# Locate an optional MsQuic installation without pulling in a second TLS runtime.
#
# The project supports either:
# -DCMVR_MSQUIC_ROOT=/path/to/msquic/prefix
# or a package placed below:
# dependency/<arch>/third_party/msquic/<version>/
#
# Result:
# MsQuic_FOUND
# MsQuic::msquic
include_guard(GLOBAL)
set(CMVR_MSQUIC_ROOT "" CACHE PATH "MsQuic installation prefix")
set(_cmvr_msquic_hints)
if(CMVR_MSQUIC_ROOT)
list(APPEND _cmvr_msquic_hints "${CMVR_MSQUIC_ROOT}")
endif()
if(DEFINED PROJECT_SOURCE_DIR AND DEFINED ARCH)
file(GLOB _cmvr_msquic_bundled_roots LIST_DIRECTORIES true
"${PROJECT_SOURCE_DIR}/dependency/${ARCH}/third_party/msquic/*")
list(APPEND _cmvr_msquic_hints
"${PROJECT_SOURCE_DIR}/dependency/${ARCH}/third_party/msquic"
${_cmvr_msquic_bundled_roots})
endif()
find_path(MsQuic_INCLUDE_DIR
NAMES msquic.h
HINTS ${_cmvr_msquic_hints}
PATH_SUFFIXES include)
find_library(MsQuic_LIBRARY
NAMES msquic libmsquic
HINTS ${_cmvr_msquic_hints}
PATH_SUFFIXES lib lib64 bin)
include(FindPackageHandleStandardArgs)
find_package_handle_standard_args(MsQuic
REQUIRED_VARS MsQuic_INCLUDE_DIR MsQuic_LIBRARY)
if(MsQuic_FOUND AND NOT TARGET MsQuic::msquic)
add_library(MsQuic::msquic UNKNOWN IMPORTED)
set_target_properties(MsQuic::msquic PROPERTIES
IMPORTED_LOCATION "${MsQuic_LIBRARY}"
INTERFACE_INCLUDE_DIRECTORIES "${MsQuic_INCLUDE_DIR}")
endif()
mark_as_advanced(MsQuic_INCLUDE_DIR MsQuic_LIBRARY)

View File

@ -6,7 +6,10 @@ add_subdirectory(hardware)
add_subdirectory(algorithms) add_subdirectory(algorithms)
add_subdirectory(devices) add_subdirectory(devices)
add_subdirectory(manager/device_manager) add_subdirectory(manager/device_manager)
add_subdirectory(manager/media_source_hub)
add_subdirectory(service/quic_edge)
add_subdirectory(task) add_subdirectory(task)
add_subdirectory(task/quic_edge_task)
add_subdirectory(manager/task_manager) add_subdirectory(manager/task_manager)
add_subdirectory(service) add_subdirectory(service)
add_subdirectory(simulate) add_subdirectory(simulate)

View File

@ -0,0 +1,125 @@
# Algorithms 模块开发指南
`algorithms/` 保存与具体厂商协议无关的运动学、规划、控制和感知算法。算法接受通用类型或显式接口输入,不应直接解析设备报文,也不应承担 gRPC/QUIC 传输职责。
返回[项目总览](../../README.md)。
## 当前结构
| 目录 | 主要能力 | 主要 CMake target |
| --- | --- | --- |
| `kinematics/ik_solver/` | Pinocchio DLS/QP、SRS、LAWBA 逆运动学 | `cmvr_es::ik_solver` |
| `motion_planner/base_motion/` | TOPPRA、S 曲线、笛卡尔速度限制 | `cmvr_es::base_motion` |
| `motion_planner/arm_motion/` | MoveJ、MoveL、SpeedL 机械臂规划 | `cmvr_es::algorithms::arm_motion` |
| `controllers/` | PID、IBVS、笛卡尔速度控制 | `cmvr_es::algorithms::controller`、`cmvr_es::algorithms::arm_control` |
| `perception/` | AprilTag 和视觉定位 | `cmvr_es::perception` |
顶层入口是 [`CMakeLists.txt`](CMakeLists.txt)。
## 依赖边界
- 算法层可以依赖 `common/`、Eigen、Pinocchio、OSQP、TOPPRA、OpenCV、ViSP 等;
- 不包含串口、CAN、HTTP 或厂商 SDK 协议处理;
- 不启动 gRPC/QUIC 服务或管理设备生命周期;
- 不从算法内部读取全局配置文件,构造或 `configure` 时显式传入配置;
- 可复用算法不应主动取得 `DeviceManager` 单例。
当前部分 controller target 仍链接 `device_manager`,这是现有耦合。新增算法应优先通过参数、回调或窄接口注入设备状态,避免继续扩大该依赖。
## 扩展已有算法类别
### 1. 定义或复用抽象接口
常用接口:
- [`IKSolver`](kinematics/ik_solver/common/include/ik_solver.h)
- [`JointMotionPlanner`](motion_planner/arm_motion/joint_motion/joint_motion_planner.h)
- [`CartesianMotionPlanner`](motion_planner/arm_motion/cartesian_motion/cartesian_motion_planner.h)
接口应明确:
- 输入输出单位和坐标系;
- 是否修改内部状态;
- 是否线程安全;
- 失败时输出是否保持不变;
- 是否支持实时循环,以及最大允许耗时。
### 2. 增加配置
在 [`../../protos/README.md`](../../protos/README.md) 指导下:
1. 为算法增加独立配置 message
2. 在所属 `oneof algorithm` 中增加新字段和新 tag
3. 不复用已发布 tag
4. 为迭代次数、容差、速度和加速度设置有效范围;
5. 在默认设备配置中给出显式参数。
### 3. 实现与工厂注册
将实现放在对应类别子目录,并修改实际工厂:
- IK[`ik_solver_factory.h`](kinematics/ik_solver/ik_solver_factory.h)
- MoveJ[`joint_motion_planner_factory.h`](motion_planner/arm_motion/joint_motion/joint_motion_planner_factory.h)
- MoveL / SpeedL[`cartesian_motion_planner_factory.h`](motion_planner/arm_motion/cartesian_motion/cartesian_motion_planner_factory.h)
工厂失败应返回 `nullptr` 并记录清晰原因不能静默回退到另一个算法。MoveL 和 SpeedL 的实现必须保持配置组合一致。
### 4. 更新 CMake
- 将实现 `.cpp` 加入对应 library
- 使用项目已有 alias target
- 通过 `target_include_directories` 暴露公共头;
- 将依赖放入使用它的最小 target
- 测试源文件不能加入生产共享库;
- 新增三方依赖时同步根依赖发现逻辑和 `request.txt`
## 数值与机器人语义
算法扩展至少需要明确:
- 关节位置单位为 rad速度为 rad/s
- 笛卡尔平移为 m旋转和角速度为 rad
- base、tool、world、user frame 的转换方向;
- URDF base frame、tip frame 和关节顺序;
- 位置、速度、加速度和 jerk 限制;
- 奇异点、不可达目标和求解超时行为;
- measured state 与算法内部 seed 的更新时机。
IK 在求解前应使用真实关节角更新 seed。MoveL 连续求解时,应使用上一步解更新下一步状态,不能一直使用初始状态。
## 测试要求
每个新算法至少覆盖:
1. 正常输入;
2. 空输入、自由度不匹配和 NaN/Inf
3. 关节限位与速度限制;
4. 不可达目标和不收敛;
5. 坐标系转换;
6. 确定性和重复调用;
7. 若用于实时控制,统计最坏执行时间;
8. 与一个已知模型或离线参考结果对比。
当前不少算法测试只通过 `add_executable()` 构建,没有登记到 CTest。新增无设备测试应放在 `BUILD_TESTING` 条件内,并使用 `add_test()`需要图形界面、RealSense 或 MuJoCo 的测试应明确标为集成测试,不得阻塞默认无设备测试。
## 新增算法类别
如果现有类别无法承载:
1. 在 `algorithms/<category>/` 新建目录;
2. 定义协议无关抽象接口;
3. 定义配置 Proto 和工厂;
4. 提供单独 CMake library 与 `cmvr_es::...` alias
5. 在 [`algorithms/CMakeLists.txt`](CMakeLists.txt) 添加子目录;
6. 由设备或任务层注入使用,不让算法反向控制服务层;
7. 添加无设备单元测试和真实设备/仿真集成测试。
## 提交检查
- [ ] 厂商协议没有进入算法接口
- [ ] 单位、坐标系和关节顺序明确
- [ ] 工厂已注册且配置组合经过校验
- [ ] 不可达、超时和数值异常可观测
- [ ] 测试没有被编入生产共享库
- [ ] 无设备测试已登记到 CTest
- [ ] 实时路径没有日志洪泛和无界内存分配

111
cmvr-es/common/README.md Normal file
View File

@ -0,0 +1,111 @@
# Common 模块开发指南
`common/` 保存可被设备、算法、管理器和协议层复用的基础能力。这里适合放稳定、协议无关、厂商无关的类型与工具,不适合放设备连接、业务服务或任务调度逻辑。
返回[项目总览](../../README.md)。
## 目录职责
| 目录 | 职责 |
| --- | --- |
| `base/` | 日志、基础常量、gRPC 辅助函数和线程安全缓冲区 |
| `config/` | 配置根目录解析和 Proto Text 配置加载 |
| `io/` | Protobuf 二进制与 TextFormat 文件读写 |
| `math/` | 坐标变换、关节限制、QP 和运动数学 |
| `media/` | 协议无关媒体模型以及 FFmpeg 采集、编码、写文件能力 |
| `types/` | 跨后端共享的领域类型,例如 AGV、机械臂和几何类型 |
| `vision/` | 图像显示、投影等视觉辅助代码 |
## 依赖边界
新增公共组件时应遵守:
- 不依赖 `service/`、`task/` 或具体厂商设备实现;
- 不保存 gRPC/QUIC 连接、session 或客户端状态;
- 通用类型不包含厂商报文字段、端口号和私有错误码;
- 需要调用设备的逻辑应放在 manager adapter、service 或 task
- 需要第三方库的 `.cpp` 组件应通过明确的 CMake target 暴露依赖;
- 避免在公共头文件中使用全局 `using namespace` 或引入大体量实现头。
当前 `common` 共享库目标是 `cmvr_es::common`,日志是独立目标 `cmvr_es::logging`。新增 `.cpp` 文件时,需要更新 [`CMakeLists.txt`](CMakeLists.txt) 或对应子目录 CMake纯头文件不需要加入 `add_library` 源文件列表。
## 新增共享类型
1. 选择 `types/<domain>/` 或已有领域文件;
2. 类型使用明确单位,例如米、弧度、秒、纳秒;
3. 为容器长度、自由度和数值范围提供校验函数;
4. 保持控制器无关,将厂商字段转换为通用枚举或结果;
5. 确认不会迫使所有调用方引入设备 SDK
6. 增加边界值和错误输入测试。
AGV 通用类型应参考 [`types/agv/agv_types.h`](types/agv/agv_types.h),机械臂通用类型应参考 [`types/arm/arm_types.h`](types/arm/arm_types.h)。不要为了一个具体控制器把协议结构塞回 `abstract_*.h`
## 媒体模型
[`media/media_frame.h`](media/media_frame.h) 中的 `TrackDescriptor`、`MediaFrame` 及 payload 在构造后不可变,可被多个协议消费者共享。
扩展媒体字段时需要保持:
- `TrackDescriptor::generation` 非零,编码参数变化时创建新 descriptor
- PTS、DTS 和 duration 使用 descriptor 的 `time_base`
- `capture_time_ns` 使用单调时钟,供节奏控制和延迟统计;
- `capture_utc_ns` 只作为可选墙上时间,不能用于计算持续时间;
- H.264/H.265 明确 `ANNEX_B``AVCC`
- AAC、Opus、PCM 明确 payload format、采样率和声道数
- 不把 QUIC、gRPC 或浏览器专有字段加入通用帧。
设备媒体接入流程见 [`../manager/README.md`](../manager/README.md) 的 MediaSourceHub 章节。
## 环形队列选择
[`base/ring_buffer.h`](base/ring_buffer.h) 当前包含三类缓冲区:
| 类型 | 使用场景 | 重要约束 |
| --- | --- | --- |
| `RingBuffer<T>` | 只需要保存最近 N 项并批量读取 | 覆盖最旧项,没有阻塞读取 |
| `SPMCRingBuffer<T>` | 历史单生产者场景 | 独立 `reader_tail` 只能由一个线程拥有 |
| `BroadcastFrameRing<T>` | 新的媒体或广播式多消费者场景 | 每个消费者使用独立 Cursor保存不可变共享对象 |
新的实时多消费者模块优先使用 `BroadcastFrameRing<T>`
- capacity 必须大于零;
- 同一 Cursor 不得被多个线程同时读取或移动;
- 慢消费者落后时会跳到最旧保留项,并得到精确 dropped count
- `reset()` 开启新 generation旧 Cursor 在下一次成功读取时看到变化;
- `close()` 唤醒等待者,关闭后不能继续发布;
- 不要先读取 head 再无锁读取槽位,应使用队列提供的原子读取接口。
## 配置和文件路径
[`config/config_files.h`](config/config_files.h) 提供:
- `resolveConfigFile()`:相对根配置目录解析业务配置;
- `resolveResourceFile()`:在配置根及其父目录中查找模型等资源;
- `loadConfigFile()` / `saveConfigFile()`:读写 Proto Text 配置。
进程启动后配置根由 `main.cpp` 设置。公共组件不应自行使用当前工作目录拼接配置路径。
[`io/proto_file_io.h`](io/proto_file_io.h) 写出的 TextFormat 文件权限为 `0600`。保存运行时配置前,应确认目标目录存在,并避免把生产密钥写入仓库。
## 新增公共组件
1. 确认能力确实会被两个及以上模块复用;
2. 定义最小 API 和所有权、线程安全、错误语义;
3. 将头文件放入合适子目录,将实现放入相邻 `.cpp`
4. 更新 CMake target 和 `target_link_libraries`
5. 不使用未声明的传递依赖;
6. 增加无设备单元测试;
7. 对并发组件增加关闭、超时、覆盖、取消和析构测试;
8. 使用 ASan/TSan 时检查生命周期和数据竞争。
推荐测试目标放在组件相邻的 `tests/`,并在 `BUILD_TESTING` 下通过 `add_test()` 登记。仅创建 `_test` 可执行文件不会自动进入 CTest。
## 提交检查
- [ ] API 不依赖具体设备或传输协议
- [ ] 公共类型有明确单位和有效性规则
- [ ] 所有权及线程安全写入注释
- [ ] 新增 `.cpp` 和依赖已经加入 CMake
- [ ] 缓冲区关闭能够唤醒等待线程
- [ ] 不记录密码、私钥或大块媒体 payload
- [ ] 无设备测试可以在开发主机运行

View File

@ -6,16 +6,25 @@
#define CMVR_ES_RING_BUFFER_H #define CMVR_ES_RING_BUFFER_H
#pragma once #pragma once
#include <chrono>
#include <condition_variable>
#include <cstdint>
#include <deque> #include <deque>
#include <memory>
#include <mutex> #include <mutex>
#include <vector>
#include <atomic>
#include <optional> #include <optional>
#include <stdexcept>
#include <utility>
#include <vector>
template<typename T> template<typename T>
class RingBuffer { class RingBuffer {
public: public:
explicit RingBuffer(size_t capacity) : capacity_(capacity) {} explicit RingBuffer(size_t capacity) : capacity_(capacity) {
if (capacity_ == 0) {
throw std::invalid_argument("RingBuffer capacity must be greater than zero");
}
}
void push(const T& item) { void push(const T& item) {
std::lock_guard<std::mutex> lock(mutex_); std::lock_guard<std::mutex> lock(mutex_);
@ -50,47 +59,49 @@ template<typename T>
class SPMCRingBuffer { class SPMCRingBuffer {
public: public:
explicit SPMCRingBuffer(size_t capacity) explicit SPMCRingBuffer(size_t capacity)
: buffer_(capacity), capacity_(capacity), : buffer_(capacity), capacity_(capacity) {
head_(0), tail_(0) {} if (capacity_ == 0) {
throw std::invalid_argument("SPMCRingBuffer capacity must be greater than zero");
}
}
// 写入操作(仅支持单个生产者) // 写入操作(仅支持单个生产者)
void push(const T& item) { void push(const T& item) {
size_t head = head_.load(std::memory_order_relaxed); {
size_t tail = tail_.load(std::memory_order_acquire); std::lock_guard<std::mutex> lock(mutex_);
buffer_[head % capacity_] = item; buffer_[head_ % capacity_] = item;
head = head + 1; ++head_;
head_.store(head, std::memory_order_release); if (head_ - tail_ > capacity_) {
if (head - tail >= capacity_) {
// 队列满,覆盖最旧的数据 // 队列满,覆盖最旧的数据
tail_.store(tail + 1, std::memory_order_release); tail_ = head_ - capacity_;
} }
} }
condition_.notify_all();
}
// 单消费者使用(内部 tail_ // 单消费者使用(内部 tail_
std::optional<T> pop() { std::optional<T> pop() {
size_t tail = tail_.load(std::memory_order_relaxed); std::lock_guard<std::mutex> lock(mutex_);
size_t head = head_.load(std::memory_order_acquire); if (tail_ >= head_) return std::nullopt;
if (tail >= head) return std::nullopt; T value = buffer_[tail_ % capacity_];
T value = buffer_[tail % capacity_]; ++tail_;
tail_.store(tail + 1, std::memory_order_release);
return value; return value;
} }
std::optional<T> getLast() { std::optional<T> getLast() const {
size_t tail = tail_.load(std::memory_order_relaxed); std::lock_guard<std::mutex> lock(mutex_);
size_t head = head_.load(std::memory_order_acquire); if (tail_ >= head_) return std::nullopt;
if (tail >= head) return std::nullopt; return buffer_[(head_ - 1) % capacity_];
T value = buffer_[head_ % capacity_];
return value;
} }
// 多消费者使用(每个读者独立维护 reader_tail // 多消费者使用(每个读者独立维护 reader_tail。同一个 reader_tail 只能由
// 一个消费线程拥有,且不要把该游标与无参 pop() 的共享 tail_ 混合作为同一路读取。
std::optional<T> pop(size_t& reader_tail) const { std::optional<T> pop(size_t& reader_tail) const {
size_t head = head_.load(std::memory_order_acquire); std::lock_guard<std::mutex> lock(mutex_);
if (reader_tail >= head) return std::nullopt; if (reader_tail >= head_) return std::nullopt;
if (head > reader_tail + capacity_) { if (reader_tail < tail_) {
// 数据已被覆盖,跳过无效读取区间 // 数据已被覆盖,跳过无效读取区间
reader_tail = head - capacity_; reader_tail = tail_;
return std::nullopt; return std::nullopt;
} }
T value = buffer_[reader_tail % capacity_]; T value = buffer_[reader_tail % capacity_];
@ -98,16 +109,52 @@ public:
return value; return value;
} }
// 在同一次加锁中把独立读游标跳到当前最新元素并读取,避免先 getHead()
// 再 pop() 时被高速覆盖造成的检查/读取竞态。
std::optional<T> getLatest(size_t& reader_tail) const {
std::lock_guard<std::mutex> lock(mutex_);
if (tail_ >= head_) {
return std::nullopt;
}
reader_tail = head_ - 1;
T value = buffer_[reader_tail % capacity_];
++reader_tail;
return value;
}
template<class Rep, class Period>
std::optional<T> waitPop(
size_t& reader_tail,
const std::chrono::duration<Rep, Period>& timeout) const {
std::unique_lock<std::mutex> lock(mutex_);
condition_.wait_for(lock, timeout, [&] { return reader_tail < head_; });
if (reader_tail >= head_) {
return std::nullopt;
}
if (reader_tail < tail_) {
reader_tail = tail_;
}
if (reader_tail >= head_) {
return std::nullopt;
}
T value = buffer_[reader_tail % capacity_];
++reader_tail;
return value;
}
size_t size() const { size_t size() const {
return head_.load(std::memory_order_acquire) - tail_.load(std::memory_order_acquire); std::lock_guard<std::mutex> lock(mutex_);
return head_ - tail_;
} }
size_t getHead() const { size_t getHead() const {
return head_.load(std::memory_order_acquire); std::lock_guard<std::mutex> lock(mutex_);
return head_;
} }
size_t getTail() const { size_t getTail() const {
return tail_.load(std::memory_order_acquire); std::lock_guard<std::mutex> lock(mutex_);
return tail_;
} }
bool empty() const { bool empty() const {
@ -119,16 +166,244 @@ public:
} }
void clear() { void clear() {
head_.store(0, std::memory_order_release); {
tail_.store(0, std::memory_order_release); std::lock_guard<std::mutex> lock(mutex_);
// Keep sequence numbers monotonic so cursors created before clear()
// cannot alias newly published slots after the reset.
tail_ = head_;
}
condition_.notify_all();
} }
private: private:
std::vector<T> buffer_; mutable std::vector<T> buffer_;
const size_t capacity_; const size_t capacity_;
mutable std::mutex mutex_;
mutable std::condition_variable condition_;
size_t head_{0}; // 单调写序号clear() 仅推进 tail_避免旧游标 ABA。
size_t tail_{0}; // 当前仍保留的最旧序号,同时也是 pop() 的共享读指针。
};
std::atomic<size_t> head_; // 共享写指针 // 线程安全的多消费者广播缓冲区。缓冲区只保存不可变共享对象,消费者通过各自
std::atomic<size_t> tail_; // 共享读指针(仅用于 SPSC 模式) // 的 Cursor 独立前进;慢消费者被覆盖的数据会累计到 Cursor::dropped_count。
// Cursor 是单线程所有权对象,不可由多个线程同时读写;每个消费者应创建自己的 Cursor。
template<typename T>
class BroadcastFrameRing {
public:
using ValuePtr = std::shared_ptr<const T>;
enum class StartPosition {
NEXT_PUBLISHED,
OLDEST_AVAILABLE,
LATEST_AVAILABLE
};
struct Cursor {
uint64_t generation{0};
uint64_t next_sequence{0};
uint64_t dropped_count{0};
StartPosition start_position{StartPosition::NEXT_PUBLISHED};
private:
bool generation_changed{false};
uint64_t reported_dropped_count{0};
friend class BroadcastFrameRing<T>;
};
struct ReadResult {
ValuePtr value;
uint64_t generation{0};
uint64_t sequence{0};
uint64_t dropped_count{0};
uint64_t dropped_since_last_read{0};
bool generation_changed{false};
};
struct Stats {
size_t capacity{0};
size_t size{0};
uint64_t generation{0};
uint64_t next_sequence{0};
uint64_t dropped_count{0};
bool closed{false};
};
explicit BroadcastFrameRing(const size_t capacity)
: capacity_(capacity) {
if (capacity_ == 0) {
throw std::invalid_argument("BroadcastFrameRing capacity must be greater than zero");
}
}
BroadcastFrameRing(const BroadcastFrameRing&) = delete;
BroadcastFrameRing& operator=(const BroadcastFrameRing&) = delete;
Cursor makeCursor(const StartPosition start_position = StartPosition::NEXT_PUBLISHED) const {
std::lock_guard<std::mutex> lock(mutex_);
Cursor cursor;
cursor.generation = generation_;
cursor.start_position = start_position;
cursor.next_sequence = startSequenceLocked_(start_position);
return cursor;
}
std::optional<uint64_t> publish(ValuePtr value) {
if (!value) {
return std::nullopt;
}
std::optional<uint64_t> published_sequence;
{
std::lock_guard<std::mutex> lock(mutex_);
if (closed_) {
return std::nullopt;
}
const uint64_t sequence = next_sequence_++;
if (entries_.size() == capacity_) {
entries_.pop_front();
++dropped_count_;
}
entries_.push_back(Entry{generation_, sequence, std::move(value)});
published_sequence = sequence;
}
condition_.notify_all();
return published_sequence;
}
std::optional<ReadResult> tryRead(Cursor& cursor) const {
std::lock_guard<std::mutex> lock(mutex_);
return tryReadLocked_(cursor);
}
template<class Rep, class Period>
std::optional<ReadResult> waitRead(
Cursor& cursor,
const std::chrono::duration<Rep, Period>& timeout) const {
const auto deadline = std::chrono::steady_clock::now() + timeout;
std::unique_lock<std::mutex> lock(mutex_);
while (true) {
if (auto result = tryReadLocked_(cursor)) {
return result;
}
if (closed_) {
return std::nullopt;
}
if (condition_.wait_until(lock, deadline) == std::cv_status::timeout) {
return tryReadLocked_(cursor);
}
}
}
// 开始一个新的发布代次。旧 Cursor 会在下一次成功读取时收到
// generation_changed=true序号从 0 重新开始。
uint64_t reset() {
uint64_t generation = 0;
{
std::lock_guard<std::mutex> lock(mutex_);
entries_.clear();
++generation_;
next_sequence_ = 0;
closed_ = false;
generation = generation_;
}
condition_.notify_all();
return generation;
}
void close() {
{
std::lock_guard<std::mutex> lock(mutex_);
closed_ = true;
}
condition_.notify_all();
}
bool closed() const {
std::lock_guard<std::mutex> lock(mutex_);
return closed_;
}
Stats stats() const {
std::lock_guard<std::mutex> lock(mutex_);
return Stats{capacity_, entries_.size(), generation_, next_sequence_, dropped_count_, closed_};
}
private:
struct Entry {
uint64_t generation;
uint64_t sequence;
ValuePtr value;
};
uint64_t startSequenceLocked_(const StartPosition start_position) const {
if (entries_.empty()) {
return next_sequence_;
}
switch (start_position) {
case StartPosition::OLDEST_AVAILABLE:
return entries_.front().sequence;
case StartPosition::LATEST_AVAILABLE:
return entries_.back().sequence;
case StartPosition::NEXT_PUBLISHED:
default:
return next_sequence_;
}
}
void synchronizeCursorGenerationLocked_(Cursor& cursor) const {
if (cursor.generation == generation_) {
return;
}
cursor.generation = generation_;
cursor.next_sequence = startSequenceLocked_(cursor.start_position);
cursor.generation_changed = true;
}
std::optional<ReadResult> tryReadLocked_(Cursor& cursor) const {
synchronizeCursorGenerationLocked_(cursor);
if (entries_.empty()) {
return std::nullopt;
}
const uint64_t oldest_sequence = entries_.front().sequence;
if (cursor.next_sequence < oldest_sequence) {
cursor.dropped_count += oldest_sequence - cursor.next_sequence;
cursor.next_sequence = oldest_sequence;
}
if (cursor.next_sequence >= next_sequence_) {
return std::nullopt;
}
const size_t index = static_cast<size_t>(cursor.next_sequence - oldest_sequence);
if (index >= entries_.size()) {
return std::nullopt;
}
const Entry& entry = entries_[index];
++cursor.next_sequence;
const uint64_t dropped_since_last = cursor.dropped_count - cursor.reported_dropped_count;
cursor.reported_dropped_count = cursor.dropped_count;
ReadResult result;
result.value = entry.value;
result.generation = entry.generation;
result.sequence = entry.sequence;
result.dropped_count = cursor.dropped_count;
result.dropped_since_last_read = dropped_since_last;
result.generation_changed = cursor.generation_changed;
cursor.generation_changed = false;
return result;
}
const size_t capacity_;
mutable std::mutex mutex_;
mutable std::condition_variable condition_;
std::deque<Entry> entries_;
uint64_t generation_{1};
uint64_t next_sequence_{0};
uint64_t dropped_count_{0};
bool closed_{false};
}; };

View File

@ -0,0 +1,236 @@
#ifndef CMVR_ES_COMMON_MEDIA_MEDIA_FRAME_H
#define CMVR_ES_COMMON_MEDIA_MEDIA_FRAME_H
#pragma once
#include <cstddef>
#include <cstdint>
#include <memory>
#include <stdexcept>
#include <string>
#include <utility>
#include <vector>
namespace cmvr::media {
enum class MediaKind : uint8_t {
UNKNOWN = 0,
VIDEO = 1,
AUDIO = 2
};
enum class Codec : uint8_t {
UNKNOWN = 0,
H264 = 1,
H265 = 2,
OPUS = 3,
PCM_S16LE = 4,
AAC = 5
};
enum class PayloadFormat : uint8_t {
UNKNOWN = 0,
ANNEX_B = 1,
AVCC = 2,
RAW = 3,
OPUS_PACKET = 4,
AAC_ADTS = 5
};
struct Rational {
int32_t numerator{0};
int32_t denominator{1};
constexpr bool valid() const noexcept {
return numerator > 0 && denominator > 0;
}
};
constexpr bool operator==(const Rational lhs, const Rational rhs) noexcept {
return lhs.numerator == rhs.numerator && lhs.denominator == rhs.denominator;
}
constexpr bool operator!=(const Rational lhs, const Rational rhs) noexcept {
return !(lhs == rhs);
}
// A TrackDescriptor is immutable after construction. Reconfiguration is represented by
// publishing a new descriptor with a larger generation and attaching it to later frames.
class TrackDescriptor final {
public:
struct Config {
std::string id;
std::string source_id;
MediaKind kind{MediaKind::UNKNOWN};
Codec codec{Codec::UNKNOWN};
PayloadFormat payload_format{PayloadFormat::UNKNOWN};
Rational time_base{};
uint32_t width{0};
uint32_t height{0};
uint32_t sample_rate{0};
uint32_t channels{0};
uint32_t nominal_rate{0};
float fx{0.0F};
float fy{0.0F};
float cx{0.0F};
float cy{0.0F};
std::vector<float> distortion;
uint64_t generation{1};
std::vector<uint8_t> codec_config;
};
explicit TrackDescriptor(Config config)
: id(std::move(config.id)),
source_id(std::move(config.source_id)),
kind(config.kind),
codec(config.codec),
payload_format(config.payload_format),
time_base(config.time_base),
width(config.width),
height(config.height),
sample_rate(config.sample_rate),
channels(config.channels),
nominal_rate(config.nominal_rate),
fx(config.fx),
fy(config.fy),
cx(config.cx),
cy(config.cy),
distortion(std::move(config.distortion)),
generation(config.generation),
codec_config(std::move(config.codec_config)) {
if (id.empty()) {
throw std::invalid_argument("TrackDescriptor id must not be empty");
}
if (source_id.empty()) {
throw std::invalid_argument("TrackDescriptor source_id must not be empty");
}
if (kind == MediaKind::UNKNOWN) {
throw std::invalid_argument("TrackDescriptor kind must not be UNKNOWN");
}
if (!time_base.valid()) {
throw std::invalid_argument("TrackDescriptor time_base is invalid");
}
if (generation == 0) {
throw std::invalid_argument("TrackDescriptor generation must be greater than zero");
}
}
const std::string id;
const std::string source_id;
const MediaKind kind;
const Codec codec;
const PayloadFormat payload_format;
const Rational time_base;
const uint32_t width;
const uint32_t height;
const uint32_t sample_rate;
const uint32_t channels;
const uint32_t nominal_rate;
const float fx;
const float fy;
const float cx;
const float cy;
const std::vector<float> distortion;
const uint64_t generation;
const std::vector<uint8_t> codec_config;
};
using TrackDescriptorPtr = std::shared_ptr<const TrackDescriptor>;
inline bool equivalentTrackDescriptor(
const TrackDescriptor& lhs,
const TrackDescriptor& rhs) noexcept {
return lhs.id == rhs.id &&
lhs.source_id == rhs.source_id &&
lhs.kind == rhs.kind &&
lhs.codec == rhs.codec &&
lhs.payload_format == rhs.payload_format &&
lhs.time_base == rhs.time_base &&
lhs.width == rhs.width &&
lhs.height == rhs.height &&
lhs.sample_rate == rhs.sample_rate &&
lhs.channels == rhs.channels &&
lhs.nominal_rate == rhs.nominal_rate &&
lhs.fx == rhs.fx &&
lhs.fy == rhs.fy &&
lhs.cx == rhs.cx &&
lhs.cy == rhs.cy &&
lhs.distortion == rhs.distortion &&
lhs.generation == rhs.generation &&
lhs.codec_config == rhs.codec_config;
}
// MediaFrame and its payload are immutable and therefore safe to share across all protocol
// adapters and consumers without copying. PTS/DTS use TrackDescriptor::time_base;
// capture_time_ns is monotonic for pacing, while capture_utc_ns is optional wall time.
class MediaFrame final {
public:
using Payload = std::vector<uint8_t>;
using PayloadPtr = std::shared_ptr<const Payload>;
struct Config {
TrackDescriptorPtr descriptor;
Payload payload;
uint64_t sequence{0};
int64_t pts{0};
int64_t dts{0};
int64_t duration{0};
uint64_t capture_time_ns{0};
int64_t capture_utc_ns{0};
bool key_frame{false};
bool discontinuity{false};
};
explicit MediaFrame(Config config)
: descriptor(std::move(config.descriptor)),
payload(std::make_shared<const Payload>(std::move(config.payload))),
sequence(config.sequence),
pts(config.pts),
dts(config.dts),
duration(config.duration),
capture_time_ns(config.capture_time_ns),
capture_utc_ns(config.capture_utc_ns),
key_frame(config.key_frame),
discontinuity(config.discontinuity) {
if (!descriptor) {
throw std::invalid_argument("MediaFrame descriptor must not be null");
}
}
const uint8_t* data() const noexcept {
return payload->empty() ? nullptr : payload->data();
}
size_t size() const noexcept {
return payload->size();
}
bool empty() const noexcept {
return payload->empty();
}
const TrackDescriptorPtr descriptor;
const PayloadPtr payload;
const uint64_t sequence;
const int64_t pts;
const int64_t dts;
const int64_t duration;
const uint64_t capture_time_ns;
const int64_t capture_utc_ns;
const bool key_frame;
const bool discontinuity;
};
using MediaFramePtr = std::shared_ptr<const MediaFrame>;
inline TrackDescriptorPtr makeTrackDescriptor(TrackDescriptor::Config config) {
return std::make_shared<const TrackDescriptor>(std::move(config));
}
inline MediaFramePtr makeMediaFrame(MediaFrame::Config config) {
return std::make_shared<const MediaFrame>(std::move(config));
}
} // namespace cmvr::media
#endif // CMVR_ES_COMMON_MEDIA_MEDIA_FRAME_H

112
cmvr-es/config/README.md Normal file
View File

@ -0,0 +1,112 @@
# Config 模块开发指南
`config/` 保存 CMVR-ES 的默认运行配置。配置格式是 Protobuf TextFormatSchema 位于 [`../../protos/cmvr/config/`](../../protos/cmvr/config/)。
返回[项目总览](../../README.md)。
## 配置树
```text
cmvr_es.pb.txt
├── logger/logger.pb.txt
├── manager/device_manager.pb.txt
│ └── devices/<category>/*.pb.txt
└── manager/task_manager.pb.txt
└── tasks/<task>/*.pb.txt
```
入口文件:
- [`cmvr_es.pb.txt`](cmvr_es.pb.txt)
- [`manager/device_manager.pb.txt`](manager/device_manager.pb.txt)
- [`manager/task_manager.pb.txt`](manager/task_manager.pb.txt)
## 路径规则
无参数运行时,程序读取:
```text
<cmvr_es 可执行文件所在目录>/config/cmvr_es.pb.txt
```
安装后的 `output/bin/cmvr_es` 因此会读取 `output/bin/config/cmvr_es.pb.txt`;直接运行 `build/cmvr_es` 则会查找 `build/config/cmvr_es.pb.txt`,不会自动跳到安装目录。传入显式根配置时:
```bash
./output/bin/cmvr_es /etc/cmvr-es/cmvr_es.pb.txt
```
设备、任务和证书等相对配置路径均以根配置文件所在目录解析。模型等资源通过 `ConfigHelper::resolveResourceFile()` 在配置根及父目录中查找;生产部署仍建议使用明确绝对路径。
日志配置中的相对 `directory` 以可执行文件目录解析,不以配置根解析。
## 新增设备配置
增加同类设备后端时:
1. 在 `protos/cmvr/config/<category>_config/` 增加后端 message
2. 在类别设备 message 的 `oneof backend` 中增加字段;
3. 在 `devices/<category>/``.pb.txt` 中增加实例;
4. 实例外层 `id` 必须唯一;
5. 在 [`manager/device_manager.pb.txt`](manager/device_manager.pb.txt) 增加相同 `id`、正确 `type` 和配置路径;
6. 开发默认保持 `enable: false`
7. 同步类别 factory 和 CMake
8. 在无硬件环境验证关闭状态,在真机环境单独开启。
设备集合中的 ID 与 DeviceManager 条目 ID 不一致时,工厂会拒绝创建。
## 新增任务配置
1. 在 `protos/cmvr/config/` 增加任务配置和 root message
2. 在 `tasks/<task_name>/` 增加默认 `.pb.txt`
3. 在 `task_manager.pb.txt` 增加唯一任务 ID
4. 配置正确的 `TaskType``TaskRunMode`
5. 周期任务设置大于零的 `control_period_s`
6. 服务任务使用 `TASK_RUN_MODE_BLOCKING_SERVICE`
7. 默认关闭依赖网络、证书或硬件的新任务。
任务实现流程见 [`../task/README.md`](../task/README.md)。
## 默认值与校验
- 不依赖 proto3 数值零值表达危险的生产默认值;
- timeout、队列大小、帧大小和周期应在代码中校验
- 新增 loader 对不认识的 enum 和未设置的 oneof 必须明确失败;当前个别历史路径仍有退化默认行为,不应复制;
- 设备端口、坐标系、速度和单位写入注释;
- `enable` 应由 manager 层控制,后端内部的 enable 字段不能替代 manager 开关;
- QUIC 需要 TaskManager 与 `QuicEdgeConfig.enable` 同时开启;
- QUIC 零媒体轨道是合法配置。
## 配置验证
构建后可以用 `protoc --encode` 对单个 TextFormat 文件做语法和字段验证。例如:
```bash
output/bin/protoc \
-I protos \
--encode=cmvr.config.QuicEdgeRootConfig \
protos/cmvr/config/quic_edge_config/quic_edge_config.proto \
< cmvr-es/config/tasks/quic_edge_task/quic_edge_task.pb.txt \
> /tmp/quic_edge_config.pb
```
该命令只验证 Proto Text 解析,不验证文件、设备、证书、网络和跨字段语义。最终仍需运行组件测试和进程烟雾测试。
## 生产配置
`cmake --install` 会重建 `output/bin/config/`。生产配置应复制到 `/etc/cmvr-es/` 等外部目录并显式传入。
- 不提交真实设备密码、token、私钥和生产地址
- 证书与私钥放在独立 `certs/`,使用最小读取权限;
- 为不同站点维护独立配置根,不在运行时修改仓库样例;
- 发布前检查所有 `enable`、IP、端口和设备 ID
- 变更配置 Schema 时同步 Proto 兼容性文档和平台生成代码。
## 提交检查
- [ ] TextFormat 可以被对应 root message 解析
- [ ] ID、类别和引用路径完全一致
- [ ] 新硬件和新网络任务默认关闭
- [ ] 参数单位、范围和安全默认值明确
- [ ] 没有生产凭据
- [ ] 安装覆盖不会丢失现场配置
- [ ] 无设备启动仍然成功

View File

@ -70,26 +70,30 @@ device_manager {
id: "hikvision_cam" id: "hikvision_cam"
type: DEVICE_TYPE_CAMERA type: DEVICE_TYPE_CAMERA
config_file: "devices/camera/camera.pb.txt" config_file: "devices/camera/camera.pb.txt"
enable: true # Host-development default: keep physical cameras disabled.
enable: false
} }
devices { devices {
id: "hikvision_thermal_cam" id: "hikvision_thermal_cam"
type: DEVICE_TYPE_CAMERA type: DEVICE_TYPE_CAMERA
config_file: "devices/camera/camera.pb.txt" config_file: "devices/camera/camera.pb.txt"
enable: true # Host-development default: keep physical cameras disabled.
enable: false
} }
devices { devices {
id: "mic1" id: "mic1"
type: DEVICE_TYPE_MICROPHONE type: DEVICE_TYPE_MICROPHONE
config_file: "devices/microphone/microphone.pb.txt" config_file: "devices/microphone/microphone.pb.txt"
enable: true # Host-development default: keep physical audio devices disabled.
enable: false
} }
devices { devices {
id: "spk1" id: "spk1"
type: DEVICE_TYPE_SPEAKER type: DEVICE_TYPE_SPEAKER
config_file: "devices/speaker/speaker.pb.txt" config_file: "devices/speaker/speaker.pb.txt"
enable: true # Host-development default: keep physical audio devices disabled.
enable: false
} }
} }

View File

@ -14,4 +14,12 @@ task_manager {
config_file: "tasks/grpc_server_task/grpc_server_task.pb.txt" config_file: "tasks/grpc_server_task/grpc_server_task.pb.txt"
enable: true enable: true
} }
tasks {
id: "quic_edge"
type: TASK_TYPE_QUIC_EDGE
run_mode: TASK_RUN_MODE_BLOCKING_SERVICE
config_file: "tasks/quic_edge_task/quic_edge_task.pb.txt"
# Host-development default: no MsQuic package or physical media devices.
enable: false
}
} }

View File

@ -0,0 +1,65 @@
quic_edge {
id: "quic_edge"
# The repository does not currently ship MsQuic. Keep this disabled until
# the MsQuic dependency is installed and its backend is enabled at build time.
enable: false
server_host: "quic-gateway.example.com"
server_port: 4433
alpn: "cmvr-quic-edge/1"
node_id: "cmvr-edge"
software_version: "0.1"
# The existing cmvr-es gRPC server remains the robot-control endpoint. "auto"
# selects a usable address from the interface snapshot sent at registration
# and on every heartbeat.
grpc_endpoint_host: "auto"
grpc_endpoint_port: 50052
grpc_endpoint_tls: false
include_loopback_interfaces: false
heartbeat_interval_ms: 5000
control_response_timeout_ms: 1000
tls {
ca_file: "certs/quic_gateway_ca.pem"
certificate_file: "certs/cmvr_edge_cert.pem"
private_key_file: "certs/cmvr_edge_key.pem"
server_name: "quic-gateway.example.com"
allow_insecure: false
}
reconnect {
initial_delay_ms: 500
maximum_delay_ms: 30000
multiplier: 2.0
jitter_percent: 20
connect_timeout_ms: 5000
}
maximum_datagram_bytes: 1200
maximum_control_frame_bytes: 1048576
# With 1200-byte DATAGRAMs and a 512-entry queue, 524288 stays below
# the atomic batch capacity while reserving slots for control messages.
maximum_frame_bytes: 524288
datagram_send_queue_depth: 512
media_poll_interval_ms: 2
# Zero media tracks is valid and keeps registration, IP reporting and
# heartbeat active. Add tracks only for devices enabled in DeviceManager.
# tracks {
# track_id: 1
# source_kind: SOURCE_KIND_CAMERA
# device_id: "right_hand_cam"
# source_track_id: "right_hand_cam/video/color"
# enable: true
# }
# tracks {
# track_id: 2
# source_kind: SOURCE_KIND_MICROPHONE
# device_id: "mic1"
# source_track_id: "mic1/audio/main"
# enable: true
# }
}

355
cmvr-es/devices/README.md Normal file
View File

@ -0,0 +1,355 @@
# Devices 模块开发指南
`devices/` 屏蔽厂商 SDK、通信总线和硬件型号差异对 manager、service、task 和算法层提供稳定的设备能力接口。
仓库真实目录名是 `cmvr-es/devices/`。设备层使用复数 `devices`,不是 `cmvr_es/device`
返回[项目总览](../../README.md)。
## 创建链路
```text
config/cmvr_es.pb.txt
-> config/manager/device_manager.pb.txt
-> DeviceManager
-> DeviceFactory
按 DeviceConfigEntry::DeviceType 选择设备大类
-> CameraFactory / AGVFactory / RobotArmFactory / ...
按设备配置 oneof backend 选择厂商实现
-> 具体设备类
-> AbstractDevice 和设备大类抽象接口
```
关键文件:
- [`abstract_device.h`](abstract_device.h)
- [`device_types.h`](device_types.h)
- [`../manager/device_manager/include/device_manager.h`](../manager/device_manager/include/device_manager.h)
- [`../manager/device_manager/src/device_factory.cpp`](../manager/device_manager/src/device_factory.cpp)
- [`../../protos/cmvr/config/device_manager_config/device_manager_config.proto`](../../protos/cmvr/config/device_manager_config/device_manager_config.proto)
- [`../config/manager/device_manager.pb.txt`](../config/manager/device_manager.pb.txt)
协议层和业务任务不应直接依赖厂商 SDK 类型。厂商错误码、报文和连接细节由具体后端转换为抽象接口的通用语义。
## 当前可由配置创建的设备
| 大类 | 抽象接口 | 类别工厂 | 当前可选后端 |
| --- | --- | --- | --- |
| Camera | [`camera/abstract_camera.h`](camera/abstract_camera.h) | [`camera/camera_factory.h`](camera/camera_factory.h) | UVC、RealSense、Hikvision |
| AGV | [`agv/abstract_agv.h`](agv/abstract_agv.h) | [`agv/agv_factory.h`](agv/agv_factory.h) | MyAgv、SRC1100 |
| RobotArm | [`arm/robot_arm.h`](arm/robot_arm.h) | [`arm/robot_arm_factory.h`](arm/robot_arm_factory.h) | MotorRobotArm、AUBO、Huayan |
| DexHand | [`dexhand/abstract_dexhand.h`](dexhand/abstract_dexhand.h) | [`dexhand/dexhand_factory.h`](dexhand/dexhand_factory.h) | RH56DFTP、PX6AXGen3 |
| Microphone | [`microphone/abstract_microphone.h`](microphone/abstract_microphone.h) | [`microphone/microphone_factory.h`](microphone/microphone_factory.h) | FFmpeg |
| Speaker | [`speaker/abstract_speaker.h`](speaker/abstract_speaker.h) | [`speaker/speaker_factory.h`](speaker/speaker_factory.h) | FFmpeg |
| BioHead | [`biohead/abstract_biohead.h`](biohead/abstract_biohead.h) | DeviceFactory 直接创建 | BioHeadRobot |
| MotorSystem | `motor/motor_system/` | DeviceFactory 直接创建 | CAN/MuJoCo motor group |
代码目录存在不等于已经接入配置创建链:
- MechMind Proto 和实现仍存在,但当前 CameraFactory 明确拒绝创建;
- MujocoCamera 有实现并参与部分构建,但当前没有 CameraFactory 分支;
- Battery、Gripper、Robot、CanBus 等抽象或实现不一定已注册到 DeviceFactory
- 所有已注册设备共用一个全局 ID 命名空间。
新增能力前先确认“已有代码”“可被 CMake 构建”“可被 Factory 创建”“可被 DeviceManager 配置启用”四个状态,不要混为一谈。
## 新增同类厂商后端
以新增 Camera 后端为例。
### 1. 扩展配置 Proto
修改:
```text
protos/cmvr/config/camera_config/camera_config.proto
```
新增厂商 config并加入 `CameraDeviceConfig.oneof backend`。只使用新的字段 tag不复用 reserved 或已发布 tag。
### 2. 新建后端目录
```text
camera/vendor_camera/
├── CMakeLists.txt
├── include/
│ └── vendor_camera.h
├── src/
│ └── vendor_camera.cpp
└── tests/
└── vendor_camera_test.cpp
```
### 3. 实现抽象接口
至少实现:
- `typeName()`
- `init()`
- 实际支持的 `start()` / `stop()`
- 状态查询和该类别核心能力;
- 若支持实时媒体,完整实现流接口和并发停止。
不要为了厂商特例向抽象类加入 SDK handle、私有报文或厂商专有结构。只有多个后端都需要的稳定语义才进入抽象接口或 `common/types/`
### 4. 类别工厂注册
在 [`camera/camera_factory.h`](camera/camera_factory.h) 的 `backend_case()` 增加创建分支。
AGV、DexHand、Microphone、Speaker 等遵循相同模式。新增同类后端通常不需要修改全局 DeviceFactory。
### 5. CMake 聚合
1. 在 `camera/CMakeLists.txt` 增加 `add_subdirectory(vendor_camera)`
2. 让类别 target 链接新后端 target
3. 安装需要随应用分发的共享库;
4. 厂商 SDK 路径使用 `dependency/${ARCH}/third_party/...`,不能硬编码 x86
5. 需要特殊 RPATH 时参考 Hikvision、Huayan 等现有实现。
推荐 target 形式:
```cmake
add_library(vendor_camera SHARED
src/vendor_camera.cpp
)
target_include_directories(vendor_camera
PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}
)
target_link_libraries(vendor_camera
PUBLIC cmvr_es::proto
PRIVATE vendor_sdk
)
add_library(cmvr_es::device::vendor_camera ALIAS vendor_camera)
install(TARGETS vendor_camera LIBRARY DESTINATION lib)
```
### 6. 默认配置
在类别集合配置增加实例,并在 DeviceManager 配置增加同 ID 条目:
```protobuf
devices {
id: "camera_front"
type: DEVICE_TYPE_CAMERA
config_file: "devices/camera/camera.pb.txt"
enable: false
}
```
新硬件默认 `enable: false`,确保无设备开发主机仍可启动。
## 新增全新设备大类
当现有抽象无法表达新设备能力时,还需要:
1. 在 [`device_types.h`](device_types.h) 增加 `DeviceKind``toString()`
2. 新增类别抽象接口;
3. 将稳定公共数据放入 `../common/types/<category>/`
4. 新增配置 Proto
5. 在 `DeviceConfigEntry::DeviceType` 使用新的 enum 数值;
6. 在 `DeviceFactory::DeviceFactory()` 注册 creator
7. 更新 DeviceManager 的 type-to-string 日志映射;
8. 为 `getDevice<AbstractNewDevice>()` 增加显式模板实例化;
9. 更新 `devices/CMakeLists.txt`、类别 CMake 和 device_manager target 链接;
10. 若平台需要访问,独立增加 API Proto 和 gRPC service。
不得复用 `device_manager_config.proto` 中已 reserved 的 enum 数值或名称。新增设备类别不会自动生成平台 RPC。
## 配置和 ID
集合类型设备必须满足:
```text
DeviceConfigEntry.id
==
CameraDeviceConfig / AGVDeviceConfig / ... 的外层 id
```
厂商 backend 内部 ID 可以为空,由类别工厂补齐;若填写,也必须与外层 ID 相同。
其他约束:
- 相对配置路径以根 `cmvr_es.pb.txt` 所在目录解析;
- 生产密码、token 和证书不得提交到样例配置;
- 单个设备 init 失败时会被跳过,进程仍可能继续启动;
- 有初始化依赖的设备按配置顺序排列,例如 MotorSystem 在依赖它的 RobotArm 前;
- DeviceManager stop 遍历 unordered_map不能依赖跨设备停止顺序
- 当前不支持 service 运行期间并发热插拔设备集合。
配置细节见 [`../config/README.md`](../config/README.md)。
## 生命周期约定
| 接口 | 当前语义 |
| --- | --- |
| 构造函数 | 保存配置和轻量校验,不启动长期工作线程 |
| `init()` | DeviceManager 构造期间对启用设备调用 |
| `start()` | 当前 main 不统一调用,由 RPC、媒体或其他 owner 显式触发 |
| `startStreaming()` | 获取一个实时流生产租约 |
| `stopStreaming()` | 释放租约,最后一份租约停止生产者 |
| `stop()` | 完整停止设备,必须幂等 |
| `update()` | 当前没有统一 scheduler 自动调用 |
| 析构函数 | 回收线程、SDK callback、socket、fd 和 handle |
实现要求:
- init/start/stop 的重复调用有明确结果;
- stop 和最后一次 stopStreaming 返回前停止所有发布;
- 不持有 worker 退出所需的锁执行 join
- SDK callback 不获取停止路径长期持有的控制锁;
- getState 使用与状态写入相同的锁;
- 含 `std::string`、vector 等状态不能无锁复制;
- 析构函数调用安全停止路径;
- callback 捕获对象前保证 owner 生命周期。
并发停止可参考:
- [`camera/uvc_camera/`](camera/uvc_camera/)
- [`camera/hikvision_camera/`](camera/hikvision_camera/)
- [`camera/hikvision_camera/tests/`](camera/hikvision_camera/tests/)
当前 main 收到退出信号只停止 TaskManager没有显式调用 DeviceManager stop设备析构仍必须可靠。
## 摄像头与麦克风实时流
设备实现抽象流接口后,由 [`../manager/media_source_hub/`](../manager/media_source_hub/) 适配给 gRPC 和 QUIC不应在设备后端实现两套协议代码。
当前 Hub 轨道:
```text
<camera-id>/video/color
<microphone-id>/audio/main
```
当前 adapter 只把 `StreamFrameData.rgbFrame` 注册为彩色视频轨道depthFrame 尚未注册为 Hub 深度轨道。
### Camera 完整编码帧
每个 access unit 应正确填写:
- `rgbFrame`
- `codec`H.264 或 H.265
- `width`、`height`、`fps`
- `bKey`
- `stream_epoch`
- `sequence`
- `capture_monotonic_ns`
- `capture_utc_ns`
- `pts`、`dts`
- `time_base_num`、`time_base_den`
- `duration`
- `discontinuity`
- `codec_config_generation`
- `codec_config`
H.264/H.265 后端必须识别关键帧,并尽量实现 `requestKeyFrame()`
### Microphone 完整音频包
应正确填写:
- `data`
- `sample_rate`
- `channels`
- `format``codec`
- `nb_samples`
- sequence、时间戳、time base、duration
- stream epoch、discontinuity 和 codec generation
### 生产者重启
采集/编码生产者真正停止并重新启动时:
1. 增加 `stream_epoch`
2. 将 source sequence 重置为 0
3. 增加 `codec_config_generation`
4. 确保后续帧携带完整的新编码元数据;
5. 将第一帧标记为 discontinuity
6. 视频从关键帧恢复输出。
设备后端不直接发布 `TrackDescriptor`。`device_media_source_adapter.cpp` 会根据帧元数据生成或更新 descriptor。
### 运行期编码配置变化
编码器没有重启、只在同一 stream epoch 内改变分辨率、codec config 等参数时:
1. 保持 `stream_epoch` 不变;
2. 保持 sequence 连续递增;
3. 增加 `codec_config_generation`,或让其他描述字段反映变化;
4. 在后续帧中携带新元数据;
5. 标记 discontinuity
6. H.264/H.265 从新的关键帧恢复。
adapter 检测到描述变化后创建新 descriptor设备后端不要自行维护协议侧 descriptor 状态。
## 设备侧环形队列安全
现有设备后端多使用 `SPMCRingBuffer<T>`
- 只允许一个逻辑生产者;
- 每个消费者独立持有读游标;
- 同一读游标不能跨线程并发访问;
- 不对同一路读取混用无参 `pop()` 和带游标 `pop(index)`
- 需要最新帧时使用 `getLatest(index)`
- 不要先取 head 再分两步读取,避免检查/读取竞态;
- 满队列覆盖旧数据是实时媒体的预期行为;
- `waitEncodedFrame()` 必须有有限 timeout不能永久阻塞。
MediaSourceHub Subscription 同样是单消费者对象,不同协议或客户端必须各自订阅。
发布后的 `MediaFrame`、`TrackDescriptor` 和 payload 不可再修改。
## 测试要求
至少覆盖:
- 配置缺失、非法参数和 ID 不一致;
- Factory 选择正确后端;
- init/start/stop 重复执行;
- init 失败后无残留线程和句柄;
- stop 与 SDK callback 并发;
- 最后一个流租约释放后不再发布;
- 多消费者使用独立游标;
- 环形队列覆盖和丢帧;
- epoch、sequence、关键帧和 codec generation
- 设备断开、超时和重连。
无硬件参考测试:
- [`camera/hikvision_camera/tests/hikvision_camera_callback_test.cpp`](camera/hikvision_camera/tests/hikvision_camera_callback_test.cpp)
- [`../manager/media_source_hub/tests/media_source_hub_test.cpp`](../manager/media_source_hub/tests/media_source_hub_test.cpp)
```bash
cmake -S . -B build \
-DCMVR_ARCH=x86 \
-DBUILD_TESTING=ON \
-DCMVR_MEDIA_SOURCE_HUB_BUILD_TESTS=ON
cmake --build build -j"$(nproc)"
ctest \
--test-dir build \
-R 'hikvision_camera_callback_test|media_source_hub_test' \
--output-on-failure
```
新测试必须在 `BUILD_TESTING` 下使用 `add_test()` 登记。只创建 executable 不会自动被 CTest 执行。
## 提交检查
- [ ] 抽象接口没有厂商 SDK 类型
- [ ] manager、外层配置和 backend ID 一致
- [ ] 新硬件配置默认关闭
- [ ] 生命周期支持重复调用
- [ ] stop 是同步发布屏障
- [ ] 状态读写使用同一把锁
- [ ] 每个实时流只有一个生产者
- [ ] 每个消费者使用独立游标
- [ ] 时间戳、sequence 和编码元数据完整
- [ ] Factory 和类别 CMake 均已接入
- [ ] 厂商运行库有安装规则
- [ ] 有无真实硬件的自动测试
- [ ] 平台能力变化已评估 Proto 和 service

View File

@ -2,6 +2,11 @@
#define CMVR_ES_ABSTRACT_CAMERA_H #define CMVR_ES_ABSTRACT_CAMERA_H
#pragma once #pragma once
#include <cstdint>
#include <chrono>
#include <string>
#include <vector>
#include <opencv2/opencv.hpp> #include <opencv2/opencv.hpp>
#include "../abstract_device.h" #include "../abstract_device.h"
#include <Eigen/Core> #include <Eigen/Core>
@ -29,12 +34,27 @@ namespace cmvr::device {
std::vector<uint8_t> depthFrame; std::vector<uint8_t> depthFrame;
//编码格式 //编码格式
std::string codec = ".h264"; std::string codec = ".h264";
Rs2Intrinsics intrinsics; Rs2Intrinsics intrinsics{};
int width; int width = 0;
int height; int height = 0;
int fps; int fps = 0;
bool bKey; bool bKey = false;
bool depthKey; bool depthKey = false;
// Protocol-neutral real-time metadata. The producer fills these values
// when a complete encoded access unit is published.
uint64_t stream_epoch = 0;
uint64_t sequence = 0;
int64_t capture_monotonic_ns = 0;
int64_t capture_utc_ns = 0;
int64_t pts = 0;
int64_t dts = 0;
int32_t time_base_num = 1;
int32_t time_base_den = 1;
int64_t duration = 0;
bool discontinuity = false;
uint32_t codec_config_generation = 0;
std::vector<uint8_t> codec_config;
}; };
enum class PtzCommand { enum class PtzCommand {
@ -64,7 +84,10 @@ namespace cmvr::device {
~AbstractCamera() override = default; ~AbstractCamera() override = default;
DeviceKind kind() const noexcept override { return DeviceKind::Camera; } DeviceKind kind() const noexcept override { return DeviceKind::Camera; }
inline void getState(CameraState &state) {state = state_;} // Every implementation must take the same lock used by its state_
// writers; CameraState contains std::string and cannot be snapshotted
// safely while another thread mutates it.
virtual void getState(CameraState &state) = 0;
virtual void getRGBImage(cv::Mat &color, Rs2Intrinsics& intrinsics) {} virtual void getRGBImage(cv::Mat &color, Rs2Intrinsics& intrinsics) {}
virtual void getDepthImage(cv::Mat &depth, Rs2Intrinsics& intrinsics) {} virtual void getDepthImage(cv::Mat &depth, Rs2Intrinsics& intrinsics) {}
virtual void getRGBDImages(cv::Mat &color, cv::Mat &depth, Rs2Intrinsics& intrinsics) {} virtual void getRGBDImages(cv::Mat &color, cv::Mat &depth, Rs2Intrinsics& intrinsics) {}
@ -73,6 +96,14 @@ namespace cmvr::device {
virtual void pauseRecording() {} virtual void pauseRecording() {}
virtual void resumeRecording() {} virtual void resumeRecording() {}
virtual void getEncodedFrame(StreamFrameData& frame_data, size_t& index) {} virtual void getEncodedFrame(StreamFrameData& frame_data, size_t& index) {}
virtual bool waitEncodedFrame(
StreamFrameData& frame_data,
size_t& index,
std::chrono::milliseconds timeout) {
(void)timeout;
getEncodedFrame(frame_data, index);
return !frame_data.rgbFrame.empty() || !frame_data.depthFrame.empty();
}
virtual bool getLatestEncodedFrame(StreamFrameData& frame_data, size_t& next_index) { virtual bool getLatestEncodedFrame(StreamFrameData& frame_data, size_t& next_index) {
return false; return false;
} }
@ -80,10 +111,12 @@ namespace cmvr::device {
virtual bool startStreaming() {return true;} virtual bool startStreaming() {return true;}
virtual void stopStreaming() {} virtual void stopStreaming() {}
virtual bool controlPtz(PtzCommand command, bool stop, int speed) { virtual bool controlPtz(PtzCommand command, bool stop, int speed) {
state_.is_error = true; (void)command;
state_.error_message = "PTZ control unsupported"; (void)stop;
(void)speed;
return false; return false;
} }
virtual bool requestKeyFrame() { return false; }
virtual Eigen::Vector3f get3DPointFromPixel(int u, int v) {return {0,0,0};} virtual Eigen::Vector3f get3DPointFromPixel(int u, int v) {return {0,0,0};}
protected: protected:
CameraState state_{}; CameraState state_{};

View File

@ -50,6 +50,9 @@ endif()
find_path(HIKVISION_JSONCPP_INCLUDE_DIR NAMES json/json.h PATH_SUFFIXES jsoncpp) find_path(HIKVISION_JSONCPP_INCLUDE_DIR NAMES json/json.h PATH_SUFFIXES jsoncpp)
message(STATUS "[hikvision_camera] jsoncpp include dir: ${HIKVISION_JSONCPP_INCLUDE_DIR}") message(STATUS "[hikvision_camera] jsoncpp include dir: ${HIKVISION_JSONCPP_INCLUDE_DIR}")
if(HIKVISION_JSONCPP_INCLUDE_DIR)
target_include_directories(hikvision_camera PRIVATE "${HIKVISION_JSONCPP_INCLUDE_DIR}")
endif()
target_link_libraries(hikvision_camera target_link_libraries(hikvision_camera
PUBLIC PUBLIC
@ -71,3 +74,49 @@ add_library(cmvr_es::device::hikvision_camera ALIAS hikvision_camera)
install(TARGETS hikvision_camera LIBRARY DESTINATION lib) install(TARGETS hikvision_camera LIBRARY DESTINATION lib)
install(FILES ${HIKVISION_SDK_LIBS} DESTINATION lib) install(FILES ${HIKVISION_SDK_LIBS} DESTINATION lib)
install(DIRECTORY "${HIKVISION_SDK_COM_DIR}" DESTINATION lib) install(DIRECTORY "${HIKVISION_SDK_COM_DIR}" DESTINATION lib)
if(BUILD_TESTING)
add_executable(hikvision_camera_callback_test
tests/hikvision_camera_callback_test.cpp
src/hikvision_camera.cpp
)
target_include_directories(hikvision_camera_callback_test
PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}
"${HIKVISION_SDK_ROOT}/include"
)
if(HIKVISION_JSONCPP_INCLUDE_DIR)
target_include_directories(
hikvision_camera_callback_test
PRIVATE
"${HIKVISION_JSONCPP_INCLUDE_DIR}"
)
endif()
target_compile_definitions(hikvision_camera_callback_test
PRIVATE
HIKVISION_SDK_LIB_DIR="${HIKVISION_SDK_LIB_DIR}/"
)
# The test supplies a small in-process HCNetSDK fake, so it exercises the
# callback/stop ordering without requiring a camera or loading hcnetsdk.
target_link_libraries(hikvision_camera_callback_test
PRIVATE
glog
opencv_core
cmvr_es::proto
${HIKVISION_JSONCPP_LINK}
pthread
)
add_test(
NAME hikvision_camera_callback_test
COMMAND hikvision_camera_callback_test
)
set_tests_properties(hikvision_camera_callback_test PROPERTIES TIMEOUT 10)
if(UNIX AND NOT APPLE)
get_property(_hikvision_test_library_dirs DIRECTORY PROPERTY LINK_DIRECTORIES)
list(PREPEND _hikvision_test_library_dirs "${CMAKE_BINARY_DIR}/cmvr_compiler_runtime")
list(JOIN _hikvision_test_library_dirs ":" _hikvision_test_library_path)
set_tests_properties(hikvision_camera_callback_test PROPERTIES
ENVIRONMENT "LD_LIBRARY_PATH=${_hikvision_test_library_path}"
)
endif()
endif()

View File

@ -1,11 +1,10 @@
#ifndef CMVR_ES_HIKVISION_CAMERA_H #ifndef CMVR_ES_HIKVISION_CAMERA_H
#define CMVR_ES_HIKVISION_CAMERA_H #define CMVR_ES_HIKVISION_CAMERA_H
#include <atomic> #include <cstdint>
#include <memory> #include <memory>
#include <mutex> #include <mutex>
#include <string> #include <string>
#include <cstdint>
#include <vector> #include <vector>
#include "common/base/ring_buffer.h" #include "common/base/ring_buffer.h"
@ -22,6 +21,7 @@ public:
bool init() override; bool init() override;
bool start() override; bool start() override;
bool stop() override; bool stop() override;
void getState(CameraState& state) override;
void getRGBImage(cv::Mat& color, Rs2Intrinsics& intrinsics) override; void getRGBImage(cv::Mat& color, Rs2Intrinsics& intrinsics) override;
void getDepthImage(cv::Mat& depth, Rs2Intrinsics& intrinsics) override; void getDepthImage(cv::Mat& depth, Rs2Intrinsics& intrinsics) override;
@ -32,13 +32,16 @@ public:
void pauseRecording() override; void pauseRecording() override;
void resumeRecording() override; void resumeRecording() override;
void getEncodedFrame(StreamFrameData& frame_data, size_t& index) override; void getEncodedFrame(StreamFrameData& frame_data, size_t& index) override;
bool waitEncodedFrame(StreamFrameData& frame_data, size_t& index, std::chrono::milliseconds timeout) override;
bool getLatestEncodedFrame(StreamFrameData& frame_data, size_t& next_index) override; bool getLatestEncodedFrame(StreamFrameData& frame_data, size_t& next_index) override;
bool startStreaming() override; bool startStreaming() override;
void stopStreaming() override; void stopStreaming() override;
bool controlPtz(PtzCommand command, bool stop, int speed) override; bool controlPtz(PtzCommand command, bool stop, int speed) override;
bool executeJsonCommand(const std::string& request_json, std::string& response_json) override; bool executeJsonCommand(const std::string& request_json, std::string& response_json) override;
bool requestKeyFrame() override;
void onEsData(unsigned int packet_type, void onEsData(long real_handle,
unsigned int packet_type,
unsigned char* buffer, unsigned char* buffer,
unsigned int buffer_size, unsigned int buffer_size,
unsigned int width, unsigned int width,
@ -86,11 +89,19 @@ private:
std::string current_video_path_; std::string current_video_path_;
mutable std::mutex ctrl_mtx_; mutable std::mutex ctrl_mtx_;
std::mutex stream_mtx_; // SDK callbacks must never take ctrl_mtx_: NET_DVR_StopRealPlay may wait
// for an in-flight callback while stop() owns that mutex. This mutex is the
// single synchronization domain for callback publication and stream state.
mutable std::mutex callback_mtx_;
bool callback_publishing_enabled_ = false;
long callback_preview_handle_ = -1;
std::vector<uint8_t> es_stream_header_; std::vector<uint8_t> es_stream_header_;
bool has_es_stream_header_ = false; bool has_es_stream_header_ = false;
std::atomic<bool> awaiting_key_frame_{false}; bool awaiting_key_frame_ = false;
int stream_count_ = 0; int stream_count_ = 0;
uint64_t stream_epoch_ = 0;
uint64_t stream_sequence_ = 0;
uint32_t codec_config_generation_ = 0;
}; };
} // namespace cmvr::device } // namespace cmvr::device

View File

@ -1,8 +1,9 @@
#include "../include/hikvision_camera.h" #include "../include/hikvision_camera.h"
#include <atomic>
#include <algorithm> #include <algorithm>
#include <atomic>
#include <cctype> #include <cctype>
#include <chrono>
#include <cstdint> #include <cstdint>
#include <cstdio> #include <cstdio>
#include <cstring> #include <cstring>
@ -217,13 +218,14 @@ std::string makeJsonResult(bool success, const std::string& error_message = "")
} }
void CALLBACK hikvisionEsRealPlayCallback( void CALLBACK hikvisionEsRealPlayCallback(
LONG, NET_DVR_PACKET_INFO_EX* packet_info, void* user) LONG real_handle, NET_DVR_PACKET_INFO_EX* packet_info, void* user)
{ {
auto* camera = static_cast<cmvr::device::HikvisionCamera*>(user); auto* camera = static_cast<cmvr::device::HikvisionCamera*>(user);
if (!camera || !packet_info) { if (!camera || !packet_info) {
return; return;
} }
camera->onEsData(packet_info->dwPacketType, camera->onEsData(real_handle,
packet_info->dwPacketType,
packet_info->pPacketBuffer, packet_info->pPacketBuffer,
packet_info->dwPacketSize, packet_info->dwPacketSize,
packet_info->wWidth, packet_info->wWidth,
@ -252,6 +254,9 @@ HikvisionCamera::HikvisionCamera(const config::HikvisionCameraConfig& camera)
codec_ = camera_.codec().empty() ? "H264" : camera_.codec(); codec_ = camera_.codec().empty() ? "H264" : camera_.codec();
sdk_path_ = normalizeSdkPath( sdk_path_ = normalizeSdkPath(
camera_.sdk_path().empty() ? defaultRuntimeSdkPath() : camera_.sdk_path()); camera_.sdk_path().empty() ? defaultRuntimeSdkPath() : camera_.sdk_path());
// Keep the shared_ptr itself immutable after construction. Readers and the
// SDK callback may use it concurrently; the ring buffer owns its locking.
stream_frame_buffer_ = std::make_shared<SPMCRingBuffer<StreamFrameData>>(buffer_size_);
state_.fps = fps_; state_.fps = fps_;
state_.width = width_; state_.width = width_;
@ -282,7 +287,6 @@ bool HikvisionCamera::init()
return false; return false;
} }
stream_frame_buffer_ = std::make_shared<SPMCRingBuffer<StreamFrameData>>(buffer_size_);
stream_frame_buffer_->clear(); stream_frame_buffer_->clear();
resetStreamState_(); resetStreamState_();
@ -334,7 +338,6 @@ bool HikvisionCamera::stop()
stopRecordingUnlocked_(); stopRecordingUnlocked_();
} }
state_.is_streaming = false; state_.is_streaming = false;
awaiting_key_frame_.store(false, std::memory_order_release);
stream_count_ = 0; stream_count_ = 0;
resetStreamState_(); resetStreamState_();
stopPreview_(); stopPreview_();
@ -347,8 +350,15 @@ bool HikvisionCamera::stop()
return true; return true;
} }
void HikvisionCamera::getState(CameraState& state)
{
std::lock_guard lock(ctrl_mtx_);
state = state_;
}
void HikvisionCamera::getRGBImage(cv::Mat& color, Rs2Intrinsics& intrinsics) void HikvisionCamera::getRGBImage(cv::Mat& color, Rs2Intrinsics& intrinsics)
{ {
std::lock_guard lock(ctrl_mtx_);
clear_error_(); clear_error_();
fillIntrinsics_(intrinsics); fillIntrinsics_(intrinsics);
setError_("getRGBImage unsupported: HikvisionCamera only forwards stream data"); setError_("getRGBImage unsupported: HikvisionCamera only forwards stream data");
@ -357,6 +367,7 @@ void HikvisionCamera::getRGBImage(cv::Mat& color, Rs2Intrinsics& intrinsics)
void HikvisionCamera::getDepthImage(cv::Mat& depth, Rs2Intrinsics&) void HikvisionCamera::getDepthImage(cv::Mat& depth, Rs2Intrinsics&)
{ {
std::lock_guard lock(ctrl_mtx_);
setError_("getDepthImage unsupported usage"); setError_("getDepthImage unsupported usage");
depth.release(); depth.release();
} }
@ -418,17 +429,41 @@ void HikvisionCamera::stopRecordingUnlocked_()
void HikvisionCamera::pauseRecording() void HikvisionCamera::pauseRecording()
{ {
std::lock_guard lock(ctrl_mtx_);
setError_("pauseRecording unsupported by Hikvision SDK recording"); setError_("pauseRecording unsupported by Hikvision SDK recording");
} }
void HikvisionCamera::resumeRecording() void HikvisionCamera::resumeRecording()
{ {
std::lock_guard lock(ctrl_mtx_);
setError_("resumeRecording unsupported by Hikvision SDK recording"); setError_("resumeRecording unsupported by Hikvision SDK recording");
} }
void HikvisionCamera::getEncodedFrame(StreamFrameData& frame_data, size_t& index) void HikvisionCamera::getEncodedFrame(StreamFrameData& frame_data, size_t& index)
{ {
getLatestEncodedFrame(frame_data, index); if (!stream_frame_buffer_) {
return;
}
auto frame = stream_frame_buffer_->pop(index);
if (frame) {
frame_data = std::move(*frame);
}
}
bool HikvisionCamera::waitEncodedFrame(
StreamFrameData& frame_data,
size_t& index,
const std::chrono::milliseconds timeout)
{
if (!stream_frame_buffer_) {
return false;
}
auto frame = stream_frame_buffer_->waitPop(index, timeout);
if (!frame) {
return false;
}
frame_data = std::move(*frame);
return !frame_data.rgbFrame.empty() || !frame_data.depthFrame.empty();
} }
bool HikvisionCamera::getLatestEncodedFrame(StreamFrameData& frame_data, size_t& next_index) bool HikvisionCamera::getLatestEncodedFrame(StreamFrameData& frame_data, size_t& next_index)
@ -437,19 +472,12 @@ bool HikvisionCamera::getLatestEncodedFrame(StreamFrameData& frame_data, size_t&
return false; return false;
} }
const size_t head = stream_frame_buffer_->getHead(); auto frame = stream_frame_buffer_->getLatest(next_index);
if (head == 0) {
return false;
}
size_t latest_index = head - 1;
auto frame = stream_frame_buffer_->pop(latest_index);
if (!frame.has_value()) { if (!frame.has_value()) {
return false; return false;
} }
frame_data = frame.value(); frame_data = frame.value();
next_index = latest_index;
return true; return true;
} }
@ -468,13 +496,25 @@ bool HikvisionCamera::startStreaming()
if (stream_frame_buffer_) { if (stream_frame_buffer_) {
stream_frame_buffer_->clear(); stream_frame_buffer_->clear();
} }
awaiting_key_frame_.store(true, std::memory_order_release); std::lock_guard callback_lock(callback_mtx_);
if (callback_preview_handle_ < 0 ||
callback_preview_handle_ != static_cast<long>(real_handle_)) {
setError_("preview callback is not active");
return false;
}
++stream_epoch_;
stream_sequence_ = 0;
++codec_config_generation_;
awaiting_key_frame_ = true;
callback_publishing_enabled_ = true;
} }
state_.is_streaming = true; state_.is_streaming = true;
++stream_count_; ++stream_count_;
if (first_stream && !requestKeyFrame_()) { if (first_stream && !requestKeyFrame_()) {
awaiting_key_frame_.store(false, std::memory_order_release); // Keep waiting for the next natural I-frame. Publishing P/B frames
// immediately would make a newly attached decoder start corrupted.
CMVR_LOG(WARNING) << "[HikvisionCamera] key-frame request failed; "
<< "waiting for the next natural I-frame";
} }
return true; return true;
} }
@ -486,8 +526,13 @@ void HikvisionCamera::stopStreaming()
--stream_count_; --stream_count_;
} }
if (stream_count_ == 0) { if (stream_count_ == 0) {
// Wait for an already-running callback to finish its publication, then
// prevent both queued and future callbacks from publishing. No frame
// can be pushed after this critical section has completed.
std::lock_guard callback_lock(callback_mtx_);
callback_publishing_enabled_ = false;
awaiting_key_frame_ = false;
state_.is_streaming = false; state_.is_streaming = false;
awaiting_key_frame_.store(false, std::memory_order_release);
} }
} }
@ -517,6 +562,22 @@ bool HikvisionCamera::controlPtz(PtzCommand command, bool stop, int speed)
return true; return true;
} }
bool HikvisionCamera::requestKeyFrame()
{
std::lock_guard lock(ctrl_mtx_);
clear_error_();
if (!state_.is_opened || user_id_ < 0) {
setError_("camera not opened");
return false;
}
if (!requestKeyFrame_()) {
setError_(sdkError_(
stream_type_ == 0 ? "NET_DVR_MakeKeyFrame" : "NET_DVR_MakeKeyFrameSub"));
return false;
}
return true;
}
bool HikvisionCamera::requestKeyFrame_() bool HikvisionCamera::requestKeyFrame_()
{ {
if (user_id_ < 0) { if (user_id_ < 0) {
@ -524,7 +585,7 @@ bool HikvisionCamera::requestKeyFrame_()
return false; return false;
} }
const bool is_sub_stream = stream_type_ == 1; const bool is_sub_stream = stream_type_ != 0;
const BOOL success = is_sub_stream const BOOL success = is_sub_stream
? NET_DVR_MakeKeyFrameSub(user_id_, channel_) ? NET_DVR_MakeKeyFrameSub(user_id_, channel_)
: NET_DVR_MakeKeyFrame(user_id_, channel_); : NET_DVR_MakeKeyFrame(user_id_, channel_);
@ -579,7 +640,11 @@ bool HikvisionCamera::executeJsonCommand(const std::string& request_json, std::s
const int speed = jsonIntField(root, "speed", 0); const int speed = jsonIntField(root, "speed", 0);
if (!controlPtz(ptz_command, stop, speed)) { if (!controlPtz(ptz_command, stop, speed)) {
response_json = makeJsonResult(false, state_.error_message); CameraState state;
getState(state);
response_json = makeJsonResult(
false,
state.error_message.empty() ? "failed to control PTZ" : state.error_message);
return false; return false;
} }
@ -595,21 +660,37 @@ bool HikvisionCamera::executeJsonCommand(const std::string& request_json, std::s
} }
void HikvisionCamera::onEsData( void HikvisionCamera::onEsData(
unsigned int packet_type, const long real_handle,
const unsigned int packet_type,
unsigned char* buffer, unsigned char* buffer,
unsigned int buffer_size, const unsigned int buffer_size,
unsigned int packet_width, const unsigned int packet_width,
unsigned int packet_height) const unsigned int packet_height)
{ {
if (!buffer || buffer_size == 0) { if (!buffer || buffer_size == 0) {
return; return;
} }
// Never take ctrl_mtx_ from an SDK callback. NET_DVR_StopRealPlay may wait
// for this callback while stop() owns ctrl_mtx_. Holding callback_mtx_
// through publication also preserves the ring's single-producer contract.
std::lock_guard callback_lock(callback_mtx_);
if (callback_preview_handle_ != real_handle || !stream_frame_buffer_) {
return;
}
if (packet_type == kHikvisionPacketFileHeader) { if (packet_type == kHikvisionPacketFileHeader) {
std::lock_guard<std::mutex> lock(stream_mtx_); const bool changed =
if (!has_es_stream_header_) { !has_es_stream_header_ ||
es_stream_header_.size() != buffer_size ||
!std::equal(es_stream_header_.begin(), es_stream_header_.end(), buffer);
if (changed) {
es_stream_header_.assign(buffer, buffer + buffer_size); es_stream_header_.assign(buffer, buffer + buffer_size);
has_es_stream_header_ = true; has_es_stream_header_ = true;
++codec_config_generation_;
if (callback_publishing_enabled_) {
awaiting_key_frame_ = true;
}
CMVR_LOG(INFO) << "[HikvisionCamera] received ES stream header, size=" << buffer_size; CMVR_LOG(INFO) << "[HikvisionCamera] received ES stream header, size=" << buffer_size;
} }
return; return;
@ -634,16 +715,16 @@ void HikvisionCamera::onEsData(
<< packet_type << ", size=" << buffer_size; << packet_type << ", size=" << buffer_size;
} }
if (!state_.is_streaming || !stream_frame_buffer_) { if (!callback_publishing_enabled_) {
return; return;
} }
const bool is_key_frame = packet_type == kHikvisionPacketVideoIFrame; const bool is_key_frame = packet_type == kHikvisionPacketVideoIFrame;
if (awaiting_key_frame_.load(std::memory_order_acquire)) { if (awaiting_key_frame_) {
if (!is_key_frame) { if (!is_key_frame) {
return; return;
} }
awaiting_key_frame_.store(false, std::memory_order_release); awaiting_key_frame_ = false;
CMVR_LOG(INFO) << "[HikvisionCamera] received first key frame after stream start"; CMVR_LOG(INFO) << "[HikvisionCamera] received first key frame after stream start";
} }
@ -666,19 +747,39 @@ void HikvisionCamera::pushEncodedFrame_(
} }
StreamFrameData frame_data; StreamFrameData frame_data;
const auto capture_monotonic = std::chrono::steady_clock::now();
const auto capture_utc = std::chrono::system_clock::now();
frame_data.rgbFrame.assign(buffer, buffer + buffer_size); frame_data.rgbFrame.assign(buffer, buffer + buffer_size);
frame_data.codec = codec_; frame_data.codec = codec_;
frame_data.fps = fps_; frame_data.fps = fps_;
frame_data.width = packet_width > 0 ? static_cast<int>(packet_width) : width_; frame_data.width = packet_width > 0 ? static_cast<int>(packet_width) : width_;
frame_data.height = packet_height > 0 ? static_cast<int>(packet_height) : height_; frame_data.height = packet_height > 0 ? static_cast<int>(packet_height) : height_;
frame_data.bKey = is_key_frame; frame_data.bKey = is_key_frame;
frame_data.stream_epoch = stream_epoch_;
frame_data.sequence = stream_sequence_++;
frame_data.capture_monotonic_ns = std::chrono::duration_cast<std::chrono::nanoseconds>(
capture_monotonic.time_since_epoch()).count();
frame_data.capture_utc_ns = std::chrono::duration_cast<std::chrono::nanoseconds>(
capture_utc.time_since_epoch()).count();
frame_data.pts = static_cast<int64_t>(frame_data.sequence);
frame_data.dts = frame_data.pts;
frame_data.time_base_num = 1;
frame_data.time_base_den = std::max(1, fps_);
frame_data.duration = 1;
frame_data.codec_config_generation = codec_config_generation_;
// Hikvision labels packet type 0 as a "file header", but the bundled SDK
// does not guarantee that it is a decoder-ready VPS/SPS/PPS blob. Keep it
// only for change detection until its format is verified on real hardware.
fillIntrinsics_(frame_data.intrinsics); fillIntrinsics_(frame_data.intrinsics);
stream_frame_buffer_->push(frame_data); stream_frame_buffer_->push(frame_data);
} }
void HikvisionCamera::resetStreamState_() void HikvisionCamera::resetStreamState_()
{ {
std::lock_guard<std::mutex> lock(stream_mtx_); std::lock_guard callback_lock(callback_mtx_);
callback_publishing_enabled_ = false;
callback_preview_handle_ = -1;
awaiting_key_frame_ = false;
es_stream_header_.clear(); es_stream_header_.clear();
has_es_stream_header_ = false; has_es_stream_header_ = false;
} }
@ -758,8 +859,20 @@ bool HikvisionCamera::startPreview_()
setError_(sdkError_("NET_DVR_RealPlay_V40")); setError_(sdkError_("NET_DVR_RealPlay_V40"));
return false; return false;
} }
{
std::lock_guard callback_lock(callback_mtx_);
callback_publishing_enabled_ = false;
callback_preview_handle_ = static_cast<long>(real_handle_);
awaiting_key_frame_ = false;
}
if (!NET_DVR_SetESRealPlayCallBack(real_handle_, hikvisionEsRealPlayCallback, this)) { if (!NET_DVR_SetESRealPlayCallBack(real_handle_, hikvisionEsRealPlayCallback, this)) {
setError_(sdkError_("NET_DVR_SetESRealPlayCallBack")); setError_(sdkError_("NET_DVR_SetESRealPlayCallBack"));
{
std::lock_guard callback_lock(callback_mtx_);
callback_publishing_enabled_ = false;
callback_preview_handle_ = -1;
awaiting_key_frame_ = false;
}
NET_DVR_StopRealPlay(real_handle_); NET_DVR_StopRealPlay(real_handle_);
real_handle_ = -1; real_handle_ = -1;
return false; return false;
@ -769,8 +882,18 @@ bool HikvisionCamera::startPreview_()
void HikvisionCamera::stopPreview_() void HikvisionCamera::stopPreview_()
{ {
if (real_handle_ >= 0) { const int preview_handle = real_handle_;
NET_DVR_StopRealPlay(real_handle_); {
// Invalidate before asking the SDK to stop. We intentionally release
// callback_mtx_ before NET_DVR_StopRealPlay because that function may
// wait for an SDK callback to return.
std::lock_guard callback_lock(callback_mtx_);
callback_publishing_enabled_ = false;
callback_preview_handle_ = -1;
awaiting_key_frame_ = false;
}
if (preview_handle >= 0) {
NET_DVR_StopRealPlay(preview_handle);
real_handle_ = -1; real_handle_ = -1;
} }
} }

View File

@ -0,0 +1,382 @@
#include "../include/hikvision_camera.h"
#include <atomic>
#include <chrono>
#include <cstdint>
#include <iostream>
#include <mutex>
#include <string>
#include <thread>
#include <vector>
#include "HCNetSDK.h"
namespace {
using EsDataCallback =
void(CALLBACK*)(LONG, NET_DVR_PACKET_INFO_EX*, void*);
constexpr DWORD kFileHeader = 0;
constexpr DWORD kVideoIFrame = 1;
constexpr DWORD kVideoPFrame = 3;
std::mutex g_fake_sdk_mutex;
EsDataCallback g_es_data_callback = nullptr;
void* g_es_data_user = nullptr;
LONG g_real_handle = 42;
std::atomic<int> g_stop_callback_count{0};
std::atomic<int> g_key_frame_request_count{0};
std::atomic<int> g_ptz_call_count{0};
std::atomic<DWORD> g_last_ptz_command{0};
std::atomic<DWORD> g_last_ptz_stop{0};
std::atomic<DWORD> g_last_ptz_speed{0};
void resetFakeSdk()
{
std::lock_guard lock(g_fake_sdk_mutex);
g_es_data_callback = nullptr;
g_es_data_user = nullptr;
g_stop_callback_count = 0;
g_key_frame_request_count = 0;
g_ptz_call_count = 0;
g_last_ptz_command = 0;
g_last_ptz_stop = 0;
g_last_ptz_speed = 0;
}
void emitEsPacket(
const DWORD packet_type,
const LONG real_handle,
std::vector<BYTE> payload,
const WORD width = 640,
const WORD height = 360)
{
EsDataCallback callback = nullptr;
void* user = nullptr;
{
std::lock_guard lock(g_fake_sdk_mutex);
callback = g_es_data_callback;
user = g_es_data_user;
}
if (!callback) {
return;
}
NET_DVR_PACKET_INFO_EX packet{};
packet.wWidth = width;
packet.wHeight = height;
packet.dwPacketType = packet_type;
packet.dwPacketSize = static_cast<DWORD>(payload.size());
packet.pPacketBuffer = payload.data();
callback(real_handle, &packet, user);
}
void emitIFrame(const LONG real_handle = g_real_handle)
{
emitEsPacket(
kVideoIFrame,
real_handle,
{0x00, 0x00, 0x00, 0x01, 0x65, 0x88, 0x84, 0x21});
}
void emitPFrame(const LONG real_handle = g_real_handle)
{
emitEsPacket(
kVideoPFrame,
real_handle,
{0x00, 0x00, 0x00, 0x01, 0x41, 0x9A, 0x20});
}
bool check(const bool condition, const char* expression, const int line)
{
if (condition) {
return true;
}
std::cerr << "CHECK failed at line " << line << ": " << expression << '\n';
return false;
}
#define CHECK_TRUE(expression) \
do { \
if (!check(static_cast<bool>(expression), #expression, __LINE__)) { \
return false; \
} \
} while (false)
cmvr::config::HikvisionCameraConfig makeConfig()
{
cmvr::config::HikvisionCameraConfig config;
config.set_id("hikvision_callback_test");
config.set_ip("127.0.0.1");
config.set_username("admin");
config.set_password("test-only");
config.set_port(8000);
config.set_channel(1);
config.set_stream_type(0);
config.set_link_mode(0);
config.set_width(1920);
config.set_height(1080);
config.set_fps(25);
config.set_codec("H264");
config.set_buffer_size(32);
return config;
}
bool testCallbackPublicationLifecycle()
{
resetFakeSdk();
cmvr::device::HikvisionCamera camera(makeConfig());
CHECK_TRUE(camera.init());
CHECK_TRUE(camera.start());
CHECK_TRUE(camera.startStreaming());
CHECK_TRUE(g_key_frame_request_count.load() == 1);
size_t cursor = 0;
cmvr::device::StreamFrameData frame;
// A new subscriber must not receive an undecodable inter frame, and a
// delayed callback from an older preview handle must also be rejected.
emitPFrame();
emitIFrame(g_real_handle - 1);
CHECK_TRUE(!camera.waitEncodedFrame(
frame, cursor, std::chrono::milliseconds(10)));
emitIFrame();
CHECK_TRUE(camera.waitEncodedFrame(
frame, cursor, std::chrono::milliseconds(50)));
CHECK_TRUE(frame.stream_epoch == 1);
CHECK_TRUE(frame.sequence == 0);
CHECK_TRUE(frame.codec_config_generation == 1);
CHECK_TRUE(frame.bKey);
CHECK_TRUE(frame.width == 640);
CHECK_TRUE(frame.height == 360);
CHECK_TRUE(camera.requestKeyFrame());
CHECK_TRUE(g_key_frame_request_count.load() == 2);
CHECK_TRUE(camera.controlPtz(
cmvr::device::PtzCommand::PanLeft, false, 99));
CHECK_TRUE(g_ptz_call_count.load() == 1);
CHECK_TRUE(g_last_ptz_command.load() == PAN_LEFT);
CHECK_TRUE(g_last_ptz_stop.load() == 0);
CHECK_TRUE(g_last_ptz_speed.load() == 7);
std::string json_response;
CHECK_TRUE(camera.executeJsonCommand(
R"({"command":"ptz","direction":"zoom_in","speed":3})",
json_response));
CHECK_TRUE(json_response.find(R"("success":true)") != std::string::npos);
CHECK_TRUE(g_ptz_call_count.load() == 2);
CHECK_TRUE(g_last_ptz_command.load() == ZOOM_IN);
CHECK_TRUE(g_last_ptz_speed.load() == 3);
// A changed SDK file header invalidates the descriptor generation, but is
// not exposed as decoder config until its vendor-specific format is known.
emitEsPacket(kFileHeader, g_real_handle, {0x01, 0x02, 0x03});
emitPFrame();
CHECK_TRUE(!camera.waitEncodedFrame(
frame, cursor, std::chrono::milliseconds(10)));
emitIFrame();
CHECK_TRUE(camera.waitEncodedFrame(
frame, cursor, std::chrono::milliseconds(50)));
CHECK_TRUE(frame.sequence == 1);
CHECK_TRUE(frame.codec_config_generation == 2);
CHECK_TRUE(frame.codec_config.empty());
constexpr int kConcurrentCallbacks = 8;
std::vector<std::thread> producers;
producers.reserve(kConcurrentCallbacks);
for (int i = 0; i < kConcurrentCallbacks; ++i) {
producers.emplace_back(emitPFrame, g_real_handle);
}
for (auto& producer : producers) {
producer.join();
}
size_t latest_cursor = 0;
cmvr::device::StreamFrameData latest;
CHECK_TRUE(camera.getLatestEncodedFrame(latest, latest_cursor));
CHECK_TRUE(latest.stream_epoch == 1);
CHECK_TRUE(latest.sequence == static_cast<uint64_t>(kConcurrentCallbacks + 1));
CHECK_TRUE(latest_cursor == static_cast<size_t>(kConcurrentCallbacks + 2));
for (int sequence = 2; sequence <= kConcurrentCallbacks + 1; ++sequence) {
CHECK_TRUE(camera.waitEncodedFrame(
frame, cursor, std::chrono::milliseconds(50)));
CHECK_TRUE(frame.stream_epoch == 1);
CHECK_TRUE(frame.sequence == static_cast<uint64_t>(sequence));
}
camera.stopStreaming();
emitIFrame();
CHECK_TRUE(!camera.waitEncodedFrame(
frame, cursor, std::chrono::milliseconds(10)));
CHECK_TRUE(camera.startStreaming());
CHECK_TRUE(g_key_frame_request_count.load() == 3);
emitPFrame();
emitIFrame(g_real_handle - 1);
CHECK_TRUE(!camera.waitEncodedFrame(
frame, cursor, std::chrono::milliseconds(10)));
emitIFrame();
CHECK_TRUE(camera.waitEncodedFrame(
frame, cursor, std::chrono::milliseconds(50)));
CHECK_TRUE(frame.stream_epoch == 2);
CHECK_TRUE(frame.sequence == 0);
CHECK_TRUE(frame.codec_config_generation == 3);
// The fake StopRealPlay invokes the SDK callback synchronously. stop()
// owns ctrl_mtx_ here, proving the callback neither takes that mutex nor
// publishes after the preview handle has been invalidated.
CHECK_TRUE(camera.stop());
CHECK_TRUE(g_stop_callback_count.load() == 1);
CHECK_TRUE(!camera.waitEncodedFrame(
frame, cursor, std::chrono::milliseconds(10)));
return true;
}
} // namespace
extern "C" {
BOOL NET_DVR_Init()
{
return TRUE;
}
BOOL NET_DVR_Cleanup()
{
return TRUE;
}
BOOL NET_DVR_SetConnectTime(DWORD, DWORD)
{
return TRUE;
}
BOOL NET_DVR_SetReconnect(DWORD, BOOL)
{
return TRUE;
}
BOOL NET_DVR_SetLogToFile(DWORD, char*, BOOL)
{
return TRUE;
}
LONG NET_DVR_Login_V40(
LPNET_DVR_USER_LOGIN_INFO,
LPNET_DVR_DEVICEINFO_V40)
{
return 7;
}
BOOL NET_DVR_Logout(LONG)
{
return TRUE;
}
DWORD NET_DVR_GetLastError()
{
return 0;
}
LONG NET_DVR_RealPlay_V40(
LONG,
LPNET_DVR_PREVIEWINFO,
REALDATACALLBACK,
void*)
{
return g_real_handle;
}
BOOL NET_DVR_SetESRealPlayCallBack(
LONG,
EsDataCallback callback,
void* user)
{
std::lock_guard lock(g_fake_sdk_mutex);
g_es_data_callback = callback;
g_es_data_user = user;
return TRUE;
}
BOOL NET_DVR_StopRealPlay(const LONG real_handle)
{
EsDataCallback callback = nullptr;
void* user = nullptr;
{
std::lock_guard lock(g_fake_sdk_mutex);
callback = g_es_data_callback;
user = g_es_data_user;
}
if (callback) {
std::vector<BYTE> idr{
0x00, 0x00, 0x00, 0x01, 0x65, 0x88, 0x84, 0x21
};
NET_DVR_PACKET_INFO_EX packet{};
packet.wWidth = 640;
packet.wHeight = 360;
packet.dwPacketType = kVideoIFrame;
packet.dwPacketSize = static_cast<DWORD>(idr.size());
packet.pPacketBuffer = idr.data();
callback(real_handle, &packet, user);
++g_stop_callback_count;
}
{
std::lock_guard lock(g_fake_sdk_mutex);
g_es_data_callback = nullptr;
g_es_data_user = nullptr;
}
return TRUE;
}
BOOL NET_DVR_SaveRealData(LONG, char*)
{
return TRUE;
}
BOOL NET_DVR_StopSaveRealData(LONG)
{
return TRUE;
}
BOOL NET_DVR_MakeKeyFrame(LONG, LONG)
{
++g_key_frame_request_count;
return TRUE;
}
BOOL NET_DVR_MakeKeyFrameSub(LONG, LONG)
{
++g_key_frame_request_count;
return TRUE;
}
BOOL NET_DVR_PTZControlWithSpeed_Other(
LONG,
LONG,
const DWORD command,
const DWORD stop,
const DWORD speed)
{
++g_ptz_call_count;
g_last_ptz_command = command;
g_last_ptz_stop = stop;
g_last_ptz_speed = speed;
return TRUE;
}
} // extern "C"
int main()
{
if (!testCallbackPublicationLifecycle()) {
return 1;
}
std::cout << "hikvision_camera_callback_test: PASS\n";
return 0;
}

View File

@ -30,6 +30,7 @@ namespace cmvr::device
bool init() override; bool init() override;
bool start() override; bool start() override;
bool stop() override; bool stop() override;
void getState(CameraState& state) override;
void getRGBImage(cv::Mat& color, Rs2Intrinsics& intrinsics) override; void getRGBImage(cv::Mat& color, Rs2Intrinsics& intrinsics) override;
void getDepthImage(cv::Mat& depth, Rs2Intrinsics& intrinsics) override; void getDepthImage(cv::Mat& depth, Rs2Intrinsics& intrinsics) override;
void getRGBDImages(cv::Mat &color, cv::Mat &depth, Rs2Intrinsics& intrinsics) override; void getRGBDImages(cv::Mat &color, cv::Mat &depth, Rs2Intrinsics& intrinsics) override;

View File

@ -50,6 +50,11 @@ bool MechmindCamera::init() {
return true; return true;
} }
void MechmindCamera::getState(CameraState& state) {
std::lock_guard lock(dev_mtx_);
state = state_;
}
bool MechmindCamera::start() { bool MechmindCamera::start() {
try { try {
std::lock_guard lock(dev_mtx_); std::lock_guard lock(dev_mtx_);
@ -68,6 +73,7 @@ bool MechmindCamera::start() {
return true; return true;
} }
catch (const std::exception& e) { catch (const std::exception& e) {
std::lock_guard lock(dev_mtx_);
const string error_msg = "[MechmindCamera] (start): " + string(e.what()); const string error_msg = "[MechmindCamera] (start): " + string(e.what());
CMVR_LOG(ERROR) << error_msg; CMVR_LOG(ERROR) << error_msg;
state_.is_error = true; state_.is_error = true;
@ -85,6 +91,7 @@ bool MechmindCamera::stop() {
return true; return true;
} }
catch (const exception &e) { catch (const exception &e) {
std::lock_guard lock(dev_mtx_);
const string error_msg = "[MechmindCamera] (stop): " + string(e.what()); const string error_msg = "[MechmindCamera] (stop): " + string(e.what());
CMVR_LOG(ERROR) << error_msg; CMVR_LOG(ERROR) << error_msg;
state_.is_error = true; state_.is_error = true;
@ -141,6 +148,7 @@ void MechmindCamera::getRGBImage(cv::Mat& color, Rs2Intrinsics& intrinsics) {
} }
} }
catch (const exception &e) { catch (const exception &e) {
std::lock_guard lock(dev_mtx_);
const string error_msg = "[MechmindCamera] (getRGBImage): " + string(e.what()); const string error_msg = "[MechmindCamera] (getRGBImage): " + string(e.what());
CMVR_LOG(ERROR) << error_msg; CMVR_LOG(ERROR) << error_msg;
state_.is_error = true; state_.is_error = true;
@ -175,6 +183,7 @@ void MechmindCamera::getDepthImage(cv::Mat& depth, Rs2Intrinsics& intrinsics) {
depth = cv::Mat(depthMap.height(), depthMap.width(), CV_32FC1, depthMap.data()); depth = cv::Mat(depthMap.height(), depthMap.width(), CV_32FC1, depthMap.data());
} }
catch (const exception &e) { catch (const exception &e) {
std::lock_guard lock(dev_mtx_);
const string error_msg = "[MechmindCamera] (getDepthImage): " + string(e.what()); const string error_msg = "[MechmindCamera] (getDepthImage): " + string(e.what());
CMVR_LOG(ERROR) << error_msg; CMVR_LOG(ERROR) << error_msg;
state_.is_error = true; state_.is_error = true;
@ -258,6 +267,7 @@ void MechmindCamera::getRGBDImages(cv::Mat& color, cv::Mat& depth, Rs2Intrinsics
textured_plc_to_rgbd_(textured_pcl, color, depth); textured_plc_to_rgbd_(textured_pcl, color, depth);
} }
catch (const exception &e) { catch (const exception &e) {
std::lock_guard lock(dev_mtx_);
const string error_msg = "[MechmindCamera] (getRGBDImages): " + string(e.what()); const string error_msg = "[MechmindCamera] (getRGBDImages): " + string(e.what());
CMVR_LOG(ERROR) << error_msg; CMVR_LOG(ERROR) << error_msg;
state_.is_error = true; state_.is_error = true;
@ -269,12 +279,14 @@ void MechmindCamera::getRGBDImages(cv::Mat& color, cv::Mat& depth, Rs2Intrinsics
} }
void MechmindCamera::startRecording(const std::string& video_path) { void MechmindCamera::startRecording(const std::string& video_path) {
std::lock_guard lock(dev_mtx_);
state_.is_error = true; state_.is_error = true;
state_.error_message = "startRecording is not implemented"; state_.error_message = "startRecording is not implemented";
CMVR_LOG(ERROR) << "[MechmindCamera] (startRecording): " << state_.error_message; CMVR_LOG(ERROR) << "[MechmindCamera] (startRecording): " << state_.error_message;
} }
void MechmindCamera::stopRecording() { void MechmindCamera::stopRecording() {
std::lock_guard lock(dev_mtx_);
state_.is_error = true; state_.is_error = true;
state_.error_message = "stopRecording is not implemented"; state_.error_message = "stopRecording is not implemented";
CMVR_LOG(ERROR) << "[MechmindCamera] (stopRecording): " << state_.error_message; CMVR_LOG(ERROR) << "[MechmindCamera] (stopRecording): " << state_.error_message;

View File

@ -25,6 +25,7 @@ public:
~MujocoCamera() override = default; ~MujocoCamera() override = default;
std::string typeName() const override { return "MujocoCamera"; } std::string typeName() const override { return "MujocoCamera"; }
void getState(CameraState& state) override;
void setFovyDeg(double fovy_deg); void setFovyDeg(double fovy_deg);
void setConsumeNewFrameOnly(bool enable); void setConsumeNewFrameOnly(bool enable);

View File

@ -11,6 +11,11 @@ namespace cmvr::device {
MujocoCamera::MujocoCamera(FetchRgbdFn fetch_rgbd_fn) MujocoCamera::MujocoCamera(FetchRgbdFn fetch_rgbd_fn)
: fetch_rgbd_fn_(std::move(fetch_rgbd_fn)) {} : fetch_rgbd_fn_(std::move(fetch_rgbd_fn)) {}
void MujocoCamera::getState(CameraState& state) {
std::lock_guard<std::mutex> lock(mtx_);
state = state_;
}
void MujocoCamera::setFovyDeg(double fovy_deg) { void MujocoCamera::setFovyDeg(double fovy_deg) {
std::lock_guard<std::mutex> lock(mtx_); std::lock_guard<std::mutex> lock(mtx_);
fovy_deg_ = fovy_deg; fovy_deg_ = fovy_deg;

View File

@ -6,6 +6,7 @@
#define REALSENSE_CAMERA_H #define REALSENSE_CAMERA_H
#include "../../uvc_camera/include/uvc_camera.h" #include "../../uvc_camera/include/uvc_camera.h"
#include <atomic>
#include <librealsense2/rs.hpp> #include <librealsense2/rs.hpp>
#include <librealsense2/hpp/rs_internal.hpp> #include <librealsense2/hpp/rs_internal.hpp>
@ -22,6 +23,7 @@ namespace cmvr::device{
bool init() override; bool init() override;
bool start() override; bool start() override;
bool stop() override; bool stop() override;
void getState(CameraState& state) override;
void getRGBImage(cv::Mat& color, Rs2Intrinsics& intrinsics) override; void getRGBImage(cv::Mat& color, Rs2Intrinsics& intrinsics) override;
void getDepthImage(cv::Mat& depth, Rs2Intrinsics& intrinsics) override; void getDepthImage(cv::Mat& depth, Rs2Intrinsics& intrinsics) override;
void getRGBDImages(cv::Mat &color, cv::Mat &depth, Rs2Intrinsics& intrinsics) override; void getRGBDImages(cv::Mat &color, cv::Mat &depth, Rs2Intrinsics& intrinsics) override;
@ -36,10 +38,12 @@ namespace cmvr::device{
int width, int height, int fps); int width, int height, int fps);
static bool encodeFrameWithEncoder(std::shared_ptr<FfmpegEncoderInfo>& encoder,const cv::Mat& frame,std::vector<uint8_t>& encoded_frame,bool& is_key); static bool encodeFrameWithEncoder(std::shared_ptr<FfmpegEncoderInfo>& encoder,const cv::Mat& frame,std::vector<uint8_t>& encoded_frame,bool& is_key);
void getEncodedFrame(StreamFrameData& frame_data, size_t& index) override; void getEncodedFrame(StreamFrameData& frame_data, size_t& index) override;
bool waitEncodedFrame(StreamFrameData& frame_data, size_t& index, std::chrono::milliseconds timeout) override;
bool getLatestEncodedFrame(StreamFrameData& frame_data, size_t& next_index) override; bool getLatestEncodedFrame(StreamFrameData& frame_data, size_t& next_index) override;
bool startStreaming() override; bool startStreaming() override;
void stopStreaming() override; void stopStreaming() override;
bool requestKeyFrame() override;
Eigen::Vector3f get3DPointFromPixel(int u, int v) override; Eigen::Vector3f get3DPointFromPixel(int u, int v) override;
@ -47,6 +51,9 @@ namespace cmvr::device{
rs2::frameset get_frameset(bool align); rs2::frameset get_frameset(bool align);
void streaming_worker_(); void streaming_worker_();
void recording_worker_(); void recording_worker_();
void join_stream_thread_() noexcept;
void join_recording_thread_() noexcept;
void cleanup_recording_resources_(bool finalize_file) noexcept;
private: private:
int fps_; int fps_;
int width_; int width_;
@ -103,9 +110,16 @@ namespace cmvr::device{
size_t recordingIndex_ = 0; size_t recordingIndex_ = 0;
size_t getImageIndex_ = 0; size_t getImageIndex_ = 0;
bool is_streaming_running = false; // Worker threads never read CameraState directly. Public lifecycle
bool is_recording_running = false; // methods update CameraState under ctrl_mtx_, while these atomics form
// the cancellation/running handshake with the workers.
std::atomic<bool> capture_requested_{false};
std::atomic<bool> capture_running_{false};
std::atomic<bool> recording_requested_{false};
std::atomic<bool> recording_running_{false};
int stream_count_ = 0; int stream_count_ = 0;
std::atomic<uint64_t> stream_epoch_{0};
std::atomic<uint32_t> codec_config_generation_{0};
cv::Mat latest_depth_; cv::Mat latest_depth_;
std::mutex depth_mtx_; std::mutex depth_mtx_;

View File

@ -1,4 +1,6 @@
#include "common/base/logging/logger.h" #include "common/base/logging/logger.h"
#include <algorithm>
#include <chrono>
// //
// Created by linbo on 2025/6/18. // Created by linbo on 2025/6/18.
// //
@ -140,10 +142,87 @@ RealsenseCamera::~RealsenseCamera() {
} }
} }
void RealsenseCamera::join_stream_thread_() noexcept {
auto thread = std::move(stream_thread_);
if (!thread || !thread->joinable()) {
return;
}
try {
if (thread->get_id() == std::this_thread::get_id()) {
CMVR_LOG(ERROR) << "[RealsenseCamera] refusing to join capture thread from itself";
thread->detach();
return;
}
thread->join();
} catch (const std::exception& e) {
CMVR_LOG(ERROR) << "[RealsenseCamera] failed to join capture thread: " << e.what();
if (thread->joinable()) {
thread->detach();
}
}
}
void RealsenseCamera::join_recording_thread_() noexcept {
auto thread = std::move(recording_thread_);
if (!thread || !thread->joinable()) {
return;
}
try {
if (thread->get_id() == std::this_thread::get_id()) {
CMVR_LOG(ERROR) << "[RealsenseCamera] refusing to join recording thread from itself";
thread->detach();
return;
}
thread->join();
} catch (const std::exception& e) {
CMVR_LOG(ERROR) << "[RealsenseCamera] failed to join recording thread: " << e.what();
if (thread->joinable()) {
thread->detach();
}
}
}
void RealsenseCamera::cleanup_recording_resources_(const bool finalize_file) noexcept {
if (packet_) {
av_packet_free(&packet_);
}
if (stream_ && stream_->codecpar->extradata) {
av_free(stream_->codecpar->extradata);
stream_->codecpar->extradata = nullptr;
stream_->codecpar->extradata_size = 0;
}
if (format_context_) {
if (!(format_context_->oformat->flags & AVFMT_NOFILE) && format_context_->pb) {
avio_closep(&format_context_->pb);
}
avformat_free_context(format_context_);
format_context_ = nullptr;
}
stream_ = nullptr;
if (finalize_file && !current_video_path_.empty()) {
const std::string temp_path = current_video_path_ + ".temp";
if (rename(temp_path.c_str(), current_video_path_.c_str()) != 0) {
state_.is_error = true;
state_.error_message =
"failed to rename temp file: " + temp_path + " -> " + current_video_path_;
CMVR_LOG(ERROR) << "[RealsenseCamera] (cleanup recording): "
<< state_.error_message;
}
}
current_video_path_.clear();
}
bool RealsenseCamera::init() { bool RealsenseCamera::init() {
try { try {
std::lock_guard lock(ctrl_mtx_); std::lock_guard lock(ctrl_mtx_);
clear_error_(); clear_error_();
if (state_.is_opened || capture_requested_.load() || stream_thread_) {
state_.is_error = true;
state_.error_message = "camera must be stopped before reinitialization";
CMVR_LOG(ERROR) << "[RealsenseCamera] (init): " << state_.error_message;
return false;
}
state_.is_initialized = false; state_.is_initialized = false;
stream_frame_buffer_ = std::make_shared<SPMCRingBuffer<StreamFrameData>>(buffer_size_); stream_frame_buffer_ = std::make_shared<SPMCRingBuffer<StreamFrameData>>(buffer_size_);
@ -184,6 +263,7 @@ bool RealsenseCamera::init() {
return true; return true;
} }
catch (const exception &error){ catch (const exception &error){
std::lock_guard lock(ctrl_mtx_);
state_.is_initialized = false; state_.is_initialized = false;
CMVR_LOG(ERROR) << "realsense camera connect error:" << error.what(); CMVR_LOG(ERROR) << "realsense camera connect error:" << error.what();
state_.is_error = true; state_.is_error = true;
@ -193,6 +273,11 @@ bool RealsenseCamera::init() {
} }
void RealsenseCamera::getState(CameraState& state) {
std::lock_guard lock(ctrl_mtx_);
state = state_;
}
bool RealsenseCamera::start() { bool RealsenseCamera::start() {
std::lock_guard lock(ctrl_mtx_); std::lock_guard lock(ctrl_mtx_);
clear_error_(); clear_error_();
@ -297,25 +382,29 @@ bool RealsenseCamera::start() {
} }
bool RealsenseCamera::stop() { bool RealsenseCamera::stop() {
//先停止录制再关闭摄像头
if (state_.is_recording) {
try {
stopRecording();
} catch (const std::exception& e) {
CMVR_LOG(WARNING) << "[RealsenseCamera] (stop): stopRecording failed: " << e.what();
}
}
std::lock_guard lock(ctrl_mtx_); std::lock_guard lock(ctrl_mtx_);
clear_error_(); clear_error_();
if (!state_.is_opened || !state_.is_initialized) {
state_.is_opened = false; // Publish cancellation before stopping the pipeline. pipeline::stop()
return true; // wakes a worker blocked in wait_for_frames(); the joins below guarantee
} // that no thread can access this object after stop()/destruction returns.
const bool finalize_recording = state_.is_recording || recording_requested_.load();
state_.is_recording = false;
state_.is_streaming = false;
stream_count_ = 0;
recording_requested_.store(false);
capture_requested_.store(false);
try { try {
pipe_.stop(); pipe_.stop();
} catch (const std::exception& e) { } catch (const std::exception& e) {
CMVR_LOG(WARNING) << "[RealsenseCamera] (stop): " << e.what(); CMVR_LOG(WARNING) << "[RealsenseCamera] (stop): " << e.what();
} }
join_recording_thread_();
join_stream_thread_();
cleanup_recording_resources_(finalize_recording);
align_.reset(); align_.reset();
pipe_ = rs2::pipeline(); pipe_ = rs2::pipeline();
state_.is_opened = false; state_.is_opened = false;
@ -323,13 +412,6 @@ bool RealsenseCamera::stop() {
} }
void RealsenseCamera::getRGBImage(cv::Mat& color, Rs2Intrinsics& intrinsics) { void RealsenseCamera::getRGBImage(cv::Mat& color, Rs2Intrinsics& intrinsics) {
intrinsics.cx = intrinsics_.ppx;
intrinsics.cy = intrinsics_.ppy;
intrinsics.fx = intrinsics_.fx;
intrinsics.fy = intrinsics_.fy;
for (int i = 0; i < 5 ; i++) {
intrinsics.coeffs[i] = intrinsics_.coeffs[i];
}
try { try {
std::lock_guard lock(ctrl_mtx_); std::lock_guard lock(ctrl_mtx_);
clear_error_(); clear_error_();
@ -340,6 +422,13 @@ void RealsenseCamera::getRGBImage(cv::Mat& color, Rs2Intrinsics& intrinsics) {
color.release(); color.release();
return; return;
} }
intrinsics.cx = intrinsics_.ppx;
intrinsics.cy = intrinsics_.ppy;
intrinsics.fx = intrinsics_.fx;
intrinsics.fy = intrinsics_.fy;
for (int i = 0; i < 5 ; i++) {
intrinsics.coeffs[i] = intrinsics_.coeffs[i];
}
// 先尝试从队列取数据,没有的话再直接从摄像头读取 // 先尝试从队列取数据,没有的话再直接从摄像头读取
auto frame = stream_frame_buffer_->pop(getImageIndex_); auto frame = stream_frame_buffer_->pop(getImageIndex_);
if (frame.has_value()) { if (frame.has_value()) {
@ -370,6 +459,7 @@ void RealsenseCamera::getRGBImage(cv::Mat& color, Rs2Intrinsics& intrinsics) {
// CMVR_LOG(INFO) << "realsense get rgb frame successfully"; // CMVR_LOG(INFO) << "realsense get rgb frame successfully";
} }
catch (const std::exception& e) { catch (const std::exception& e) {
std::lock_guard lock(ctrl_mtx_);
state_.is_error = true; state_.is_error = true;
state_.error_message = "[RealsenseCamera] (getRGBImage): Failed to get image: " + std::string(e.what()); state_.error_message = "[RealsenseCamera] (getRGBImage): Failed to get image: " + std::string(e.what());
CMVR_LOG(ERROR) << state_.error_message; CMVR_LOG(ERROR) << state_.error_message;
@ -378,6 +468,16 @@ void RealsenseCamera::getRGBImage(cv::Mat& color, Rs2Intrinsics& intrinsics) {
} }
void RealsenseCamera::getDepthImage(cv::Mat& depth, Rs2Intrinsics& intrinsics) { void RealsenseCamera::getDepthImage(cv::Mat& depth, Rs2Intrinsics& intrinsics) {
std::lock_guard lock(ctrl_mtx_);
clear_error_();
if (!state_.is_opened) {
state_.is_error = true;
state_.error_message = "camera not opened";
CMVR_LOG(ERROR) << "[RealsenseCamera] (getDepthImage): " << state_.error_message;
depth.release();
return;
}
intrinsics.cx = intrinsics_.ppx; intrinsics.cx = intrinsics_.ppx;
intrinsics.cy = intrinsics_.ppy; intrinsics.cy = intrinsics_.ppy;
intrinsics.fx = intrinsics_.fx; intrinsics.fx = intrinsics_.fx;
@ -424,6 +524,17 @@ void RealsenseCamera::getDepthImage(cv::Mat& depth, Rs2Intrinsics& intrinsics) {
} }
void RealsenseCamera::getRGBDImages(cv::Mat &color, cv::Mat &depth, Rs2Intrinsics& intrinsics) { void RealsenseCamera::getRGBDImages(cv::Mat &color, cv::Mat &depth, Rs2Intrinsics& intrinsics) {
std::lock_guard lock(ctrl_mtx_);
clear_error_();
if (!state_.is_opened) {
state_.is_error = true;
state_.error_message = "camera not opened";
CMVR_LOG(ERROR) << "[RealsenseCamera] (getRGBDImages): " << state_.error_message;
color.release();
depth.release();
return;
}
intrinsics.cx = intrinsics_.ppx; intrinsics.cx = intrinsics_.ppx;
intrinsics.cy = intrinsics_.ppy; intrinsics.cy = intrinsics_.ppy;
intrinsics.fx = intrinsics_.fx; intrinsics.fx = intrinsics_.fx;
@ -498,15 +609,9 @@ void RealsenseCamera::startRecording(const std::string &video_path) {
} }
auto cleanup_recording_resources = [this]() { auto cleanup_recording_resources = [this]() {
if (packet_) av_packet_free(&packet_); recording_requested_.store(false);
if (stream_ && stream_->codecpar->extradata) av_free(stream_->codecpar->extradata);
if (format_context_) {
if (!(format_context_->oformat->flags & AVFMT_NOFILE) && format_context_->pb) avio_closep(&format_context_->pb);
avformat_free_context(format_context_);
}
stream_ = nullptr;
format_context_ = nullptr;
state_.is_recording = false; state_.is_recording = false;
cleanup_recording_resources_(false);
}; };
try { try {
@ -595,36 +700,40 @@ void RealsenseCamera::startRecording(const std::string &video_path) {
cleanup_recording_resources(); cleanup_recording_resources();
return; return;
} }
//不在录像也不在流传输,但是采集线程没有退出时。 // Reap a worker left behind by a previous capture session before
if (!state_.is_streaming && !state_.is_recording) { // starting a new one.
if (stream_thread_) { if (!capture_requested_.load() && stream_thread_) {
if (stream_thread_->joinable()) { join_stream_thread_();
stream_thread_->join();
is_streaming_running = false;
}
stream_thread_.reset();
}
} }
// 开启录像 // 开启录像
state_.is_recording = true; state_.is_recording = true;
recording_requested_.store(true);
//开启流采集线程 //开启流采集线程
if (!stream_thread_) { if (!stream_thread_) {
capture_requested_.store(true);
stream_thread_ = make_shared<thread>(&RealsenseCamera::streaming_worker_, this); stream_thread_ = make_shared<thread>(&RealsenseCamera::streaming_worker_, this);
//延时100ms等待流线程获取图像 //延时100ms等待流线程获取图像
std::this_thread::sleep_for(std::chrono::milliseconds(100)); std::this_thread::sleep_for(std::chrono::milliseconds(100));
} }
// 启动录像线程 // 启动录像线程
frame_count_ = 0; frame_count_ = 0;
if (recording_thread_) { if (recording_thread_) {
if (recording_thread_->joinable()) { recording_requested_.store(false);
recording_thread_->join(); join_recording_thread_();
is_recording_running = false; recording_requested_.store(true);
}
recording_thread_.reset();
} }
recording_thread_ = make_shared<thread>(&RealsenseCamera::recording_worker_, this); recording_thread_ = make_shared<thread>(&RealsenseCamera::recording_worker_, this);
} catch (const std::exception& e) { } catch (const std::exception& e) {
recording_requested_.store(false);
join_recording_thread_();
if (stream_count_ == 0) {
capture_requested_.store(false);
join_stream_thread_();
}
cleanup_recording_resources(); cleanup_recording_resources();
state_.is_error = true; state_.is_error = true;
state_.error_message = "[RealsenseCamera] (startRecording): " + std::string(e.what()); state_.error_message = "[RealsenseCamera] (startRecording): " + std::string(e.what());
@ -646,41 +755,19 @@ void RealsenseCamera::stopRecording() {
return; return;
} }
// 1. 停止录像线程 // 1. Stop and join the recording worker before releasing its FFmpeg
// resources. The atomic is the worker's sole lifecycle signal.
state_.is_recording = false; state_.is_recording = false;
if (recording_thread_ && recording_thread_->joinable()) { recording_requested_.store(false);
recording_thread_->join(); join_recording_thread_();
recording_thread_.reset(); cleanup_recording_resources_(true);
}
// 2. 清理FFmpeg资源 // Recording and live streaming share one capture worker. Once the last
if (packet_) { // user is gone, cancel and reap it as part of the lease release.
av_packet_free(&packet_); if (stream_count_ == 0) {
packet_ = nullptr; capture_requested_.store(false);
join_stream_thread_();
} }
if (stream_ && stream_->codecpar->extradata) {
av_free(stream_->codecpar->extradata);
stream_->codecpar->extradata = nullptr;
stream_->codecpar->extradata_size = 0;
}
if (format_context_) {
if (!(format_context_->oformat->flags & AVFMT_NOFILE) && format_context_->pb) {
avio_closep(&format_context_->pb); // 关闭文件
}
avformat_free_context(format_context_); // 释放格式上下文
format_context_ = nullptr;
}
stream_ = nullptr;
// 3. 重命名临时文件为目标文件
std::string temp_path = current_video_path_ + ".temp";
if (rename(temp_path.c_str(), current_video_path_.c_str()) != 0) {
state_.is_error = true;
state_.error_message = "failed to rename temp file: " + temp_path + " -> " + current_video_path_;
CMVR_LOG(ERROR) << "[RealsenseCamera] (stopRecording): " << state_.error_message;
return;
}
current_video_path_.clear();
} }
void RealsenseCamera::pauseRecording() { void RealsenseCamera::pauseRecording() {
@ -704,32 +791,36 @@ rs2::frameset RealsenseCamera::get_frameset(bool align) {
} }
void RealsenseCamera::streaming_worker_() { void RealsenseCamera::streaming_worker_() {
std::string failure;
capture_running_.store(true);
const uint64_t stream_epoch = stream_epoch_.fetch_add(1) + 1;
const uint32_t codec_config_generation = codec_config_generation_.fetch_add(1) + 1;
uint64_t stream_sequence = 0;
try { try {
// 计算理论上每帧之间的间隔时间(毫秒) // 计算理论上每帧之间的间隔时间(毫秒)
const int frame_interval = 1000 / fps_; const int frame_interval = 1000 / std::max(1, fps_);
bool success = false; bool success = false;
is_streaming_running = true;
// 处于流传输或者录像状态时就不退出线程 // Streaming and recording share this producer. Lifecycle state is
while (state_.is_streaming || state_.is_recording) { // communicated exclusively through the atomic cancellation token.
while (capture_requested_.load()) {
// 记录当前帧处理开始时间 // 记录当前帧处理开始时间
auto frame_start_time = std::chrono::high_resolution_clock::now(); auto frame_start_time = std::chrono::high_resolution_clock::now();
const auto capture_monotonic = std::chrono::steady_clock::now();
const auto capture_utc = std::chrono::system_clock::now();
rs2::frameset frames; rs2::frameset frames;
frames = get_frameset(true); frames = get_frameset(true);
rs2::frame color_frame = frames.get_color_frame(); rs2::frame color_frame = frames.get_color_frame();
rs2::frame depth_frame = frames.get_depth_frame(); rs2::frame depth_frame = frames.get_depth_frame();
if (!color_frame) { if (!color_frame) {
state_.is_error = true; failure = "missing color frame";
state_.error_message = "missing color frame";
CMVR_LOG(ERROR) << "[RealsenseCamera]streaming_worker_: " << state_.error_message;
break; break;
} }
if (stream_mode_ == RGBD_MODE && !depth_frame) { if (stream_mode_ == RGBD_MODE && !depth_frame) {
state_.is_error = true; failure = "missing depth frame in RGBD mode";
state_.error_message = "missing depth frame in RGBD mode";
CMVR_LOG(ERROR) << "[RealsenseCamera]streaming_worker_: " << state_.error_message;
break; break;
} }
@ -775,6 +866,24 @@ void RealsenseCamera::streaming_worker_() {
frame_data.width = encode_width_; frame_data.width = encode_width_;
frame_data.height = encode_height_; frame_data.height = encode_height_;
frame_data.codec = codec_; frame_data.codec = codec_;
frame_data.stream_epoch = stream_epoch;
frame_data.sequence = stream_sequence++;
frame_data.capture_monotonic_ns = std::chrono::duration_cast<std::chrono::nanoseconds>(
capture_monotonic.time_since_epoch()).count();
frame_data.capture_utc_ns = std::chrono::duration_cast<std::chrono::nanoseconds>(
capture_utc.time_since_epoch()).count();
frame_data.pts = rgbEncoder_ ? rgbEncoder_->frame->pts : static_cast<int64_t>(frame_data.sequence);
frame_data.dts = frame_data.pts;
frame_data.time_base_num = 1;
frame_data.time_base_den = std::max(1, fps_);
frame_data.duration = 1;
frame_data.codec_config_generation = codec_config_generation;
if (frame_data.bKey && rgbEncoder_ && rgbEncoder_->codec_context &&
rgbEncoder_->codec_context->extradata && rgbEncoder_->codec_context->extradata_size > 0) {
frame_data.codec_config.assign(
rgbEncoder_->codec_context->extradata,
rgbEncoder_->codec_context->extradata + rgbEncoder_->codec_context->extradata_size);
}
stream_frame_buffer_->push(frame_data); stream_frame_buffer_->push(frame_data);
} }
// 计算从帧开始到现在的总耗时 // 计算从帧开始到现在的总耗时
@ -794,31 +903,42 @@ void RealsenseCamera::streaming_worker_() {
} }
} }
is_streaming_running = false;
// 线程结束时清空队列
stream_frame_buffer_->clear();
recordingIndex_ = 0;
getImageIndex_ = 0;
streamIndex_ = 0;
} }
catch (const std::exception &e) { catch (const std::exception &e) {
// 线程结束时清空队列 // pipe_.stop() intentionally interrupts wait_for_frames() during
// shutdown. Only report failures that happened while capture was
// still requested.
if (capture_requested_.load()) {
failure = e.what();
}
}
const bool unexpected_stop = capture_requested_.exchange(false);
capture_running_.store(false);
if (stream_frame_buffer_) {
stream_frame_buffer_->clear(); stream_frame_buffer_->clear();
// 确保线程状态正确更新 }
is_streaming_running = false;
state_.is_error = true; if (unexpected_stop && !failure.empty()) {
state_.error_message = e.what(); CMVR_LOG(ERROR) << "[RealsenseCamera] streaming worker stopped: " << failure;
CMVR_LOG(ERROR) << "[RealsenseCamera]streaming_worker_ error:" << state_.error_message;
} }
} }
void RealsenseCamera::recording_worker_() { void RealsenseCamera::recording_worker_() {
is_recording_running = true; recording_running_.store(true);
const int frame_interval = 1000 / fps_; const int frame_interval = 1000 / std::max(1, fps_);
bool is_first_key = false; bool is_first_key = false;
try { try {
//保证当前采集线程正常运行 //保证当前采集线程正常运行
while (state_.is_recording && is_streaming_running) { while (recording_requested_.load()) {
if (!capture_running_.load()) {
if (!capture_requested_.load()) {
break;
}
std::this_thread::sleep_for(std::chrono::milliseconds(5));
continue;
}
// 等待缓冲区有数据 // 等待缓冲区有数据
if (stream_frame_buffer_->empty()) { if (stream_frame_buffer_->empty()) {
std::this_thread::sleep_for(std::chrono::milliseconds(frame_interval)); std::this_thread::sleep_for(std::chrono::milliseconds(frame_interval));
@ -886,8 +1006,8 @@ void RealsenseCamera::recording_worker_() {
CMVR_LOG(ERROR) << "Recording thread error: " << e.what(); CMVR_LOG(ERROR) << "Recording thread error: " << e.what();
} }
is_recording_running = false; recording_requested_.store(false);
state_.is_recording = false; recording_running_.store(false);
} }
// 初始化单个编码器的通用函数 // 初始化单个编码器的通用函数
@ -1071,6 +1191,9 @@ bool RealsenseCamera::encodeFrameWithEncoder(std::shared_ptr<FfmpegEncoderInfo>&
return false; return false;
} }
encoded_frame.clear();
is_key = false;
// 确保输入帧尺寸匹配(不变) // 确保输入帧尺寸匹配(不变)
if (frame.cols != encoder->width || frame.rows != encoder->height) { if (frame.cols != encoder->width || frame.rows != encoder->height) {
CMVR_LOG(ERROR) << "Frame size does not match encoder dimensions"; CMVR_LOG(ERROR) << "Frame size does not match encoder dimensions";
@ -1084,6 +1207,9 @@ bool RealsenseCamera::encodeFrameWithEncoder(std::shared_ptr<FfmpegEncoderInfo>&
// 【修复3设置递增的PTS确保编码器正确处理I帧请求】 // 【修复3设置递增的PTS确保编码器正确处理I帧请求】
encoder->frame->pts = encoder->frame_pts++; // 分配唯一PTS encoder->frame->pts = encoder->frame_pts++; // 分配唯一PTS
encoder->frame->pict_type = encoder->force_key_frame.exchange(false)
? AV_PICTURE_TYPE_I
: AV_PICTURE_TYPE_NONE;
// 根据cv::Mat的类型设置源格式不变 // 根据cv::Mat的类型设置源格式不变
AVPixelFormat src_pix_fmt; AVPixelFormat src_pix_fmt;
@ -1147,9 +1273,6 @@ bool RealsenseCamera::encodeFrameWithEncoder(std::shared_ptr<FfmpegEncoderInfo>&
if (encoder->packet->flags & AV_PKT_FLAG_KEY) { if (encoder->packet->flags & AV_PKT_FLAG_KEY) {
is_key = true; is_key = true;
//CMVR_LOG(INFO) << "Encoded I frame (size: " << encoder->packet->size << " bytes)"; //CMVR_LOG(INFO) << "Encoded I frame (size: " << encoder->packet->size << " bytes)";
} else {
is_key = false;
//CMVR_LOG(INFO) << "Encoded P frame (size: " << encoder->packet->size << " bytes)";
} }
// 预留足够空间,避免多次内存分配 // 预留足够空间,避免多次内存分配
@ -1189,61 +1312,99 @@ void RealsenseCamera::getEncodedFrame(StreamFrameData& frame_data, size_t& index
} }
} }
bool RealsenseCamera::waitEncodedFrame(
StreamFrameData& frame_data,
size_t& index,
const std::chrono::milliseconds timeout) {
if (!stream_frame_buffer_) {
return false;
}
auto frame = stream_frame_buffer_->waitPop(index, timeout);
if (!frame) {
return false;
}
frame_data = std::move(*frame);
return !frame_data.rgbFrame.empty() || !frame_data.depthFrame.empty();
}
bool RealsenseCamera::getLatestEncodedFrame(StreamFrameData& frame_data, size_t& next_index) { bool RealsenseCamera::getLatestEncodedFrame(StreamFrameData& frame_data, size_t& next_index) {
if (!stream_frame_buffer_) { if (!stream_frame_buffer_) {
return false; return false;
} }
const size_t head = stream_frame_buffer_->getHead(); auto frame = stream_frame_buffer_->getLatest(next_index);
if (head == 0) {
return false;
}
size_t latest_index = head - 1;
auto frame = stream_frame_buffer_->pop(latest_index);
if (!frame.has_value()) { if (!frame.has_value()) {
return false; return false;
} }
frame_data = frame.value(); frame_data = frame.value();
next_index = latest_index;
return true; return true;
} }
bool RealsenseCamera::startStreaming() bool RealsenseCamera::startStreaming()
{ {
std::lock_guard lock(ctrl_mtx_); std::lock_guard lock(ctrl_mtx_);
//不在录像也不在流传输,但是采集线程没有退出时。 clear_error_();
if (!state_.is_streaming && !state_.is_recording) { if (!state_.is_initialized || !state_.is_opened) {
if (stream_thread_) { state_.is_error = true;
if (stream_thread_->joinable()) { state_.error_message = "camera not opened";
stream_thread_->join(); CMVR_LOG(ERROR) << "[RealsenseCamera] (startStreaming): " << state_.error_message;
is_streaming_running = false; return false;
}
stream_thread_.reset();
} }
// A completed worker remains joinable until it is reaped. Reap it before
// creating a replacement; otherwise destruction would call terminate().
if (!capture_requested_.load() && stream_thread_) {
join_stream_thread_();
} }
//开启流采集线程 //开启流采集线程
if (!stream_thread_) { if (!stream_thread_) {
capture_requested_.store(true);
state_.is_streaming = true; state_.is_streaming = true;
try {
stream_thread_ = make_shared<thread>(&RealsenseCamera::streaming_worker_, this); stream_thread_ = make_shared<thread>(&RealsenseCamera::streaming_worker_, this);
} catch (const std::exception& e) {
//延时100ms等待流线程获取图像 capture_requested_.store(false);
std::this_thread::sleep_for(std::chrono::milliseconds(100)); state_.is_streaming = false;
state_.is_error = true;
state_.error_message = "failed to create capture thread: " + std::string(e.what());
CMVR_LOG(ERROR) << "[RealsenseCamera] (startStreaming): "
<< state_.error_message;
return false;
} }
stream_count_++; }
++stream_count_;
state_.is_streaming = true;
return true; return true;
} }
void RealsenseCamera::stopStreaming() void RealsenseCamera::stopStreaming()
{ {
std::lock_guard lock(ctrl_mtx_); std::lock_guard lock(ctrl_mtx_);
stream_count_--; if (stream_count_ > 0) {
--stream_count_;
}
if (stream_count_ == 0) if (stream_count_ == 0)
{ {
// 当前已经没有正在使用的流了,编码采集线程状态修改 // Recording may still own the shared producer. Otherwise release the
// final lease synchronously so no joinable thread is left behind.
state_.is_streaming = false; state_.is_streaming = false;
if (!recording_requested_.load()) {
capture_requested_.store(false);
join_stream_thread_();
} }
}
}
bool RealsenseCamera::requestKeyFrame()
{
std::lock_guard lock(ctrl_mtx_);
if (!rgbEncoder_) {
return false;
}
rgbEncoder_->force_key_frame.store(true);
return true;
} }

View File

@ -2,7 +2,19 @@ add_library(uvc_camera SHARED src/uvc_camera.cpp)
target_include_directories(uvc_camera PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) target_include_directories(uvc_camera PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
target_link_libraries(uvc_camera PUBLIC glog opencv_core opencv_imgproc cmvr_es::proto) target_link_libraries(uvc_camera
PUBLIC
glog
opencv_core
opencv_imgproc
cmvr_es::proto
PRIVATE
opencv_videoio
avcodec
avformat
avutil
swscale
)
add_library(cmvr_es::device::uvc_camera ALIAS uvc_camera) add_library(cmvr_es::device::uvc_camera ALIAS uvc_camera)
install(TARGETS uvc_camera LIBRARY DESTINATION lib) install(TARGETS uvc_camera LIBRARY DESTINATION lib)

View File

@ -5,6 +5,11 @@
#ifndef CMVR_ES_UVC_CAMERA_H #ifndef CMVR_ES_UVC_CAMERA_H
#define CMVR_ES_UVC_CAMERA_H #define CMVR_ES_UVC_CAMERA_H
#include <atomic>
#include <memory>
#include <mutex>
#include <thread>
#include "common/base/ring_buffer.h" #include "common/base/ring_buffer.h"
#include "camera/abstract_camera.h" #include "camera/abstract_camera.h"
@ -33,6 +38,7 @@ namespace cmvr::device {
int height = 0; // 图像高度 int height = 0; // 图像高度
int fps = 0; // 帧率 int fps = 0; // 帧率
int64_t frame_pts = 0; int64_t frame_pts = 0;
std::atomic<bool> force_key_frame{false};
bool bRunning = false; // 是否进行编码 bool bRunning = false; // 是否进行编码
AVCodecContext* codec_context = nullptr; // 编码器上下文 AVCodecContext* codec_context = nullptr; // 编码器上下文
AVFrame* frame = nullptr; // 输入帧 AVFrame* frame = nullptr; // 输入帧
@ -73,6 +79,7 @@ namespace cmvr::device {
~UVCCamera() override; ~UVCCamera() override;
std::string typeName() const override { return "UVCCamera"; } std::string typeName() const override { return "UVCCamera"; }
void getState(CameraState& state) override;
bool init() override; bool init() override;
bool start() override; bool start() override;
bool stop() override; bool stop() override;
@ -89,11 +96,23 @@ namespace cmvr::device {
int width, int height, int fps); int width, int height, int fps);
static bool encodeFrameWithEncoder(std::shared_ptr<FfmpegEncoderInfo>& encoder,const cv::Mat& frame,std::vector<uint8_t>& encoded_frame,bool& is_key); static bool encodeFrameWithEncoder(std::shared_ptr<FfmpegEncoderInfo>& encoder,const cv::Mat& frame,std::vector<uint8_t>& encoded_frame,bool& is_key);
void getEncodedFrame(StreamFrameData& frame_data, size_t& index) override; void getEncodedFrame(StreamFrameData& frame_data, size_t& index) override;
bool waitEncodedFrame(StreamFrameData& frame_data, size_t& index, std::chrono::milliseconds timeout) override;
bool getLatestEncodedFrame(StreamFrameData& frame_data, size_t& next_index) override; bool getLatestEncodedFrame(StreamFrameData& frame_data, size_t& next_index) override;
bool startStreaming() override; bool startStreaming() override;
void stopStreaming() override; void stopStreaming() override;
bool requestKeyFrame() override;
private: private:
// Public lifecycle operations are serialized by lifecycle_mtx_. Worker
// threads never acquire it, so they can always be collected without a
// lifecycle/control lock inversion.
bool open_capture_locked_();
bool ensure_capture_worker_();
bool stop_capture_worker_if_unused_(bool force);
void stop_recording_impl_(bool warn_if_inactive);
void cleanup_recording_resources_locked_();
void reset_stream_metadata_();
void set_worker_error_(const std::string& message);
void streaming_worker_(); void streaming_worker_();
void recording_worker_(); void recording_worker_();
@ -106,13 +125,15 @@ namespace cmvr::device {
cv::VideoCapture cap_; cv::VideoCapture cap_;
size_t buffer_size_; size_t buffer_size_;
std::string codec_; std::string codec_;
CameraMode mode_; CameraMode mode_{PHOTO_MODE};
// std::shared_ptr<cv::Mat> current_image_; // std::shared_ptr<cv::Mat> current_image_;
std::shared_ptr<SPMCRingBuffer<StreamFrameData>> stream_frame_buffer_; std::shared_ptr<SPMCRingBuffer<StreamFrameData>> stream_frame_buffer_;
std::string current_video_path_; std::string current_video_path_;
std::mutex lifecycle_mtx_{};
std::mutex ctrl_mtx_{}; std::mutex ctrl_mtx_{};
std::mutex stream_metadata_mtx_{};
std::unique_ptr<cv::VideoWriter> video_writer_; std::unique_ptr<cv::VideoWriter> video_writer_;
std::shared_ptr<std::thread> stream_thread_; std::shared_ptr<std::thread> stream_thread_;
std::shared_ptr<std::thread> recording_thread_; std::shared_ptr<std::thread> recording_thread_;
@ -122,7 +143,6 @@ namespace cmvr::device {
std::mutex frame_mutex_; // 保护最新帧的访问 std::mutex frame_mutex_; // 保护最新帧的访问
std::string output_path_; std::string output_path_;
bool is_recording_ = false;
// ffmpeg录像 // ffmpeg录像
AVFormatContext* format_context_ = nullptr; AVFormatContext* format_context_ = nullptr;
AVCodecContext* codec_context_ = nullptr; AVCodecContext* codec_context_ = nullptr;
@ -134,13 +154,15 @@ namespace cmvr::device {
std::shared_ptr<FfmpegEncoderInfo> rgbEncoder_;//rgb图像编码 std::shared_ptr<FfmpegEncoderInfo> rgbEncoder_;//rgb图像编码
std::shared_ptr<FfmpegEncoderInfo> depthEncoder_;//深度图编码 std::shared_ptr<FfmpegEncoderInfo> depthEncoder_;//深度图编码
size_t streamIndex_ = 0;
size_t recordingIndex_ = 0; size_t recordingIndex_ = 0;
size_t getImageIndex_ = 0; std::atomic<bool> capture_requested_{false};
std::atomic<bool> capture_running_{false};
bool is_streaming_running = false; std::atomic<bool> recording_requested_{false};
bool is_recording_running = false; std::atomic<bool> recording_running_{false};
int stream_count_ = 0; int stream_count_ = 0;
uint64_t stream_epoch_ = 0;
uint64_t stream_sequence_ = 0;
uint32_t codec_config_generation_ = 0;
config::UVCCameraConfig camera_; config::UVCCameraConfig camera_;

View File

@ -1,4 +1,7 @@
#include "common/base/logging/logger.h" #include "common/base/logging/logger.h"
#include <algorithm>
#include <chrono>
#include <utility>
// //
// Created by xtkuang on 2025/5/30. // Created by xtkuang on 2025/5/30.
// //
@ -48,15 +51,188 @@ UVCCamera::UVCCamera(const config::UVCCameraConfig& camera):camera_(camera)
} }
UVCCamera::~UVCCamera() { UVCCamera::~UVCCamera() {
stop(); stop();
if (stream_thread_ && stream_thread_->joinable()) }
stream_thread_->join();
if (recording_thread_ && recording_thread_->joinable()) void UVCCamera::getState(CameraState& state) {
recording_thread_->join(); std::lock_guard lock(ctrl_mtx_);
state = state_;
}
bool UVCCamera::open_capture_locked_() {
if (cap_.isOpened()) {
return true;
}
cap_.open(serial_, cv::CAP_V4L2);
this_thread::sleep_for(chrono::milliseconds(100));
if (!cap_.isOpened()) {
state_.is_error = true;
state_.error_message = "Failed to open USB camera at index " + serial_;
CMVR_LOG(ERROR) << "[UVCCamera] (open_capture_locked_): "
<< state_.error_message;
return false;
}
cap_.set(cv::CAP_PROP_FOURCC, cv::VideoWriter::fourcc('M', 'J', 'P', 'G'));
if (!cap_.set(cv::CAP_PROP_FRAME_WIDTH, width_)) {
state_.is_error = true;
state_.error_message = "set width failed";
} else if (!cap_.set(cv::CAP_PROP_FRAME_HEIGHT, height_)) {
state_.is_error = true;
state_.error_message = "set height failed";
} else if (!cap_.set(cv::CAP_PROP_FPS, fps_)) {
state_.is_error = true;
state_.error_message = "set fps failed";
} else {
state_.width = width_;
state_.height = height_;
state_.fps = fps_;
return true;
}
CMVR_LOG(ERROR) << "[UVCCamera] (open_capture_locked_): "
<< state_.error_message;
cap_.release();
return false;
}
void UVCCamera::reset_stream_metadata_() {
{
std::lock_guard metadata_lock(stream_metadata_mtx_);
++stream_epoch_;
stream_sequence_ = 0;
++codec_config_generation_;
}
if (rgbEncoder_) {
rgbEncoder_->force_key_frame.store(true);
}
}
void UVCCamera::set_worker_error_(const std::string& message) {
std::lock_guard lock(ctrl_mtx_);
state_.is_error = true;
state_.error_message = message;
}
bool UVCCamera::ensure_capture_worker_() {
std::shared_ptr<std::thread> stale_worker;
{
std::lock_guard lock(ctrl_mtx_);
if (stream_thread_ && capture_requested_.load()) {
return true;
}
stale_worker = std::move(stream_thread_);
}
if (stale_worker && stale_worker->joinable()) {
stale_worker->join();
}
std::lock_guard lock(ctrl_mtx_);
// A failed/finished worker may leave an open but unusable V4L2 backend.
// It is safe to reset only after that worker has been joined.
if (stale_worker && cap_.isOpened()) {
cap_.release();
}
if (!state_.is_initialized || !state_.is_opened) {
state_.is_error = true;
state_.error_message = "camera not opened";
return false;
}
if (!open_capture_locked_()) {
return false;
}
// A producer restart while streaming is a discontinuity even if the
// logical subscriber lease did not change.
if (stream_count_ > 0) {
reset_stream_metadata_();
}
capture_requested_.store(true);
try {
stream_thread_ = make_shared<thread>(&UVCCamera::streaming_worker_, this);
} catch (const std::exception& e) {
capture_requested_.store(false);
state_.is_error = true;
state_.error_message =
"failed to create capture thread: " + std::string(e.what());
CMVR_LOG(ERROR) << "[UVCCamera] (ensure_capture_worker_): "
<< state_.error_message;
return false;
}
return true;
}
bool UVCCamera::stop_capture_worker_if_unused_(const bool force) {
std::shared_ptr<std::thread> worker;
std::shared_ptr<SPMCRingBuffer<StreamFrameData>> buffer;
{
std::lock_guard lock(ctrl_mtx_);
if (!force && (stream_count_ > 0 || recording_requested_.load())) {
return true;
}
capture_requested_.store(false);
worker = std::move(stream_thread_);
buffer = stream_frame_buffer_;
}
try {
if (worker && worker->joinable()) {
worker->join();
}
} catch (const std::exception& e) {
set_worker_error_("failed to join capture thread: " + std::string(e.what()));
return false;
}
capture_running_.store(false);
{
// The worker no longer touches either cap_ or its shared backend, so
// release is ordered after all waitAny()/retrieve() calls.
std::lock_guard lock(ctrl_mtx_);
if (cap_.isOpened()) {
cap_.release();
}
}
if (buffer) {
buffer->clear();
}
return true;
}
void UVCCamera::cleanup_recording_resources_locked_() {
if (packet_) {
av_packet_free(&packet_);
}
if (stream_ && stream_->codecpar->extradata) {
av_free(stream_->codecpar->extradata);
stream_->codecpar->extradata = nullptr;
stream_->codecpar->extradata_size = 0;
}
if (format_context_) {
if (!(format_context_->oformat->flags & AVFMT_NOFILE) &&
format_context_->pb) {
avio_closep(&format_context_->pb);
}
avformat_free_context(format_context_);
}
stream_ = nullptr;
format_context_ = nullptr;
} }
bool UVCCamera::init() { bool UVCCamera::init() {
std::lock_guard lifecycle_lock(lifecycle_mtx_);
std::lock_guard lock(ctrl_mtx_); std::lock_guard lock(ctrl_mtx_);
clear_error_(); clear_error_();
if (state_.is_opened || stream_thread_ || recording_thread_ ||
stream_count_ > 0 || recording_requested_.load()) {
state_.is_error = true;
state_.error_message = "cannot initialize while capture is active";
CMVR_LOG(ERROR) << "[UVCCamera] (init): " << state_.error_message;
return false;
}
state_.is_initialized = false; state_.is_initialized = false;
stream_frame_buffer_ = std::make_shared<SPMCRingBuffer<StreamFrameData>>(buffer_size_); stream_frame_buffer_ = std::make_shared<SPMCRingBuffer<StreamFrameData>>(buffer_size_);
@ -65,37 +241,7 @@ bool UVCCamera::init() {
cap_.release(); cap_.release();
} }
cap_.open(serial_, cv::CAP_V4L2); if (!open_capture_locked_()) {
this_thread::sleep_for(chrono::milliseconds(100));
if (!cap_.isOpened()) {
state_.is_error = true;
state_.error_message = "Failed to open USB camera at index " + serial_;
CMVR_LOG(ERROR) << "[UVCCamera] (init)" << state_.error_message;
return false;
}
// 设置格式为MJPG
cap_.set(cv::CAP_PROP_FOURCC, cv::VideoWriter::fourcc('M', 'J', 'P', 'G'));
if (!cap_.set(cv::CAP_PROP_FRAME_WIDTH, width_)) {
state_.is_error = true;
state_.error_message = "set width failed";
CMVR_LOG(ERROR) << "[UVCCamera] (init): set width failed";
return false;
}
state_.width = width_;
if (!cap_.set(cv::CAP_PROP_FRAME_HEIGHT, height_)) {
state_.is_error = true;
state_.error_message = "set height failed";
CMVR_LOG(ERROR) << "[UVCCamera] (init): set height failed";
return false;
}
state_.height = height_;
if (!cap_.set(cv::CAP_PROP_FPS, fps_)) {
state_.is_error = true;
state_.error_message = "set fps failed";
CMVR_LOG(ERROR) << "[UVCCamera] (init): set fps failed";
return false; return false;
} }
//初始化编码器 //初始化编码器
@ -114,6 +260,7 @@ bool UVCCamera::init() {
} }
bool UVCCamera::start() { bool UVCCamera::start() {
std::lock_guard lifecycle_lock(lifecycle_mtx_);
std::lock_guard lock(ctrl_mtx_); std::lock_guard lock(ctrl_mtx_);
clear_error_(); clear_error_();
if (!state_.is_initialized) { if (!state_.is_initialized) {
@ -123,40 +270,10 @@ bool UVCCamera::start() {
return false; return false;
} }
if (state_.is_opened) { if (state_.is_opened) {
CMVR_LOG(WARNING) << "[RealsenseCamera] (start): camera already started"; CMVR_LOG(WARNING) << "[UVCCamera] (start): camera already started";
return true; return true;
} }
cap_.open(serial_, cv::CAP_V4L2); if (!open_capture_locked_()) {
this_thread::sleep_for(chrono::milliseconds(100));
if (!cap_.isOpened()) {
state_.is_error = true;
state_.error_message = "Failed to open USB camera at index " + serial_;
CMVR_LOG(ERROR) << "[UVCCamera] (init)" << state_.error_message;
return false;
}
// 设置格式为MJPG
cap_.set(cv::CAP_PROP_FOURCC, cv::VideoWriter::fourcc('M', 'J', 'P', 'G'));
if (!cap_.set(cv::CAP_PROP_FRAME_WIDTH, width_)) {
state_.is_error = true;
state_.error_message = "set width failed";
CMVR_LOG(ERROR) << "[UVCCamera] (init): set width failed";
return false;
}
state_.width = width_;
if (!cap_.set(cv::CAP_PROP_FRAME_HEIGHT, height_)) {
state_.is_error = true;
state_.error_message = "set height failed";
CMVR_LOG(ERROR) << "[UVCCamera] (init): set height failed";
return false;
}
state_.height = height_;
if (!cap_.set(cv::CAP_PROP_FPS, fps_)) {
state_.is_error = true;
state_.error_message = "set fps failed";
CMVR_LOG(ERROR) << "[UVCCamera] (init): set fps failed";
return false; return false;
} }
state_.is_opened = true; state_.is_opened = true;
@ -164,42 +281,24 @@ bool UVCCamera::start() {
} }
bool UVCCamera::stop() { bool UVCCamera::stop() {
//先停止录制再关闭摄像头 std::lock_guard lifecycle_lock(lifecycle_mtx_);
if (state_.is_recording) {
stopRecording(); if (mode_ == VIDEO_MODE) {
stop_recording_impl_(false);
} }
{
std::lock_guard lock(ctrl_mtx_); std::lock_guard lock(ctrl_mtx_);
clear_error_(); clear_error_();
try { stream_count_ = 0;
if (!state_.is_opened || !state_.is_initialized) {
CMVR_LOG(WARNING) << "[UVCCamera] (stop): Camera already closed";
return true;
}
if (mode_ == VIDEO_MODE){
state_.is_streaming = false; state_.is_streaming = false;
if (stream_thread_->joinable()) {
stream_thread_->join();
stream_thread_.reset();
stream_thread_ = nullptr;
}
}
if (cap_.isOpened()) {
cap_.release();
}
state_.is_opened = false; state_.is_opened = false;
return true;
}
catch (exception &e) {
CMVR_LOG(ERROR) << "[UVCCamera] (stop): " << e.what();
state_.is_error = true;
state_.error_message = e.what();
return false;
} }
return stop_capture_worker_if_unused_(true);
} }
void UVCCamera::getRGBImage(cv::Mat& color, Rs2Intrinsics& intrinsics) void UVCCamera::getRGBImage(cv::Mat& color, Rs2Intrinsics& intrinsics)
{ {
(void)intrinsics;
std::lock_guard lock(ctrl_mtx_); std::lock_guard lock(ctrl_mtx_);
clear_error_(); clear_error_();
if (mode_ == PHOTO_MODE) { if (mode_ == PHOTO_MODE) {
@ -231,6 +330,8 @@ void UVCCamera::getRGBImage(cv::Mat& color, Rs2Intrinsics& intrinsics)
} }
void UVCCamera::getDepthImage(cv::Mat& depth, Rs2Intrinsics& intrinsics) { void UVCCamera::getDepthImage(cv::Mat& depth, Rs2Intrinsics& intrinsics) {
(void)intrinsics;
std::lock_guard lock(ctrl_mtx_);
state_.is_error = true; state_.is_error = true;
state_.error_message = "getDepthImage unsupported usage"; state_.error_message = "getDepthImage unsupported usage";
CMVR_LOG(ERROR) << "[UVCCamera] (getDepthImage): " << state_.error_message; CMVR_LOG(ERROR) << "[UVCCamera] (getDepthImage): " << state_.error_message;
@ -238,6 +339,8 @@ void UVCCamera::getDepthImage(cv::Mat& depth, Rs2Intrinsics& intrinsics) {
} }
void UVCCamera::getRGBDImages(cv::Mat &color, cv::Mat &depth, Rs2Intrinsics& intrinsics) { void UVCCamera::getRGBDImages(cv::Mat &color, cv::Mat &depth, Rs2Intrinsics& intrinsics) {
(void)intrinsics;
std::lock_guard lock(ctrl_mtx_);
state_.is_error = true; state_.is_error = true;
state_.error_message = "getRGBDImages unsupported usage"; state_.error_message = "getRGBDImages unsupported usage";
CMVR_LOG(ERROR) << "[UVCCamera] (getRGBDImages): " << state_.error_message; CMVR_LOG(ERROR) << "[UVCCamera] (getRGBDImages): " << state_.error_message;
@ -246,7 +349,8 @@ void UVCCamera::getRGBDImages(cv::Mat &color, cv::Mat &depth, Rs2Intrinsics& int
} }
void UVCCamera::startRecording(const std::string &video_path) { void UVCCamera::startRecording(const std::string &video_path) {
std::lock_guard lock(ctrl_mtx_); std::lock_guard lifecycle_lock(lifecycle_mtx_);
std::unique_lock lock(ctrl_mtx_);
clear_error_(); clear_error_();
if (mode_ != VIDEO_MODE) { if (mode_ != VIDEO_MODE) {
state_.is_error = true; state_.is_error = true;
@ -268,14 +372,8 @@ void UVCCamera::startRecording(const std::string &video_path) {
} }
auto cleanup_recording_resources = [this]() { auto cleanup_recording_resources = [this]() {
if (packet_) av_packet_free(&packet_); cleanup_recording_resources_locked_();
if (stream_ && stream_->codecpar->extradata) av_free(stream_->codecpar->extradata); recording_requested_.store(false);
if (format_context_) {
if (!(format_context_->oformat->flags & AVFMT_NOFILE) && format_context_->pb) avio_closep(&format_context_->pb);
avformat_free_context(format_context_);
}
stream_ = nullptr;
format_context_ = nullptr;
state_.is_recording = false; state_.is_recording = false;
}; };
@ -365,36 +463,38 @@ void UVCCamera::startRecording(const std::string &video_path) {
cleanup_recording_resources(); cleanup_recording_resources();
return; return;
} }
//不在录像也不在流传输,但是采集线程没有退出时。 recordingIndex_ = stream_frame_buffer_
if (!state_.is_streaming && !state_.is_recording) { ? stream_frame_buffer_->getHead()
if (stream_thread_) { : 0;
if (stream_thread_->joinable()) {
stream_thread_->join();
is_streaming_running = false;
}
stream_thread_.reset();
}
}
// 开启录像
state_.is_recording = true;
//开启流采集线程
if (!stream_thread_) {
stream_thread_ = make_shared<thread>(&UVCCamera::streaming_worker_, this);
//延时100ms等待流线程获取图像
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
// 启动录像线程
if (recording_thread_) {
if (recording_thread_->joinable()) {
recording_thread_->join();
is_recording_running = false;
}
recording_thread_.reset();
}
frame_count_ = 0; frame_count_ = 0;
recording_thread_ = make_shared<thread>(&UVCCamera::recording_worker_, this); recording_requested_.store(true);
state_.is_recording = true;
lock.unlock();
if (!ensure_capture_worker_()) {
lock.lock();
cleanup_recording_resources();
return;
}
lock.lock();
try {
recording_thread_ =
make_shared<thread>(&UVCCamera::recording_worker_, this);
} catch (const std::exception& e) { } catch (const std::exception& e) {
cleanup_recording_resources();
state_.is_error = true;
state_.error_message =
"failed to create recording thread: " + std::string(e.what());
CMVR_LOG(ERROR) << "[UVCCamera] (startRecording): "
<< state_.error_message;
lock.unlock();
stop_capture_worker_if_unused_(false);
}
} catch (const std::exception& e) {
if (!lock.owns_lock()) {
lock.lock();
}
cleanup_recording_resources(); cleanup_recording_resources();
state_.is_error = true; state_.is_error = true;
state_.error_message = "[UVCCamera] (startRecording): " + std::string(e.what()); state_.error_message = "[UVCCamera] (startRecording): " + std::string(e.what());
@ -403,54 +503,70 @@ void UVCCamera::startRecording(const std::string &video_path) {
} }
void UVCCamera::stopRecording() { void UVCCamera::stopRecording() {
std::lock_guard lifecycle_lock(lifecycle_mtx_);
stop_recording_impl_(true);
}
void UVCCamera::stop_recording_impl_(const bool warn_if_inactive) {
std::shared_ptr<std::thread> worker;
std::string completed_video_path;
{
std::lock_guard lock(ctrl_mtx_); std::lock_guard lock(ctrl_mtx_);
clear_error_(); clear_error_();
if (mode_ != VIDEO_MODE) { if (mode_ != VIDEO_MODE) {
if (warn_if_inactive) {
state_.is_error = true; state_.is_error = true;
state_.error_message = "stopRecording only supports VIDEO_MODE"; state_.error_message =
CMVR_LOG(ERROR) << "[UVCCamera] (stopRecording): " << state_.error_message; "stopRecording only supports VIDEO_MODE";
CMVR_LOG(ERROR) << "[UVCCamera] (stopRecording): "
<< state_.error_message;
}
return; return;
} }
if (!state_.is_recording) { if (!state_.is_recording && !recording_thread_) {
CMVR_LOG(WARNING) << "[UVCCamera] (stopRecording): not recording"; if (warn_if_inactive) {
CMVR_LOG(WARNING)
<< "[UVCCamera] (stopRecording): not recording";
}
return; return;
} }
// 1. 停止录像线程 recording_requested_.store(false);
state_.is_recording = false; state_.is_recording = false;
if (recording_thread_ && recording_thread_->joinable()) { worker = std::move(recording_thread_);
recording_thread_->join(); completed_video_path = current_video_path_;
recording_thread_.reset();
} }
// 2. 清理FFmpeg资源 try {
if (packet_) { if (worker && worker->joinable()) {
av_packet_free(&packet_); worker->join();
packet_ = nullptr;
} }
if (stream_ && stream_->codecpar->extradata) { } catch (const std::exception& e) {
av_free(stream_->codecpar->extradata); set_worker_error_(
stream_->codecpar->extradata = nullptr; "failed to join recording thread: " + std::string(e.what()));
stream_->codecpar->extradata_size = 0;
} }
if (format_context_) { recording_running_.store(false);
if (!(format_context_->oformat->flags & AVFMT_NOFILE) && format_context_->pb) {
avio_closep(&format_context_->pb); // 关闭文件
}
avformat_free_context(format_context_); // 释放格式上下文
format_context_ = nullptr;
}
stream_ = nullptr;
// 3. 重命名临时文件为目标文件 {
std::string temp_path = current_video_path_ + ".temp"; std::lock_guard lock(ctrl_mtx_);
if (rename(temp_path.c_str(), current_video_path_.c_str()) != 0) { cleanup_recording_resources_locked_();
if (!completed_video_path.empty()) {
const std::string temp_path = completed_video_path + ".temp";
if (rename(temp_path.c_str(), completed_video_path.c_str()) != 0) {
state_.is_error = true; state_.is_error = true;
state_.error_message = "failed to rename temp file: " + temp_path + " -> " + current_video_path_; state_.error_message =
CMVR_LOG(ERROR) << "[UVCCamera] (stopRecording): " << state_.error_message; "failed to rename temp file: " + temp_path + " -> " +
return; completed_video_path;
CMVR_LOG(ERROR) << "[UVCCamera] (stopRecording): "
<< state_.error_message;
}
} }
current_video_path_.clear(); current_video_path_.clear();
}
// The capture producer is shared with streaming. Releasing the recording
// lease only stops it when no stream lease remains.
stop_capture_worker_if_unused_(false);
} }
void UVCCamera::pauseRecording() { void UVCCamera::pauseRecording() {
@ -462,23 +578,50 @@ void UVCCamera::resumeRecording() {
} }
void UVCCamera::streaming_worker_() { void UVCCamera::streaming_worker_() {
std::shared_ptr<SPMCRingBuffer<StreamFrameData>> buffer;
std::vector<cv::VideoCapture> capture_streams;
{
std::lock_guard lock(ctrl_mtx_);
buffer = stream_frame_buffer_;
// VideoCapture is a reference-counted handle. stop() does not release
// cap_ until this worker exits, so this local handle has sole access to
// the backend's frame operations.
capture_streams.push_back(cap_);
}
capture_running_.store(true);
try { try {
// 计算理论上每帧之间的间隔时间(毫秒) // 计算理论上每帧之间的间隔时间(毫秒)
const int frame_interval = 1000 / fps_; const int frame_interval = 1000 / std::max(1, fps_);
bool success = false; bool success = false;
is_streaming_running = true;
cv::Mat frame; cv::Mat frame;
int64_t frame_count = 0; std::vector<int> ready_streams;
// 处于流传输或者录像状态时就不退出线程 constexpr int64_t kCapturePollTimeoutNs = 100'000'000;
while (state_.is_streaming || state_.is_recording) { while (capture_requested_.load()) {
// 记录当前帧处理开始时间 // 记录当前帧处理开始时间
auto frame_start_time = std::chrono::high_resolution_clock::now(); auto frame_start_time = std::chrono::high_resolution_clock::now();
if (!cap_.read(frame) || frame.empty()) { const auto capture_monotonic = std::chrono::steady_clock::now();
state_.is_error = true; const auto capture_utc = std::chrono::system_clock::now();
state_.error_message = "failed to read frame"; ready_streams.clear();
CMVR_LOG(ERROR) << "[UVCCamera]streaming_worker_: " << state_.error_message; const bool frame_ready = cv::VideoCapture::waitAny(
capture_streams, ready_streams, kCapturePollTimeoutNs);
if (!capture_requested_.load()) {
break;
}
if (!frame_ready) {
continue;
}
if (ready_streams.empty() || ready_streams.front() != 0 ||
!capture_streams.front().retrieve(frame) || frame.empty()) {
if (capture_requested_.load()) {
set_worker_error_("failed to retrieve frame");
CMVR_LOG(ERROR)
<< "[UVCCamera]streaming_worker_: "
<< "failed to retrieve frame";
}
break;
}
if (!capture_requested_.load()) {
break; break;
} }
@ -505,7 +648,36 @@ void UVCCamera::streaming_worker_() {
frame_data.width = encode_width_; frame_data.width = encode_width_;
frame_data.height = encode_height_; frame_data.height = encode_height_;
frame_data.codec = codec_; frame_data.codec = codec_;
stream_frame_buffer_->push(frame_data); frame_data.capture_monotonic_ns = std::chrono::duration_cast<std::chrono::nanoseconds>(
capture_monotonic.time_since_epoch()).count();
frame_data.capture_utc_ns = std::chrono::duration_cast<std::chrono::nanoseconds>(
capture_utc.time_since_epoch()).count();
frame_data.time_base_num = 1;
frame_data.time_base_den = std::max(1, fps_);
frame_data.duration = 1;
if (frame_data.bKey && rgbEncoder_ && rgbEncoder_->codec_context &&
rgbEncoder_->codec_context->extradata && rgbEncoder_->codec_context->extradata_size > 0) {
frame_data.codec_config.assign(
rgbEncoder_->codec_context->extradata,
rgbEncoder_->codec_context->extradata + rgbEncoder_->codec_context->extradata_size);
}
// Keep metadata allocation and publication in one critical
// section. Once reset_stream_metadata_() returns, no old
// epoch frame can be published afterwards.
{
std::lock_guard metadata_lock(stream_metadata_mtx_);
frame_data.stream_epoch = stream_epoch_;
frame_data.sequence = stream_sequence_++;
frame_data.codec_config_generation =
codec_config_generation_;
frame_data.pts = rgbEncoder_
? rgbEncoder_->frame->pts
: static_cast<int64_t>(frame_data.sequence);
frame_data.dts = frame_data.pts;
if (buffer) {
buffer->push(frame_data);
}
}
} }
else else
{ {
@ -524,50 +696,48 @@ void UVCCamera::streaming_worker_() {
std::this_thread::sleep_for(std::chrono::milliseconds(sleep_time)); std::this_thread::sleep_for(std::chrono::milliseconds(sleep_time));
} }
} }
is_streaming_running = false;
// 线程结束时清空队列
stream_frame_buffer_->clear();
recordingIndex_ = 0;
getImageIndex_ = 0;
streamIndex_ = 0;
} }
catch (const std::exception &e) { catch (const std::exception &e) {
// 线程结束时清空队列 if (capture_requested_.load()) {
stream_frame_buffer_->clear(); set_worker_error_(e.what());
// 确保线程状态正确更新 CMVR_LOG(ERROR) << "[UVCCamera]streaming_worker_ error:"
is_streaming_running = false; << e.what();
state_.is_error = true; }
state_.error_message = e.what(); }
CMVR_LOG(ERROR) << "[UVCCamera]streaming_worker_ error:" << state_.error_message; catch (...) {
if (capture_requested_.load()) {
set_worker_error_("unknown capture worker error");
CMVR_LOG(ERROR)
<< "[UVCCamera]streaming_worker_: unknown worker error";
}
}
capture_requested_.store(false);
capture_running_.store(false);
if (buffer) {
buffer->clear();
} }
} }
void UVCCamera::recording_worker_() { void UVCCamera::recording_worker_() {
is_recording_running = true; std::shared_ptr<SPMCRingBuffer<StreamFrameData>> buffer;
const int frame_interval = 1000 / fps_; {
std::lock_guard lock(ctrl_mtx_);
buffer = stream_frame_buffer_;
}
recording_running_.store(true);
bool is_first_key = false; bool is_first_key = false;
try { try {
//保证当前采集线程正常运行 while (recording_requested_.load()) {
while (state_.is_recording && is_streaming_running) { auto frame_data_opt = buffer
// 等待缓冲区有数据 ? buffer->waitPop(recordingIndex_, std::chrono::milliseconds(100))
if (stream_frame_buffer_->empty()) { : std::nullopt;
std::this_thread::sleep_for(std::chrono::milliseconds(frame_interval));
continue;
}
// 1. 从缓冲区取编码好的帧
auto frame_data_opt = stream_frame_buffer_->pop(recordingIndex_);
if (!frame_data_opt.has_value()) { if (!frame_data_opt.has_value()) {
std::this_thread::sleep_for(std::chrono::milliseconds(frame_interval));
continue; continue;
} }
StreamFrameData frame_data = frame_data_opt.value(); StreamFrameData frame_data = std::move(*frame_data_opt);
// 2. 验证编码数据有效性 // 2. 验证编码数据有效性
if (frame_data.rgbFrame.empty()) { if (frame_data.rgbFrame.empty()) {
std::this_thread::sleep_for(std::chrono::milliseconds(frame_interval));
continue; continue;
} }
@ -606,19 +776,22 @@ void UVCCamera::recording_worker_() {
if (av_interleaved_write_frame(format_context_, packet_) < 0) { if (av_interleaved_write_frame(format_context_, packet_) < 0) {
CMVR_LOG(ERROR) << "写入第" << frame_count_ << "帧失败"; CMVR_LOG(ERROR) << "写入第" << frame_count_ << "帧失败";
} }
std::this_thread::sleep_for(std::chrono::milliseconds(33));
} }
// 7. 写入文件尾(完成封装) // 7. 写入文件尾(完成封装)
if (format_context_) {
av_write_trailer(format_context_); av_write_trailer(format_context_);
} catch (const std::exception& e) {
CMVR_LOG(ERROR) << "录像线程错误: " << e.what();
} }
is_recording_running = false; } catch (const std::exception& e) {
state_.is_recording = false; set_worker_error_("recording worker error: " + std::string(e.what()));
CMVR_LOG(ERROR) << "录像线程错误: " << e.what();
} catch (...) {
set_worker_error_("unknown recording worker error");
CMVR_LOG(ERROR) << "录像线程未知错误";
}
recording_running_.store(false);
} }
// 初始化单个编码器的通用函数 // 初始化单个编码器的通用函数
@ -727,6 +900,9 @@ bool UVCCamera::encodeFrameWithEncoder(std::shared_ptr<FfmpegEncoderInfo>& encod
return false; return false;
} }
encoded_frame.clear();
is_key = false;
// 确保输入帧尺寸匹配(不变) // 确保输入帧尺寸匹配(不变)
if (frame.cols != encoder->width || frame.rows != encoder->height) { if (frame.cols != encoder->width || frame.rows != encoder->height) {
CMVR_LOG(ERROR) << "Frame size does not match encoder dimensions"; CMVR_LOG(ERROR) << "Frame size does not match encoder dimensions";
@ -735,6 +911,9 @@ bool UVCCamera::encodeFrameWithEncoder(std::shared_ptr<FfmpegEncoderInfo>& encod
// 【修复3设置递增的PTS确保编码器正确处理I帧请求】 // 【修复3设置递增的PTS确保编码器正确处理I帧请求】
encoder->frame->pts = encoder->frame_pts++; // 分配唯一PTS encoder->frame->pts = encoder->frame_pts++; // 分配唯一PTS
encoder->frame->pict_type = encoder->force_key_frame.exchange(false)
? AV_PICTURE_TYPE_I
: AV_PICTURE_TYPE_NONE;
// 根据cv::Mat的类型设置源格式不变 // 根据cv::Mat的类型设置源格式不变
AVPixelFormat src_pix_fmt; AVPixelFormat src_pix_fmt;
@ -797,9 +976,6 @@ bool UVCCamera::encodeFrameWithEncoder(std::shared_ptr<FfmpegEncoderInfo>& encod
if (encoder->packet->flags & AV_PKT_FLAG_KEY) { if (encoder->packet->flags & AV_PKT_FLAG_KEY) {
is_key = true; is_key = true;
//CMVR_LOG(INFO) << "Encoded I frame (size: " << encoder->packet->size << " bytes)"; //CMVR_LOG(INFO) << "Encoded I frame (size: " << encoder->packet->size << " bytes)";
} else {
is_key = false;
//CMVR_LOG(INFO) << "Encoded P frame (size: " << encoder->packet->size << " bytes)";
} }
// 预留足够空间,避免多次内存分配 // 预留足够空间,避免多次内存分配
@ -815,11 +991,16 @@ bool UVCCamera::encodeFrameWithEncoder(std::shared_ptr<FfmpegEncoderInfo>& encod
// 验证帧有效性NALU起始码、元数据 // 验证帧有效性NALU起始码、元数据
if (!encoded_frame.empty()) { if (!encoded_frame.empty()) {
// 检查NALU起始码 // 检查NALU起始码
bool has_start_code = false; const bool has_three_byte_start_code =
if ((encoded_frame[0] == 0 && encoded_frame[1] == 0 && encoded_frame[2] == 1) || encoded_frame.size() >= 3 &&
(encoded_frame[0] == 0 && encoded_frame[1] == 0 && encoded_frame[2] == 0 && encoded_frame[3] == 1)) { encoded_frame[0] == 0 && encoded_frame[1] == 0 &&
has_start_code = true; encoded_frame[2] == 1;
} const bool has_four_byte_start_code =
encoded_frame.size() >= 4 &&
encoded_frame[0] == 0 && encoded_frame[1] == 0 &&
encoded_frame[2] == 0 && encoded_frame[3] == 1;
const bool has_start_code =
has_three_byte_start_code || has_four_byte_start_code;
if (!has_start_code) { if (!has_start_code) {
CMVR_LOG(ERROR) << "Invalid frame: no NALU start code!"; CMVR_LOG(ERROR) << "Invalid frame: no NALU start code!";
return false; return false;
@ -832,66 +1013,133 @@ bool UVCCamera::encodeFrameWithEncoder(std::shared_ptr<FfmpegEncoderInfo>& encod
} }
void UVCCamera::getEncodedFrame(StreamFrameData& frame_data, size_t& index) { void UVCCamera::getEncodedFrame(StreamFrameData& frame_data, size_t& index) {
std::shared_ptr<SPMCRingBuffer<StreamFrameData>> buffer;
{
std::lock_guard lock(ctrl_mtx_);
buffer = stream_frame_buffer_;
}
if (!buffer) {
return;
}
// 环形队列取数据的index由接口传入 // 环形队列取数据的index由接口传入
auto frame = stream_frame_buffer_->pop(index); auto frame = buffer->pop(index);
if (frame.has_value()) { if (frame.has_value()) {
frame_data = frame.value(); frame_data = frame.value();
} }
} }
bool UVCCamera::waitEncodedFrame(
StreamFrameData& frame_data,
size_t& index,
const std::chrono::milliseconds timeout) {
std::shared_ptr<SPMCRingBuffer<StreamFrameData>> buffer;
{
std::lock_guard lock(ctrl_mtx_);
buffer = stream_frame_buffer_;
}
if (!buffer) {
return false;
}
auto frame = buffer->waitPop(index, timeout);
if (!frame) {
return false;
}
frame_data = std::move(*frame);
return !frame_data.rgbFrame.empty() || !frame_data.depthFrame.empty();
}
bool UVCCamera::getLatestEncodedFrame(StreamFrameData& frame_data, size_t& next_index) { bool UVCCamera::getLatestEncodedFrame(StreamFrameData& frame_data, size_t& next_index) {
if (!stream_frame_buffer_) { std::shared_ptr<SPMCRingBuffer<StreamFrameData>> buffer;
{
std::lock_guard lock(ctrl_mtx_);
buffer = stream_frame_buffer_;
}
if (!buffer) {
return false; return false;
} }
const size_t head = stream_frame_buffer_->getHead(); auto frame = buffer->getLatest(next_index);
if (head == 0) {
return false;
}
size_t latest_index = head - 1;
auto frame = stream_frame_buffer_->pop(latest_index);
if (!frame.has_value()) { if (!frame.has_value()) {
return false; return false;
} }
frame_data = frame.value(); frame_data = frame.value();
next_index = latest_index;
return true; return true;
} }
bool UVCCamera::startStreaming() bool UVCCamera::startStreaming()
{ {
std::lock_guard lifecycle_lock(lifecycle_mtx_);
bool first_lease = false;
bool producer_already_running = false;
{
std::lock_guard lock(ctrl_mtx_); std::lock_guard lock(ctrl_mtx_);
//不在录像也不在流传输,但是采集线程没有退出时。 clear_error_();
if (!state_.is_streaming && !state_.is_recording) { if (mode_ != VIDEO_MODE) {
if (stream_thread_) { state_.is_error = true;
if (stream_thread_->joinable()) { state_.error_message =
stream_thread_->join(); "startStreaming only supports VIDEO_MODE";
is_streaming_running = false; CMVR_LOG(ERROR) << "[UVCCamera] (startStreaming): "
<< state_.error_message;
return false;
} }
stream_thread_.reset(); if (!state_.is_initialized || !state_.is_opened) {
state_.is_error = true;
state_.error_message = "camera not opened";
CMVR_LOG(ERROR) << "[UVCCamera] (startStreaming): "
<< state_.error_message;
return false;
} }
}
//开启流采集线程
if (!stream_thread_) {
state_.is_streaming = true;
stream_thread_ = make_shared<thread>(&UVCCamera::streaming_worker_, this);
//延时100ms等待流线程获取图像 first_lease = stream_count_ == 0;
std::this_thread::sleep_for(std::chrono::milliseconds(100)); producer_already_running =
stream_thread_ && capture_requested_.load();
++stream_count_;
state_.is_streaming = true;
}
// If recording already owns the producer, starting a new externally
// visible stream still begins a fresh metadata epoch. A newly created
// producer resets it inside ensure_capture_worker_().
if (first_lease && producer_already_running) {
reset_stream_metadata_();
}
if (!ensure_capture_worker_()) {
std::lock_guard lock(ctrl_mtx_);
if (stream_count_ > 0) {
--stream_count_;
}
if (stream_count_ == 0) {
state_.is_streaming = false;
}
return false;
} }
stream_count_++;
return true; return true;
} }
void UVCCamera::stopStreaming() void UVCCamera::stopStreaming()
{ {
std::lock_guard lock(ctrl_mtx_); std::lock_guard lifecycle_lock(lifecycle_mtx_);
stream_count_--;
if (stream_count_ == 0)
{ {
// 当前已经没有正在使用的流了,编码采集线程状态修改 std::lock_guard lock(ctrl_mtx_);
if (stream_count_ > 0) {
--stream_count_;
}
if (stream_count_ == 0) {
state_.is_streaming = false; state_.is_streaming = false;
} }
}
// Recording is an independent lease on the same producer.
stop_capture_worker_if_unused_(false);
}
bool UVCCamera::requestKeyFrame()
{
std::lock_guard lock(ctrl_mtx_);
if (!rgbEncoder_) {
return false;
}
rgbEncoder_->force_key_frame.store(true);
return true;
} }

View File

@ -6,6 +6,7 @@
#define CMVR_ES_ABSTRACT_MICROPHONE_H #define CMVR_ES_ABSTRACT_MICROPHONE_H
#pragma once #pragma once
#include <chrono>
#include <cstddef> #include <cstddef>
#include "devices/abstract_device.h" #include "devices/abstract_device.h"
@ -27,6 +28,14 @@ namespace cmvr::device{
virtual bool startStreaming() {return true;} virtual bool startStreaming() {return true;}
virtual void stopStreaming() {} virtual void stopStreaming() {}
virtual void getEncodedFrame(AudioStreamFrameData& frame_data, size_t& index) {} virtual void getEncodedFrame(AudioStreamFrameData& frame_data, size_t& index) {}
virtual bool waitEncodedFrame(
AudioStreamFrameData& frame_data,
size_t& index,
std::chrono::milliseconds timeout) {
(void)timeout;
getEncodedFrame(frame_data, index);
return !frame_data.data.empty();
}
virtual bool getLatestEncodedFrame(AudioStreamFrameData& frame_data, size_t& next_index) { virtual bool getLatestEncodedFrame(AudioStreamFrameData& frame_data, size_t& next_index) {
return false; return false;
} }

View File

@ -43,6 +43,7 @@ namespace cmvr::device {
bool startStreaming() override; bool startStreaming() override;
void stopStreaming() override; void stopStreaming() override;
void getEncodedFrame(AudioStreamFrameData& frame_data, size_t& index) override; void getEncodedFrame(AudioStreamFrameData& frame_data, size_t& index) override;
bool waitEncodedFrame(AudioStreamFrameData& frame_data, size_t& index, std::chrono::milliseconds timeout) override;
bool getLatestEncodedFrame(AudioStreamFrameData& frame_data, size_t& next_index) override; bool getLatestEncodedFrame(AudioStreamFrameData& frame_data, size_t& next_index) override;
private: private:
@ -51,6 +52,8 @@ namespace cmvr::device {
void closeFFmpeg(); void closeFFmpeg();
bool startCapture_(bool write_file); bool startCapture_(bool write_file);
void stopCapture_(); void stopCapture_();
bool recoverStreamingCapture_(int read_error);
static int interruptCallback_(void* opaque);
AudioStreamFormat currentStreamFormat_() const; AudioStreamFormat currentStreamFormat_() const;
void pushEncodedPacket_(const AVPacket* packet); void pushEncodedPacket_(const AVPacket* packet);
@ -70,6 +73,7 @@ namespace cmvr::device {
std::string output_file; std::string output_file;
std::string format_name; std::string format_name;
std::atomic<bool> is_capturing_{false}; std::atomic<bool> is_capturing_{false};
std::atomic<bool> interrupt_requested_{false};
std::atomic<bool> write_output_file_{false}; std::atomic<bool> write_output_file_{false};
std::atomic<bool> is_paused; std::atomic<bool> is_paused;
std::mutex pause_mutex; std::mutex pause_mutex;
@ -82,10 +86,18 @@ namespace cmvr::device {
std::string input_device_; std::string input_device_;
std::shared_ptr<std::thread> audio_thread_; std::shared_ptr<std::thread> audio_thread_;
// Serializes FFmpeg context start/stop and keeps a new shared lease from
// starting until the previous capture thread has exited and been joined.
std::mutex capture_mutex_;
std::mutex mtx_; std::mutex mtx_;
std::shared_ptr<SPMCRingBuffer<AudioStreamFrameData>> stream_frame_buffer_; std::shared_ptr<SPMCRingBuffer<AudioStreamFrameData>> stream_frame_buffer_;
// Protected by mtx_. The audio thread snapshots these values while it
// assigns metadata to an encoded frame.
int stream_count_ = 0; int stream_count_ = 0;
size_t buffer_size_ = 256; size_t buffer_size_ = 256;
uint64_t stream_epoch_ = 0;
uint64_t stream_sequence_ = 0;
uint32_t codec_config_generation_ = 0;
config::FFMpegMicroPhoneConfig config_; config::FFMpegMicroPhoneConfig config_;
}; };

View File

@ -108,6 +108,10 @@ bool ffmpegMicroPhone::start()
bool ffmpegMicroPhone::stop() bool ffmpegMicroPhone::stop()
{ {
// Keep the lifecycle lock until FFmpeg has observed the interrupt request,
// the capture thread has exited, and all contexts have been released.
// A concurrent start cannot revive the device halfway through that cleanup.
std::lock_guard capture_lock(capture_mutex_);
{ {
lock_guard lock(mtx_); lock_guard lock(mtx_);
stream_count_ = 0; stream_count_ = 0;
@ -121,7 +125,10 @@ bool ffmpegMicroPhone::stop()
void ffmpegMicroPhone::pause() void ffmpegMicroPhone::pause()
{ {
is_paused = true; is_paused = true;
{
lock_guard lock(mtx_);
state_.is_error = false; state_.is_error = false;
}
CMVR_LOG(INFO) << "[ffmpegMicroPhone] (pause): Success, id=" << id_; CMVR_LOG(INFO) << "[ffmpegMicroPhone] (pause): Success, id=" << id_;
} }
@ -129,7 +136,10 @@ void ffmpegMicroPhone::resume()
{ {
is_paused = false; is_paused = false;
pause_cv.notify_one(); pause_cv.notify_one();
{
lock_guard lock(mtx_);
state_.is_error = false; state_.is_error = false;
}
CMVR_LOG(INFO) << "[ffmpegMicroPhone] (resume): Success, id=" << id_; CMVR_LOG(INFO) << "[ffmpegMicroPhone] (resume): Success, id=" << id_;
} }
@ -141,6 +151,8 @@ void ffmpegMicroPhone::getState(MicrophoneState& state)
void ffmpegMicroPhone::startRecording(const std::string& outputFilePath) void ffmpegMicroPhone::startRecording(const std::string& outputFilePath)
{ {
std::lock_guard capture_lock(capture_mutex_);
{
lock_guard lock(mtx_); lock_guard lock(mtx_);
if (state_.is_recording) { if (state_.is_recording) {
CMVR_LOG(ERROR) << "[ffmpegMicroPhone] (startRecording): microphone is already recording"; CMVR_LOG(ERROR) << "[ffmpegMicroPhone] (startRecording): microphone is already recording";
@ -156,18 +168,26 @@ void ffmpegMicroPhone::startRecording(const std::string& outputFilePath)
size_t dot_pos = outputFilePath.find_last_of('.'); size_t dot_pos = outputFilePath.find_last_of('.');
format_name = dot_pos == std::string::npos ? "wav" : outputFilePath.substr(dot_pos + 1); format_name = dot_pos == std::string::npos ? "wav" : outputFilePath.substr(dot_pos + 1);
output_file = outputFilePath; output_file = outputFilePath;
}
if (!startCapture_(true)) { if (!startCapture_(true)) {
lock_guard lock(mtx_);
state_.is_error = true; state_.is_error = true;
state_.error_message = "FFmpeg init error"; state_.error_message = "FFmpeg init error";
return; return;
} }
{
lock_guard lock(mtx_);
state_.is_recording = true; state_.is_recording = true;
state_.is_error = false;
state_.error_message.clear();
}
} }
void ffmpegMicroPhone::stopRecording() void ffmpegMicroPhone::stopRecording()
{ {
std::lock_guard capture_lock(capture_mutex_);
{ {
lock_guard lock(mtx_); lock_guard lock(mtx_);
if (!state_.is_recording) { if (!state_.is_recording) {
@ -193,6 +213,8 @@ int ffmpegMicroPhone::getVolume()
bool ffmpegMicroPhone::startStreaming() bool ffmpegMicroPhone::startStreaming()
{ {
std::lock_guard capture_lock(capture_mutex_);
{
lock_guard lock(mtx_); lock_guard lock(mtx_);
if (state_.is_recording) { if (state_.is_recording) {
state_.is_error = true; state_.is_error = true;
@ -200,34 +222,55 @@ bool ffmpegMicroPhone::startStreaming()
CMVR_LOG(ERROR) << "[ffmpegMicroPhone] " << state_.error_message; CMVR_LOG(ERROR) << "[ffmpegMicroPhone] " << state_.error_message;
return false; return false;
} }
if (stream_count_ == 0) {
stream_frame_buffer_->clear(); if (stream_count_ > 0 && is_capturing_.load(std::memory_order_acquire)) {
format_name = "pcm_s16le";
output_file.clear();
if (!startCapture_(false)) {
state_.is_error = true;
state_.error_message = "FFmpeg init error";
return false;
}
}
++stream_count_; ++stream_count_;
state_.is_running = true; state_.is_running = true;
state_.is_error = false; state_.is_error = false;
state_.error_message.clear(); state_.error_message.clear();
return true; return true;
}
stream_frame_buffer_->clear();
++stream_epoch_;
stream_sequence_ = 0;
++codec_config_generation_;
format_name = "pcm_s16le";
output_file.clear();
}
if (!startCapture_(false)) {
lock_guard lock(mtx_);
state_.is_error = true;
state_.error_message = "FFmpeg init error";
return false;
}
{
lock_guard lock(mtx_);
// capture_mutex_ prevents another start/stop transition from changing
// the lease count while the first capture is being initialized.
++stream_count_;
state_.is_running = true;
state_.is_error = false;
state_.error_message.clear();
}
return true;
} }
void ffmpegMicroPhone::stopStreaming() void ffmpegMicroPhone::stopStreaming()
{ {
bool should_stop = false; std::lock_guard capture_lock(capture_mutex_);
bool last_lease = false;
{ {
lock_guard lock(mtx_); lock_guard lock(mtx_);
if (stream_count_ > 0) { if (stream_count_ == 0) {
return;
}
--stream_count_; --stream_count_;
last_lease = stream_count_ == 0;
} }
should_stop = stream_count_ == 0 && !state_.is_recording; if (last_lease) {
}
if (should_stop) {
stopCapture_(); stopCapture_();
} }
} }
@ -243,55 +286,95 @@ void ffmpegMicroPhone::getEncodedFrame(AudioStreamFrameData& frame_data, size_t&
} }
} }
bool ffmpegMicroPhone::waitEncodedFrame(
AudioStreamFrameData& frame_data,
size_t& index,
const std::chrono::milliseconds timeout)
{
if (!stream_frame_buffer_) {
return false;
}
auto frame = stream_frame_buffer_->waitPop(index, timeout);
if (!frame) {
return false;
}
frame_data = std::move(*frame);
return !frame_data.data.empty();
}
bool ffmpegMicroPhone::getLatestEncodedFrame(AudioStreamFrameData& frame_data, size_t& next_index) bool ffmpegMicroPhone::getLatestEncodedFrame(AudioStreamFrameData& frame_data, size_t& next_index)
{ {
if (!stream_frame_buffer_) { if (!stream_frame_buffer_) {
return false; return false;
} }
const size_t head = stream_frame_buffer_->getHead(); auto frame = stream_frame_buffer_->getLatest(next_index);
if (head == 0) {
return false;
}
size_t latest_index = head - 1;
auto frame = stream_frame_buffer_->pop(latest_index);
if (!frame.has_value()) { if (!frame.has_value()) {
return false; return false;
} }
frame_data = frame.value(); frame_data = frame.value();
next_index = latest_index;
return true; return true;
} }
bool ffmpegMicroPhone::startCapture_(bool write_file) bool ffmpegMicroPhone::startCapture_(bool write_file)
{ {
if (is_capturing_) { // The caller holds capture_mutex_ for the complete transition.
if (is_capturing_.load(std::memory_order_acquire)) {
return true; return true;
} }
write_output_file_ = write_file; // Collect a worker that ended because of an unrecoverable input error
if (!initFFmpeg()) { // before replacing its std::thread object or FFmpeg contexts.
if (audio_thread_) {
if (audio_thread_->joinable()) {
audio_thread_->join();
}
audio_thread_.reset();
closeFFmpeg(); closeFFmpeg();
}
interrupt_requested_.store(false, std::memory_order_release);
write_output_file_.store(write_file, std::memory_order_release);
if (!initFFmpeg()) {
interrupt_requested_.store(true, std::memory_order_release);
closeFFmpeg();
write_output_file_.store(false, std::memory_order_release);
return false; return false;
} }
is_capturing_ = true; is_capturing_.store(true, std::memory_order_release);
is_paused = false; is_paused.store(false, std::memory_order_release);
state_.is_running = true; try {
audio_thread_ = std::make_shared<std::thread>(&ffmpegMicroPhone::audioThread, this); audio_thread_ = std::make_shared<std::thread>(&ffmpegMicroPhone::audioThread, this);
} catch (const std::exception& e) {
CMVR_LOG(ERROR) << "[ffmpegMicroPhone] Could not start capture thread: " << e.what();
interrupt_requested_.store(true, std::memory_order_release);
is_capturing_.store(false, std::memory_order_release);
closeFFmpeg();
write_output_file_.store(false, std::memory_order_release);
return false;
}
{
lock_guard lock(mtx_);
state_.is_running = true;
}
return true; return true;
} }
void ffmpegMicroPhone::stopCapture_() void ffmpegMicroPhone::stopCapture_()
{ {
if (!is_capturing_) { // AVIOInterruptCB is FFmpeg's supported way to abort a blocking input
// operation. Set it before clearing the run flag so av_read_frame() can
// return AVERROR_EXIT and the thread can reach the join below.
interrupt_requested_.store(true, std::memory_order_release);
const bool was_capturing = is_capturing_.exchange(false, std::memory_order_acq_rel);
is_paused.store(false, std::memory_order_release);
pause_cv.notify_all();
if (!was_capturing && !audio_thread_) {
lock_guard lock(mtx_);
state_.is_running = false;
return; return;
} }
is_capturing_ = false;
is_paused = false;
pause_cv.notify_all();
if (audio_thread_) { if (audio_thread_) {
if (audio_thread_->joinable()) { if (audio_thread_->joinable()) {
audio_thread_->join(); audio_thread_->join();
@ -299,16 +382,70 @@ void ffmpegMicroPhone::stopCapture_()
audio_thread_.reset(); audio_thread_.reset();
} }
if (write_output_file_ && output_fmt_ctx) { if (write_output_file_.load(std::memory_order_acquire) && output_fmt_ctx) {
av_write_trailer(output_fmt_ctx); av_write_trailer(output_fmt_ctx);
} }
closeFFmpeg(); closeFFmpeg();
write_output_file_ = false; write_output_file_.store(false, std::memory_order_release);
lock_guard lock(mtx_); lock_guard lock(mtx_);
state_.is_running = false; state_.is_running = false;
} }
int ffmpegMicroPhone::interruptCallback_(void* opaque)
{
const auto* microphone = static_cast<const ffmpegMicroPhone*>(opaque);
return microphone &&
microphone->interrupt_requested_.load(std::memory_order_acquire)
? 1
: 0;
}
bool ffmpegMicroPhone::recoverStreamingCapture_(const int read_error)
{
if (write_output_file_.load(std::memory_order_acquire)) {
return false;
}
const std::string read_error_message =
"audio input read failed: " + avErrorToString(read_error);
{
lock_guard lock(mtx_);
state_.is_error = true;
state_.error_message = read_error_message + "; reconnecting";
}
CMVR_LOG(ERROR) << "[ffmpegMicroPhone] " << read_error_message
<< "; reconnecting input";
auto retry_delay = std::chrono::milliseconds(100);
constexpr auto kMaximumRetryDelay = std::chrono::milliseconds(2000);
while (is_capturing_.load(std::memory_order_acquire) &&
!interrupt_requested_.load(std::memory_order_acquire)) {
closeFFmpeg();
if (initFFmpeg()) {
{
lock_guard lock(mtx_);
++stream_epoch_;
stream_sequence_ = 0;
++codec_config_generation_;
state_.is_running = true;
state_.is_error = false;
state_.error_message.clear();
}
CMVR_LOG(INFO) << "[ffmpegMicroPhone] Audio input reconnected";
return true;
}
std::unique_lock<std::mutex> wait_lock(pause_mutex);
pause_cv.wait_for(wait_lock, retry_delay, [this] {
return interrupt_requested_.load(std::memory_order_acquire) ||
!is_capturing_.load(std::memory_order_acquire);
});
retry_delay = std::min(retry_delay * 2, kMaximumRetryDelay);
}
return false;
}
bool ffmpegMicroPhone::initFFmpeg() bool ffmpegMicroPhone::initFFmpeg()
{ {
int err = 0; int err = 0;
@ -334,6 +471,15 @@ bool ffmpegMicroPhone::initFFmpeg()
CMVR_LOG(INFO) << "[ffmpegMicroPhone] Open input device: format=" CMVR_LOG(INFO) << "[ffmpegMicroPhone] Open input device: format="
<< input_format_name << ", device=" << input_device_; << input_format_name << ", device=" << input_device_;
input_fmt_ctx = avformat_alloc_context();
if (!input_fmt_ctx) {
CMVR_LOG(ERROR) << "[ffmpegMicroPhone] Could not allocate input format context";
av_dict_free(&input_options);
return false;
}
input_fmt_ctx->interrupt_callback.callback = &ffmpegMicroPhone::interruptCallback_;
input_fmt_ctx->interrupt_callback.opaque = this;
err = avformat_open_input(&input_fmt_ctx, input_device_.c_str(), input_fmt, &input_options); err = avformat_open_input(&input_fmt_ctx, input_device_.c_str(), input_fmt, &input_options);
av_dict_free(&input_options); av_dict_free(&input_options);
if (err < 0) { if (err < 0) {
@ -488,22 +634,49 @@ void ffmpegMicroPhone::audioThread()
CMVR_LOG(ERROR) << "[ffmpegMicroPhone] Could not allocate packets"; CMVR_LOG(ERROR) << "[ffmpegMicroPhone] Could not allocate packets";
av_packet_free(&input_pkt); av_packet_free(&input_pkt);
av_packet_free(&output_pkt); av_packet_free(&output_pkt);
is_capturing_.store(false, std::memory_order_release);
lock_guard lock(mtx_);
state_.is_running = false;
state_.is_error = true;
state_.error_message = "could not allocate capture packets";
return; return;
} }
while (is_capturing_) { while (is_capturing_.load(std::memory_order_acquire)) {
if (is_paused) { if (is_paused.load(std::memory_order_acquire)) {
unique_lock<std::mutex> lock(pause_mutex); unique_lock<std::mutex> lock(pause_mutex);
pause_cv.wait(lock, [this] { return !is_paused || !is_capturing_; }); pause_cv.wait(lock, [this] {
return !is_paused.load(std::memory_order_acquire) ||
!is_capturing_.load(std::memory_order_acquire);
});
continue; continue;
} }
const int read_ret = av_read_frame(input_fmt_ctx, input_pkt); const int read_ret = av_read_frame(input_fmt_ctx, input_pkt);
if (read_ret < 0) { if (read_ret < 0) {
if (interrupt_requested_.load(std::memory_order_acquire)) {
break;
}
if (read_ret == AVERROR(EAGAIN)) { if (read_ret == AVERROR(EAGAIN)) {
std::this_thread::sleep_for(std::chrono::milliseconds(1)); std::this_thread::sleep_for(std::chrono::milliseconds(1));
continue; continue;
} }
av_packet_unref(input_pkt);
if (recoverStreamingCapture_(read_ret)) {
continue;
}
if (interrupt_requested_.load(std::memory_order_acquire) ||
!is_capturing_.load(std::memory_order_acquire)) {
break;
}
{
lock_guard lock(mtx_);
state_.is_error = true;
state_.error_message =
"audio input read failed: " + avErrorToString(read_ret);
}
CMVR_LOG(ERROR) << "[ffmpegMicroPhone] Audio capture stopped: "
<< avErrorToString(read_ret);
break; break;
} }
@ -574,6 +747,7 @@ void ffmpegMicroPhone::audioThread()
} }
} }
if (audio_codec_ctx) {
avcodec_send_frame(audio_codec_ctx, nullptr); avcodec_send_frame(audio_codec_ctx, nullptr);
while (avcodec_receive_packet(audio_codec_ctx, output_pkt) == 0) { while (avcodec_receive_packet(audio_codec_ctx, output_pkt) == 0) {
if (write_output_file_ && output_fmt_ctx && audio_st) { if (write_output_file_ && output_fmt_ctx && audio_st) {
@ -587,9 +761,19 @@ void ffmpegMicroPhone::audioThread()
pushEncodedPacket_(output_pkt); pushEncodedPacket_(output_pkt);
av_packet_unref(output_pkt); av_packet_unref(output_pkt);
} }
}
av_packet_free(&input_pkt); av_packet_free(&input_pkt);
av_packet_free(&output_pkt); av_packet_free(&output_pkt);
if (!interrupt_requested_.load(std::memory_order_acquire)) {
is_capturing_.store(false, std::memory_order_release);
lock_guard lock(mtx_);
state_.is_running = false;
if (!state_.is_error) {
state_.is_error = true;
state_.error_message = "audio capture thread exited unexpectedly";
}
}
} }
AudioStreamFormat ffmpegMicroPhone::currentStreamFormat_() const AudioStreamFormat ffmpegMicroPhone::currentStreamFormat_() const
@ -605,6 +789,8 @@ AudioStreamFormat ffmpegMicroPhone::currentStreamFormat_() const
case AV_CODEC_ID_PCM_S16LE: case AV_CODEC_ID_PCM_S16LE:
case AV_CODEC_ID_PCM_S16BE: case AV_CODEC_ID_PCM_S16BE:
return AudioStreamFormat::PCM; return AudioStreamFormat::PCM;
case AV_CODEC_ID_OPUS:
return AudioStreamFormat::OPUS;
default: default:
return AudioStreamFormat::UNKNOWN; return AudioStreamFormat::UNKNOWN;
} }
@ -612,18 +798,54 @@ AudioStreamFormat ffmpegMicroPhone::currentStreamFormat_() const
void ffmpegMicroPhone::pushEncodedPacket_(const AVPacket* packet) void ffmpegMicroPhone::pushEncodedPacket_(const AVPacket* packet)
{ {
if (!packet || !packet->data || packet->size <= 0 || stream_count_ <= 0 || !stream_frame_buffer_) { if (!packet || !packet->data || packet->size <= 0 || !stream_frame_buffer_) {
return; return;
} }
uint64_t stream_epoch = 0;
uint64_t stream_sequence = 0;
uint32_t codec_config_generation = 0;
{
lock_guard lock(mtx_);
if (stream_count_ <= 0) {
return;
}
stream_epoch = stream_epoch_;
stream_sequence = stream_sequence_++;
codec_config_generation = codec_config_generation_;
}
AudioStreamFrameData frame; AudioStreamFrameData frame;
const auto capture_monotonic = std::chrono::steady_clock::now();
const auto capture_utc = std::chrono::system_clock::now();
frame.data.assign(packet->data, packet->data + packet->size); frame.data.assign(packet->data, packet->data + packet->size);
frame.sample_rate = sample_rate_; frame.sample_rate = sample_rate_;
frame.channels = channels_; frame.channels = channels_;
frame.format = currentStreamFormat_(); frame.format = currentStreamFormat_();
frame.codec = audio_codec_ctx && audio_codec_ctx->codec ? audio_codec_ctx->codec->name : "pcm_s16le"; frame.codec = audio_codec_ctx && audio_codec_ctx->codec ? audio_codec_ctx->codec->name : "pcm_s16le";
frame.pts = packet->pts; frame.pts = packet->pts;
frame.dts = packet->dts;
frame.nb_samples = audio_frame ? audio_frame->nb_samples : 0; frame.nb_samples = audio_frame ? audio_frame->nb_samples : 0;
frame.stream_epoch = stream_epoch;
frame.sequence = stream_sequence;
frame.capture_monotonic_ns = std::chrono::duration_cast<std::chrono::nanoseconds>(
capture_monotonic.time_since_epoch()).count();
frame.capture_utc_ns = std::chrono::duration_cast<std::chrono::nanoseconds>(
capture_utc.time_since_epoch()).count();
frame.time_base_num = audio_codec_ctx && audio_codec_ctx->time_base.num > 0
? audio_codec_ctx->time_base.num
: 1;
frame.time_base_den = audio_codec_ctx && audio_codec_ctx->time_base.den > 0
? audio_codec_ctx->time_base.den
: std::max(1, sample_rate_);
frame.duration = packet->duration > 0 ? packet->duration : frame.nb_samples;
frame.codec_config_generation = codec_config_generation;
if (frame.sequence == 0 && audio_codec_ctx && audio_codec_ctx->extradata &&
audio_codec_ctx->extradata_size > 0) {
frame.codec_config.assign(
audio_codec_ctx->extradata,
audio_codec_ctx->extradata + audio_codec_ctx->extradata_size);
}
stream_frame_buffer_->push(frame); stream_frame_buffer_->push(frame);
} }

View File

@ -41,6 +41,7 @@ namespace cmvr::device{
MP3 = 1, MP3 = 1,
AAC = 2, AAC = 2,
WAV = 3, WAV = 3,
OPUS = 4,
UNKNOWN = 99 UNKNOWN = 99
}; };
@ -51,7 +52,18 @@ namespace cmvr::device{
AudioStreamFormat format = AudioStreamFormat::PCM; AudioStreamFormat format = AudioStreamFormat::PCM;
std::string codec = "pcm_s16le"; std::string codec = "pcm_s16le";
int64_t pts = 0; int64_t pts = 0;
int64_t dts = 0;
int nb_samples = 0; int nb_samples = 0;
uint64_t stream_epoch = 0;
uint64_t sequence = 0;
int64_t capture_monotonic_ns = 0;
int64_t capture_utc_ns = 0;
int32_t time_base_num = 1;
int32_t time_base_den = 44100;
int64_t duration = 0;
bool discontinuity = false;
uint32_t codec_config_generation = 0;
std::vector<uint8_t> codec_config;
}; };
// ------------------------------------- robot ------------------------------------- // ------------------------------------- robot -------------------------------------

View File

@ -0,0 +1,89 @@
# Hardware 模块开发指南
`hardware/` 提供串口等可复用的底层传输能力。设备命令、寄存器含义和厂商协议应保留在 `devices/` 的具体后端中。
返回[项目总览](../../README.md)。
## 当前组件
| 文件 | 作用 |
| --- | --- |
| [`abstract_serial_transport.h`](include/abstract_serial_transport.h) | 可替换、可测试的串口传输接口 |
| [`posix_serial_transport.h`](include/posix_serial_transport.h) | Linux POSIX 串口实现,支持超时和任意正波特率 |
| [`serial_interface.h`](include/serial_interface.h) | 现有 RS485/CAN 寄存器辅助接口 |
| [`esp32_serial_port.h`](include/esp32_serial_port.h) | ESP32/舵机串口辅助实现 |
新设备优先依赖 `AbstractSerialTransport`,通过构造或 setter 注入实现。这样单元测试可以使用 fake transport不需要真实 `/dev/tty*`
## 底层与设备层边界
适合放在 `hardware/`
- 打开、关闭和查询传输状态;
- 有限超时的字节读写;
- 输入缓冲清理;
- OS 错误转换和统一错误文本;
- 与设备语义无关的帧读写工具。
应放在 `devices/<category>/<backend>/`
- 帧头、命令码、寄存器地址和校验规则;
- 重试、握手和设备状态机;
- 设备 ID、关节、传感器和错误码含义
- 后台接收线程及其与设备状态的同步。
## 新增传输实现
1. 实现 `AbstractSerialTransport` 或为新总线定义同样窄的抽象;
2. 析构函数必须可靠释放 fd、handle 和后台线程;
3. `open()` 失败后对象保持可再次打开;
4. `close()` 应幂等;
5. 所有阻塞读取必须支持有限 timeout
6. `lastError()` 返回最近一次操作的可诊断错误;
7. 更新 [`CMakeLists.txt`](CMakeLists.txt) 和 `cmvr_es::hardware` target
8. 使用 pseudo terminal、socketpair 或 fake 实现编写无设备测试。
## 并发与事务
`PosixSerialTransport` 使用互斥锁保护单次 `open/close/read/write`。这不等于一个“write command + read response”复合事务天然不会与其他线程交错。
具体驱动必须:
- 由一个 I/O 线程独占 transport或在驱动层为完整请求/响应加锁;
- 在关闭前先通知并 join 接收线程;
- 不持有设备状态锁执行长时间 I/O
- 超时后清理残留输入,避免下一请求读到旧响应;
- 不让回调在 transport 锁内反向调用设备;
- 为部分写、短读、EINTR、EAGAIN 和设备拔出定义行为。
## 权限和部署
Linux 设备通常需要:
- 将运行用户加入 `dialout`、`video` 或设备专用组;
- 使用 udev rule 固定权限和稳定设备别名;
- CAN 接口在服务启动前完成 `ip link` 配置;
- 不依赖开发机上的临时 `chmod 777`
- 在日志中记录逻辑设备名,不输出密码和完整敏感报文。
## 新增总线类型
新增 CAN、TCP、USB 等通用传输时:
1. 定义只描述字节/帧传输的抽象接口;
2. 将 Linux/SDK 实现与抽象分离;
3. 让设备后端依赖抽象而不是全局单例;
4. 提供 fake 实现和故障注入;
5. 记录线程安全级别和调用顺序;
6. 更新安装依赖及 systemd 权限说明。
## 测试清单
- [ ] 打开、重复打开、关闭和重复关闭
- [ ] 非法路径和非法参数
- [ ] 完整写、部分写和对端断开
- [ ] 精确长度读取、短读和超时
- [ ] EINTR/EAGAIN 重试
- [ ] I/O 期间关闭
- [ ] 多线程调用或显式拒绝并发
- [ ] 析构后没有 fd 和线程泄漏

View File

@ -16,6 +16,7 @@
#include "manager/device_manager/include/device_manager.h" #include "manager/device_manager/include/device_manager.h"
#include "manager/task_manager/include/task_manager.h" #include "manager/task_manager/include/task_manager.h"
#include "task/grpc_server_task/include/grpc_server_task.h" #include "task/grpc_server_task/include/grpc_server_task.h"
#include "task/quic_edge_task/include/quic_edge_task.h"
namespace { namespace {
@ -113,6 +114,7 @@ int main(int argc, char* argv[])
cmvr::device::DeviceManager::getInstance(device_manager_root.device_manager()); cmvr::device::DeviceManager::getInstance(device_manager_root.device_manager());
cmvr::task::registerGrpcServerTaskFactory(); cmvr::task::registerGrpcServerTaskFactory();
cmvr::task::registerQuicEdgeTaskFactory();
if (app_config.task_manager_config_file().empty()) { if (app_config.task_manager_config_file().empty()) {
CMVR_LOG(ERROR) << "TaskManager config file is empty"; CMVR_LOG(ERROR) << "TaskManager config file is empty";

226
cmvr-es/manager/README.md Normal file
View File

@ -0,0 +1,226 @@
# Manager 模块开发指南
`manager/` 负责组织设备、任务和协议无关媒体源。Manager 管理对象生命周期和调度,不实现厂商协议,也不实现平台 wire protocol。
返回[项目总览](../../README.md)。
## 当前管理器
| 目录 | CMake target | 职责 |
| --- | --- | --- |
| [`device_manager/`](device_manager/) | `cmvr_es::device_manager` | 按配置创建、初始化、查询和批量启停设备 |
| [`task_manager/`](task_manager/) | `cmvr_es::task_manager` | 创建任务、校验运行模式、统一启停和调度周期任务 |
| [`media_source_hub/`](media_source_hub/) | `cmvr_es::media_source_hub`、`cmvr_es::device_media_source_adapter` | 实时媒体源注册、按需启停和多消费者分发 |
`manager/` 当前没有聚合 `CMakeLists.txt`,三个子目录由 [`../CMakeLists.txt`](../CMakeLists.txt) 分别加入。新增 manager 时必须显式更新该文件。
## 进程生命周期
当前 [`../main.cpp`](../main.cpp) 的顺序是:
1. 加载根配置并设置全局配置根;
2. 构造 `DeviceManager`
3. 创建启用的设备并调用 `device->init()`
4. 注册 gRPC、QUIC TaskFactory creator
5. 构造 `TaskManager` 并调用启用任务的 `init()`
6. `startRunTask()` 启动任务和周期调度线程;
7. 收到 SIGINT/SIGTERM 后调用 `TaskManager::stopRunTask()`
重要限制:
- DeviceManager 构造不会自动调用全部设备的 `start()`
- 当前主退出路径没有调用 `DeviceManager::stop()`
- `SystemService/StopAll` 会调用 DeviceManager stop
- `DeviceManager::destroyInstance()` 不调用设备 stop销毁前必须先显式停止
- `TaskManager::destroyInstance()` 会调用 `stopRunTask()`,但 manager 未处于 running 状态时该调用会直接返回;
- DeviceManager 和 TaskManager 都是首次配置生效的单例,不支持热加载。
## DeviceManager
关键文件:
- [`device_manager/include/device_manager.h`](device_manager/include/device_manager.h)
- [`device_manager/include/device_factory.h`](device_manager/include/device_factory.h)
- [`device_manager/src/device_factory.cpp`](device_manager/src/device_factory.cpp)
- [`../devices/README.md`](../devices/README.md)
### 增加现有类别的新后端
例如增加一种摄像头:
1. 扩展类别配置 Proto 的 `oneof backend`
2. 实现对应抽象设备;
3. 修改 `CameraFactory::create()`
4. 增加 CMake target
5. 在摄像头集合配置中增加实例;
6. 在 DeviceManager 配置中增加相同 ID 的条目。
这种扩展通常不修改全局 `DeviceFactory`,因为 `DEVICE_TYPE_CAMERA` 已经路由到 CameraFactory。
### 增加全新设备类别
还需要:
1. 扩展 `DeviceConfigEntry::DeviceType`
2. 扩展 `DeviceKind` 和字符串映射;
3. 在 `DeviceFactory::DeviceFactory()` 注册 creator
4. 扩展 DeviceManager 的设备类型日志映射;
5. 为 `getDevice<NewAbstractType>()` 增加显式模板实例化;
6. 在 device_manager target 链接新设备 target
7. 如需平台访问,增加 API Proto、gRPC service 和系统设备类型映射。
`DeviceFactory::registerCreator()` 虽然是 public但 factory 是 DeviceManager 的私有成员,当前不是运行时插件入口。新增全局设备类别仍需修改 `device_factory.cpp`
### 容器和顺序约束
- 配置启用条目按配置顺序创建并 `init()`
- 有初始化依赖的设备应把依赖项写在使用方之前;
- `start()`、`stop()` 遍历 `unordered_map`,不能依赖启停顺序;
- 某个设备 start 返回 false 时,当前实现会继续启动其他设备且不会回滚;
- DeviceManager 不捕获设备 init/start/stop 抛出的异常,后端应把预期失败转换为返回值,不能让异常越过 manager 边界;
- collection 配置要求 manager entry ID 能找到同 ID 子配置;
- ID 重复、不匹配或配置路径为空都会拒绝创建;
- `registerDevice()` 不会替调用方调用 `init()`
- 运行阶段应把设备集合视为只读,动态注册必须在 service/task 启动前完成;
- `getDevice<T>()` 类型不匹配或 ID 不存在时返回空指针。
不要在仍有 service/task 持有 manager 引用时调用 `destroyInstance()`
## TaskManager
完整任务实现指南见 [`../task/README.md`](../task/README.md)。
### PERIODIC_STEP
- `control_period_s` 必须是有限正数;
- 所有周期任务的 `step()` 串行运行在同一个 scheduler 线程;
- 慢 I/O 或长计算会延迟其他周期任务;
- 只有 state 为 `RUNNING` 的任务会执行 `step()`
- `step()` 内不能同步调用 `stopRunTask()` 等待当前 scheduler 自身。
### BLOCKING_SERVICE
- TaskManager 只调用 `init/start/stop`,不调用 `step()`
- `start()` 仍必须快速返回,由任务自己持有服务线程或事件循环;
- `stop()` 必须唤醒阻塞操作并 join 自己创建的线程。
### 启停语义
- manager 构造阶段只调用 task `init()`
- 任一 task `start()` 失败,会停止此前已启动的任务且不启动 scheduler
- 返回 false 的 task 必须自行清理本次 start 已经创建的部分资源TaskManager 不会再调用该失败 task 的 stop
- TaskManager 处于 running 状态时,停止流程先停止并 join scheduler再调用每个 task 的 `stop()`
- 未启动或已经停止时,`stopRunTask()` 会直接返回,不会再次逐个调用 task stop
- 任务存储在 `unordered_map`,启动和停止顺序不确定;
- 有顺序依赖的工作应放入同一协调任务或显式建模;
- task 返回后,其内部状态并发安全由具体实现负责。
## MediaSourceHub
关键文件:
- [`media_source_hub/include/media_source_hub.h`](media_source_hub/include/media_source_hub.h)
- [`media_source_hub/src/device_media_source_adapter.cpp`](media_source_hub/src/device_media_source_adapter.cpp)
- [`../common/media/media_frame.h`](../common/media/media_frame.h)
- [`../common/base/ring_buffer.h`](../common/base/ring_buffer.h)
当前默认轨道:
| 来源 | Track ID | 默认 ring capacity |
| --- | --- | ---: |
| 摄像头彩色流 | `<device_id>/video/color` | 64 |
| 麦克风主流 | `<device_id>/audio/main` | 256 |
当前 gRPC RGB/麦克风流和 QUIC 彩色/麦克风轨道使用 HubgRPC Depth/RGBD 仍直接读取设备帧。
### 注册新媒体源
1. 创建不可变 `TrackDescriptor`
2. 使用唯一且稳定的 `track_id`
3. 提供 `start(sink, cancelled)`
4. 提供同步 `stop()`
5. 视频源按需提供 `request_key_frame()`
6. 调用 `registerSource()`
7. 生产不可变 `MediaFramePtr`
8. 保证 frame descriptor ID 与注册轨道一致;
9. 在重启、编码变化和中断时更新 producer 元数据和 discontinuity由 adapter 更新 descriptor。
Descriptor 最低要求:
- `id`、`source_id` 非空;
- kind 不能是 `UNKNOWN`
- time base 分子和分母均大于零;
- generation 大于零。
### Source callback 生命周期
- 第一位 subscriber 触发一次 source start
- 多位 subscriber 共享同一个采集生产者;
- 最后一份 Subscription reset/析构时同步调用 source stop
- Hub 会先 close ring 并唤醒消费者stop callback 必须解除生产端阻塞、停止采集并 join producer形成同步发布屏障
- start 必须在阻塞阶段检查 cancellation
- cancellation predicate 必须快速、非阻塞,不能回调同一个 Hub
- active source 不能 unregister应先销毁全部 subscriptions
- request-key-frame 与 stop 串行化,只在 source 运行时调用;
- adapter 的最后 lease 只停止媒体 streaming不等于设备级 stop。
永久不响应 cancellation 的 start 虽会被隔离以避免 use-after-free仍可能泄漏线程和外部资源不能依赖该隔离替代正确实现。
### Subscription 与广播环形队列
- 每个消费线程单独调用一次 `subscribe()`
- Subscription move-only 且为单消费者对象;
- 同一 Subscription 不能跨线程并发 read/reset
- 满队列覆盖最旧帧,不阻塞生产者;
- `dropped_since_last_read` 是该消费者实际错过的帧数;
- ring 全局 dropped count 与 cursor dropped count 含义不同;
- `NEXT_PUBLISHED` 忽略已有帧;
- `OLDEST_AVAILABLE` 从最旧保留帧开始;
- `LATEST_AVAILABLE` 读取当前最新帧;
- source 重启会 reset ring、提升 `BroadcastFrameRing` 内部 generation并把 ring 的 `ReadResult.sequence` 从 0 重新计数;
- close 唤醒等待者并拒绝新 publish。
ring generation 不等于 `TrackDescriptor::generation`ring 的 `ReadResult.sequence` 也不等于 `MediaFrame::sequence`。Descriptor generation 和媒体帧 sequence 仍由 producer/adapter 维护。
协议消费者需要分别处理 ring `generation_changed`、descriptor generation、消费者 drop 和 frame discontinuity任一不连续发生时都应传播状态帧间编码还应请求关键帧。
## 新增第四种 Manager
1. 先确认能力不是 DeviceManager、TaskManager 或 MediaSourceHub 的子职责;
2. 定义所有权、初始化、start/stop 和线程模型;
3. 避免新增无必要的全局单例;
4. 新建独立目录、头文件、实现和 CMake target
5. 在 [`../CMakeLists.txt`](../CMakeLists.txt) 增加子目录;
6. 只在 `main.cpp` 或明确的上层 owner 组装;
7. 增加无设备生命周期、失败回滚和并发测试。
## 测试
MediaSourceHub
```bash
cmake --build build --target media_source_hub_test
ctest \
--test-dir build \
-R '^media_source_hub_test$' \
--output-on-failure
```
DeviceManager 和 TaskManager 当前没有独立 CTest这是现有测试缺口。修改其行为时至少补充
- fake device 创建、ID 冲突和 init/start/stop 失败;
- 设备依赖顺序;
- 周期任务调度和慢 step
- service task 启停、重复 stop 和启动失败回滚;
- 并发查询、取消和 shutdown。
## 提交检查
- [ ] manager 没有包含厂商 wire protocol
- [ ] 初始化、start、stop 和 destroy 语义明确
- [ ] 不依赖 unordered_map 的遍历顺序
- [ ] 动态集合修改不会与查询并发
- [ ] Media Subscription 每线程独立
- [ ] stop 能唤醒并 join 所有工作线程
- [ ] 新 manager 已加入 `cmvr-es/CMakeLists.txt`
- [ ] 无设备失败路径有测试

View File

@ -0,0 +1,61 @@
if(CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR)
cmake_minimum_required(VERSION 3.22)
project(cmvr_media_source_hub LANGUAGES CXX)
enable_testing()
endif()
add_library(media_source_hub STATIC
src/media_source_hub.cpp
)
target_compile_features(media_source_hub PUBLIC cxx_std_17)
target_include_directories(media_source_hub
PUBLIC
${CMAKE_CURRENT_SOURCE_DIR}/../..
)
add_library(cmvr_es::media_source_hub ALIAS media_source_hub)
if(NOT CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR)
add_library(device_media_source_adapter STATIC
src/device_media_source_adapter.cpp
)
target_compile_features(device_media_source_adapter PUBLIC cxx_std_17)
target_include_directories(device_media_source_adapter
PUBLIC
${CMAKE_CURRENT_SOURCE_DIR}/../..
)
target_link_libraries(device_media_source_adapter
PUBLIC
cmvr_es::media_source_hub
cmvr_es::common
cmvr_es::proto
cmvr_es::logging
)
add_library(cmvr_es::device_media_source_adapter ALIAS device_media_source_adapter)
endif()
option(CMVR_MEDIA_SOURCE_HUB_BUILD_TESTS
"Build the standalone MediaSourceHub self-test"
${PROJECT_IS_TOP_LEVEL})
if(CMVR_MEDIA_SOURCE_HUB_BUILD_TESTS)
find_package(Threads REQUIRED)
add_executable(media_source_hub_test
tests/media_source_hub_test.cpp
)
target_compile_features(media_source_hub_test PRIVATE cxx_std_17)
target_link_libraries(media_source_hub_test
PRIVATE
cmvr_es::media_source_hub
Threads::Threads
)
# This self-test only links the static Hub and pthreads. In the root build,
# the project-wide third-party RUNPATH can otherwise make the loader pick up
# a vendor libstdc++.so (for example from the AUBO SDK), even though the test
# has no dependency on that SDK.
set_target_properties(media_source_hub_test PROPERTIES
SKIP_BUILD_RPATH TRUE
)
add_test(NAME media_source_hub_test COMMAND media_source_hub_test)
endif()

View File

@ -0,0 +1,38 @@
#ifndef CMVR_ES_DEVICE_MEDIA_SOURCE_ADAPTER_H
#define CMVR_ES_DEVICE_MEDIA_SOURCE_ADAPTER_H
#pragma once
#include <cstddef>
#include <memory>
#include <string>
#include "devices/camera/abstract_camera.h"
#include "devices/microphone/abstract_microphone.h"
#include "manager/media_source_hub/include/media_source_hub.h"
namespace cmvr::media {
// Process-wide protocol-neutral media hub shared by gRPC and QUIC services.
MediaSourceHub& globalMediaSourceHub();
std::string cameraColorTrackId(const std::string& device_id);
std::string microphoneTrackId(const std::string& device_id);
// Registration is idempotent for an already registered track. The adapter owns a
// short-lived pump thread and one startStreaming()/stopStreaming() lease only while
// at least one Hub subscription is active. It ensures start() succeeds but deliberately
// does not call stop(), because the base device lifecycle can also be owned by control RPCs.
bool ensureCameraMediaSource(
MediaSourceHub& hub,
const std::shared_ptr<device::AbstractCamera>& camera,
size_t ring_capacity = 64);
bool ensureMicrophoneMediaSource(
MediaSourceHub& hub,
const std::shared_ptr<device::AbstractMicrophone>& microphone,
size_t ring_capacity = 256);
} // namespace cmvr::media
#endif // CMVR_ES_DEVICE_MEDIA_SOURCE_ADAPTER_H

View File

@ -0,0 +1,127 @@
#ifndef CMVR_ES_MANAGER_MEDIA_SOURCE_HUB_H
#define CMVR_ES_MANAGER_MEDIA_SOURCE_HUB_H
#pragma once
#include <chrono>
#include <cstddef>
#include <cstdint>
#include <functional>
#include <memory>
#include <optional>
#include <string>
#include <vector>
#include "common/base/ring_buffer.h"
#include "common/media/media_frame.h"
namespace cmvr::media {
// MediaSourceHub owns no protocol-specific state. A device or capture adapter registers
// start/stop callbacks and receives a sink callback when the first consumer subscribes.
class MediaSourceHub final {
public:
using FrameRing = BroadcastFrameRing<MediaFrame>;
using FrameReadResult = FrameRing::ReadResult;
using StartPosition = FrameRing::StartPosition;
using FrameSink = std::function<void(MediaFramePtr)>;
// Cancellation checks run while MediaSourceHub protects source lifecycle
// state. Predicates must therefore be fast, non-blocking and must not call
// back into the same hub.
using CancelPredicate = std::function<bool()>;
struct SourceCallbacks {
// start() may run asynchronously. It must observe cancelled during any
// potentially blocking startup work and return false promptly once set.
// MediaSourceHub retains the callback state until a non-cooperative start
// eventually returns, so late completion cannot access destroyed state.
std::function<bool(
const FrameSink& sink,
const CancelPredicate& cancelled)> start;
// stop() is the synchronous publication barrier for the last lease and
// must unblock and join the source producer before returning.
std::function<void()> stop;
std::function<bool()> request_key_frame;
};
private:
struct SourceState;
public:
class Subscription final {
public:
Subscription() = default;
~Subscription();
Subscription(const Subscription&) = delete;
Subscription& operator=(const Subscription&) = delete;
Subscription(Subscription&& other) noexcept;
Subscription& operator=(Subscription&& other) noexcept;
// A Subscription owns one reader cursor and is single-consumer. Moving,
// resetting, or reading the same object concurrently is unsupported; use
// one independent subscription per consumer thread.
bool valid() const;
explicit operator bool() const { return valid(); }
// Returns the most recently observed immutable descriptor. A callback source may
// replace the initially registered UNKNOWN codec/config descriptor with the first
// real frame descriptor without invalidating existing subscriptions.
TrackDescriptorPtr descriptor() const;
std::optional<FrameReadResult> tryRead();
std::optional<FrameReadResult> waitRead(std::chrono::milliseconds timeout);
uint64_t droppedCount() const noexcept;
void reset();
private:
friend class MediaSourceHub;
Subscription(std::shared_ptr<SourceState> source, FrameRing::Cursor cursor);
std::shared_ptr<SourceState> source_;
FrameRing::Cursor cursor_;
bool active_{false};
};
MediaSourceHub();
~MediaSourceHub();
MediaSourceHub(const MediaSourceHub&) = delete;
MediaSourceHub& operator=(const MediaSourceHub&) = delete;
bool registerSource(
TrackDescriptorPtr initial_descriptor,
SourceCallbacks callbacks,
size_t ring_capacity = 64);
// Active sources cannot be unregistered. Destroy/reset their subscriptions first.
bool unregisterSource(const std::string& track_id);
bool hasSource(const std::string& track_id) const;
std::vector<TrackDescriptorPtr> listTracks() const;
size_t subscriberCount(const std::string& track_id) const;
// Protocol adapters can request an IDR after a discontinuity without knowing the
// concrete camera implementation. Returns false when unsupported or not running.
bool requestKeyFrame(const std::string& track_id) const;
Subscription subscribe(
const std::string& track_id,
StartPosition start_position = StartPosition::NEXT_PUBLISHED,
CancelPredicate cancelled = {});
// Stops all registered sources and invalidates outstanding subscriptions. The
// subscriptions remain destructible and their waitRead calls are awakened.
// A cooperative in-progress start is cancelled; a callback that violates the
// cancellation contract is quarantined with retained state rather than blocking
// shutdown or risking a use-after-free.
void shutdown();
private:
struct Impl;
std::shared_ptr<Impl> impl_;
};
} // namespace cmvr::media
#endif // CMVR_ES_MANAGER_MEDIA_SOURCE_HUB_H

View File

@ -0,0 +1,683 @@
#include "manager/media_source_hub/include/device_media_source_adapter.h"
#include <algorithm>
#include <atomic>
#include <chrono>
#include <cctype>
#include <cstdint>
#include <exception>
#include <iterator>
#include <limits>
#include <mutex>
#include <thread>
#include <utility>
#include <vector>
#include "common/base/logging/logger.h"
namespace cmvr::media {
namespace {
std::string normalizedCodec(std::string codec) {
codec.erase(
std::remove_if(codec.begin(), codec.end(), [](const unsigned char c) {
return !std::isalnum(c);
}),
codec.end());
std::transform(codec.begin(), codec.end(), codec.begin(), [](const unsigned char c) {
return static_cast<char>(std::tolower(c));
});
return codec;
}
Codec videoCodec(const std::string& value) {
const std::string codec = normalizedCodec(value);
if (codec == "h264" || codec == "avc" || codec == "avc1" || codec == "libx264") {
return Codec::H264;
}
if (codec == "h265" || codec == "hevc" || codec == "hvc1" || codec == "libx265") {
return Codec::H265;
}
return Codec::UNKNOWN;
}
PayloadFormat videoPayloadFormat(
const Codec codec,
const std::vector<uint8_t>& payload) noexcept {
if (codec != Codec::H264 && codec != Codec::H265) {
return PayloadFormat::UNKNOWN;
}
const bool three_byte_start_code = payload.size() >= 3 &&
payload[0] == 0U && payload[1] == 0U && payload[2] == 1U;
const bool four_byte_start_code = payload.size() >= 4 &&
payload[0] == 0U && payload[1] == 0U && payload[2] == 0U && payload[3] == 1U;
return three_byte_start_code || four_byte_start_code
? PayloadFormat::ANNEX_B
: PayloadFormat::UNKNOWN;
}
Codec audioCodec(const std::string& value) {
const std::string codec = normalizedCodec(value);
if (codec == "opus") return Codec::OPUS;
if (codec == "aac") return Codec::AAC;
if (codec == "pcms16le") return Codec::PCM_S16LE;
return Codec::UNKNOWN;
}
Codec audioCodec(const device::AudioStreamFrameData& source) {
const Codec codec = audioCodec(source.codec);
if (codec != Codec::UNKNOWN || !normalizedCodec(source.codec).empty()) {
return codec;
}
switch (source.format) {
case device::AudioStreamFormat::PCM:
return Codec::PCM_S16LE;
case device::AudioStreamFormat::AAC:
return Codec::AAC;
case device::AudioStreamFormat::OPUS:
return Codec::OPUS;
default:
return Codec::UNKNOWN;
}
}
PayloadFormat audioPayloadFormat(
const Codec codec,
const std::vector<uint8_t>& payload) noexcept {
switch (codec) {
case Codec::OPUS:
return PayloadFormat::OPUS_PACKET;
case Codec::PCM_S16LE:
return PayloadFormat::RAW;
case Codec::AAC: {
// FFmpeg encoders commonly expose raw AAC access units plus AudioSpecificConfig;
// only advertise ADTS when the sync word and layer bits are actually present.
const bool has_adts_header = payload.size() >= 2 && payload[0] == 0xFFU &&
(payload[1] & 0xF6U) == 0xF0U;
return has_adts_header ? PayloadFormat::AAC_ADTS : PayloadFormat::RAW;
}
default:
return PayloadFormat::UNKNOWN;
}
}
Rational sanitizedTimeBase(
const int32_t numerator,
const int32_t denominator,
const int32_t fallback_denominator) noexcept {
return Rational{
numerator > 0 ? numerator : 1,
denominator > 0 ? denominator : std::max(1, fallback_denominator)};
}
uint64_t descriptorGeneration(
const uint64_t stream_epoch,
const uint32_t codec_generation) {
const uint64_t generation = (stream_epoch << 32U) | codec_generation;
return generation == 0 ? 1 : generation;
}
template<typename DeviceT>
struct PumpState : public std::enable_shared_from_this<PumpState<DeviceT>> {
explicit PumpState(std::shared_ptr<DeviceT> device_ptr)
: device(std::move(device_ptr)) {}
virtual ~PumpState() {
stop();
}
bool begin(
const MediaSourceHub::FrameSink& frame_sink,
const MediaSourceHub::CancelPredicate& cancelled) {
if (!frame_sink || !device) {
return false;
}
const auto cancellation_requested = [&cancelled] {
if (!cancelled) return false;
try {
return cancelled();
} catch (...) {
return true;
}
};
if (cancellation_requested()) {
return false;
}
std::unique_lock<std::mutex> lock(mutex);
if (running.load(std::memory_order_acquire)) {
return true;
}
if (worker.joinable()) {
// A previous worker must always be collected before a new capture lease starts.
std::thread stale_worker = std::move(worker);
lock.unlock();
collectThread(std::move(stale_worker));
lock.lock();
}
sink = frame_sink;
bool streaming_attempted = false;
try {
if (!device->start()) {
sink = {};
return false;
}
if (cancellation_requested()) {
sink = {};
return false;
}
streaming_attempted = true;
if (!device->startStreaming()) {
sink = {};
lock.unlock();
stopDeviceStreaming();
return false;
}
streaming_started = true;
if (cancellation_requested()) {
streaming_started = false;
sink = {};
lock.unlock();
stopDeviceStreaming();
return false;
}
running.store(true, std::memory_order_release);
try {
// The worker owns the pump while run() is active. This also
// makes the defensive self-stop/detach path lifetime-safe.
const auto self = this->shared_from_this();
worker = std::thread([self] { self->run(); });
} catch (...) {
running.store(false, std::memory_order_release);
streaming_started = false;
sink = {};
lock.unlock();
stopDeviceStreaming();
return false;
}
} catch (const std::exception& error) {
CMVR_LOG(ERROR) << "[DeviceMediaSourceAdapter] Failed to start media source: "
<< error.what();
sink = {};
lock.unlock();
if (streaming_attempted) stopDeviceStreaming();
return false;
} catch (...) {
CMVR_LOG(ERROR) << "[DeviceMediaSourceAdapter] Failed to start media source";
sink = {};
lock.unlock();
if (streaming_attempted) stopDeviceStreaming();
return false;
}
return true;
}
void stop() noexcept {
std::thread thread;
bool stop_streaming = false;
{
std::lock_guard<std::mutex> lock(mutex);
running.store(false, std::memory_order_release);
stop_streaming = streaming_started;
streaming_started = false;
sink = {};
thread = std::move(worker);
}
if (stop_streaming) {
stopDeviceStreaming();
}
if (thread.joinable()) {
collectThread(std::move(thread));
}
}
static void collectThread(std::thread thread) noexcept {
if (!thread.joinable()) {
return;
}
try {
if (thread.get_id() == std::this_thread::get_id()) {
thread.detach();
} else {
thread.join();
}
} catch (const std::exception& error) {
CMVR_LOG(ERROR) << "[DeviceMediaSourceAdapter] Failed to collect media pump: "
<< error.what();
if (thread.joinable()) {
try {
thread.detach();
} catch (...) {
// std::thread's destructor would terminate if this extremely rare
// platform error occurred; there is no recoverable ownership path.
}
}
}
}
virtual void run() = 0;
void stopDeviceStreaming() noexcept {
try {
if (device) {
device->stopStreaming();
}
} catch (const std::exception& error) {
CMVR_LOG(ERROR) << "[DeviceMediaSourceAdapter] Failed to stop media source: "
<< error.what();
} catch (...) {
CMVR_LOG(ERROR) << "[DeviceMediaSourceAdapter] Failed to stop media source";
}
}
std::shared_ptr<DeviceT> device;
std::atomic<bool> running{false};
std::mutex mutex;
std::thread worker;
MediaSourceHub::FrameSink sink;
bool streaming_started{false};
};
struct CameraPump final : PumpState<device::AbstractCamera> {
CameraPump(std::shared_ptr<device::AbstractCamera> camera, std::string id)
: PumpState(std::move(camera)), track_id(std::move(id)) {}
~CameraPump() override { stop(); }
void run() override {
size_t cursor = 0;
uint64_t last_epoch = 0;
uint64_t last_sequence = 0;
uint64_t cached_config_generation = 0;
std::vector<uint8_t> cached_codec_config;
TrackDescriptorPtr last_descriptor;
bool have_previous = false;
bool have_cached_config_generation = false;
bool waiting_for_key_frame = true;
bool pending_discontinuity = true;
bool requested_key_frame = false;
std::chrono::steady_clock::time_point last_key_frame_request;
const auto request_key_frame = [&] {
const auto now = std::chrono::steady_clock::now();
if (requested_key_frame &&
now - last_key_frame_request < std::chrono::milliseconds(250)) {
return;
}
requested_key_frame = true;
last_key_frame_request = now;
try {
device->requestKeyFrame();
} catch (...) {
// Unsupported/failed key-frame requests fall back to the encoder's GOP.
}
};
request_key_frame();
while (running.load(std::memory_order_acquire)) {
device::StreamFrameData source;
try {
if (!device->waitEncodedFrame(source, cursor, std::chrono::milliseconds(50))) {
std::this_thread::sleep_for(std::chrono::milliseconds(1));
continue;
}
} catch (const std::exception& error) {
CMVR_LOG(ERROR) << "[DeviceMediaSourceAdapter] Camera frame read failed for "
<< track_id << ": " << error.what();
std::this_thread::sleep_for(std::chrono::milliseconds(10));
continue;
} catch (...) {
CMVR_LOG(ERROR) << "[DeviceMediaSourceAdapter] Camera frame read failed for "
<< track_id;
std::this_thread::sleep_for(std::chrono::milliseconds(10));
continue;
}
if (source.rgbFrame.empty()) {
continue;
}
const uint64_t descriptor_generation = descriptorGeneration(
source.stream_epoch, source.codec_config_generation);
if (!have_cached_config_generation ||
cached_config_generation != descriptor_generation) {
cached_codec_config.clear();
cached_config_generation = descriptor_generation;
have_cached_config_generation = true;
}
if (!source.codec_config.empty()) {
cached_codec_config = source.codec_config;
}
TrackDescriptor::Config track;
track.id = track_id;
track.source_id = device->id();
track.kind = MediaKind::VIDEO;
track.codec = videoCodec(source.codec);
track.payload_format = videoPayloadFormat(track.codec, source.rgbFrame);
track.time_base = sanitizedTimeBase(
source.time_base_num,
source.time_base_den,
source.fps);
track.width = static_cast<uint32_t>(std::max(0, source.width));
track.height = static_cast<uint32_t>(std::max(0, source.height));
track.nominal_rate = static_cast<uint32_t>(std::max(0, source.fps));
track.fx = source.intrinsics.fx;
track.fy = source.intrinsics.fy;
track.cx = source.intrinsics.cx;
track.cy = source.intrinsics.cy;
track.distortion.assign(
std::begin(source.intrinsics.coeffs),
std::end(source.intrinsics.coeffs));
track.generation = descriptor_generation;
track.codec_config = cached_codec_config;
const bool epoch_changed = have_previous && source.stream_epoch != last_epoch;
const bool sequence_wrapped = have_previous &&
last_sequence == std::numeric_limits<uint64_t>::max() && source.sequence != 0;
const bool sequence_gap = have_previous && !epoch_changed &&
(sequence_wrapped ||
(last_sequence != std::numeric_limits<uint64_t>::max() &&
source.sequence != last_sequence + 1));
TrackDescriptorPtr descriptor;
try {
descriptor = makeTrackDescriptor(std::move(track));
} catch (const std::exception& error) {
CMVR_LOG(ERROR) << "[DeviceMediaSourceAdapter] Invalid camera descriptor for "
<< track_id << ": " << error.what();
pending_discontinuity = true;
last_epoch = source.stream_epoch;
last_sequence = source.sequence;
have_previous = true;
continue;
}
const bool descriptor_changed = last_descriptor &&
!equivalentTrackDescriptor(*last_descriptor, *descriptor);
const bool discontinuity = !have_previous || source.discontinuity || epoch_changed ||
sequence_gap || descriptor_changed;
const bool inter_frame_codec = descriptor->codec == Codec::H264 ||
descriptor->codec == Codec::H265;
if (discontinuity) {
pending_discontinuity = true;
if (inter_frame_codec) {
waiting_for_key_frame = true;
request_key_frame();
} else {
waiting_for_key_frame = false;
}
}
last_epoch = source.stream_epoch;
last_sequence = source.sequence;
last_descriptor = descriptor;
have_previous = true;
if (waiting_for_key_frame && !source.bKey) {
request_key_frame();
continue;
}
waiting_for_key_frame = false;
MediaFrame::Config frame;
frame.descriptor = std::move(descriptor);
frame.payload = std::move(source.rgbFrame);
frame.sequence = source.sequence;
frame.pts = source.pts;
frame.dts = source.dts;
frame.duration = source.duration;
frame.capture_time_ns = source.capture_monotonic_ns > 0
? static_cast<uint64_t>(source.capture_monotonic_ns)
: 0;
frame.capture_utc_ns = source.capture_utc_ns;
frame.key_frame = source.bKey;
frame.discontinuity = pending_discontinuity;
MediaSourceHub::FrameSink current_sink;
{
std::lock_guard<std::mutex> lock(mutex);
current_sink = sink;
}
if (current_sink) {
try {
current_sink(makeMediaFrame(std::move(frame)));
pending_discontinuity = false;
} catch (const std::exception& error) {
CMVR_LOG(ERROR) << "[DeviceMediaSourceAdapter] Invalid camera frame for "
<< track_id << ": " << error.what();
pending_discontinuity = true;
}
}
}
}
std::string track_id;
};
struct MicrophonePump final : PumpState<device::AbstractMicrophone> {
MicrophonePump(std::shared_ptr<device::AbstractMicrophone> microphone, std::string id)
: PumpState(std::move(microphone)), track_id(std::move(id)) {}
~MicrophonePump() override { stop(); }
void run() override {
size_t cursor = 0;
uint64_t last_epoch = 0;
uint64_t last_sequence = 0;
uint64_t cached_config_generation = 0;
std::vector<uint8_t> cached_codec_config;
TrackDescriptorPtr last_descriptor;
bool have_previous = false;
bool have_cached_config_generation = false;
bool pending_discontinuity = true;
while (running.load(std::memory_order_acquire)) {
device::AudioStreamFrameData source;
try {
if (!device->waitEncodedFrame(source, cursor, std::chrono::milliseconds(50))) {
std::this_thread::sleep_for(std::chrono::milliseconds(1));
continue;
}
} catch (const std::exception& error) {
CMVR_LOG(ERROR) << "[DeviceMediaSourceAdapter] Microphone frame read failed for "
<< track_id << ": " << error.what();
std::this_thread::sleep_for(std::chrono::milliseconds(10));
continue;
} catch (...) {
CMVR_LOG(ERROR) << "[DeviceMediaSourceAdapter] Microphone frame read failed for "
<< track_id;
std::this_thread::sleep_for(std::chrono::milliseconds(10));
continue;
}
if (source.data.empty()) {
continue;
}
const uint64_t descriptor_generation = descriptorGeneration(
source.stream_epoch, source.codec_config_generation);
if (!have_cached_config_generation ||
cached_config_generation != descriptor_generation) {
cached_codec_config.clear();
cached_config_generation = descriptor_generation;
have_cached_config_generation = true;
}
if (!source.codec_config.empty()) {
cached_codec_config = source.codec_config;
}
TrackDescriptor::Config track;
track.id = track_id;
track.source_id = device->id();
track.kind = MediaKind::AUDIO;
track.codec = audioCodec(source);
track.payload_format = audioPayloadFormat(track.codec, source.data);
track.time_base = sanitizedTimeBase(
source.time_base_num,
source.time_base_den,
source.sample_rate);
track.sample_rate = static_cast<uint32_t>(std::max(0, source.sample_rate));
track.channels = static_cast<uint32_t>(std::max(0, source.channels));
// Packet sample counts belong to MediaFrame::duration, not immutable track metadata.
track.nominal_rate = 0;
track.generation = descriptor_generation;
track.codec_config = cached_codec_config;
const bool epoch_changed = have_previous && source.stream_epoch != last_epoch;
const bool sequence_wrapped = have_previous &&
last_sequence == std::numeric_limits<uint64_t>::max() && source.sequence != 0;
const bool sequence_gap = have_previous && !epoch_changed &&
(sequence_wrapped ||
(last_sequence != std::numeric_limits<uint64_t>::max() &&
source.sequence != last_sequence + 1));
TrackDescriptorPtr descriptor;
try {
descriptor = makeTrackDescriptor(std::move(track));
} catch (const std::exception& error) {
CMVR_LOG(ERROR) << "[DeviceMediaSourceAdapter] Invalid microphone descriptor for "
<< track_id << ": " << error.what();
pending_discontinuity = true;
last_epoch = source.stream_epoch;
last_sequence = source.sequence;
have_previous = true;
continue;
}
const bool descriptor_changed = last_descriptor &&
!equivalentTrackDescriptor(*last_descriptor, *descriptor);
if (!have_previous || source.discontinuity || epoch_changed || sequence_gap ||
descriptor_changed) {
pending_discontinuity = true;
}
last_epoch = source.stream_epoch;
last_sequence = source.sequence;
last_descriptor = descriptor;
have_previous = true;
MediaFrame::Config frame;
frame.descriptor = std::move(descriptor);
frame.payload = std::move(source.data);
frame.sequence = source.sequence;
frame.pts = source.pts;
frame.dts = source.dts;
frame.duration = source.duration > 0 ? source.duration : source.nb_samples;
frame.capture_time_ns = source.capture_monotonic_ns > 0
? static_cast<uint64_t>(source.capture_monotonic_ns)
: 0;
frame.capture_utc_ns = source.capture_utc_ns;
frame.key_frame = true;
frame.discontinuity = pending_discontinuity;
MediaSourceHub::FrameSink current_sink;
{
std::lock_guard<std::mutex> lock(mutex);
current_sink = sink;
}
if (current_sink) {
try {
current_sink(makeMediaFrame(std::move(frame)));
pending_discontinuity = false;
} catch (const std::exception& error) {
CMVR_LOG(ERROR) << "[DeviceMediaSourceAdapter] Invalid microphone frame for "
<< track_id << ": " << error.what();
pending_discontinuity = true;
}
}
}
}
std::string track_id;
};
TrackDescriptorPtr initialTrack(
std::string track_id,
std::string source_id,
const MediaKind kind) {
TrackDescriptor::Config config;
config.id = std::move(track_id);
config.source_id = std::move(source_id);
config.kind = kind;
// A placeholder descriptor is never emitted as a media sample, but it
// still carries a mathematically valid neutral time base.
config.time_base = Rational{1, 1};
config.generation = 1;
return makeTrackDescriptor(std::move(config));
}
} // namespace
MediaSourceHub& globalMediaSourceHub() {
static MediaSourceHub hub;
return hub;
}
std::string cameraColorTrackId(const std::string& device_id) {
return device_id + "/video/color";
}
std::string microphoneTrackId(const std::string& device_id) {
return device_id + "/audio/main";
}
bool ensureCameraMediaSource(
MediaSourceHub& hub,
const std::shared_ptr<device::AbstractCamera>& camera,
const size_t ring_capacity) {
if (!camera || camera->id().empty()) {
return false;
}
const std::string track_id = cameraColorTrackId(camera->id());
if (hub.hasSource(track_id)) {
return true;
}
const auto pump = std::make_shared<CameraPump>(camera, track_id);
MediaSourceHub::SourceCallbacks callbacks;
callbacks.start = [pump](
const MediaSourceHub::FrameSink& sink,
const MediaSourceHub::CancelPredicate& cancelled) {
return pump->begin(sink, cancelled);
};
callbacks.stop = [pump] { pump->stop(); };
callbacks.request_key_frame = [camera] { return camera->requestKeyFrame(); };
const bool registered = hub.registerSource(
initialTrack(track_id, camera->id(), MediaKind::VIDEO),
std::move(callbacks),
ring_capacity);
if (!registered && !hub.hasSource(track_id)) {
CMVR_LOG(ERROR) << "[DeviceMediaSourceAdapter] Failed to register camera track: " << track_id;
return false;
}
return true;
}
bool ensureMicrophoneMediaSource(
MediaSourceHub& hub,
const std::shared_ptr<device::AbstractMicrophone>& microphone,
const size_t ring_capacity) {
if (!microphone || microphone->id().empty()) {
return false;
}
const std::string track_id = microphoneTrackId(microphone->id());
if (hub.hasSource(track_id)) {
return true;
}
const auto pump = std::make_shared<MicrophonePump>(microphone, track_id);
MediaSourceHub::SourceCallbacks callbacks;
callbacks.start = [pump](
const MediaSourceHub::FrameSink& sink,
const MediaSourceHub::CancelPredicate& cancelled) {
return pump->begin(sink, cancelled);
};
callbacks.stop = [pump] { pump->stop(); };
const bool registered = hub.registerSource(
initialTrack(track_id, microphone->id(), MediaKind::AUDIO),
std::move(callbacks),
ring_capacity);
if (!registered && !hub.hasSource(track_id)) {
CMVR_LOG(ERROR) << "[DeviceMediaSourceAdapter] Failed to register microphone track: " << track_id;
return false;
}
return true;
}
} // namespace cmvr::media

View File

@ -0,0 +1,588 @@
#include "manager/media_source_hub/include/media_source_hub.h"
#include <algorithm>
#include <atomic>
#include <condition_variable>
#include <mutex>
#include <thread>
#include <unordered_map>
#include <utility>
namespace cmvr::media {
struct MediaSourceHub::SourceState final : public std::enable_shared_from_this<SourceState> {
enum class Lifecycle {
STOPPED,
STARTING,
RUNNING,
STOPPING
};
struct StartAttempt {
size_t waiters{0};
bool completed{false};
bool succeeded{false};
std::atomic<bool> cancel_requested{false};
};
SourceState(
TrackDescriptorPtr initial_descriptor,
SourceCallbacks source_callbacks,
const size_t ring_capacity)
: track_id(initial_descriptor->id),
descriptor(std::move(initial_descriptor)),
callbacks(std::move(source_callbacks)),
ring(ring_capacity) {}
FrameSink makeSink() {
const std::weak_ptr<SourceState> weak_source = shared_from_this();
return [weak_source](MediaFramePtr frame) {
if (const auto source = weak_source.lock()) {
source->acceptFrame(std::move(frame));
}
};
}
void acceptFrame(MediaFramePtr frame) {
if (!frame || !frame->descriptor || frame->descriptor->id != track_id) {
return;
}
{
std::lock_guard<std::mutex> lock(lifecycle_mutex);
if (!registered ||
(lifecycle != Lifecycle::STARTING && lifecycle != Lifecycle::RUNNING)) {
return;
}
}
const TrackDescriptorPtr current = std::atomic_load(&descriptor);
if (!current || !equivalentTrackDescriptor(*current, *frame->descriptor)) {
// C++17 atomic shared_ptr free functions provide an atomic descriptor snapshot to
// all subscriptions while frames remain immutable.
std::atomic_store(&descriptor, frame->descriptor);
}
ring.publish(std::move(frame));
}
static bool isCancelled(const CancelPredicate& cancelled) noexcept {
if (!cancelled) return false;
try {
return cancelled();
} catch (...) {
return true;
}
}
bool invokeStart(
const FrameSink& sink,
const CancelPredicate& cancelled) noexcept {
std::lock_guard<std::mutex> callback_lock(callback_mutex);
try {
return callbacks.start && callbacks.start(sink, cancelled);
} catch (...) {
return false;
}
}
void invokeStop() noexcept {
std::lock_guard<std::mutex> callback_lock(callback_mutex);
try {
if (callbacks.stop) callbacks.stop();
} catch (...) {
}
}
void completeStart(
const std::shared_ptr<StartAttempt>& attempt,
const bool started) {
bool stop_abandoned_start = false;
{
std::lock_guard<std::mutex> lock(lifecycle_mutex);
if (start_attempt != attempt || lifecycle != Lifecycle::STARTING) {
return;
}
attempt->completed = true;
attempt->succeeded = started;
if (started && registered && attempt->waiters != 0U) {
lifecycle = Lifecycle::RUNNING;
} else if (started) {
lifecycle = Lifecycle::STOPPING;
ring.close();
stop_abandoned_start = true;
} else {
lifecycle = Lifecycle::STOPPED;
}
lifecycle_condition.notify_all();
}
if (stop_abandoned_start) {
invokeStop();
std::lock_guard<std::mutex> lock(lifecycle_mutex);
if (lifecycle == Lifecycle::STOPPING) {
lifecycle = Lifecycle::STOPPED;
}
lifecycle_condition.notify_all();
}
}
bool acquire(
const StartPosition start_position,
FrameRing::Cursor& cursor,
const CancelPredicate& cancelled) {
std::unique_lock<std::mutex> lock(lifecycle_mutex);
while (lifecycle == Lifecycle::STOPPING) {
if (!registered || isCancelled(cancelled)) return false;
lifecycle_condition.wait_for(lock, std::chrono::milliseconds(10));
}
if (!registered || isCancelled(cancelled)) return false;
if (lifecycle == Lifecycle::RUNNING) {
++subscriber_count;
lock.unlock();
cursor = ring.makeCursor(start_position);
return true;
}
std::shared_ptr<StartAttempt> attempt;
if (lifecycle == Lifecycle::STOPPED) {
lifecycle = Lifecycle::STARTING;
ring.reset();
const FrameSink sink = makeSink();
attempt = std::make_shared<StartAttempt>();
attempt->waiters = 1U;
start_attempt = attempt;
const auto self = shared_from_this();
try {
std::thread([self, attempt, sink]() {
const CancelPredicate cancelled = [attempt] {
return attempt->cancel_requested.load(
std::memory_order_acquire);
};
const bool started = self->invokeStart(sink, cancelled);
self->completeStart(attempt, started);
}).detach();
} catch (...) {
start_attempt.reset();
lifecycle = Lifecycle::STOPPED;
lifecycle_condition.notify_all();
return false;
}
} else if (lifecycle == Lifecycle::STARTING) {
attempt = start_attempt;
if (!attempt || attempt->cancel_requested.load(std::memory_order_acquire)) {
return false;
}
++attempt->waiters;
} else {
return false;
}
while (registered && !attempt->completed) {
if (isCancelled(cancelled)) {
if (attempt->waiters != 0U) --attempt->waiters;
if (attempt->waiters == 0U) {
attempt->cancel_requested.store(true, std::memory_order_release);
}
lifecycle_condition.notify_all();
return false;
}
lifecycle_condition.wait_for(lock, std::chrono::milliseconds(10));
}
const bool caller_cancelled = isCancelled(cancelled);
const bool acquired = !caller_cancelled && registered && attempt->completed &&
attempt->succeeded &&
lifecycle == Lifecycle::RUNNING;
if (attempt->waiters != 0U) --attempt->waiters;
if (!acquired) {
if (attempt->waiters == 0U) {
attempt->cancel_requested.store(true, std::memory_order_release);
}
const bool stop_unclaimed_source =
start_attempt == attempt && attempt->waiters == 0U &&
subscriber_count == 0U &&
lifecycle == Lifecycle::RUNNING;
if (stop_unclaimed_source) {
lifecycle = Lifecycle::STOPPING;
ring.close();
lock.unlock();
invokeStop();
lock.lock();
if (lifecycle == Lifecycle::STOPPING) {
lifecycle = Lifecycle::STOPPED;
}
lifecycle_condition.notify_all();
}
return false;
}
++subscriber_count;
lock.unlock();
cursor = ring.makeCursor(start_position);
return true;
}
void release() {
std::unique_lock<std::mutex> lock(lifecycle_mutex);
if (subscriber_count == 0) {
return;
}
--subscriber_count;
if (subscriber_count != 0 || lifecycle != Lifecycle::RUNNING) {
return;
}
lifecycle = Lifecycle::STOPPING;
ring.close();
lock.unlock();
invokeStop();
lock.lock();
lifecycle = Lifecycle::STOPPED;
lifecycle_condition.notify_all();
}
bool deactivateIfUnused() {
std::unique_lock<std::mutex> lock(lifecycle_mutex);
if (subscriber_count != 0) {
return false;
}
registered = false;
ring.close();
if (lifecycle == Lifecycle::STARTING) {
if (start_attempt) {
start_attempt->cancel_requested.store(true, std::memory_order_release);
}
lifecycle_condition.notify_all();
return true;
}
if (lifecycle == Lifecycle::STOPPING || lifecycle == Lifecycle::STOPPED) {
lifecycle_condition.notify_all();
return true;
}
lifecycle = Lifecycle::STOPPING;
lock.unlock();
invokeStop();
lock.lock();
if (lifecycle == Lifecycle::STOPPING) {
lifecycle = Lifecycle::STOPPED;
}
lifecycle_condition.notify_all();
return true;
}
void shutdown() {
std::unique_lock<std::mutex> lock(lifecycle_mutex);
registered = false;
ring.close();
if (lifecycle == Lifecycle::STARTING) {
if (start_attempt) {
start_attempt->cancel_requested.store(true, std::memory_order_release);
}
lifecycle_condition.notify_all();
return;
}
if (lifecycle == Lifecycle::STOPPING) {
lifecycle_condition.notify_all();
return;
}
if (lifecycle == Lifecycle::STOPPED) {
lifecycle_condition.notify_all();
return;
}
lifecycle = Lifecycle::STOPPING;
lock.unlock();
invokeStop();
lock.lock();
if (lifecycle == Lifecycle::STOPPING) {
lifecycle = Lifecycle::STOPPED;
}
lifecycle_condition.notify_all();
}
bool validForSubscription() const {
std::lock_guard<std::mutex> lock(lifecycle_mutex);
return registered && lifecycle == Lifecycle::RUNNING;
}
size_t subscriberCount() const {
std::lock_guard<std::mutex> lock(lifecycle_mutex);
return subscriber_count;
}
bool requestKeyFrame() const {
// Serialize with stop first, then re-check lifecycle. A stop that has
// already begun rejects the request; a stop that begins afterwards
// waits for this callback before invoking the device stop barrier.
std::lock_guard<std::mutex> callback_lock(callback_mutex);
{
std::lock_guard<std::mutex> lock(lifecycle_mutex);
if (!registered || lifecycle != Lifecycle::RUNNING || !callbacks.request_key_frame) {
return false;
}
}
try {
return callbacks.request_key_frame();
} catch (...) {
return false;
}
}
TrackDescriptorPtr currentDescriptor() const {
return std::atomic_load(&descriptor);
}
const std::string track_id;
mutable TrackDescriptorPtr descriptor;
const SourceCallbacks callbacks;
FrameRing ring;
mutable std::mutex callback_mutex;
mutable std::mutex lifecycle_mutex;
std::condition_variable lifecycle_condition;
Lifecycle lifecycle{Lifecycle::STOPPED};
size_t subscriber_count{0};
bool registered{true};
std::shared_ptr<StartAttempt> start_attempt;
};
struct MediaSourceHub::Impl final {
mutable std::mutex mutex;
std::unordered_map<std::string, std::shared_ptr<SourceState>> sources;
};
MediaSourceHub::Subscription::Subscription(
std::shared_ptr<SourceState> source,
FrameRing::Cursor cursor)
: source_(std::move(source)),
cursor_(std::move(cursor)),
active_(static_cast<bool>(source_)) {}
MediaSourceHub::Subscription::~Subscription() {
reset();
}
MediaSourceHub::Subscription::Subscription(Subscription&& other) noexcept
: source_(std::move(other.source_)),
cursor_(other.cursor_),
active_(other.active_) {
other.active_ = false;
}
MediaSourceHub::Subscription& MediaSourceHub::Subscription::operator=(Subscription&& other) noexcept {
if (this == &other) {
return *this;
}
reset();
source_ = std::move(other.source_);
cursor_ = other.cursor_;
active_ = other.active_;
other.active_ = false;
return *this;
}
bool MediaSourceHub::Subscription::valid() const {
return active_ && source_ && source_->validForSubscription();
}
TrackDescriptorPtr MediaSourceHub::Subscription::descriptor() const {
return source_ ? source_->currentDescriptor() : nullptr;
}
std::optional<MediaSourceHub::FrameReadResult> MediaSourceHub::Subscription::tryRead() {
if (!active_ || !source_) {
return std::nullopt;
}
return source_->ring.tryRead(cursor_);
}
std::optional<MediaSourceHub::FrameReadResult> MediaSourceHub::Subscription::waitRead(
const std::chrono::milliseconds timeout) {
if (!active_ || !source_) {
return std::nullopt;
}
return source_->ring.waitRead(cursor_, timeout);
}
uint64_t MediaSourceHub::Subscription::droppedCount() const noexcept {
return cursor_.dropped_count;
}
void MediaSourceHub::Subscription::reset() {
if (active_ && source_) {
source_->release();
}
active_ = false;
source_.reset();
}
MediaSourceHub::MediaSourceHub()
: impl_(std::make_shared<Impl>()) {}
MediaSourceHub::~MediaSourceHub() {
shutdown();
}
bool MediaSourceHub::registerSource(
TrackDescriptorPtr initial_descriptor,
SourceCallbacks callbacks,
const size_t ring_capacity) {
if (!impl_ || !initial_descriptor || initial_descriptor->id.empty() ||
!callbacks.start || ring_capacity == 0) {
return false;
}
std::shared_ptr<SourceState> source;
try {
source = std::make_shared<SourceState>(
std::move(initial_descriptor), std::move(callbacks), ring_capacity);
} catch (...) {
return false;
}
std::lock_guard<std::mutex> lock(impl_->mutex);
return impl_->sources.emplace(source->track_id, std::move(source)).second;
}
bool MediaSourceHub::unregisterSource(const std::string& track_id) {
if (!impl_ || track_id.empty()) {
return false;
}
std::shared_ptr<SourceState> source;
{
std::lock_guard<std::mutex> lock(impl_->mutex);
const auto it = impl_->sources.find(track_id);
if (it == impl_->sources.end()) {
return false;
}
source = it->second;
}
if (!source->deactivateIfUnused()) {
return false;
}
std::lock_guard<std::mutex> lock(impl_->mutex);
const auto it = impl_->sources.find(track_id);
if (it != impl_->sources.end() && it->second == source) {
impl_->sources.erase(it);
return true;
}
return false;
}
bool MediaSourceHub::hasSource(const std::string& track_id) const {
if (!impl_) {
return false;
}
std::lock_guard<std::mutex> lock(impl_->mutex);
return impl_->sources.find(track_id) != impl_->sources.end();
}
std::vector<TrackDescriptorPtr> MediaSourceHub::listTracks() const {
std::vector<std::shared_ptr<SourceState>> sources;
if (!impl_) {
return {};
}
{
std::lock_guard<std::mutex> lock(impl_->mutex);
sources.reserve(impl_->sources.size());
for (const auto& [track_id, source] : impl_->sources) {
(void)track_id;
sources.push_back(source);
}
}
std::vector<TrackDescriptorPtr> descriptors;
descriptors.reserve(sources.size());
for (const auto& source : sources) {
descriptors.push_back(source->currentDescriptor());
}
std::sort(descriptors.begin(), descriptors.end(), [](const auto& lhs, const auto& rhs) {
if (!lhs) return static_cast<bool>(rhs);
if (!rhs) return false;
return lhs->id < rhs->id;
});
return descriptors;
}
size_t MediaSourceHub::subscriberCount(const std::string& track_id) const {
if (!impl_) {
return 0;
}
std::shared_ptr<SourceState> source;
{
std::lock_guard<std::mutex> lock(impl_->mutex);
const auto it = impl_->sources.find(track_id);
if (it == impl_->sources.end()) {
return 0;
}
source = it->second;
}
return source->subscriberCount();
}
bool MediaSourceHub::requestKeyFrame(const std::string& track_id) const {
if (!impl_) {
return false;
}
std::shared_ptr<SourceState> source;
{
std::lock_guard<std::mutex> lock(impl_->mutex);
const auto it = impl_->sources.find(track_id);
if (it == impl_->sources.end()) {
return false;
}
source = it->second;
}
return source->requestKeyFrame();
}
MediaSourceHub::Subscription MediaSourceHub::subscribe(
const std::string& track_id,
const StartPosition start_position,
CancelPredicate cancelled) {
if (!impl_) {
return {};
}
std::shared_ptr<SourceState> source;
{
std::lock_guard<std::mutex> lock(impl_->mutex);
const auto it = impl_->sources.find(track_id);
if (it == impl_->sources.end()) {
return {};
}
source = it->second;
}
FrameRing::Cursor cursor;
if (!source->acquire(start_position, cursor, cancelled)) {
return {};
}
return Subscription(std::move(source), std::move(cursor));
}
void MediaSourceHub::shutdown() {
if (!impl_) {
return;
}
std::vector<std::shared_ptr<SourceState>> sources;
{
std::lock_guard<std::mutex> lock(impl_->mutex);
sources.reserve(impl_->sources.size());
for (auto& [track_id, source] : impl_->sources) {
(void)track_id;
sources.push_back(std::move(source));
}
impl_->sources.clear();
}
for (const auto& source : sources) {
source->shutdown();
}
}
} // namespace cmvr::media

View File

@ -0,0 +1,514 @@
#include "manager/media_source_hub/include/media_source_hub.h"
#include <atomic>
#include <chrono>
#include <future>
#include <iostream>
#include <mutex>
#include <stdexcept>
#include <string>
#include <thread>
#include <type_traits>
#include <utility>
#include <vector>
namespace {
using namespace std::chrono_literals;
using cmvr::media::Codec;
using cmvr::media::MediaFrame;
using cmvr::media::MediaFramePtr;
using cmvr::media::MediaKind;
using cmvr::media::MediaSourceHub;
using cmvr::media::PayloadFormat;
using cmvr::media::Rational;
using cmvr::media::TrackDescriptor;
using cmvr::media::TrackDescriptorPtr;
static_assert(!std::is_copy_assignable<MediaFrame>::value, "MediaFrame must be immutable");
static_assert(!std::is_copy_assignable<TrackDescriptor>::value, "TrackDescriptor must be immutable");
static_assert(std::is_same<MediaFramePtr::element_type, const MediaFrame>::value,
"MediaFramePtr must share const frames");
int failures = 0;
#define CHECK_TRUE(expression) \
do { \
if (!(expression)) { \
std::cerr << __FILE__ << ':' << __LINE__ << " check failed: " #expression << '\n'; \
++failures; \
} \
} while (false)
TrackDescriptorPtr makeVideoDescriptor(
const Codec codec,
const uint64_t generation,
std::vector<uint8_t> codec_config = {}) {
TrackDescriptor::Config config;
config.id = "camera.front.video";
config.source_id = "camera.front";
config.kind = MediaKind::VIDEO;
config.codec = codec;
config.payload_format = codec == Codec::UNKNOWN ? PayloadFormat::UNKNOWN : PayloadFormat::ANNEX_B;
config.time_base = Rational{1, 90000};
config.width = 640;
config.height = 360;
config.nominal_rate = 30;
config.generation = generation;
config.codec_config = std::move(codec_config);
return cmvr::media::makeTrackDescriptor(std::move(config));
}
MediaFramePtr makeFrame(
TrackDescriptorPtr descriptor,
const uint64_t sequence,
const uint8_t marker) {
MediaFrame::Config config;
config.descriptor = std::move(descriptor);
config.payload = {marker, static_cast<uint8_t>(marker + 1)};
config.sequence = sequence;
config.pts = static_cast<int64_t>(sequence * 3000);
config.dts = config.pts;
config.duration = 3000;
config.capture_time_ns = sequence * 1000000;
config.capture_utc_ns = 1700000000000000000LL + static_cast<int64_t>(sequence);
config.key_frame = sequence == 0;
return cmvr::media::makeMediaFrame(std::move(config));
}
void testMediaMetadataValidation() {
TrackDescriptor::Config invalid;
invalid.id = "invalid.video";
invalid.source_id = "invalid";
invalid.kind = MediaKind::VIDEO;
invalid.time_base = Rational{0, 1};
bool rejected = false;
try {
(void)cmvr::media::makeTrackDescriptor(std::move(invalid));
} catch (const std::invalid_argument&) {
rejected = true;
}
CHECK_TRUE(rejected);
const auto frame = makeFrame(makeVideoDescriptor(Codec::H264, 1), 7, 1);
CHECK_TRUE(frame->capture_time_ns == 7000000);
CHECK_TRUE(frame->capture_utc_ns == 1700000000000000007LL);
CHECK_TRUE(frame->duration == 3000);
}
void testLegacySpmcCompatibility() {
bool ring_zero_capacity_rejected = false;
try {
RingBuffer<int> invalid_ring(0);
} catch (const std::invalid_argument&) {
ring_zero_capacity_rejected = true;
}
CHECK_TRUE(ring_zero_capacity_rejected);
bool spmc_zero_capacity_rejected = false;
try {
SPMCRingBuffer<int> invalid_ring(0);
} catch (const std::invalid_argument&) {
spmc_zero_capacity_rejected = true;
}
CHECK_TRUE(spmc_zero_capacity_rejected);
SPMCRingBuffer<int> ring(2);
ring.push(10);
ring.push(20);
CHECK_TRUE(ring.size() == 2);
CHECK_TRUE(ring.getHead() == 2);
CHECK_TRUE(ring.getTail() == 0);
CHECK_TRUE(ring.getLast().has_value() && *ring.getLast() == 20);
size_t reader = 0;
CHECK_TRUE(ring.pop(reader).has_value());
ring.push(30);
ring.push(40);
CHECK_TRUE(!ring.pop(reader).has_value());
CHECK_TRUE(reader == ring.getTail());
CHECK_TRUE(ring.pop(reader).has_value());
ring.clear();
CHECK_TRUE(ring.empty());
CHECK_TRUE(ring.getHead() == 4);
ring.push(50);
CHECK_TRUE(!ring.pop(reader).has_value());
const auto after_clear = ring.pop(reader);
CHECK_TRUE(after_clear.has_value() && *after_clear == 50);
}
void testBroadcastFrameRing() {
using Ring = BroadcastFrameRing<MediaFrame>;
Ring ring(2);
const auto descriptor = makeVideoDescriptor(Codec::H264, 1, {1, 2, 3});
auto cursor = ring.makeCursor(Ring::StartPosition::OLDEST_AVAILABLE);
CHECK_TRUE(ring.publish(makeFrame(descriptor, 0, 10)).value() == 0);
CHECK_TRUE(ring.publish(makeFrame(descriptor, 1, 20)).value() == 1);
CHECK_TRUE(ring.publish(makeFrame(descriptor, 2, 30)).value() == 2);
const auto first = ring.tryRead(cursor);
CHECK_TRUE(first.has_value());
CHECK_TRUE(first->sequence == 1);
CHECK_TRUE(first->value->sequence == 1);
CHECK_TRUE(first->dropped_count == 1);
CHECK_TRUE(first->dropped_since_last_read == 1);
CHECK_TRUE(ring.stats().dropped_count == 1);
const auto second = ring.tryRead(cursor);
CHECK_TRUE(second.has_value() && second->sequence == 2);
CHECK_TRUE(second->dropped_since_last_read == 0);
auto waiting_cursor = ring.makeCursor(Ring::StartPosition::NEXT_PUBLISHED);
auto waiting_read = std::async(std::launch::async, [&ring, &waiting_cursor] {
return ring.waitRead(waiting_cursor, 1s);
});
std::this_thread::sleep_for(10ms);
ring.publish(makeFrame(descriptor, 3, 40));
CHECK_TRUE(waiting_read.wait_for(500ms) == std::future_status::ready);
CHECK_TRUE(waiting_read.get().has_value());
const uint64_t next_generation = ring.reset();
CHECK_TRUE(next_generation == 2);
ring.publish(makeFrame(descriptor, 4, 50));
const auto after_reset = ring.tryRead(cursor);
CHECK_TRUE(after_reset.has_value());
CHECK_TRUE(after_reset->generation == 2);
CHECK_TRUE(after_reset->sequence == 0);
CHECK_TRUE(after_reset->generation_changed);
auto close_cursor = ring.makeCursor(Ring::StartPosition::NEXT_PUBLISHED);
auto close_wait = std::async(std::launch::async, [&ring, &close_cursor] {
return ring.waitRead(close_cursor, 2s);
});
ring.close();
CHECK_TRUE(close_wait.wait_for(500ms) == std::future_status::ready);
CHECK_TRUE(!close_wait.get().has_value());
CHECK_TRUE(!ring.publish(makeFrame(descriptor, 5, 60)).has_value());
}
void testBroadcastConcurrency() {
using Ring = BroadcastFrameRing<MediaFrame>;
constexpr uint64_t frame_count = 500;
Ring ring(frame_count);
const auto descriptor = makeVideoDescriptor(Codec::H264, 1, {1, 2, 3});
auto first_cursor = ring.makeCursor(Ring::StartPosition::OLDEST_AVAILABLE);
auto second_cursor = ring.makeCursor(Ring::StartPosition::OLDEST_AVAILABLE);
auto consume = [&ring](Ring::Cursor& cursor) {
uint64_t expected = 0;
while (expected < frame_count) {
const auto result = ring.waitRead(cursor, 1s);
if (!result || result->sequence != expected || result->value->sequence != expected) {
return false;
}
++expected;
}
return cursor.dropped_count == 0;
};
auto first_consumer = std::async(std::launch::async, consume, std::ref(first_cursor));
auto second_consumer = std::async(std::launch::async, consume, std::ref(second_cursor));
std::thread producer([&ring, &descriptor] {
for (uint64_t sequence = 0; sequence < frame_count; ++sequence) {
ring.publish(makeFrame(descriptor, sequence, static_cast<uint8_t>(sequence)));
}
});
producer.join();
CHECK_TRUE(first_consumer.get());
CHECK_TRUE(second_consumer.get());
}
void testHubLifecycleAndDescriptorRefresh() {
MediaSourceHub hub;
const auto initial_descriptor = makeVideoDescriptor(Codec::UNKNOWN, 1);
std::atomic<int> start_count{0};
std::atomic<int> stop_count{0};
std::atomic<int> key_frame_requests{0};
std::mutex sink_mutex;
MediaSourceHub::FrameSink sink;
MediaSourceHub::SourceCallbacks callbacks;
callbacks.start = [&](const MediaSourceHub::FrameSink& callback_sink,
const MediaSourceHub::CancelPredicate&) {
{
std::lock_guard<std::mutex> lock(sink_mutex);
sink = callback_sink;
}
++start_count;
return true;
};
callbacks.stop = [&] {
++stop_count;
std::lock_guard<std::mutex> lock(sink_mutex);
sink = {};
};
callbacks.request_key_frame = [&] {
++key_frame_requests;
return true;
};
CHECK_TRUE(hub.registerSource(initial_descriptor, callbacks, 4));
CHECK_TRUE(!hub.registerSource(initial_descriptor, callbacks, 4));
CHECK_TRUE(hub.hasSource(initial_descriptor->id));
CHECK_TRUE(hub.listTracks().size() == 1);
auto first = hub.subscribe(initial_descriptor->id);
auto second = hub.subscribe(initial_descriptor->id);
CHECK_TRUE(first.valid() && second.valid());
CHECK_TRUE(hub.requestKeyFrame(initial_descriptor->id));
CHECK_TRUE(key_frame_requests == 1);
CHECK_TRUE(start_count == 1);
CHECK_TRUE(hub.subscriberCount(initial_descriptor->id) == 2);
CHECK_TRUE(first.descriptor()->codec == Codec::UNKNOWN);
CHECK_TRUE(!hub.unregisterSource(initial_descriptor->id));
// Content changes at the same generation must atomically replace the initial descriptor.
const auto actual_descriptor = makeVideoDescriptor(Codec::H264, 1, {0, 0, 0, 1, 0x67});
MediaSourceHub::FrameSink producer;
{
std::lock_guard<std::mutex> lock(sink_mutex);
producer = sink;
}
CHECK_TRUE(static_cast<bool>(producer));
const auto shared_frame = makeFrame(actual_descriptor, 0, 70);
producer(shared_frame);
const auto first_read = first.waitRead(500ms);
const auto second_read = second.waitRead(500ms);
CHECK_TRUE(first_read.has_value() && first_read->value == shared_frame);
CHECK_TRUE(second_read.has_value() && second_read->value == shared_frame);
CHECK_TRUE(first.descriptor() == actual_descriptor);
CHECK_TRUE(second.descriptor()->codec_config == actual_descriptor->codec_config);
first.reset();
CHECK_TRUE(stop_count == 0);
CHECK_TRUE(hub.subscriberCount(initial_descriptor->id) == 1);
second.reset();
CHECK_TRUE(stop_count == 1);
CHECK_TRUE(!hub.requestKeyFrame(initial_descriptor->id));
CHECK_TRUE(hub.subscriberCount(initial_descriptor->id) == 0);
{
auto restarted = hub.subscribe(initial_descriptor->id);
CHECK_TRUE(restarted.valid());
CHECK_TRUE(start_count == 2);
}
CHECK_TRUE(stop_count == 2);
CHECK_TRUE(hub.unregisterSource(initial_descriptor->id));
CHECK_TRUE(!hub.hasSource(initial_descriptor->id));
}
void testHubFailedStartAndShutdown() {
MediaSourceHub hub;
const auto descriptor = makeVideoDescriptor(Codec::UNKNOWN, 1);
std::atomic<int> start_attempts{0};
std::atomic<int> retry_stop_count{0};
MediaSourceHub::SourceCallbacks failed_callbacks;
failed_callbacks.start = [&](const MediaSourceHub::FrameSink&,
const MediaSourceHub::CancelPredicate&) {
return ++start_attempts >= 2;
};
failed_callbacks.stop = [&] { ++retry_stop_count; };
CHECK_TRUE(hub.registerSource(descriptor, std::move(failed_callbacks), 2));
auto failed = hub.subscribe(descriptor->id);
CHECK_TRUE(!failed.valid());
auto retry = hub.subscribe(descriptor->id);
CHECK_TRUE(retry.valid());
retry.reset();
CHECK_TRUE(start_attempts == 2);
CHECK_TRUE(retry_stop_count == 1);
CHECK_TRUE(hub.unregisterSource(descriptor->id));
std::atomic<int> stop_count{0};
MediaSourceHub::SourceCallbacks callbacks;
callbacks.start = [](const MediaSourceHub::FrameSink&,
const MediaSourceHub::CancelPredicate&) {
return true;
};
callbacks.stop = [&] { ++stop_count; };
CHECK_TRUE(hub.registerSource(descriptor, std::move(callbacks), 2));
auto live = hub.subscribe(descriptor->id);
CHECK_TRUE(live.valid());
hub.shutdown();
CHECK_TRUE(stop_count == 1);
CHECK_TRUE(!live.valid());
CHECK_TRUE(!live.waitRead(50ms).has_value());
}
void testKeyFrameRequestIsOrderedBeforeStop() {
MediaSourceHub hub;
const auto descriptor = makeVideoDescriptor(Codec::H264, 1);
std::atomic<bool> key_frame_entered{false};
std::atomic<bool> release_key_frame{false};
std::atomic<int> stop_count{0};
MediaSourceHub::SourceCallbacks callbacks;
callbacks.start = [](const MediaSourceHub::FrameSink&,
const MediaSourceHub::CancelPredicate&) {
return true;
};
callbacks.stop = [&] { ++stop_count; };
callbacks.request_key_frame = [&] {
key_frame_entered.store(true, std::memory_order_release);
while (!release_key_frame.load(std::memory_order_acquire)) {
std::this_thread::sleep_for(1ms);
}
return true;
};
CHECK_TRUE(hub.registerSource(descriptor, std::move(callbacks), 2));
auto subscription = hub.subscribe(descriptor->id);
CHECK_TRUE(subscription.valid());
auto key_frame = std::async(std::launch::async, [&] {
return hub.requestKeyFrame(descriptor->id);
});
const auto enter_deadline = std::chrono::steady_clock::now() + 500ms;
while (!key_frame_entered.load(std::memory_order_acquire) &&
std::chrono::steady_clock::now() < enter_deadline) {
std::this_thread::sleep_for(1ms);
}
CHECK_TRUE(key_frame_entered.load(std::memory_order_acquire));
auto stop = std::async(std::launch::async, [&] { subscription.reset(); });
CHECK_TRUE(stop.wait_for(20ms) == std::future_status::timeout);
CHECK_TRUE(stop_count.load(std::memory_order_acquire) == 0);
release_key_frame.store(true, std::memory_order_release);
CHECK_TRUE(key_frame.get());
CHECK_TRUE(stop.wait_for(500ms) == std::future_status::ready);
stop.get();
CHECK_TRUE(stop_count.load(std::memory_order_acquire) == 1);
CHECK_TRUE(!hub.requestKeyFrame(descriptor->id));
}
void testHubCancelsBlockedStartWithoutBlockingShutdown() {
MediaSourceHub hub;
const auto descriptor = makeVideoDescriptor(Codec::UNKNOWN, 1);
std::atomic<bool> start_entered{false};
std::atomic<bool> start_exited{false};
MediaSourceHub::SourceCallbacks callbacks;
callbacks.start = [&](const MediaSourceHub::FrameSink&,
const MediaSourceHub::CancelPredicate& cancelled) {
start_entered.store(true, std::memory_order_release);
while (!cancelled()) {
std::this_thread::sleep_for(2ms);
}
start_exited.store(true, std::memory_order_release);
return false;
};
CHECK_TRUE(hub.registerSource(descriptor, std::move(callbacks), 2));
auto subscription_future = std::async(std::launch::async, [&] {
return hub.subscribe(descriptor->id);
});
const auto start_deadline = std::chrono::steady_clock::now() + 500ms;
while (!start_entered.load(std::memory_order_acquire) &&
std::chrono::steady_clock::now() < start_deadline) {
std::this_thread::sleep_for(2ms);
}
auto shutdown_future = std::async(std::launch::async, [&] { hub.shutdown(); });
const bool shutdown_completed = shutdown_future.wait_for(500ms) ==
std::future_status::ready;
if (shutdown_completed) shutdown_future.get();
const bool subscribe_completed = subscription_future.wait_for(500ms) ==
std::future_status::ready;
bool invalid_subscription = false;
if (subscribe_completed) {
invalid_subscription = !subscription_future.get().valid();
}
const auto exit_deadline = std::chrono::steady_clock::now() + 500ms;
while (!start_exited.load(std::memory_order_acquire) &&
std::chrono::steady_clock::now() < exit_deadline) {
std::this_thread::sleep_for(2ms);
}
CHECK_TRUE(start_entered.load(std::memory_order_acquire));
CHECK_TRUE(shutdown_completed);
CHECK_TRUE(subscribe_completed);
CHECK_TRUE(invalid_subscription);
CHECK_TRUE(start_exited.load(std::memory_order_acquire));
}
void testHubQuarantinesNonCooperativeStart() {
MediaSourceHub hub;
const auto descriptor = makeVideoDescriptor(Codec::UNKNOWN, 1);
std::atomic<bool> start_entered{false};
std::atomic<bool> release_start{false};
std::atomic<bool> start_exited{false};
std::atomic<int> stop_count{0};
MediaSourceHub::SourceCallbacks callbacks;
callbacks.start = [&](const MediaSourceHub::FrameSink&,
const MediaSourceHub::CancelPredicate&) {
start_entered.store(true, std::memory_order_release);
while (!release_start.load(std::memory_order_acquire)) {
std::this_thread::sleep_for(2ms);
}
start_exited.store(true, std::memory_order_release);
return true;
};
callbacks.stop = [&] { ++stop_count; };
CHECK_TRUE(hub.registerSource(descriptor, std::move(callbacks), 2));
auto subscription_future = std::async(std::launch::async, [&] {
return hub.subscribe(descriptor->id);
});
const auto start_deadline = std::chrono::steady_clock::now() + 500ms;
while (!start_entered.load(std::memory_order_acquire) &&
std::chrono::steady_clock::now() < start_deadline) {
std::this_thread::sleep_for(2ms);
}
const auto shutdown_started = std::chrono::steady_clock::now();
hub.shutdown();
const bool shutdown_was_bounded =
std::chrono::steady_clock::now() - shutdown_started < 500ms;
const bool subscribe_completed = subscription_future.wait_for(500ms) ==
std::future_status::ready;
bool invalid_subscription = false;
if (subscribe_completed) {
invalid_subscription = !subscription_future.get().valid();
}
// Release the deliberately non-cooperative test callback before its stack
// captures go out of scope. Late successful startup must be stopped once.
release_start.store(true, std::memory_order_release);
const auto exit_deadline = std::chrono::steady_clock::now() + 500ms;
while ((!start_exited.load(std::memory_order_acquire) || stop_count.load() != 1) &&
std::chrono::steady_clock::now() < exit_deadline) {
std::this_thread::sleep_for(2ms);
}
CHECK_TRUE(start_entered.load(std::memory_order_acquire));
CHECK_TRUE(shutdown_was_bounded);
CHECK_TRUE(subscribe_completed);
CHECK_TRUE(invalid_subscription);
CHECK_TRUE(start_exited.load(std::memory_order_acquire));
CHECK_TRUE(stop_count.load() == 1);
}
} // namespace
int main() {
testMediaMetadataValidation();
testLegacySpmcCompatibility();
testBroadcastFrameRing();
testBroadcastConcurrency();
testHubLifecycleAndDescriptorRefresh();
testHubFailedStartAndShutdown();
testKeyFrameRequestIsOrderedBeforeStop();
testHubCancelsBlockedStartWithoutBlockingShutdown();
testHubQuarantinesNonCooperativeStart();
if (failures != 0) {
std::cerr << failures << " media_source_hub checks failed\n";
return 1;
}
std::cout << "media_source_hub self-test passed\n";
return 0;
}

View File

@ -36,6 +36,8 @@ const char* taskConfigTypeToString(const config::TaskConfigEntry::TaskType type)
return "TASK_TYPE_TOUCH_SCREEN"; return "TASK_TYPE_TOUCH_SCREEN";
case config::TaskConfigEntry::TASK_TYPE_GRPC_SERVER: case config::TaskConfigEntry::TASK_TYPE_GRPC_SERVER:
return "TASK_TYPE_GRPC_SERVER"; return "TASK_TYPE_GRPC_SERVER";
case config::TaskConfigEntry::TASK_TYPE_QUIC_EDGE:
return "TASK_TYPE_QUIC_EDGE";
case config::TaskConfigEntry::TASK_TYPE_UNKNOWN: case config::TaskConfigEntry::TASK_TYPE_UNKNOWN:
default: default:
return "TASK_TYPE_UNKNOWN"; return "TASK_TYPE_UNKNOWN";

View File

@ -21,6 +21,8 @@ target_link_libraries(service PRIVATE
cmvr_es::task_manager cmvr_es::task_manager
cmvr_es::algorithms::controller cmvr_es::algorithms::controller
cmvr_es::task cmvr_es::task
cmvr_es::media_source_hub
cmvr_es::device_media_source_adapter
protobuf::libprotobuf protobuf::libprotobuf
) )

205
cmvr-es/service/README.md Normal file
View File

@ -0,0 +1,205 @@
# Service 模块开发指南
`service/` 实现边缘端对外协议和设备抽象之间的适配。Service 负责解析请求、查找设备、转换 DTO 和返回结果,不负责创建具体设备后端。
返回[项目总览](../../README.md)。
## 当前结构
| 目录 | 职责 |
| --- | --- |
| `grpc/` | 入站设备控制、状态查询和兼容流式接口 |
| `quic_edge/` | 边缘端主动连接平台的 QUIC client、控制状态机和媒体 packetizer |
| `quic_edge/tests/` | 已登记到 CTest 的 QUIC 协议测试 |
两个遗留 gRPC client test 位于 `grpc/src/*_client_test.cpp`,当前没有通过 `add_test()` 登记。
gRPC 和 QUIC 的职责边界:
- 机械臂、AGV 等可靠控制继续使用 gRPC
- 节点注册、心跳和 IP 上报使用 QUIC reliable stream
- 实时音视频使用 QUIC DATAGRAM
- `quic_edge/` 不是平台 Gateway也不是浏览器服务器。
## 新增 gRPC Service
当前没有动态 service registry必须完成以下全部步骤。
### 1. 定义 Proto
在 [`../../protos/cmvr/api/`](../../protos/cmvr/api/) 增加或扩展:
- `<domain>_command.proto`
- `<domain>_service.proto`
import 路径必须相对于 `protos/`。兼容规则见 [`../../protos/README.md`](../../protos/README.md)。
### 2. 实现 Service
目录约定:
```text
service/grpc/
├── include/grpc_example_service.h
└── src/grpc_example_service.cpp
```
实现类继承生成的:
```cpp
cmvr::api::ExampleService::Service
```
通过已初始化的 `DeviceManager` 获取抽象设备。不要在 service 中创建厂商 SDK 对象,不要绕过设备 factory。
### 3. 加入 service target
将实现 `.cpp` 加入 [`CMakeLists.txt`](CMakeLists.txt) 的 `service` library并声明最小依赖。
### 4. 注册到 GrpcServerTask
还必须修改:
- [`../task/grpc_server_task/include/grpc_server_task.h`](../task/grpc_server_task/include/grpc_server_task.h)
- [`../task/grpc_server_task/src/grpc_server_task.cpp`](../task/grpc_server_task/src/grpc_server_task.cpp)
完成:
1. 增加 service owner
2. 在 start 中构造;
3. 调用 `builder.RegisterService(...)`
4. 在 `clearServices()` 中 reset。
漏掉该步骤时项目可能编译成功,但服务不会出现在 reflection 或运行时。
### 5. 测试
- 直接测试 service handler 或启动临时 gRPC server
- 覆盖设备不存在、类型不匹配、设备错误和取消;
- 使用 grpcurl/reflection 验证服务全名;
- 流式 RPC 覆盖客户端断开和慢消费者;
- 在 CMake 中使用 `if(BUILD_TESTING)` 包裹测试目标,并通过 `add_test()` 登记。
`grpc_arm_client_test``grpc_hlc_client_test` 是未登记到 CTest 的历史可执行文件,不能代表默认自动覆盖。
## gRPC 实现约束
### 错误语义
当前历史服务存在两种风格:
- gRPC status 返回 OK业务失败写入 Feedback header
- 使用非 OK gRPC status 表达 transport/API 失败。
扩展已有服务时保持其兼容语义。新增服务必须在设计时明确:
- 哪些错误使用 gRPC status
- 哪些错误使用业务 Feedback
- 是否允许部分成功;
- deadline/cancellation 如何映射;
- 不得同时返回互相矛盾的 transport 和业务状态。
### 流式 RPC
- 检查 `context->IsCancelled()`
- 检查 `Read()` / `Write()` 返回;
- 使用 RAII 或 MediaSourceHub Subscription 释放 producer lease
- 不持有设备状态锁进行网络写;
- 为 wait/read 使用有限 timeout
- 慢客户端不能阻塞设备生产线程;
- H.264/H.265 丢帧后等待关键帧恢复。
当前仅 gRPC RGB 和麦克风流使用 MediaSourceHubDepth/RGBD 仍直接读取设备帧。
### 当前安全状态
GrpcServerTask 使用同步 `grpc::ServerBuilder``grpc::InsecureServerCredentials()`。reflection 由配置控制。当前没有 gRPC TLS、认证、授权或标准 health service。
QUIC 配置中的 `grpc_endpoint_tls` 只是上报字段,不会启用 gRPC TLS。
## 扩展 QUIC Edge
关键层次:
| 层 | 主要文件 |
| --- | --- |
| 控制流 framing | `quic_edge/src/control_framing.cpp` |
| DATAGRAM 固定头和分片 | `quic_edge/src/datagram_packetizer.cpp` |
| 会话和状态机 | `quic_edge/src/quic_edge_service.cpp` |
| 传输抽象 | `quic_edge/include/quic_transport.h` |
| MsQuic 后端 | `quic_edge/src/msquic_transport.cpp` |
| 设备媒体适配 | `quic_edge/src/quic_edge_device_adapter.cpp` |
线协议见 [`../../protos/cmvr/quic_edge/v1/README.md`](../../protos/cmvr/quic_edge/v1/README.md)。
### 新增 Transport 后端
1. 实现 `QuicTransport` 完整接口;
2. 明确 callback 所在线程;
3. stop/close 后不得再访问已销毁 service
4. `QUEUED` 表示 transport 接管待发送数据;
5. `WOULD_BLOCK``ERROR` 不得接管任何字节;
6. DATAGRAM batch 本地准入必须原子;
7. native send 部分失败时关闭连接并清理 session
8. 添加 fake transport 故障注入测试;
9. 在默认 transport factory 中显式选择后端。
### 新增控制消息
不能只修改 Proto还要同步
- envelope 构造与发送;
- 入站 dispatch
- 合法状态和消息时序;
- message sequence 校验;
- session ID 和 heartbeat sequence 校验;
- reconnect 后状态清理;
- Java Gateway 对端;
- framing、状态机和 fake transport 测试。
当前 Edge 入站只接受:
- `NodeRegisterResponse`
- `NodeHeartbeatAck`
- `ProtocolError`
虽然 Proto 定义了 `MediaSessionClose`,本版本 Edge 收到它仍会判为 unexpected不应将其描述为已实现的双向控制能力。
### 跨 QUIC 通道顺序
Edge 会先调用可靠流发送 session/descriptor再调用 DATAGRAM 发送媒体,但 QUIC stream 与 DATAGRAM 没有跨通道到达顺序保证。
Gateway 必须容忍 DATAGRAM 先到,对未知 session epoch 或 codec generation 的数据有界暂存或丢弃。
## QUIC 测试要求
参考 [`quic_edge/CMakeLists.txt`](quic_edge/CMakeLists.txt) 和 `quic_edge_protocol_test`,至少覆盖:
- 控制消息拆包、粘包和超限;
- 重复或倒退 sequence
- 注册、ACK 超时和重连;
- session epoch 清理;
- DATAGRAM header 字节序;
- 分片边界和超大帧;
- 原子队列准入和背压;
- 丢帧、generation 和关键帧恢复;
- stop 与 callback 并发。
```bash
cmake --build build --target quic_edge_protocol_test
ctest \
--test-dir build \
-R '^quic_edge_protocol_test$' \
--output-on-failure
```
## 提交检查
- [ ] service 不创建具体硬件后端
- [ ] Proto、实现、CMake 和 GrpcServerTask 注册均已更新
- [ ] 错误语义与已有服务兼容
- [ ] deadline、cancel、Read/Write 失败均处理
- [ ] 流式资源通过 RAII 释放
- [ ] gRPC 安全能力没有被配置字段误描述
- [ ] QUIC 状态机与 Gateway 同步更新
- [ ] 协议测试已登记到 CTest

View File

@ -1,10 +1,14 @@
#include "common/base/logging/logger.h" #include "common/base/logging/logger.h"
#include "manager/media_source_hub/include/device_media_source_adapter.h"
// //
// Created by xtkuang on 2025/6/1. // Created by xtkuang on 2025/6/1.
// //
#include "../include/grpc_camera_service.h" #include "../include/grpc_camera_service.h"
#include <algorithm>
#include <chrono>
#include <cstdint>
#include <limits> #include <limits>
using namespace std; using namespace std;
@ -61,6 +65,41 @@ bool toPtzCommand(cmvr::api::ControlPtzCommand_Command command, PtzCommand& out)
return false; return false;
} }
} }
// The legacy depth/RGBD RPCs acquire the camera's shared producer directly
// instead of going through MediaSourceHub. Keep that lease exception-safe:
// cancellation, a failed Write(), or any conversion error must release exactly
// the one startStreaming() reference acquired by this call.
class CameraStreamingLease final {
public:
explicit CameraStreamingLease(std::shared_ptr<AbstractCamera> camera)
: camera_(std::move(camera)) {
active_ = camera_ && camera_->startStreaming();
}
~CameraStreamingLease() {
if (!active_ || !camera_) {
return;
}
try {
camera_->stopStreaming();
} catch (const std::exception& e) {
CMVR_LOG(ERROR) << "[gRPCCameraServiceImpl] failed to release camera stream lease: "
<< e.what();
} catch (...) {
CMVR_LOG(ERROR) << "[gRPCCameraServiceImpl] failed to release camera stream lease";
}
}
CameraStreamingLease(const CameraStreamingLease&) = delete;
CameraStreamingLease& operator=(const CameraStreamingLease&) = delete;
explicit operator bool() const noexcept { return active_; }
private:
std::shared_ptr<AbstractCamera> camera_;
bool active_{false};
};
} }
gRPCCameraServiceImpl::gRPCCameraServiceImpl(): dmgr_(DeviceManager::getInstance()) {} gRPCCameraServiceImpl::gRPCCameraServiceImpl(): dmgr_(DeviceManager::getInstance()) {}
@ -391,6 +430,10 @@ grpc::Status gRPCCameraServiceImpl::ControlPtz(grpc::ServerContext* context,
return failResponse(response, "Invalid PTZ command"); return failResponse(response, "Invalid PTZ command");
} }
if (request->action() != api::ControlPtzCommand_Action_START &&
request->action() != api::ControlPtzCommand_Action_STOP) {
return failResponse(response, "Invalid PTZ action");
}
const bool stop = request->action() == api::ControlPtzCommand_Action_STOP; const bool stop = request->action() == api::ControlPtzCommand_Action_STOP;
if (!dev->controlPtz(command, stop, static_cast<int>(request->speed()))) { if (!dev->controlPtz(command, stop, static_cast<int>(request->speed()))) {
CameraState state{}; CameraState state{};
@ -418,9 +461,11 @@ grpc::Status gRPCCameraServiceImpl::GetDepthImageStream(grpc::ServerContext* con
try { try {
//读取首次传递的数据获取设备id //读取首次传递的数据获取设备id
api::GetDepthImageStreamCommand_Request request; api::GetDepthImageStreamCommand_Request request;
stream->Read(&request); if (!stream->Read(&request)) {
return grpc::Status::OK;
}
string dev_id = request.header().device_id(); string dev_id = request.header().device_id();
CMVR_LOG(INFO) << "[gRPCCameraServiceImpl] (GetRGBImageStream): start,id=" << dev_id; CMVR_LOG(INFO) << "[gRPCCameraServiceImpl] (GetDepthImageStream): start,id=" << dev_id;
const auto dev = dmgr_.getDevice<AbstractCamera>(dev_id); const auto dev = dmgr_.getDevice<AbstractCamera>(dev_id);
if (!dev) { if (!dev) {
api::GetDepthImageStreamCommand_Feedback response; api::GetDepthImageStreamCommand_Feedback response;
@ -430,8 +475,17 @@ grpc::Status gRPCCameraServiceImpl::GetDepthImageStream(grpc::ServerContext* con
stream->Write(response); stream->Write(response);
return grpc::Status::OK; return grpc::Status::OK;
} }
CameraStreamingLease stream_lease(dev);
if (!stream_lease) {
api::GetDepthImageStreamCommand_Feedback response;
response.mutable_header()->set_success(false);
response.mutable_header()->set_error_message("Failed to start camera stream: " + dev_id);
setCurrentTimestamp(response.mutable_header()->mutable_timestamp());
stream->Write(response);
return grpc::Status::OK;
}
int nFrameCount = 0; int nFrameCount = 0;
dev->startStreaming();
size_t index = 0; size_t index = 0;
while (true) while (true)
{ {
@ -443,164 +497,16 @@ grpc::Status gRPCCameraServiceImpl::GetDepthImageStream(grpc::ServerContext* con
api::GetDepthImageStreamCommand_Feedback response; api::GetDepthImageStreamCommand_Feedback response;
cmvr::device::StreamFrameData frame_data; cmvr::device::StreamFrameData frame_data;
dev->getEncodedFrame(frame_data,index); if (dev->waitEncodedFrame(frame_data, index, std::chrono::milliseconds(100)) &&
if (!frame_data.rgbFrame.empty()) { !frame_data.depthFrame.empty()) {
response.mutable_header()->set_success(true); response.mutable_header()->set_success(true);
setCurrentTimestamp(response.mutable_header()->mutable_timestamp());
response.mutable_depth_frame()->set_data(frame_data.depthFrame.data(), frame_data.depthFrame.size()); response.mutable_depth_frame()->set_data(frame_data.depthFrame.data(), frame_data.depthFrame.size());
response.mutable_depth_frame()->set_is_key_frame(frame_data.depthKey); response.mutable_depth_frame()->set_is_key_frame(frame_data.depthKey);
response.mutable_depth_frame()->set_codec(frame_data.codec); response.mutable_depth_frame()->set_codec(frame_data.codec);
response.mutable_depth_frame()->set_width(frame_data.width); response.mutable_depth_frame()->set_width(frame_data.width);
response.mutable_depth_frame()->set_height(frame_data.height); response.mutable_depth_frame()->set_height(frame_data.height);
response.mutable_intrinsics()->set_fx(frame_data.intrinsics.fx);
response.mutable_intrinsics()->set_fy(frame_data.intrinsics.fx);
response.mutable_intrinsics()->set_cx(frame_data.intrinsics.fx);
response.mutable_intrinsics()->set_cy(frame_data.intrinsics.fx);
for (int i = 0; i < 5 ; i++) {
response.mutable_intrinsics()->add_coeffs(frame_data.intrinsics.coeffs[i]);
}
response.set_seq_no(nFrameCount++);
grpc::WriteOptions options;
options.set_last_message();
if (!stream->Write(response)) {
CMVR_LOG(INFO) << "[gRPCCameraServiceImpl] (stream->Write) failed,id=" << dev_id;
break;
}
}
}
CMVR_LOG(INFO) << "[gRPCCameraServiceImpl] (GetRGBImageStream): end,id=" << dev_id;
dev->stopStreaming();
return grpc::Status::OK;
}
catch (exception &e) {
api::GetDepthImageStreamCommand_Feedback response;
response.mutable_header()->set_error_message(e.what());
setCurrentTimestamp(response.mutable_header()->mutable_timestamp());
stream->Write(response);
return grpc::Status::OK;
}
}
grpc::Status gRPCCameraServiceImpl::GetRGBDImagesStream(grpc::ServerContext* context
, grpc::ServerReaderWriter<cmvr::api::GetRGBDImagesStreamCommand_Feedback, cmvr::api::GetRGBDImagesStreamCommand_Request>* stream){
try {
//读取首次传递的数据获取设备id
api::GetRGBDImagesStreamCommand_Request request;
stream->Read(&request);
string dev_id = request.header().device_id();
CMVR_LOG(INFO) << "[gRPCCameraServiceImpl] (GetRGBImageStream): start,id=" << dev_id;
const auto dev = dmgr_.getDevice<AbstractCamera>(dev_id);
if (!dev) {
api::GetRGBDImagesStreamCommand_Feedback response;
response.mutable_header()->set_success(false);
response.mutable_header()->set_error_message("Camera device not found: " + dev_id);
setCurrentTimestamp(response.mutable_header()->mutable_timestamp());
stream->Write(response);
return grpc::Status::OK;
}
int nFrameCount = 0;
dev->startStreaming();
size_t index = 0;
while (true)
{
if (context->IsCancelled())
{
CMVR_LOG(INFO) << "[gRPCCameraServiceImpl](GetRGBDImagesStream) context is cancelled,id=" << dev_id;
break;
}
api::GetRGBDImagesStreamCommand_Feedback response;
cmvr::device::StreamFrameData frame_data;
dev->getEncodedFrame(frame_data,index);
if (!frame_data.rgbFrame.empty()) {
response.mutable_header()->set_success(true);
response.mutable_color_frame()->set_data(frame_data.rgbFrame.data(), frame_data.rgbFrame.size());
response.mutable_color_frame()->set_is_key_frame(frame_data.bKey);
response.mutable_color_frame()->set_codec(frame_data.codec);
response.mutable_color_frame()->set_width(frame_data.width);
response.mutable_color_frame()->set_height(frame_data.height);
response.mutable_depth_frame()->set_data(frame_data.depthFrame.data(), frame_data.depthFrame.size());
response.mutable_depth_frame()->set_is_key_frame(frame_data.depthKey);
response.mutable_depth_frame()->set_codec(frame_data.codec);
response.mutable_depth_frame()->set_width(frame_data.width);
response.mutable_depth_frame()->set_height(frame_data.height);
response.mutable_intrinsics()->set_fx(frame_data.intrinsics.fx);
response.mutable_intrinsics()->set_fy(frame_data.intrinsics.fx);
response.mutable_intrinsics()->set_cx(frame_data.intrinsics.fx);
response.mutable_intrinsics()->set_cy(frame_data.intrinsics.fx);
for (int i = 0; i < 5 ; i++) {
response.mutable_intrinsics()->add_coeffs(frame_data.intrinsics.coeffs[i]);
}
response.set_seq_no(nFrameCount++);
grpc::WriteOptions options;
options.set_last_message();
if (!stream->Write(response)) {
CMVR_LOG(INFO) << "[gRPCCameraServiceImpl] (stream->Write) failed,id=" << dev_id;
break;
}
}
}
CMVR_LOG(INFO) << "[gRPCCameraServiceImpl] (GetRGBDImagesStream): end,id=" << dev_id;
dev->stopStreaming();
return grpc::Status::OK;
}
catch (exception &e) {
api::GetRGBDImagesStreamCommand_Feedback response;
response.mutable_header()->set_error_message(e.what());
setCurrentTimestamp(response.mutable_header()->mutable_timestamp());
stream->Write(response);
return grpc::Status::OK;
}
}
grpc::Status gRPCCameraServiceImpl::GetRGBImageStream(grpc::ServerContext* context, grpc::ServerReaderWriter<cmvr::api::GetRGBImageStreamCommand_Feedback, cmvr::api::GetRGBImageStreamCommand_Request>* stream){
try {
//读取首次传递的数据获取设备id
api::GetRGBImageStreamCommand_Request request;
stream->Read(&request);
string dev_id = request.header().device_id();
CMVR_LOG(INFO) << "[gRPCCameraServiceImpl] (GetRGBImageStream): start,id=" << dev_id;
const auto dev = dmgr_.getDevice<AbstractCamera>(dev_id);
if (!dev) {
api::GetRGBImageStreamCommand_Feedback response;
response.mutable_header()->set_success(false);
response.mutable_header()->set_error_message("Camera device not found: " + dev_id);
setCurrentTimestamp(response.mutable_header()->mutable_timestamp());
stream->Write(response);
return grpc::Status::OK;
}
int nFrameCount = 0;
dev->startStreaming();
size_t last_sent_index = std::numeric_limits<size_t>::max();
while (true)
{
if (context->IsCancelled())
{
CMVR_LOG(INFO) << "[gRPCCameraServiceImpl](GetRGBImageStream) context is cancelled,id=" << dev_id;
break;
}
api::GetRGBImageStreamCommand_Feedback response;
cmvr::device::StreamFrameData frame_data;
size_t next_index = last_sent_index;
if (!dev->getLatestEncodedFrame(frame_data, next_index) ||
frame_data.rgbFrame.empty() ||
next_index == last_sent_index) {
std::this_thread::sleep_for(std::chrono::milliseconds(1));
continue;
}
response.mutable_header()->set_success(true);
response.mutable_color_frame()->set_data(frame_data.rgbFrame.data(), frame_data.rgbFrame.size());
response.mutable_color_frame()->set_is_key_frame(frame_data.bKey);
response.mutable_color_frame()->set_codec(frame_data.codec);
response.mutable_color_frame()->set_width(frame_data.width);
response.mutable_color_frame()->set_height(frame_data.height);
response.mutable_intrinsics()->set_fx(frame_data.intrinsics.fx); response.mutable_intrinsics()->set_fx(frame_data.intrinsics.fx);
response.mutable_intrinsics()->set_fy(frame_data.intrinsics.fy); response.mutable_intrinsics()->set_fy(frame_data.intrinsics.fy);
response.mutable_intrinsics()->set_cx(frame_data.intrinsics.cx); response.mutable_intrinsics()->set_cx(frame_data.intrinsics.cx);
@ -615,14 +521,236 @@ grpc::Status gRPCCameraServiceImpl::GetRGBImageStream(grpc::ServerContext* conte
CMVR_LOG(INFO) << "[gRPCCameraServiceImpl] (stream->Write) failed,id=" << dev_id; CMVR_LOG(INFO) << "[gRPCCameraServiceImpl] (stream->Write) failed,id=" << dev_id;
break; break;
} }
last_sent_index = next_index;
} }
CMVR_LOG(INFO) << "[gRPCCameraServiceImpl] (GetRGBImageStream): end,id=" << dev_id; }
dev->stopStreaming(); CMVR_LOG(INFO) << "[gRPCCameraServiceImpl] (GetDepthImageStream): end,id=" << dev_id;
return grpc::Status::OK; return grpc::Status::OK;
} }
catch (exception &e) { catch (const exception &e) {
api::GetRGBImageStreamCommand_Feedback response; api::GetDepthImageStreamCommand_Feedback response;
response.mutable_header()->set_success(false);
response.mutable_header()->set_error_message(e.what());
setCurrentTimestamp(response.mutable_header()->mutable_timestamp());
stream->Write(response);
return grpc::Status::OK;
}
}
grpc::Status gRPCCameraServiceImpl::GetRGBDImagesStream(grpc::ServerContext* context
, grpc::ServerReaderWriter<cmvr::api::GetRGBDImagesStreamCommand_Feedback, cmvr::api::GetRGBDImagesStreamCommand_Request>* stream){
try {
//读取首次传递的数据获取设备id
api::GetRGBDImagesStreamCommand_Request request;
if (!stream->Read(&request)) {
return grpc::Status::OK;
}
string dev_id = request.header().device_id();
CMVR_LOG(INFO) << "[gRPCCameraServiceImpl] (GetRGBDImagesStream): start,id=" << dev_id;
const auto dev = dmgr_.getDevice<AbstractCamera>(dev_id);
if (!dev) {
api::GetRGBDImagesStreamCommand_Feedback response;
response.mutable_header()->set_success(false);
response.mutable_header()->set_error_message("Camera device not found: " + dev_id);
setCurrentTimestamp(response.mutable_header()->mutable_timestamp());
stream->Write(response);
return grpc::Status::OK;
}
CameraStreamingLease stream_lease(dev);
if (!stream_lease) {
api::GetRGBDImagesStreamCommand_Feedback response;
response.mutable_header()->set_success(false);
response.mutable_header()->set_error_message("Failed to start camera stream: " + dev_id);
setCurrentTimestamp(response.mutable_header()->mutable_timestamp());
stream->Write(response);
return grpc::Status::OK;
}
int nFrameCount = 0;
size_t index = 0;
while (true)
{
if (context->IsCancelled())
{
CMVR_LOG(INFO) << "[gRPCCameraServiceImpl](GetRGBDImagesStream) context is cancelled,id=" << dev_id;
break;
}
api::GetRGBDImagesStreamCommand_Feedback response;
cmvr::device::StreamFrameData frame_data;
if (dev->waitEncodedFrame(frame_data, index, std::chrono::milliseconds(100)) &&
!frame_data.rgbFrame.empty() &&
!frame_data.depthFrame.empty()) {
response.mutable_header()->set_success(true);
setCurrentTimestamp(response.mutable_header()->mutable_timestamp());
response.mutable_color_frame()->set_data(frame_data.rgbFrame.data(), frame_data.rgbFrame.size());
response.mutable_color_frame()->set_is_key_frame(frame_data.bKey);
response.mutable_color_frame()->set_codec(frame_data.codec);
response.mutable_color_frame()->set_width(frame_data.width);
response.mutable_color_frame()->set_height(frame_data.height);
response.mutable_depth_frame()->set_data(frame_data.depthFrame.data(), frame_data.depthFrame.size());
response.mutable_depth_frame()->set_is_key_frame(frame_data.depthKey);
response.mutable_depth_frame()->set_codec(frame_data.codec);
response.mutable_depth_frame()->set_width(frame_data.width);
response.mutable_depth_frame()->set_height(frame_data.height);
response.mutable_intrinsics()->set_fx(frame_data.intrinsics.fx);
response.mutable_intrinsics()->set_fy(frame_data.intrinsics.fy);
response.mutable_intrinsics()->set_cx(frame_data.intrinsics.cx);
response.mutable_intrinsics()->set_cy(frame_data.intrinsics.cy);
for (int i = 0; i < 5 ; i++) {
response.mutable_intrinsics()->add_coeffs(frame_data.intrinsics.coeffs[i]);
}
response.set_seq_no(nFrameCount++);
if (!stream->Write(response)) {
CMVR_LOG(INFO) << "[gRPCCameraServiceImpl] (stream->Write) failed,id=" << dev_id;
break;
}
}
}
CMVR_LOG(INFO) << "[gRPCCameraServiceImpl] (GetRGBDImagesStream): end,id=" << dev_id;
return grpc::Status::OK;
}
catch (const exception &e) {
api::GetRGBDImagesStreamCommand_Feedback response;
response.mutable_header()->set_success(false);
response.mutable_header()->set_error_message(e.what());
setCurrentTimestamp(response.mutable_header()->mutable_timestamp());
stream->Write(response);
return grpc::Status::OK;
}
}
grpc::Status gRPCCameraServiceImpl::GetRGBImageStream(grpc::ServerContext* context, grpc::ServerReaderWriter<cmvr::api::GetRGBImageStreamCommand_Feedback, cmvr::api::GetRGBImageStreamCommand_Request>* stream){
try {
//读取首次传递的数据获取设备id
api::GetRGBImageStreamCommand_Request request;
if (!stream->Read(&request)) {
return grpc::Status::OK;
}
string dev_id = request.header().device_id();
CMVR_LOG(INFO) << "[gRPCCameraServiceImpl] (GetRGBImageStream): start,id=" << dev_id;
const auto dev = dmgr_.getDevice<AbstractCamera>(dev_id);
if (!dev) {
api::GetRGBImageStreamCommand_Feedback response;
response.mutable_header()->set_success(false);
response.mutable_header()->set_error_message("Camera device not found: " + dev_id);
setCurrentTimestamp(response.mutable_header()->mutable_timestamp());
stream->Write(response);
return grpc::Status::OK;
}
auto& media_hub = cmvr::media::globalMediaSourceHub();
const std::string track_id = cmvr::media::cameraColorTrackId(dev_id);
if (!cmvr::media::ensureCameraMediaSource(media_hub, dev)) {
api::GetRGBImageStreamCommand_Feedback response;
response.mutable_header()->set_success(false);
response.mutable_header()->set_error_message("Failed to register camera media source: " + dev_id);
setCurrentTimestamp(response.mutable_header()->mutable_timestamp());
stream->Write(response);
return grpc::Status::OK;
}
auto subscription = media_hub.subscribe(
track_id,
cmvr::media::MediaSourceHub::StartPosition::NEXT_PUBLISHED,
[context] { return context->IsCancelled(); });
if (!subscription) {
api::GetRGBImageStreamCommand_Feedback response;
response.mutable_header()->set_success(false);
response.mutable_header()->set_error_message("Failed to subscribe camera media source: " + dev_id);
setCurrentTimestamp(response.mutable_header()->mutable_timestamp());
stream->Write(response);
return grpc::Status::OK;
}
bool waiting_for_key_frame = true;
auto last_key_frame_request = std::chrono::steady_clock::now();
media_hub.requestKeyFrame(track_id);
const auto request_key_frame_if_due = [&] {
const auto now = std::chrono::steady_clock::now();
if (now - last_key_frame_request >= std::chrono::milliseconds(500)) {
media_hub.requestKeyFrame(track_id);
last_key_frame_request = now;
}
};
while (true)
{
if (context->IsCancelled())
{
CMVR_LOG(INFO) << "[gRPCCameraServiceImpl](GetRGBImageStream) context is cancelled,id=" << dev_id;
break;
}
const auto read = subscription.waitRead(std::chrono::milliseconds(100));
if (!read || !read->value || read->value->empty()) {
if (!subscription.valid()) {
break;
}
if (waiting_for_key_frame) {
request_key_frame_if_due();
}
continue;
}
const auto& frame = *read->value;
const auto descriptor = frame.descriptor;
if (!descriptor) {
continue;
}
const bool inter_frame_codec = descriptor->codec == cmvr::media::Codec::H264 ||
descriptor->codec == cmvr::media::Codec::H265;
if (!inter_frame_codec || descriptor->payload_format != cmvr::media::PayloadFormat::ANNEX_B) {
api::GetRGBImageStreamCommand_Feedback response;
response.mutable_header()->set_success(false);
response.mutable_header()->set_error_message(
"Unsupported camera stream codec or payload format: " + dev_id);
setCurrentTimestamp(response.mutable_header()->mutable_timestamp());
stream->Write(response);
break;
}
if (read->dropped_since_last_read > 0 || read->generation_changed || frame.discontinuity) {
waiting_for_key_frame = true;
media_hub.requestKeyFrame(track_id);
last_key_frame_request = std::chrono::steady_clock::now();
}
if (waiting_for_key_frame && !frame.key_frame) {
request_key_frame_if_due();
continue;
}
waiting_for_key_frame = false;
api::GetRGBImageStreamCommand_Feedback response;
response.mutable_header()->set_success(true);
setCurrentTimestamp(response.mutable_header()->mutable_timestamp());
response.mutable_color_frame()->set_data(frame.data(), frame.size());
response.mutable_color_frame()->set_is_key_frame(frame.key_frame);
response.mutable_color_frame()->set_codec(
descriptor->codec == cmvr::media::Codec::H264 ? "h264" :
descriptor->codec == cmvr::media::Codec::H265 ? "h265" : "unknown");
response.mutable_color_frame()->set_width(static_cast<int32_t>(descriptor->width));
response.mutable_color_frame()->set_height(static_cast<int32_t>(descriptor->height));
response.mutable_intrinsics()->set_fx(descriptor->fx);
response.mutable_intrinsics()->set_fy(descriptor->fy);
response.mutable_intrinsics()->set_cx(descriptor->cx);
response.mutable_intrinsics()->set_cy(descriptor->cy);
for (const float coefficient : descriptor->distortion) {
response.mutable_intrinsics()->add_coeffs(coefficient);
}
response.set_seq_no(static_cast<int32_t>(std::min<uint64_t>(
frame.sequence,
static_cast<uint64_t>(std::numeric_limits<int32_t>::max()))));
if (!stream->Write(response)) {
CMVR_LOG(INFO) << "[gRPCCameraServiceImpl] (stream->Write) failed,id=" << dev_id;
break;
}
}
CMVR_LOG(INFO) << "[gRPCCameraServiceImpl] (GetRGBImageStream): end,id=" << dev_id;
return grpc::Status::OK;
}
catch (const exception &e) {
api::GetRGBImageStreamCommand_Feedback response;
response.mutable_header()->set_success(false);
response.mutable_header()->set_error_message(e.what()); response.mutable_header()->set_error_message(e.what());
setCurrentTimestamp(response.mutable_header()->mutable_timestamp()); setCurrentTimestamp(response.mutable_header()->mutable_timestamp());
stream->Write(response); stream->Write(response);

View File

@ -1,6 +1,9 @@
#include "common/base/logging/logger.h" #include "common/base/logging/logger.h"
#include "manager/media_source_hub/include/device_media_source_adapter.h"
#include <algorithm>
#include <chrono> #include <chrono>
#include <thread> #include <cstdint>
#include <limits>
// //
// Created by linbo on 2025/6/13. // Created by linbo on 2025/6/13.
// Created by xtkuang on 2025/6/13. // Created by xtkuang on 2025/6/13.
@ -21,30 +24,6 @@ grpc::Status failResponse(ResponseT* response, const std::string& message) {
return grpc::Status::OK; return grpc::Status::OK;
} }
cmvr::api::AudioData_AudioFormat toProtoAudioFormat(AudioStreamFormat format) {
switch (format) {
case AudioStreamFormat::PCM:
return cmvr::api::AudioData_AudioFormat_PCM;
case AudioStreamFormat::MP3:
return cmvr::api::AudioData_AudioFormat_MP3;
case AudioStreamFormat::AAC:
return cmvr::api::AudioData_AudioFormat_AAC;
case AudioStreamFormat::WAV:
return cmvr::api::AudioData_AudioFormat_WAV;
default:
return cmvr::api::AudioData_AudioFormat_PCM;
}
}
void fillProtoAudioData(const AudioStreamFrameData& frame, cmvr::api::AudioData* audio) {
audio->set_data(frame.data.data(), frame.data.size());
audio->set_sample_rate(frame.sample_rate);
audio->set_channels(frame.channels);
audio->set_format(toProtoAudioFormat(frame.format));
audio->set_codec(frame.codec);
audio->set_pts(frame.pts);
audio->set_nb_samples(frame.nb_samples);
}
} }
gRPCMicroPhoneServiceImpl::gRPCMicroPhoneServiceImpl(): dmgr_(DeviceManager::getInstance()) {} gRPCMicroPhoneServiceImpl::gRPCMicroPhoneServiceImpl(): dmgr_(DeviceManager::getInstance()) {}
@ -171,6 +150,7 @@ grpc::Status gRPCMicroPhoneServiceImpl::ResumeRecord(grpc::ServerContext* contex
grpc::Status gRPCMicroPhoneServiceImpl::StreamAudio(grpc::ServerContext* context, grpc::Status gRPCMicroPhoneServiceImpl::StreamAudio(grpc::ServerContext* context,
const api::StreamMicAudioCommand_Request* request, const api::StreamMicAudioCommand_Request* request,
grpc::ServerWriter<api::StreamMicAudioCommand_Feedback>* writer) { grpc::ServerWriter<api::StreamMicAudioCommand_Feedback>* writer) {
try {
const string dev_id = request->header().device_id(); const string dev_id = request->header().device_id();
CMVR_LOG(INFO) << "[gRPCMicroPhoneServiceImpl] (StreamAudio): id=" << dev_id; CMVR_LOG(INFO) << "[gRPCMicroPhoneServiceImpl] (StreamAudio): id=" << dev_id;
const auto dev = dmgr_.getDevice<AbstractMicrophone>(dev_id); const auto dev = dmgr_.getDevice<AbstractMicrophone>(dev_id);
@ -183,44 +163,93 @@ grpc::Status gRPCMicroPhoneServiceImpl::StreamAudio(grpc::ServerContext* context
return grpc::Status::OK; return grpc::Status::OK;
} }
if (!dev->start()) { auto& media_hub = cmvr::media::globalMediaSourceHub();
const std::string track_id = cmvr::media::microphoneTrackId(dev_id);
if (!cmvr::media::ensureMicrophoneMediaSource(media_hub, dev)) {
api::StreamMicAudioCommand_Feedback feedback; api::StreamMicAudioCommand_Feedback feedback;
feedback.mutable_header()->set_success(false); feedback.mutable_header()->set_success(false);
feedback.mutable_header()->set_error_message("Failed to start microphone: " + dev_id); feedback.mutable_header()->set_error_message("Failed to register microphone media source: " + dev_id);
setCurrentTimestamp(feedback.mutable_header()->mutable_timestamp()); setCurrentTimestamp(feedback.mutable_header()->mutable_timestamp());
writer->Write(feedback); writer->Write(feedback);
return grpc::Status::OK; return grpc::Status::OK;
} }
if (!dev->startStreaming()) { auto subscription = media_hub.subscribe(
track_id,
cmvr::media::MediaSourceHub::StartPosition::NEXT_PUBLISHED,
[context] { return context->IsCancelled(); });
if (!subscription) {
api::StreamMicAudioCommand_Feedback feedback; api::StreamMicAudioCommand_Feedback feedback;
feedback.mutable_header()->set_success(false); feedback.mutable_header()->set_success(false);
feedback.mutable_header()->set_error_message("Failed to start microphone streaming: " + dev_id); feedback.mutable_header()->set_error_message("Failed to subscribe microphone media source: " + dev_id);
setCurrentTimestamp(feedback.mutable_header()->mutable_timestamp()); setCurrentTimestamp(feedback.mutable_header()->mutable_timestamp());
writer->Write(feedback); writer->Write(feedback);
return grpc::Status::OK; return grpc::Status::OK;
} }
size_t index = 0;
while (!context->IsCancelled()) { while (!context->IsCancelled()) {
AudioStreamFrameData frame; const auto read = subscription.waitRead(std::chrono::milliseconds(100));
dev->getEncodedFrame(frame, index); if (!read || !read->value || read->value->empty()) {
if (frame.data.empty()) { if (!subscription.valid()) {
std::this_thread::sleep_for(std::chrono::milliseconds(2)); break;
}
continue;
}
const auto& frame = *read->value;
const auto descriptor = frame.descriptor;
if (!descriptor) {
continue; continue;
} }
api::StreamMicAudioCommand_Feedback feedback; api::StreamMicAudioCommand_Feedback feedback;
feedback.mutable_header()->set_success(true); feedback.mutable_header()->set_success(true);
setCurrentTimestamp(feedback.mutable_header()->mutable_timestamp()); setCurrentTimestamp(feedback.mutable_header()->mutable_timestamp());
fillProtoAudioData(frame, feedback.mutable_audio()); auto* audio = feedback.mutable_audio();
audio->set_data(frame.data(), frame.size());
audio->set_sample_rate(static_cast<int32_t>(std::min<uint32_t>(
descriptor->sample_rate,
static_cast<uint32_t>(std::numeric_limits<int32_t>::max()))));
audio->set_channels(static_cast<int32_t>(std::min<uint32_t>(
descriptor->channels,
static_cast<uint32_t>(std::numeric_limits<int32_t>::max()))));
if (descriptor->codec == cmvr::media::Codec::PCM_S16LE) {
audio->set_format(cmvr::api::AudioData_AudioFormat_PCM);
audio->set_codec("pcm_s16le");
} else if (descriptor->codec == cmvr::media::Codec::OPUS) {
audio->set_format(cmvr::api::AudioData_AudioFormat_OPUS);
audio->set_codec("opus");
} else if (descriptor->codec == cmvr::media::Codec::AAC) {
audio->set_format(cmvr::api::AudioData_AudioFormat_AAC);
audio->set_codec("aac");
} else {
feedback.mutable_header()->set_success(false);
feedback.mutable_header()->set_error_message(
"Unsupported microphone stream codec: " + dev_id);
feedback.clear_audio();
writer->Write(feedback);
break;
}
audio->set_pts(frame.pts);
const int64_t sample_count = frame.duration > 0
? frame.duration
: static_cast<int64_t>(descriptor->nominal_rate);
audio->set_nb_samples(static_cast<int32_t>(std::clamp<int64_t>(
sample_count,
0,
std::numeric_limits<int32_t>::max())));
if (!writer->Write(feedback)) { if (!writer->Write(feedback)) {
break; break;
} }
} }
dev->stopStreaming();
return grpc::Status::OK; return grpc::Status::OK;
} catch (const std::exception& error) {
api::StreamMicAudioCommand_Feedback feedback;
feedback.mutable_header()->set_success(false);
feedback.mutable_header()->set_error_message(error.what());
setCurrentTimestamp(feedback.mutable_header()->mutable_timestamp());
writer->Write(feedback);
return grpc::Status::OK;
}
} }
grpc::Status gRPCMicroPhoneServiceImpl::SetVolume(grpc::ServerContext* context, grpc::Status gRPCMicroPhoneServiceImpl::SetVolume(grpc::ServerContext* context,

View File

@ -0,0 +1,59 @@
find_package(Threads REQUIRED)
add_library(quic_edge_service STATIC
src/control_framing.cpp
src/datagram_packetizer.cpp
src/quic_edge_types.cpp
src/quic_transport.cpp
src/quic_edge_service.cpp
src/quic_edge_device_adapter.cpp
)
target_compile_features(quic_edge_service PUBLIC cxx_std_17)
target_include_directories(quic_edge_service PUBLIC ${PROJECT_SOURCE_DIR}/cmvr-es)
target_link_libraries(quic_edge_service
PUBLIC
cmvr_es::proto
cmvr_es::media_source_hub
PRIVATE
cmvr_es::device_media_source_adapter
cmvr_es::device_manager
Threads::Threads
)
if(CMVR_HAS_MSQUIC)
target_sources(quic_edge_service PRIVATE src/msquic_transport.cpp)
target_compile_definitions(quic_edge_service PRIVATE CMVR_HAS_MSQUIC=1)
target_link_libraries(quic_edge_service PRIVATE MsQuic::msquic)
# Bundle a repository-local or explicitly supplied MsQuic runtime with the
# existing relocatable output tree. System packages remain system-owned.
string(FIND "${MsQuic_LIBRARY}" "${PROJECT_SOURCE_DIR}/dependency/"
_cmvr_bundled_msquic_index)
if(MsQuic_LIBRARY MATCHES "\\.so" AND
(_cmvr_bundled_msquic_index EQUAL 0 OR CMVR_MSQUIC_ROOT))
get_filename_component(_cmvr_msquic_library_dir "${MsQuic_LIBRARY}" DIRECTORY)
install(DIRECTORY "${_cmvr_msquic_library_dir}/" DESTINATION lib
FILES_MATCHING PATTERN "libmsquic.so*")
endif()
endif()
add_library(cmvr_es::quic_edge_service ALIAS quic_edge_service)
if(BUILD_TESTING)
add_executable(quic_edge_protocol_test tests/quic_edge_protocol_test.cpp)
target_compile_features(quic_edge_protocol_test PRIVATE cxx_std_17)
target_link_libraries(quic_edge_protocol_test
PRIVATE
cmvr_es::quic_edge_service
cmvr_es::media_source_hub
Threads::Threads
)
add_test(NAME quic_edge_protocol_test COMMAND quic_edge_protocol_test)
if(UNIX AND NOT APPLE)
get_property(_quic_test_library_dirs DIRECTORY PROPERTY LINK_DIRECTORIES)
list(PREPEND _quic_test_library_dirs "${CMAKE_BINARY_DIR}/cmvr_compiler_runtime")
list(JOIN _quic_test_library_dirs ":" _quic_test_library_path)
set_tests_properties(quic_edge_protocol_test PROPERTIES
ENVIRONMENT "LD_LIBRARY_PATH=${_quic_test_library_path}")
endif()
endif()

View File

@ -0,0 +1,54 @@
#ifndef CMVR_ES_QUIC_EDGE_CONTROL_FRAMING_H
#define CMVR_ES_QUIC_EDGE_CONTROL_FRAMING_H
#include <cstddef>
#include <cstdint>
#include <string>
#include <vector>
namespace cmvr::quic_edge {
class ControlFrameEncoder {
public:
static bool encode(const std::uint8_t* payload,
std::size_t payload_size,
std::size_t maximum_payload_size,
std::vector<std::uint8_t>* framed,
std::string* error);
static bool encode(const std::vector<std::uint8_t>& payload,
std::size_t maximum_payload_size,
std::vector<std::uint8_t>* framed,
std::string* error)
{
return encode(payload.data(), payload.size(), maximum_payload_size, framed, error);
}
};
class ControlFrameDecoder {
public:
explicit ControlFrameDecoder(std::size_t maximum_payload_size);
bool push(const std::uint8_t* data,
std::size_t size,
std::vector<std::vector<std::uint8_t>>* decoded_frames,
std::string* error);
bool push(const std::vector<std::uint8_t>& data,
std::vector<std::vector<std::uint8_t>>* decoded_frames,
std::string* error)
{
return push(data.data(), data.size(), decoded_frames, error);
}
void reset();
std::size_t bufferedBytes() const { return buffer_.size(); }
private:
std::size_t maximum_payload_size_;
std::vector<std::uint8_t> buffer_;
};
} // namespace cmvr::quic_edge
#endif // CMVR_ES_QUIC_EDGE_CONTROL_FRAMING_H

View File

@ -0,0 +1,47 @@
#ifndef CMVR_ES_QUIC_EDGE_DATAGRAM_PACKETIZER_H
#define CMVR_ES_QUIC_EDGE_DATAGRAM_PACKETIZER_H
#include <cstddef>
#include <cstdint>
#include <string>
#include <vector>
#include "service/quic_edge/include/quic_edge_types.h"
namespace cmvr::quic_edge {
class DatagramPacketizer {
public:
explicit DatagramPacketizer(std::uint64_t initial_packet_sequence = 0);
bool packetize(const media::MediaFrame& frame,
std::uint32_t wire_track_id,
std::uint32_t codec_generation_token,
std::uint64_t session_epoch,
std::uint64_t wire_frame_sequence,
bool force_discontinuity,
std::size_t maximum_datagram_bytes,
std::vector<DatagramPacket>* packets,
std::string* error);
static bool decodeHeader(const std::uint8_t* data,
std::size_t size,
DatagramHeader* header,
std::string* error);
static bool decodeHeader(const std::vector<std::uint8_t>& packet,
DatagramHeader* header,
std::string* error)
{
return decodeHeader(packet.data(), packet.size(), header, error);
}
std::uint64_t nextPacketSequence() const { return next_packet_sequence_; }
private:
std::uint64_t next_packet_sequence_;
};
} // namespace cmvr::quic_edge
#endif // CMVR_ES_QUIC_EDGE_DATAGRAM_PACKETIZER_H

View File

@ -0,0 +1,197 @@
#ifndef CMVR_ES_QUIC_EDGE_SERVICE_H
#define CMVR_ES_QUIC_EDGE_SERVICE_H
#include <chrono>
#include <condition_variable>
#include <cstdint>
#include <functional>
#include <memory>
#include <mutex>
#include <optional>
#include <string>
#include <thread>
#include <unordered_map>
#include <vector>
#include "cmvr/config/quic_edge_config/quic_edge_config.pb.h"
#include "manager/media_source_hub/include/media_source_hub.h"
#include "service/quic_edge/include/control_framing.h"
#include "service/quic_edge/include/datagram_packetizer.h"
#include "service/quic_edge/include/quic_transport.h"
namespace cmvr::quic_edge {
enum class QuicEdgeServiceState {
UNINITIALIZED,
DISABLED,
STOPPED,
CONNECTING,
REGISTERING,
ONLINE,
BACKOFF,
FAILED,
};
const char* toString(QuicEdgeServiceState state);
struct QuicEdgeStats {
std::uint64_t connection_attempts{0};
std::uint64_t successful_connections{0};
std::uint64_t reconnects{0};
std::uint64_t registrations_sent{0};
std::uint64_t registrations_accepted{0};
std::uint64_t registrations_rejected{0};
std::uint64_t heartbeats_sent{0};
std::uint64_t heartbeats_acknowledged{0};
std::uint64_t heartbeat_timeouts{0};
std::uint64_t protocol_errors{0};
std::uint64_t media_sessions_opened{0};
std::uint64_t frames_queued{0};
std::uint64_t datagrams_queued{0};
std::uint64_t frames_dropped_source{0};
std::uint64_t frames_dropped_backpressure{0};
std::uint64_t frames_dropped_oversize{0};
std::uint64_t frames_dropped_no_datagram{0};
std::uint64_t frames_skipped_waiting_keyframe{0};
std::uint64_t source_errors{0};
};
struct QuicEdgeStatus {
bool registered{false};
std::string node_id;
std::string boot_id;
std::string session_id;
std::string observed_source_ip;
std::string last_media_error;
std::uint64_t heartbeat_sequence{0};
std::uint64_t last_heartbeat_ack_unix_ms{0};
std::size_t active_media_tracks{0};
};
class QuicEdgeService {
public:
explicit QuicEdgeService(config::QuicEdgeConfig config);
QuicEdgeService(config::QuicEdgeConfig config,
std::unique_ptr<QuicTransport> transport,
media::MediaSourceHub& media_hub);
~QuicEdgeService();
QuicEdgeService(const QuicEdgeService&) = delete;
QuicEdgeService& operator=(const QuicEdgeService&) = delete;
static bool validateConfig(const config::QuicEdgeConfig& config,
std::string* error);
bool initialize(std::string* error);
bool start(std::string* error);
void stop();
QuicEdgeServiceState state() const;
std::string lastError() const;
QuicEdgeStats stats() const;
QuicEdgeStatus status() const;
bool enabled() const { return config_.enable(); }
private:
using SourceRegistrar = std::function<bool(
const config::QuicEdgeTrackConfig&, const std::string&, std::string*)>;
struct ActiveTrack {
config::QuicEdgeTrackConfig config;
std::string source_track_id;
media::MediaSourceHub::Subscription subscription;
std::optional<MediaTrackDescription> last_description;
bool waiting_for_keyframe{false};
bool keyframe_requested{false};
bool next_frame_discontinuous{false};
};
void run();
void runMedia();
bool startMediaWorker(std::string* error);
void stopMediaWorker();
void recordMediaConnectionFailure(const std::string& error);
bool performRegistration(ControlFrameDecoder* decoder, std::string* error);
bool sendNodeRegistration(std::string* error);
bool sendHeartbeat(std::uint64_t sequence, std::string* error);
bool receiveAndDispatchControl(ControlFrameDecoder* decoder,
std::chrono::milliseconds timeout,
bool* received,
std::string* error);
bool dispatchControlFrame(const std::vector<std::uint8_t>& frame,
std::string* error);
bool openMediaSession(std::vector<ActiveTrack>* tracks,
std::string* error);
void refreshMediaTracks(std::vector<ActiveTrack>* tracks);
bool hasEnabledMediaTracks() const;
bool ensureSourceRegistered(const config::QuicEdgeTrackConfig& track,
const std::string& source_track_id,
std::string* error);
bool sendSessionOpen(std::string* error);
bool sendTrackDescription(const MediaTrackDescription& description,
std::string* error);
bool sendControlEnvelope(const std::string& serialized,
std::string* error);
bool processTrack(ActiveTrack* track, bool* sent_anything,
std::string* error);
std::uint64_t shedBufferedFrames(ActiveTrack* track);
void requestKeyFrame(ActiveTrack* track);
std::uint32_t maximumFrameBytes(const ActiveTrack& track) const;
void initializeIdentity();
void resetConnectionStatus();
void recordMediaError(const std::string& error);
void setState(QuicEdgeServiceState state, const std::string& error = {});
bool waitForStop(std::chrono::milliseconds duration);
std::chrono::milliseconds nextBackoff(std::chrono::milliseconds current);
std::chrono::milliseconds jittered(std::chrono::milliseconds base);
std::uint64_t nextSessionEpoch();
config::QuicEdgeConfig config_;
std::unique_ptr<QuicTransport> transport_;
media::MediaSourceHub* media_hub_{nullptr};
bool using_default_transport_{false};
bool using_global_media_hub_{false};
SourceRegistrar source_registrar_;
std::mutex lifecycle_mutex_;
std::mutex control_send_mutex_;
mutable std::mutex mutex_;
std::condition_variable stop_cv_;
std::condition_variable media_stop_cv_;
QuicEdgeServiceState state_{QuicEdgeServiceState::UNINITIALIZED};
std::string last_error_;
std::string last_media_error_;
QuicEdgeStats stats_;
bool registered_{false};
std::string session_id_;
std::string observed_source_ip_;
std::uint64_t heartbeat_sequence_{0};
std::uint64_t outstanding_heartbeat_sequence_{0};
std::uint64_t last_heartbeat_ack_unix_ms_{0};
std::size_t active_media_tracks_{0};
bool stop_requested_{false};
bool media_stop_requested_{false};
bool media_connection_failed_{false};
std::string media_connection_error_;
std::thread worker_;
std::thread media_worker_;
std::string node_id_;
std::string boot_id_;
std::string software_version_;
DatagramPacketizer packetizer_;
std::uint64_t session_epoch_{0};
std::unordered_map<std::uint32_t, std::uint64_t> next_wire_frame_sequence_;
std::uint64_t control_message_sequence_{0};
std::uint64_t inbound_message_sequence_{0};
bool has_inbound_message_sequence_{false};
std::uint32_t effective_heartbeat_interval_ms_{0};
std::chrono::steady_clock::time_point heartbeat_deadline_{};
std::chrono::steady_clock::time_point next_heartbeat_{};
std::chrono::steady_clock::time_point next_media_source_retry_{};
bool media_session_announced_{false};
};
} // namespace cmvr::quic_edge
#endif // CMVR_ES_QUIC_EDGE_SERVICE_H

View File

@ -0,0 +1,118 @@
#ifndef CMVR_ES_QUIC_EDGE_TYPES_H
#define CMVR_ES_QUIC_EDGE_TYPES_H
#include <cstddef>
#include <cstdint>
#include <string>
#include <vector>
#include "common/media/media_frame.h"
namespace cmvr::quic_edge {
constexpr std::uint32_t kDatagramMagic = 0x434d5144U; // "CMQD"
constexpr std::uint8_t kProtocolVersion = 1U;
constexpr std::size_t kDatagramHeaderBytes = 64U;
enum DatagramFlag : std::uint16_t {
DATAGRAM_FLAG_NONE = 0,
DATAGRAM_FLAG_KEY_FRAME = 1U << 0U,
DATAGRAM_FLAG_RESERVED_1 = 1U << 1U,
DATAGRAM_FLAG_DISCONTINUITY = 1U << 2U,
};
// All integer fields are serialized in network byte order. codec_generation
// is a compact token for the full 64-bit MediaSourceHub descriptor generation
// announced on the reliable control stream.
struct DatagramHeader {
std::uint8_t protocol_version{kProtocolVersion};
media::MediaKind kind{media::MediaKind::UNKNOWN};
std::uint16_t flags{DATAGRAM_FLAG_NONE};
std::uint16_t fragment_index{0};
std::uint16_t fragment_count{0};
std::uint16_t payload_size{0};
std::uint32_t track_id{0};
std::uint32_t codec_generation{0};
std::uint64_t session_epoch{0};
std::uint64_t packet_sequence{0};
std::uint64_t frame_sequence{0};
std::uint64_t capture_timestamp_us{0};
std::uint32_t frame_size{0};
std::uint32_t fragment_offset{0};
};
struct DatagramPacket {
DatagramHeader header;
std::vector<std::uint8_t> bytes;
};
struct MediaTrackDescription {
std::uint32_t track_id{0};
std::uint32_t codec_generation_token{0};
std::string source_track_id;
std::string source_id;
media::MediaKind kind{media::MediaKind::UNKNOWN};
media::Codec codec{media::Codec::UNKNOWN};
media::PayloadFormat payload_format{media::PayloadFormat::UNKNOWN};
std::uint64_t codec_generation{0};
std::uint32_t width{0};
std::uint32_t height{0};
std::uint32_t nominal_rate{0};
std::uint32_t sample_rate{0};
std::uint32_t channels{0};
std::vector<std::uint8_t> codec_config;
bool operator==(const MediaTrackDescription& other) const
{
return track_id == other.track_id &&
codec_generation_token == other.codec_generation_token &&
source_track_id == other.source_track_id &&
source_id == other.source_id &&
kind == other.kind &&
codec == other.codec &&
payload_format == other.payload_format &&
codec_generation == other.codec_generation &&
width == other.width &&
height == other.height &&
nominal_rate == other.nominal_rate &&
sample_rate == other.sample_rate &&
channels == other.channels &&
codec_config == other.codec_config;
}
bool operator!=(const MediaTrackDescription& other) const
{
return !(*this == other);
}
};
std::uint32_t descriptorGenerationToken(std::uint64_t generation);
const char* codecName(media::Codec codec);
const char* payloadFormatName(media::PayloadFormat format);
inline MediaTrackDescription describeTrack(
const std::uint32_t wire_track_id,
const media::TrackDescriptor& descriptor)
{
MediaTrackDescription description;
description.track_id = wire_track_id;
description.codec_generation_token =
descriptorGenerationToken(descriptor.generation);
description.source_track_id = descriptor.id;
description.source_id = descriptor.source_id;
description.kind = descriptor.kind;
description.codec = descriptor.codec;
description.payload_format = descriptor.payload_format;
description.codec_generation = descriptor.generation;
description.width = descriptor.width;
description.height = descriptor.height;
description.nominal_rate = descriptor.nominal_rate;
description.sample_rate = descriptor.sample_rate;
description.channels = descriptor.channels;
description.codec_config = descriptor.codec_config;
return description;
}
} // namespace cmvr::quic_edge
#endif // CMVR_ES_QUIC_EDGE_TYPES_H

View File

@ -0,0 +1,74 @@
#ifndef CMVR_ES_QUIC_EDGE_QUIC_TRANSPORT_H
#define CMVR_ES_QUIC_EDGE_QUIC_TRANSPORT_H
#include <chrono>
#include <cstddef>
#include <cstdint>
#include <memory>
#include <string>
#include <vector>
#include "cmvr/config/quic_edge_config/quic_edge_config.pb.h"
#include "service/quic_edge/include/quic_edge_types.h"
namespace cmvr::quic_edge {
enum class TransportSendResult {
QUEUED,
WOULD_BLOCK,
DISCONNECTED,
ERROR,
};
enum class TransportReceiveResult {
DATA,
TIMEOUT,
DISCONNECTED,
ERROR,
};
class QuicTransport {
public:
virtual ~QuicTransport() = default;
virtual bool connect(const config::QuicEdgeConfig& config,
std::chrono::milliseconds timeout,
std::string* error) = 0;
virtual void disconnect() = 0;
virtual bool isConnected() const = 0;
virtual std::size_t maximumDatagramBytes() const = 0;
virtual std::size_t maximumDatagramBatchPackets() const = 0;
// QUEUED means ownership was accepted. WOULD_BLOCK and synchronous ERROR
// mean no bytes were accepted, so the caller may safely retry the complete
// framed message. DISCONNECTED means the connection is no longer usable.
virtual TransportSendResult sendControl(
std::vector<std::uint8_t> framed_message,
std::string* error) = 0;
// Returns one raw byte chunk from the reliable control stream. QUIC stream
// callback boundaries are not message boundaries; callers must feed all
// DATA chunks into their framing decoder in order.
//
// The default implementation reports DISCONNECTED so existing transports
// that only support sending remain source-compatible.
virtual TransportReceiveResult receiveControl(
std::vector<std::uint8_t>* chunk,
std::chrono::milliseconds timeout,
std::string* error);
// Local admission is atomic for the complete batch. A native QUIC API may
// still fail after accepting earlier fragments; in that case the transport
// must reset the connection so the receiver discards the partial session.
virtual TransportSendResult sendDatagramBatch(
std::vector<DatagramPacket> packets,
std::string* error) = 0;
};
bool hasCompiledMsQuicSupport();
std::unique_ptr<QuicTransport> createDefaultQuicTransport(
std::size_t send_queue_depth);
} // namespace cmvr::quic_edge
#endif // CMVR_ES_QUIC_EDGE_QUIC_TRANSPORT_H

View File

@ -0,0 +1,115 @@
#include "service/quic_edge/include/control_framing.h"
#include <algorithm>
#include <limits>
namespace cmvr::quic_edge {
namespace {
void setError(std::string* error, const std::string& message)
{
if (error) {
*error = message;
}
}
std::uint32_t readLength(const std::vector<std::uint8_t>& buffer)
{
return (static_cast<std::uint32_t>(buffer[0]) << 24U) |
(static_cast<std::uint32_t>(buffer[1]) << 16U) |
(static_cast<std::uint32_t>(buffer[2]) << 8U) |
static_cast<std::uint32_t>(buffer[3]);
}
} // namespace
bool ControlFrameEncoder::encode(const std::uint8_t* payload,
const std::size_t payload_size,
const std::size_t maximum_payload_size,
std::vector<std::uint8_t>* framed,
std::string* error)
{
if (!framed) {
setError(error, "control-frame output is null");
return false;
}
framed->clear();
if (!payload || payload_size == 0U) {
setError(error, "control-frame payload is empty");
return false;
}
if (payload_size > maximum_payload_size ||
payload_size > std::numeric_limits<std::uint32_t>::max()) {
setError(error, "control-frame payload exceeds configured limit");
return false;
}
const auto length = static_cast<std::uint32_t>(payload_size);
framed->resize(4U + payload_size);
(*framed)[0] = static_cast<std::uint8_t>(length >> 24U);
(*framed)[1] = static_cast<std::uint8_t>(length >> 16U);
(*framed)[2] = static_cast<std::uint8_t>(length >> 8U);
(*framed)[3] = static_cast<std::uint8_t>(length);
std::copy_n(payload, payload_size, framed->data() + 4U);
return true;
}
ControlFrameDecoder::ControlFrameDecoder(const std::size_t maximum_payload_size)
: maximum_payload_size_(maximum_payload_size)
{
buffer_.reserve(std::min<std::size_t>(maximum_payload_size + 4U, 4096U));
}
bool ControlFrameDecoder::push(const std::uint8_t* data,
const std::size_t size,
std::vector<std::vector<std::uint8_t>>* decoded_frames,
std::string* error)
{
if (!decoded_frames) {
setError(error, "decoded control-frame output is null");
return false;
}
if (size != 0U && !data) {
setError(error, "control-frame input is null");
return false;
}
std::size_t consumed = 0U;
while (consumed < size) {
if (buffer_.size() < 4U) {
const std::size_t prefix_bytes =
std::min<std::size_t>(4U - buffer_.size(), size - consumed);
buffer_.insert(buffer_.end(), data + consumed,
data + consumed + prefix_bytes);
consumed += prefix_bytes;
if (buffer_.size() < 4U) {
break;
}
const std::uint32_t declared = readLength(buffer_);
if (declared == 0U || declared > maximum_payload_size_) {
reset();
setError(error, "control-frame declared length is invalid");
return false;
}
}
const std::size_t total_size = 4U + readLength(buffer_);
const std::size_t bytes_needed = total_size - buffer_.size();
const std::size_t copied =
std::min<std::size_t>(bytes_needed, size - consumed);
buffer_.insert(buffer_.end(), data + consumed, data + consumed + copied);
consumed += copied;
if (buffer_.size() == total_size) {
decoded_frames->emplace_back(buffer_.begin() + 4, buffer_.end());
buffer_.clear();
}
}
return true;
}
void ControlFrameDecoder::reset()
{
buffer_.clear();
}
} // namespace cmvr::quic_edge

View File

@ -0,0 +1,270 @@
#include "service/quic_edge/include/datagram_packetizer.h"
#include <algorithm>
#include <limits>
namespace cmvr::quic_edge {
namespace {
void setError(std::string* error, const std::string& message)
{
if (error) {
*error = message;
}
}
void writeU16(std::vector<std::uint8_t>* out, const std::size_t offset,
const std::uint16_t value)
{
(*out)[offset] = static_cast<std::uint8_t>(value >> 8U);
(*out)[offset + 1U] = static_cast<std::uint8_t>(value);
}
void writeU32(std::vector<std::uint8_t>* out, const std::size_t offset,
const std::uint32_t value)
{
(*out)[offset] = static_cast<std::uint8_t>(value >> 24U);
(*out)[offset + 1U] = static_cast<std::uint8_t>(value >> 16U);
(*out)[offset + 2U] = static_cast<std::uint8_t>(value >> 8U);
(*out)[offset + 3U] = static_cast<std::uint8_t>(value);
}
void writeU64(std::vector<std::uint8_t>* out, const std::size_t offset,
const std::uint64_t value)
{
for (std::size_t i = 0; i < 8U; ++i) {
(*out)[offset + i] = static_cast<std::uint8_t>(value >> (56U - i * 8U));
}
}
std::uint16_t readU16(const std::uint8_t* data, const std::size_t offset)
{
return static_cast<std::uint16_t>(
(static_cast<std::uint16_t>(data[offset]) << 8U) |
static_cast<std::uint16_t>(data[offset + 1U]));
}
std::uint32_t readU32(const std::uint8_t* data, const std::size_t offset)
{
return (static_cast<std::uint32_t>(data[offset]) << 24U) |
(static_cast<std::uint32_t>(data[offset + 1U]) << 16U) |
(static_cast<std::uint32_t>(data[offset + 2U]) << 8U) |
static_cast<std::uint32_t>(data[offset + 3U]);
}
std::uint64_t readU64(const std::uint8_t* data, const std::size_t offset)
{
std::uint64_t value = 0;
for (std::size_t i = 0; i < 8U; ++i) {
value = (value << 8U) | static_cast<std::uint64_t>(data[offset + i]);
}
return value;
}
bool validKind(const media::MediaKind kind)
{
return kind == media::MediaKind::VIDEO || kind == media::MediaKind::AUDIO;
}
} // namespace
DatagramPacketizer::DatagramPacketizer(const std::uint64_t initial_packet_sequence)
: next_packet_sequence_(initial_packet_sequence)
{
}
bool DatagramPacketizer::packetize(const media::MediaFrame& frame,
const std::uint32_t wire_track_id,
const std::uint32_t codec_generation_token,
const std::uint64_t session_epoch,
const std::uint64_t wire_frame_sequence,
const bool force_discontinuity,
const std::size_t maximum_datagram_bytes,
std::vector<DatagramPacket>* packets,
std::string* error)
{
if (!packets) {
setError(error, "packet output is null");
return false;
}
packets->clear();
if (wire_track_id == 0U) {
setError(error, "track_id must be non-zero");
return false;
}
if (!frame.descriptor || !validKind(frame.descriptor->kind)) {
setError(error, "unsupported media kind");
return false;
}
if (codec_generation_token == 0U) {
setError(error, "codec generation token must be non-zero");
return false;
}
if (session_epoch == 0U) {
setError(error, "session_epoch must be non-zero");
return false;
}
if (wire_frame_sequence == 0U) {
setError(error, "wire_frame_sequence must be non-zero");
return false;
}
if (frame.empty()) {
setError(error, "media frame payload is empty");
return false;
}
if (frame.size() > std::numeric_limits<std::uint32_t>::max()) {
setError(error, "media frame is larger than the v1 frame_size field");
return false;
}
if (maximum_datagram_bytes <= kDatagramHeaderBytes) {
setError(error, "maximum DATAGRAM size does not leave room for payload");
return false;
}
const std::size_t payload_capacity = std::min<std::size_t>(
maximum_datagram_bytes - kDatagramHeaderBytes,
std::numeric_limits<std::uint16_t>::max());
const std::size_t fragment_count =
(frame.size() + payload_capacity - 1U) / payload_capacity;
if (fragment_count == 0U ||
fragment_count > std::numeric_limits<std::uint16_t>::max()) {
setError(error, "media frame requires too many DATAGRAM fragments");
return false;
}
if (next_packet_sequence_ >
std::numeric_limits<std::uint64_t>::max() - fragment_count) {
setError(error, "DATAGRAM packet sequence exhausted");
return false;
}
std::uint16_t flags = DATAGRAM_FLAG_NONE;
if (frame.key_frame) {
flags |= DATAGRAM_FLAG_KEY_FRAME;
}
if (frame.discontinuity || force_discontinuity) {
flags |= DATAGRAM_FLAG_DISCONTINUITY;
}
std::vector<DatagramPacket> encoded_packets;
encoded_packets.reserve(fragment_count);
for (std::size_t i = 0; i < fragment_count; ++i) {
const std::size_t offset = i * payload_capacity;
const std::size_t payload_size =
std::min(payload_capacity, frame.size() - offset);
DatagramPacket packet;
packet.header.protocol_version = kProtocolVersion;
packet.header.kind = frame.descriptor->kind;
packet.header.flags = flags;
packet.header.fragment_index = static_cast<std::uint16_t>(i);
packet.header.fragment_count = static_cast<std::uint16_t>(fragment_count);
packet.header.payload_size = static_cast<std::uint16_t>(payload_size);
packet.header.track_id = wire_track_id;
packet.header.codec_generation = codec_generation_token;
packet.header.session_epoch = session_epoch;
packet.header.packet_sequence = next_packet_sequence_ + i;
packet.header.frame_sequence = wire_frame_sequence;
packet.header.capture_timestamp_us = frame.capture_time_ns / 1000U;
packet.header.frame_size = static_cast<std::uint32_t>(frame.size());
packet.header.fragment_offset = static_cast<std::uint32_t>(offset);
packet.bytes.resize(kDatagramHeaderBytes + payload_size);
writeU32(&packet.bytes, 0U, kDatagramMagic);
packet.bytes[4U] = packet.header.protocol_version;
packet.bytes[5U] = static_cast<std::uint8_t>(packet.header.kind);
writeU16(&packet.bytes, 6U, packet.header.flags);
writeU16(&packet.bytes, 8U, static_cast<std::uint16_t>(kDatagramHeaderBytes));
writeU16(&packet.bytes, 10U, packet.header.fragment_index);
writeU16(&packet.bytes, 12U, packet.header.fragment_count);
writeU16(&packet.bytes, 14U, packet.header.payload_size);
writeU32(&packet.bytes, 16U, packet.header.track_id);
writeU32(&packet.bytes, 20U, packet.header.codec_generation);
writeU64(&packet.bytes, 24U, packet.header.session_epoch);
writeU64(&packet.bytes, 32U, packet.header.packet_sequence);
writeU64(&packet.bytes, 40U, packet.header.frame_sequence);
writeU64(&packet.bytes, 48U, packet.header.capture_timestamp_us);
writeU32(&packet.bytes, 56U, packet.header.frame_size);
writeU32(&packet.bytes, 60U, packet.header.fragment_offset);
std::copy_n(frame.data() + offset, payload_size,
packet.bytes.data() + kDatagramHeaderBytes);
encoded_packets.push_back(std::move(packet));
}
next_packet_sequence_ += fragment_count;
*packets = std::move(encoded_packets);
return true;
}
bool DatagramPacketizer::decodeHeader(const std::uint8_t* data,
const std::size_t size,
DatagramHeader* header,
std::string* error)
{
if (!data || !header) {
setError(error, "DATAGRAM input or header output is null");
return false;
}
if (size < kDatagramHeaderBytes) {
setError(error, "DATAGRAM is shorter than the v1 header");
return false;
}
if (readU32(data, 0U) != kDatagramMagic) {
setError(error, "DATAGRAM magic mismatch");
return false;
}
if (data[4U] != kProtocolVersion) {
setError(error, "unsupported DATAGRAM protocol version");
return false;
}
const auto kind = static_cast<media::MediaKind>(data[5U]);
if (!validKind(kind)) {
setError(error, "unsupported DATAGRAM media kind");
return false;
}
if (readU16(data, 8U) != kDatagramHeaderBytes) {
setError(error, "unsupported DATAGRAM header size");
return false;
}
DatagramHeader decoded;
decoded.protocol_version = data[4U];
decoded.kind = kind;
decoded.flags = readU16(data, 6U);
decoded.fragment_index = readU16(data, 10U);
decoded.fragment_count = readU16(data, 12U);
decoded.payload_size = readU16(data, 14U);
decoded.track_id = readU32(data, 16U);
decoded.codec_generation = readU32(data, 20U);
decoded.session_epoch = readU64(data, 24U);
decoded.packet_sequence = readU64(data, 32U);
decoded.frame_sequence = readU64(data, 40U);
decoded.capture_timestamp_us = readU64(data, 48U);
decoded.frame_size = readU32(data, 56U);
decoded.fragment_offset = readU32(data, 60U);
if (decoded.track_id == 0U || decoded.codec_generation == 0U ||
decoded.session_epoch == 0U) {
setError(error, "DATAGRAM has a zero track, generation, or session identifier");
return false;
}
if (decoded.fragment_count == 0U ||
decoded.fragment_index >= decoded.fragment_count) {
setError(error, "DATAGRAM fragment index/count is invalid");
return false;
}
if (decoded.payload_size != size - kDatagramHeaderBytes) {
setError(error, "DATAGRAM payload size does not match packet length");
return false;
}
if (decoded.frame_size == 0U || decoded.payload_size == 0U ||
decoded.fragment_offset > decoded.frame_size ||
decoded.payload_size > decoded.frame_size - decoded.fragment_offset) {
setError(error, "DATAGRAM fragment is outside the declared frame");
return false;
}
*header = decoded;
return true;
}
} // namespace cmvr::quic_edge

View File

@ -0,0 +1,852 @@
#include "service/quic_edge/include/quic_transport.h"
#ifdef CMVR_HAS_MSQUIC
#include <msquic.h>
#include <algorithm>
#include <condition_variable>
#include <deque>
#include <iomanip>
#include <limits>
#include <mutex>
#include <sstream>
#include <unordered_set>
#include <utility>
namespace cmvr::quic_edge {
namespace {
std::string statusText(const char* operation, const QUIC_STATUS status)
{
std::ostringstream output;
output << operation << " failed with QUIC_STATUS 0x" << std::hex
<< static_cast<std::uint64_t>(status);
return output.str();
}
constexpr std::size_t kControlFramePrefixBytes = sizeof(std::uint32_t);
constexpr std::size_t kMinimumControlReceiveQueueBytes = 64U * 1024U;
constexpr std::size_t kMaximumControlReceiveQueueBytes =
32U * 1024U * 1024U + 2U * kControlFramePrefixBytes;
constexpr std::size_t kMaximumControlReceiveQueueChunks = 4096U;
constexpr std::size_t kMaximumReservedControlSends = 8U;
constexpr std::uint64_t kMaximumHeartbeatIntervalMs = 60ULL * 60ULL * 1000ULL;
std::size_t reservedControlSends(const std::size_t queue_depth)
{
if (queue_depth <= 1U) return 0U;
return std::min<std::size_t>(
kMaximumReservedControlSends,
std::max<std::size_t>(1U, queue_depth / 16U));
}
std::size_t controlReceiveQueueBytes(const config::QuicEdgeConfig& config)
{
// Keep enough room for two maximum-sized framed control messages while
// enforcing a transport-level hard bound independent of peer behavior.
const std::uint64_t desired =
2ULL * (static_cast<std::uint64_t>(
config.maximum_control_frame_bytes()) +
kControlFramePrefixBytes);
return static_cast<std::size_t>(std::clamp<std::uint64_t>(
desired, kMinimumControlReceiveQueueBytes,
kMaximumControlReceiveQueueBytes));
}
class MsQuicTransport final : public QuicTransport {
public:
explicit MsQuicTransport(const std::size_t send_queue_depth)
: send_queue_depth_(std::max<std::size_t>(1U, send_queue_depth)),
reserved_control_sends_(reservedControlSends(send_queue_depth_))
{
}
~MsQuicTransport() override
{
disconnect();
if (registration_ && api_) api_->RegistrationClose(registration_);
if (api_) MsQuicClose(api_);
}
bool connect(const config::QuicEdgeConfig& config,
const std::chrono::milliseconds timeout,
std::string* error) override
{
disconnect();
QUIC_STATUS status = QUIC_STATUS_SUCCESS;
{
// Configuration creation and connection publication are one API
// operation. A concurrent disconnect can therefore never close a
// just-created configuration before ConnectionStart consumes it.
std::lock_guard operation_lock(api_operation_mutex_);
if (!openApi(error) || !openConfiguration(config, error)) {
return false;
}
{
std::lock_guard lock(mutex_);
shutdown_complete_ = false;
connected_ = false;
datagram_send_enabled_ = false;
maximum_datagram_bytes_ = 0U;
control_stream_start_completed_ = false;
control_stream_started_ = false;
control_stream_shutdown_complete_ = false;
control_stream_receive_closed_ = true;
control_receive_queue_.clear();
control_receive_queue_bytes_ = 0U;
maximum_control_receive_queue_bytes_ =
controlReceiveQueueBytes(config);
control_receive_error_.clear();
connect_error_.clear();
}
HQUIC opened_connection = nullptr;
status = api_->ConnectionOpen(
registration_, &MsQuicTransport::connectionCallback, this,
&opened_connection);
if (QUIC_SUCCEEDED(status)) {
{
std::lock_guard state_lock(mutex_);
connection_ = opened_connection;
}
const std::string& server_name =
config.tls().allow_insecure() || config.tls().server_name().empty()
? config.server_host() : config.tls().server_name();
status = api_->ConnectionStart(
opened_connection, configuration_, QUIC_ADDRESS_FAMILY_UNSPEC,
server_name.c_str(),
static_cast<std::uint16_t>(config.server_port()));
}
}
if (QUIC_FAILED(status)) {
setError(error, statusText("ConnectionOpen/Start", status));
closeConnectionHandles();
return false;
}
{
std::unique_lock lock(mutex_);
if (!condition_.wait_for(lock, timeout, [this]() {
return connected_ || shutdown_complete_;
}) || !connected_) {
const std::string message = connect_error_.empty()
? "MsQuic connect timed out" : connect_error_;
lock.unlock();
disconnect();
setError(error, message);
return false;
}
}
bool connection_available = false;
{
std::lock_guard operation_lock(api_operation_mutex_);
HQUIC connection = nullptr;
HQUIC opened_stream = nullptr;
{
std::lock_guard state_lock(mutex_);
connection_available = connected_ && connection_;
connection = connection_;
}
if (connection_available) {
status = api_->StreamOpen(
connection, QUIC_STREAM_OPEN_FLAG_NONE,
&MsQuicTransport::streamCallback, this, &opened_stream);
if (QUIC_SUCCEEDED(status)) {
{
std::lock_guard state_lock(mutex_);
control_stream_ = opened_stream;
control_stream_start_completed_ = false;
control_stream_started_ = false;
control_stream_shutdown_complete_ = false;
control_stream_receive_closed_ = false;
}
status = api_->StreamStart(
opened_stream, QUIC_STREAM_START_FLAG_IMMEDIATE);
}
}
}
if (!connection_available) {
setError(error, "QUIC connection closed before control stream setup");
disconnect();
return false;
}
if (QUIC_FAILED(status)) {
{
std::lock_guard lock(mutex_);
control_stream_start_completed_ = true;
control_stream_started_ = false;
}
setError(error, statusText("control StreamOpen/Start", status));
disconnect();
return false;
}
{
std::unique_lock lock(mutex_);
const bool completed = condition_.wait_for(lock, timeout, [this]() {
return control_stream_start_completed_ || !connected_;
});
if (!completed || !connected_ || !control_stream_started_) {
const std::string message = connect_error_.empty()
? "QUIC control stream start did not complete successfully"
: connect_error_;
lock.unlock();
disconnect();
setError(error, message);
return false;
}
}
return true;
}
void disconnect() override
{
HQUIC connection = nullptr;
HQUIC stream = nullptr;
bool stream_started = false;
{
// Resolve and use the handle while holding the API-operation lock.
// This prevents a concurrent closeConnectionHandles() call from
// closing the handle between the state read and ConnectionShutdown.
std::lock_guard operation_lock(api_operation_mutex_);
{
std::lock_guard lock(mutex_);
connection = connection_;
stream = control_stream_;
stream_started = control_stream_started_;
connected_ = false;
control_stream_receive_closed_ = true;
++control_receive_epoch_;
condition_.notify_all();
}
if (stream && stream_started && api_) {
const auto flags = static_cast<QUIC_STREAM_SHUTDOWN_FLAGS>(
QUIC_STREAM_SHUTDOWN_FLAG_ABORT_SEND |
QUIC_STREAM_SHUTDOWN_FLAG_ABORT_RECEIVE |
QUIC_STREAM_SHUTDOWN_FLAG_IMMEDIATE);
(void)api_->StreamShutdown(stream, flags, 0);
}
if (connection && api_) {
api_->ConnectionShutdown(
connection, QUIC_CONNECTION_SHUTDOWN_FLAG_NONE, 0);
}
}
if (stream && stream_started && api_) {
std::unique_lock lock(mutex_);
// A started stream handle may only be closed after MsQuic delivers
// SHUTDOWN_COMPLETE. IMMEDIATE schedules that completion without a
// graceful network wait, but it is not guaranteed to run inline.
condition_.wait(lock, [this, stream]() {
return control_stream_ != stream ||
control_stream_shutdown_complete_;
});
}
closeConnectionHandles();
}
bool isConnected() const override
{
std::lock_guard lock(mutex_);
return connected_;
}
std::size_t maximumDatagramBytes() const override
{
std::lock_guard lock(mutex_);
return maximum_datagram_bytes_;
}
std::size_t maximumDatagramBatchPackets() const override
{
// Static empty-queue limit. sendDatagramBatch performs the real-time
// admission check under mutex and may still return WOULD_BLOCK.
return send_queue_depth_ - reserved_control_sends_;
}
TransportSendResult sendControl(std::vector<std::uint8_t> message,
std::string* error) override
{
std::lock_guard operation_lock(api_operation_mutex_);
HQUIC stream = nullptr;
{
std::lock_guard lock(mutex_);
if (!connected_ || !control_stream_) return TransportSendResult::DISCONNECTED;
if (pending_send_count_ >= send_queue_depth_) {
return TransportSendResult::WOULD_BLOCK;
}
++pending_send_count_;
stream = control_stream_;
}
auto* context = new SendContext(this, std::move(message));
registerSend(context);
const QUIC_STATUS status = api_->StreamSend(
stream, &context->buffer, 1, QUIC_SEND_FLAG_NONE, context);
if (QUIC_FAILED(status)) {
finishSend(context);
setError(error, statusText("StreamSend", status));
return TransportSendResult::ERROR;
}
return TransportSendResult::QUEUED;
}
TransportReceiveResult receiveControl(
std::vector<std::uint8_t>* chunk,
const std::chrono::milliseconds timeout,
std::string* error) override
{
if (!chunk) {
setError(error, "control receive output must not be null");
return TransportReceiveResult::ERROR;
}
chunk->clear();
if (error) error->clear();
if (timeout.count() < 0) {
setError(error, "control receive timeout must not be negative");
return TransportReceiveResult::ERROR;
}
std::unique_lock lock(mutex_);
const std::uint64_t receive_epoch = control_receive_epoch_;
const auto ready = [this, receive_epoch]() {
return !control_receive_error_.empty() ||
!control_receive_queue_.empty() ||
control_stream_receive_closed_ || !connected_ ||
control_receive_epoch_ != receive_epoch;
};
if (!ready()) {
bool signaled = false;
if (timeout == std::chrono::milliseconds::max()) {
condition_.wait(lock, ready);
signaled = true;
} else {
signaled = condition_.wait_for(lock, timeout, ready);
}
if (!signaled) return TransportReceiveResult::TIMEOUT;
}
// A disconnect/reconnect boundary invalidates the old stream decoder
// even if a new connection became ready before this waiter ran.
if (control_receive_epoch_ != receive_epoch) {
setError(error, "QUIC control stream session changed");
return TransportReceiveResult::DISCONNECTED;
}
if (!control_receive_error_.empty()) {
setError(error, control_receive_error_);
return TransportReceiveResult::ERROR;
}
// Preserve bytes already delivered by MsQuic even when shutdown raced
// with the consumer; report DISCONNECTED only after draining them.
if (!control_receive_queue_.empty()) {
*chunk = std::move(control_receive_queue_.front());
control_receive_queue_.pop_front();
control_receive_queue_bytes_ -= chunk->size();
return TransportReceiveResult::DATA;
}
setError(error, connect_error_.empty()
? "QUIC control stream is disconnected" : connect_error_);
return TransportReceiveResult::DISCONNECTED;
}
TransportSendResult sendDatagramBatch(
std::vector<DatagramPacket> packets,
std::string* error) override
{
std::lock_guard operation_lock(api_operation_mutex_);
if (packets.empty()) {
setError(error, "empty MsQuic DATAGRAM batch");
return TransportSendResult::ERROR;
}
HQUIC connection = nullptr;
{
std::lock_guard lock(mutex_);
if (!connected_ || !connection_) return TransportSendResult::DISCONNECTED;
if (!datagram_send_enabled_) {
setError(error, "peer/path disabled QUIC DATAGRAM sending");
return TransportSendResult::ERROR;
}
for (const auto& packet : packets) {
if (packet.bytes.empty() ||
packet.bytes.size() > maximum_datagram_bytes_) {
setError(error,
"QUIC DATAGRAM exceeds the current path limit");
return TransportSendResult::ERROR;
}
}
const std::size_t datagram_capacity =
send_queue_depth_ - reserved_control_sends_;
if (pending_send_count_ >= datagram_capacity ||
packets.size() > datagram_capacity - pending_send_count_) {
return TransportSendResult::WOULD_BLOCK;
}
pending_send_count_ += packets.size();
connection = connection_;
}
std::size_t submitted = 0;
for (auto& packet : packets) {
auto* context = new SendContext(this, std::move(packet.bytes));
registerSend(context);
const QUIC_STATUS status = api_->DatagramSend(
connection, &context->buffer, 1, QUIC_SEND_FLAG_NONE, context);
if (QUIC_FAILED(status)) {
finishSend(context);
releaseReservedSends(packets.size() - submitted - 1U);
setError(error, statusText("DatagramSend", status));
// A submission error after earlier fragments is terminal. The
// reconnect/session epoch prevents receivers from combining a
// partial old frame with new traffic.
api_->ConnectionShutdown(connection,
QUIC_CONNECTION_SHUTDOWN_FLAG_SILENT, 1);
return TransportSendResult::ERROR;
}
++submitted;
}
return TransportSendResult::QUEUED;
}
private:
struct SendContext {
SendContext(MsQuicTransport* value_owner, std::vector<std::uint8_t> value)
: owner(value_owner), bytes(std::move(value))
{
buffer.Buffer = bytes.data();
buffer.Length = static_cast<std::uint32_t>(bytes.size());
}
MsQuicTransport* owner;
std::vector<std::uint8_t> bytes;
QUIC_BUFFER buffer{};
};
static QUIC_STATUS QUIC_API connectionCallback(
HQUIC connection, void* context, QUIC_CONNECTION_EVENT* event)
{
auto* self = static_cast<MsQuicTransport*>(context);
switch (event->Type) {
case QUIC_CONNECTION_EVENT_CONNECTED: {
std::lock_guard lock(self->mutex_);
if (connection != self->connection_) break;
self->connected_ = true;
self->condition_.notify_all();
break;
}
case QUIC_CONNECTION_EVENT_SHUTDOWN_INITIATED_BY_TRANSPORT: {
std::lock_guard lock(self->mutex_);
if (connection != self->connection_) break;
self->connected_ = false;
self->control_stream_receive_closed_ = true;
self->connect_error_ = statusText(
"peer/transport shutdown",
event->SHUTDOWN_INITIATED_BY_TRANSPORT.Status);
self->condition_.notify_all();
break;
}
case QUIC_CONNECTION_EVENT_SHUTDOWN_INITIATED_BY_PEER: {
std::lock_guard lock(self->mutex_);
if (connection != self->connection_) break;
self->connected_ = false;
self->control_stream_receive_closed_ = true;
self->connect_error_ = "peer closed the QUIC connection";
self->condition_.notify_all();
break;
}
case QUIC_CONNECTION_EVENT_SHUTDOWN_COMPLETE: {
std::lock_guard lock(self->mutex_);
if (connection != self->connection_) break;
self->connected_ = false;
self->control_stream_receive_closed_ = true;
self->shutdown_complete_ = true;
self->condition_.notify_all();
break;
}
case QUIC_CONNECTION_EVENT_DATAGRAM_SEND_STATE_CHANGED:
switch (event->DATAGRAM_SEND_STATE_CHANGED.State) {
case QUIC_DATAGRAM_SEND_LOST_DISCARDED:
case QUIC_DATAGRAM_SEND_ACKNOWLEDGED:
case QUIC_DATAGRAM_SEND_ACKNOWLEDGED_SPURIOUS:
case QUIC_DATAGRAM_SEND_CANCELED:
self->finishSend(static_cast<SendContext*>(
event->DATAGRAM_SEND_STATE_CHANGED.ClientContext));
break;
default:
break;
}
break;
case QUIC_CONNECTION_EVENT_DATAGRAM_STATE_CHANGED: {
std::lock_guard lock(self->mutex_);
if (connection != self->connection_) break;
self->datagram_send_enabled_ =
event->DATAGRAM_STATE_CHANGED.SendEnabled != FALSE;
self->maximum_datagram_bytes_ = self->datagram_send_enabled_
? event->DATAGRAM_STATE_CHANGED.MaxSendLength : 0U;
self->condition_.notify_all();
break;
}
default:
break;
}
return QUIC_STATUS_SUCCESS;
}
static QUIC_STATUS QUIC_API streamCallback(
HQUIC stream, void* context, QUIC_STREAM_EVENT* event)
{
auto* self = static_cast<MsQuicTransport*>(context);
switch (event->Type) {
case QUIC_STREAM_EVENT_START_COMPLETE: {
std::lock_guard lock(self->mutex_);
if (stream != self->control_stream_) break;
self->control_stream_start_completed_ = true;
self->control_stream_started_ =
QUIC_SUCCEEDED(event->START_COMPLETE.Status);
if (!self->control_stream_started_) {
self->control_stream_receive_closed_ = true;
self->connect_error_ = statusText(
"control stream start", event->START_COMPLETE.Status);
}
self->condition_.notify_all();
break;
}
case QUIC_STREAM_EVENT_RECEIVE:
return self->handleControlReceive(stream, event);
case QUIC_STREAM_EVENT_SEND_COMPLETE:
if (event->SEND_COMPLETE.Canceled != FALSE) {
std::lock_guard lock(self->mutex_);
if (stream == self->control_stream_ && self->connected_) {
if (self->connect_error_.empty()) {
self->connect_error_ =
"QUIC control stream send was canceled";
}
self->control_stream_receive_closed_ = true;
self->connected_ = false;
self->condition_.notify_all();
}
}
self->finishSend(static_cast<SendContext*>(
event->SEND_COMPLETE.ClientContext));
break;
case QUIC_STREAM_EVENT_PEER_RECEIVE_ABORTED: {
std::lock_guard lock(self->mutex_);
if (stream != self->control_stream_ || !self->connected_) break;
if (self->connect_error_.empty()) {
self->connect_error_ =
"peer aborted the QUIC control stream receive direction";
}
self->control_stream_receive_closed_ = true;
self->connected_ = false;
self->condition_.notify_all();
break;
}
case QUIC_STREAM_EVENT_PEER_SEND_SHUTDOWN:
case QUIC_STREAM_EVENT_PEER_SEND_ABORTED: {
std::lock_guard lock(self->mutex_);
if (stream != self->control_stream_) break;
self->control_stream_receive_closed_ = true;
self->condition_.notify_all();
break;
}
case QUIC_STREAM_EVENT_SHUTDOWN_COMPLETE: {
std::lock_guard lock(self->mutex_);
if (stream != self->control_stream_) break;
self->control_stream_shutdown_complete_ = true;
self->control_stream_receive_closed_ = true;
self->condition_.notify_all();
break;
}
default:
break;
}
return QUIC_STATUS_SUCCESS;
}
QUIC_STATUS handleControlReceive(
HQUIC stream, const QUIC_STREAM_EVENT* event)
{
if (event->RECEIVE.BufferCount != 0U && !event->RECEIVE.Buffers) {
return failControlReceive(
stream, "MsQuic supplied a null control receive buffer",
QUIC_STATUS_INVALID_PARAMETER);
}
std::size_t received_bytes = 0U;
for (std::uint32_t index = 0U;
index < event->RECEIVE.BufferCount; ++index) {
const QUIC_BUFFER& buffer = event->RECEIVE.Buffers[index];
if (buffer.Length != 0U && !buffer.Buffer) {
return failControlReceive(
stream, "MsQuic supplied a null control receive payload",
QUIC_STATUS_INVALID_PARAMETER);
}
if (buffer.Length >
std::numeric_limits<std::size_t>::max() - received_bytes) {
return failControlReceive(
stream, "QUIC control receive byte count overflow",
QUIC_STATUS_BUFFER_TOO_SMALL);
}
received_bytes += buffer.Length;
}
if (received_bytes == 0U) return QUIC_STATUS_SUCCESS;
std::size_t queue_limit = 0U;
{
std::lock_guard lock(mutex_);
if (stream != control_stream_) return QUIC_STATUS_SUCCESS;
queue_limit = maximum_control_receive_queue_bytes_;
}
if (received_bytes > queue_limit) {
return failControlReceive(
stream, "QUIC control receive chunk exceeds the bounded queue",
QUIC_STATUS_BUFFER_TOO_SMALL);
}
std::vector<std::uint8_t> chunk;
try {
chunk.resize(received_bytes);
std::size_t offset = 0U;
for (std::uint32_t index = 0U;
index < event->RECEIVE.BufferCount; ++index) {
const QUIC_BUFFER& buffer = event->RECEIVE.Buffers[index];
std::copy_n(buffer.Buffer, buffer.Length,
chunk.begin() + static_cast<std::ptrdiff_t>(offset));
offset += buffer.Length;
}
} catch (...) {
return failControlReceive(
stream, "unable to allocate QUIC control receive storage",
QUIC_STATUS_OUT_OF_MEMORY);
}
{
std::lock_guard lock(mutex_);
if (stream != control_stream_ ||
control_stream_receive_closed_ || !connected_) {
return QUIC_STATUS_SUCCESS;
}
if (control_receive_queue_.size() >=
kMaximumControlReceiveQueueChunks ||
control_receive_queue_bytes_ >
maximum_control_receive_queue_bytes_ - received_bytes) {
control_receive_error_ =
"QUIC control receive queue capacity exceeded";
control_stream_receive_closed_ = true;
connected_ = false;
condition_.notify_all();
return QUIC_STATUS_BUFFER_TOO_SMALL;
}
try {
control_receive_queue_.push_back(std::move(chunk));
} catch (...) {
control_receive_error_ =
"unable to enqueue QUIC control receive bytes";
control_stream_receive_closed_ = true;
connected_ = false;
condition_.notify_all();
return QUIC_STATUS_OUT_OF_MEMORY;
}
control_receive_queue_bytes_ += received_bytes;
condition_.notify_all();
}
// Returning success consumes every buffer synchronously. Their bytes
// are now owned by control_receive_queue_.
return QUIC_STATUS_SUCCESS;
}
QUIC_STATUS failControlReceive(
HQUIC stream, const char* message, const QUIC_STATUS status)
{
std::lock_guard lock(mutex_);
if (stream != control_stream_) return QUIC_STATUS_SUCCESS;
control_receive_error_ = message;
control_stream_receive_closed_ = true;
connected_ = false;
condition_.notify_all();
return status;
}
bool openApi(std::string* error)
{
if (api_) return true;
QUIC_STATUS status = MsQuicOpen2(&api_);
if (QUIC_FAILED(status)) {
setError(error, statusText("MsQuicOpen2", status));
return false;
}
const QUIC_REGISTRATION_CONFIG registration_config = {
"cmvr-es-quic-edge", QUIC_EXECUTION_PROFILE_LOW_LATENCY};
status = api_->RegistrationOpen(&registration_config, &registration_);
if (QUIC_FAILED(status)) {
setError(error, statusText("RegistrationOpen", status));
MsQuicClose(api_);
api_ = nullptr;
return false;
}
return true;
}
bool openConfiguration(const config::QuicEdgeConfig& config,
std::string* error)
{
QUIC_BUFFER alpn{};
alpn.Buffer = reinterpret_cast<std::uint8_t*>(
const_cast<char*>(config.alpn().data()));
alpn.Length = static_cast<std::uint32_t>(config.alpn().size());
QUIC_SETTINGS settings{};
settings.IsSet.DatagramReceiveEnabled = TRUE;
settings.DatagramReceiveEnabled = TRUE;
settings.IsSet.IdleTimeoutMs = TRUE;
const std::uint64_t heartbeat_safe_idle_timeout =
kMaximumHeartbeatIntervalMs +
2ULL * config.control_response_timeout_ms() + 60000ULL;
settings.IdleTimeoutMs = std::max<std::uint64_t>(
heartbeat_safe_idle_timeout,
std::max<std::uint64_t>(
30000ULL, config.reconnect().connect_timeout_ms() * 3ULL));
QUIC_STATUS status = api_->ConfigurationOpen(
registration_, &alpn, 1, &settings, sizeof(settings),
nullptr, &configuration_);
if (QUIC_FAILED(status)) {
setError(error, statusText("ConfigurationOpen", status));
return false;
}
QUIC_CREDENTIAL_CONFIG credential{};
credential.Flags = QUIC_CREDENTIAL_FLAG_CLIENT;
if (config.tls().allow_insecure()) {
credential.Flags = static_cast<QUIC_CREDENTIAL_FLAGS>(
credential.Flags | QUIC_CREDENTIAL_FLAG_NO_CERTIFICATE_VALIDATION);
} else {
credential.Flags = static_cast<QUIC_CREDENTIAL_FLAGS>(
credential.Flags | QUIC_CREDENTIAL_FLAG_SET_CA_CERTIFICATE_FILE);
credential.CaCertificateFile = config.tls().ca_file().c_str();
}
QUIC_CERTIFICATE_FILE certificate{};
if (!config.tls().certificate_file().empty()) {
certificate.CertificateFile = config.tls().certificate_file().c_str();
certificate.PrivateKeyFile = config.tls().private_key_file().c_str();
credential.Type = QUIC_CREDENTIAL_TYPE_CERTIFICATE_FILE;
credential.CertificateFile = &certificate;
} else {
credential.Type = QUIC_CREDENTIAL_TYPE_NONE;
}
status = api_->ConfigurationLoadCredential(configuration_, &credential);
if (QUIC_FAILED(status)) {
setError(error, statusText("ConfigurationLoadCredential", status));
api_->ConfigurationClose(configuration_);
configuration_ = nullptr;
return false;
}
return true;
}
void closeConnectionHandles()
{
std::lock_guard operation_lock(api_operation_mutex_);
HQUIC stream = nullptr;
HQUIC connection = nullptr;
HQUIC configuration = nullptr;
{
std::lock_guard lock(mutex_);
stream = std::exchange(control_stream_, nullptr);
connection = std::exchange(connection_, nullptr);
configuration = std::exchange(configuration_, nullptr);
connected_ = false;
datagram_send_enabled_ = false;
maximum_datagram_bytes_ = 0U;
control_stream_start_completed_ = false;
control_stream_started_ = false;
control_stream_shutdown_complete_ = false;
control_stream_receive_closed_ = true;
++control_receive_epoch_;
condition_.notify_all();
}
if (!api_) return;
if (stream) api_->StreamClose(stream);
if (connection) api_->ConnectionClose(connection);
if (configuration) api_->ConfigurationClose(configuration);
std::vector<SendContext*> abandoned_sends;
{
// ConnectionClose/StreamClose are the final handle calls. Once
// they return, no send callback may still reference these
// contexts, so any missing completion can be reclaimed safely.
std::lock_guard lock(mutex_);
abandoned_sends.assign(outstanding_sends_.begin(),
outstanding_sends_.end());
outstanding_sends_.clear();
pending_send_count_ = 0U;
}
for (auto* context : abandoned_sends) delete context;
}
void finishSend(SendContext* context)
{
if (!context) return;
bool owned = false;
{
std::lock_guard lock(mutex_);
owned = outstanding_sends_.erase(context) != 0U;
if (owned && pending_send_count_ != 0U) --pending_send_count_;
condition_.notify_all();
}
if (owned) delete context;
}
void registerSend(SendContext* context)
{
std::lock_guard lock(mutex_);
outstanding_sends_.insert(context);
}
void releaseReservedSends(const std::size_t count)
{
std::lock_guard lock(mutex_);
pending_send_count_ = count > pending_send_count_
? 0U : pending_send_count_ - count;
condition_.notify_all();
}
static void setError(std::string* error, const std::string& message)
{
if (error) *error = message;
}
const std::size_t send_queue_depth_;
const std::size_t reserved_control_sends_;
const QUIC_API_TABLE* api_{nullptr};
HQUIC registration_{nullptr};
HQUIC configuration_{nullptr};
HQUIC connection_{nullptr};
HQUIC control_stream_{nullptr};
mutable std::mutex api_operation_mutex_;
mutable std::mutex mutex_;
std::condition_variable condition_;
bool connected_{false};
bool shutdown_complete_{false};
bool datagram_send_enabled_{false};
bool control_stream_start_completed_{false};
bool control_stream_started_{false};
bool control_stream_shutdown_complete_{false};
bool control_stream_receive_closed_{true};
std::size_t maximum_datagram_bytes_{0};
std::size_t maximum_control_receive_queue_bytes_{
kMinimumControlReceiveQueueBytes};
std::size_t control_receive_queue_bytes_{0};
std::uint64_t control_receive_epoch_{0};
std::size_t pending_send_count_{0};
std::deque<std::vector<std::uint8_t>> control_receive_queue_;
std::unordered_set<SendContext*> outstanding_sends_;
std::string control_receive_error_;
std::string connect_error_;
};
} // namespace
std::unique_ptr<QuicTransport> createMsQuicTransport(
const std::size_t send_queue_depth)
{
return std::make_unique<MsQuicTransport>(send_queue_depth);
}
} // namespace cmvr::quic_edge
#endif // CMVR_HAS_MSQUIC

View File

@ -0,0 +1,67 @@
#include "service/quic_edge/include/quic_edge_service.h"
#include <utility>
#include "manager/device_manager/include/device_manager.h"
#include "manager/media_source_hub/include/device_media_source_adapter.h"
namespace cmvr::quic_edge {
QuicEdgeService::QuicEdgeService(config::QuicEdgeConfig config)
: config_(std::move(config)),
transport_(createDefaultQuicTransport(config_.datagram_send_queue_depth())),
media_hub_(&media::globalMediaSourceHub()),
using_default_transport_(true),
using_global_media_hub_(true)
{
initializeIdentity();
source_registrar_ = [this](
const config::QuicEdgeTrackConfig& track,
const std::string& source_track_id,
std::string* error) {
auto& manager = device::DeviceManager::getInstance();
bool registered = false;
switch (track.source_kind()) {
case config::QuicEdgeTrackConfig::SOURCE_KIND_CAMERA: {
auto camera =
manager.getDevice<device::AbstractCamera>(track.device_id());
if (!camera) {
if (error) {
*error = "camera unavailable for QUIC edge: " +
track.device_id();
}
return false;
}
registered = media::ensureCameraMediaSource(*media_hub_, camera);
break;
}
case config::QuicEdgeTrackConfig::SOURCE_KIND_MICROPHONE: {
auto microphone = manager.getDevice<device::AbstractMicrophone>(
track.device_id());
if (!microphone) {
if (error) {
*error = "microphone unavailable for QUIC edge: " +
track.device_id();
}
return false;
}
registered = media::ensureMicrophoneMediaSource(
*media_hub_, microphone);
break;
}
case config::QuicEdgeTrackConfig::SOURCE_KIND_UNSPECIFIED:
if (error) *error = "unsupported QUIC edge source kind";
return false;
}
if (!registered && !media_hub_->hasSource(source_track_id)) {
if (error) {
*error = "failed to register MediaSourceHub source: " +
source_track_id;
}
return false;
}
return true;
};
}
} // namespace cmvr::quic_edge

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,40 @@
#include "service/quic_edge/include/quic_edge_types.h"
namespace cmvr::quic_edge {
std::uint32_t descriptorGenerationToken(const std::uint64_t generation)
{
// Fold both halves because the shared device adapter embeds its stream
// epoch in the upper 32 bits and its codec generation in the lower half.
std::uint32_t token = static_cast<std::uint32_t>(generation) ^
static_cast<std::uint32_t>(generation >> 32U);
return token == 0U ? 1U : token;
}
const char* codecName(const media::Codec codec)
{
switch (codec) {
case media::Codec::H264: return "h264";
case media::Codec::H265: return "h265";
case media::Codec::OPUS: return "opus";
case media::Codec::PCM_S16LE: return "pcm_s16le";
case media::Codec::AAC: return "aac";
case media::Codec::UNKNOWN: return "unknown";
}
return "unknown";
}
const char* payloadFormatName(const media::PayloadFormat format)
{
switch (format) {
case media::PayloadFormat::ANNEX_B: return "annex_b";
case media::PayloadFormat::AVCC: return "avcc";
case media::PayloadFormat::RAW: return "raw";
case media::PayloadFormat::OPUS_PACKET: return "opus_packet";
case media::PayloadFormat::AAC_ADTS: return "aac_adts";
case media::PayloadFormat::UNKNOWN: return "unknown";
}
return "unknown";
}
} // namespace cmvr::quic_edge

View File

@ -0,0 +1,103 @@
#include "service/quic_edge/include/quic_transport.h"
#include <utility>
namespace cmvr::quic_edge {
#ifdef CMVR_HAS_MSQUIC
std::unique_ptr<QuicTransport> createMsQuicTransport(
std::size_t send_queue_depth);
#endif
TransportReceiveResult QuicTransport::receiveControl(
std::vector<std::uint8_t>* chunk,
std::chrono::milliseconds,
std::string* error)
{
if (!chunk) {
if (error) *error = "control receive output must not be null";
return TransportReceiveResult::ERROR;
}
chunk->clear();
if (error) {
*error = "control stream receive is not supported by this transport";
}
return TransportReceiveResult::DISCONNECTED;
}
namespace {
class UnavailableQuicTransport final : public QuicTransport {
public:
bool connect(const config::QuicEdgeConfig&,
std::chrono::milliseconds,
std::string* error) override
{
if (error) {
*error = "MsQuic support was not compiled (CMVR_HAS_MSQUIC is unset)";
}
return false;
}
void disconnect() override {}
bool isConnected() const override { return false; }
std::size_t maximumDatagramBytes() const override { return 0U; }
std::size_t maximumDatagramBatchPackets() const override { return 0U; }
TransportSendResult sendControl(std::vector<std::uint8_t>,
std::string* error) override
{
if (error) {
*error = "MsQuic support was not compiled";
}
return TransportSendResult::DISCONNECTED;
}
TransportReceiveResult receiveControl(
std::vector<std::uint8_t>* chunk,
std::chrono::milliseconds,
std::string* error) override
{
if (!chunk) {
if (error) *error = "control receive output must not be null";
return TransportReceiveResult::ERROR;
}
chunk->clear();
if (error) {
*error = "MsQuic support was not compiled";
}
return TransportReceiveResult::DISCONNECTED;
}
TransportSendResult sendDatagramBatch(std::vector<DatagramPacket>,
std::string* error) override
{
if (error) {
*error = "MsQuic support was not compiled";
}
return TransportSendResult::DISCONNECTED;
}
};
} // namespace
bool hasCompiledMsQuicSupport()
{
#ifdef CMVR_HAS_MSQUIC
return true;
#else
return false;
#endif
}
std::unique_ptr<QuicTransport> createDefaultQuicTransport(
const std::size_t send_queue_depth)
{
#ifdef CMVR_HAS_MSQUIC
return createMsQuicTransport(send_queue_depth);
#else
(void)send_queue_depth;
return std::make_unique<UnavailableQuicTransport>();
#endif
}
} // namespace cmvr::quic_edge

View File

@ -0,0 +1,694 @@
#include <atomic>
#include <chrono>
#include <condition_variable>
#include <cstdint>
#include <deque>
#include <functional>
#include <future>
#include <iostream>
#include <mutex>
#include <string>
#include <thread>
#include <utility>
#include <vector>
#include "cmvr/quic_edge/v1/quic_edge.pb.h"
#include "manager/media_source_hub/include/media_source_hub.h"
#include "service/quic_edge/include/control_framing.h"
#include "service/quic_edge/include/datagram_packetizer.h"
#include "service/quic_edge/include/quic_edge_service.h"
namespace {
#define CHECK_TRUE(expression) \
do { \
if (!(expression)) { \
std::cerr << "CHECK failed at line " << __LINE__ << ": " \
<< #expression << '\n'; \
return false; \
} \
} while (false)
using namespace cmvr;
class FakeTransport final : public quic_edge::QuicTransport {
public:
explicit FakeTransport(const bool accept_registration = true,
const bool acknowledge_heartbeats = true,
const bool valid_heartbeat_session = true,
const std::size_t media_session_would_block_count = 0U)
: accept_registration_(accept_registration),
acknowledge_heartbeats_(acknowledge_heartbeats),
valid_heartbeat_session_(valid_heartbeat_session),
media_session_would_block_count_(media_session_would_block_count)
{
}
bool connect(const config::QuicEdgeConfig&,
std::chrono::milliseconds,
std::string*) override
{
connected_.store(true);
++connect_count_;
{
std::lock_guard lock(mutex_);
connect_times_.push_back(std::chrono::steady_clock::now());
}
condition_.notify_all();
return true;
}
void disconnect() override
{
connected_.store(false);
condition_.notify_all();
}
bool isConnected() const override { return connected_.load(); }
std::size_t maximumDatagramBytes() const override { return 1200U; }
std::size_t maximumDatagramBatchPackets() const override { return 32U; }
quic_edge::TransportSendResult sendControl(
std::vector<std::uint8_t> message, std::string*) override
{
if (!connected_.load()) return quic_edge::TransportSendResult::DISCONNECTED;
std::vector<std::vector<std::uint8_t>> frames;
std::string decode_error;
quic_edge::ControlFrameDecoder decoder(1024U * 1024U);
if (!decoder.push(message, &frames, &decode_error) || frames.size() != 1U) {
return quic_edge::TransportSendResult::ERROR;
}
cmvr::quic_edge::v1::EdgeControlEnvelope envelope;
if (!envelope.ParseFromArray(frames.front().data(),
static_cast<int>(frames.front().size()))) {
return quic_edge::TransportSendResult::ERROR;
}
{
std::lock_guard lock(mutex_);
if (envelope.has_media_session_open() &&
media_session_would_block_count_ != 0U) {
--media_session_would_block_count_;
return quic_edge::TransportSendResult::WOULD_BLOCK;
}
edge_message_sequences_.push_back(envelope.message_sequence());
controls_.push_back(std::move(message));
if (envelope.has_node_register_request()) {
const auto& request = envelope.node_register_request();
last_registered_node_id_ = request.node().node_id();
last_grpc_endpoint_port_ = request.node().grpc_endpoint().port();
last_interface_count_ = request.node().local_interfaces_size();
cmvr::quic_edge::v1::EdgeControlEnvelope response;
response.set_protocol_version(quic_edge::kProtocolVersion);
response.set_message_sequence(server_message_sequence_++);
auto* registration = response.mutable_node_register_response();
registration->set_accepted(accept_registration_);
registration->set_session_id(
accept_registration_ ? "test-session" : "");
registration->set_message(
accept_registration_ ? "accepted" : "rejected for test");
registration->set_heartbeat_interval_ms(250U);
registration->set_observed_source_ip("203.0.113.10");
enqueueEnvelopeLocked(response);
} else if (envelope.has_node_heartbeat() && acknowledge_heartbeats_) {
const auto& heartbeat = envelope.node_heartbeat();
cmvr::quic_edge::v1::EdgeControlEnvelope response;
response.set_protocol_version(quic_edge::kProtocolVersion);
response.set_message_sequence(server_message_sequence_++);
auto* ack = response.mutable_node_heartbeat_ack();
ack->set_accepted(true);
ack->set_acknowledged_sequence(heartbeat.sequence());
ack->set_session_id(valid_heartbeat_session_
? heartbeat.session_id() : "");
ack->set_observed_source_ip("203.0.113.11");
enqueueEnvelopeLocked(response);
}
}
condition_.notify_all();
return quic_edge::TransportSendResult::QUEUED;
}
quic_edge::TransportReceiveResult receiveControl(
std::vector<std::uint8_t>* chunk,
const std::chrono::milliseconds timeout,
std::string*) override
{
if (!chunk) return quic_edge::TransportReceiveResult::ERROR;
std::unique_lock lock(mutex_);
if (control_receive_queue_.empty() && connected_.load()) {
condition_.wait_for(lock, timeout, [this]() {
return !control_receive_queue_.empty() || !connected_.load();
});
}
if (!control_receive_queue_.empty()) {
*chunk = std::move(control_receive_queue_.front());
control_receive_queue_.pop_front();
return quic_edge::TransportReceiveResult::DATA;
}
return connected_.load()
? quic_edge::TransportReceiveResult::TIMEOUT
: quic_edge::TransportReceiveResult::DISCONNECTED;
}
quic_edge::TransportSendResult sendDatagramBatch(
std::vector<quic_edge::DatagramPacket> packets, std::string*) override
{
if (!connected_.load()) return quic_edge::TransportSendResult::DISCONNECTED;
std::lock_guard lock(mutex_);
datagrams_.push_back(std::move(packets));
return quic_edge::TransportSendResult::QUEUED;
}
std::size_t datagramBatchCount() const
{
std::lock_guard lock(mutex_);
return datagrams_.size();
}
std::size_t controlCount() const
{
std::lock_guard lock(mutex_);
return controls_.size();
}
bool controlSequencesStrictlyIncreasing() const
{
std::lock_guard lock(mutex_);
for (std::size_t index = 1U;
index < edge_message_sequences_.size(); ++index) {
if (edge_message_sequences_[index] <=
edge_message_sequences_[index - 1U]) {
return false;
}
}
return !edge_message_sequences_.empty();
}
std::vector<std::uint64_t> datagramFrameSequences() const
{
std::lock_guard lock(mutex_);
std::vector<std::uint64_t> sequences;
for (const auto& batch : datagrams_) {
if (!batch.empty()) sequences.push_back(batch.front().header.frame_sequence);
}
return sequences;
}
std::uint64_t connectCount() const { return connect_count_.load(); }
std::vector<std::chrono::milliseconds> connectIntervals() const
{
std::lock_guard lock(mutex_);
std::vector<std::chrono::milliseconds> intervals;
for (std::size_t index = 1U; index < connect_times_.size(); ++index) {
intervals.push_back(std::chrono::duration_cast<std::chrono::milliseconds>(
connect_times_[index] - connect_times_[index - 1U]));
}
return intervals;
}
std::string lastRegisteredNodeId() const
{
std::lock_guard lock(mutex_);
return last_registered_node_id_;
}
std::uint32_t lastGrpcEndpointPort() const
{
std::lock_guard lock(mutex_);
return last_grpc_endpoint_port_;
}
int lastInterfaceCount() const
{
std::lock_guard lock(mutex_);
return last_interface_count_;
}
private:
void enqueueEnvelopeLocked(
const cmvr::quic_edge::v1::EdgeControlEnvelope& envelope)
{
std::string serialized;
if (!envelope.SerializeToString(&serialized)) return;
std::vector<std::uint8_t> framed;
std::string error;
if (quic_edge::ControlFrameEncoder::encode(
reinterpret_cast<const std::uint8_t*>(serialized.data()),
serialized.size(), 1024U * 1024U, &framed, &error)) {
const auto split = framed.size() / 2U;
control_receive_queue_.emplace_back(
framed.begin(), framed.begin() + static_cast<std::ptrdiff_t>(split));
control_receive_queue_.emplace_back(
framed.begin() + static_cast<std::ptrdiff_t>(split), framed.end());
}
}
mutable std::mutex mutex_;
std::condition_variable condition_;
std::atomic<bool> connected_{false};
std::atomic<std::uint64_t> connect_count_{0};
bool accept_registration_{true};
bool acknowledge_heartbeats_{true};
bool valid_heartbeat_session_{true};
std::size_t media_session_would_block_count_{0U};
std::uint64_t server_message_sequence_{0};
std::string last_registered_node_id_;
std::uint32_t last_grpc_endpoint_port_{0};
int last_interface_count_{0};
std::vector<std::vector<std::uint8_t>> controls_;
std::deque<std::vector<std::uint8_t>> control_receive_queue_;
std::vector<std::vector<quic_edge::DatagramPacket>> datagrams_;
std::vector<std::uint64_t> edge_message_sequences_;
std::vector<std::chrono::steady_clock::time_point> connect_times_;
};
config::QuicEdgeConfig validConfig(const std::string& source_track_id)
{
config::QuicEdgeConfig config;
config.set_id("quic-test");
config.set_enable(true);
config.set_server_host("127.0.0.1");
config.set_server_port(4433);
config.set_alpn("cmvr-quic-edge/1");
config.set_node_id("test-node");
config.set_software_version("test-version");
config.set_grpc_endpoint_host("auto");
config.set_grpc_endpoint_port(50052U);
config.set_grpc_endpoint_tls(false);
config.set_heartbeat_interval_ms(250U);
config.set_control_response_timeout_ms(100U);
config.set_include_loopback_interfaces(true);
config.mutable_tls()->set_allow_insecure(true);
config.mutable_reconnect()->set_initial_delay_ms(5);
config.mutable_reconnect()->set_maximum_delay_ms(20);
config.mutable_reconnect()->set_multiplier(2.0);
config.mutable_reconnect()->set_connect_timeout_ms(50);
config.set_maximum_datagram_bytes(1200);
config.set_maximum_control_frame_bytes(4096);
config.set_maximum_frame_bytes(32 * 1024);
config.set_datagram_send_queue_depth(32);
config.set_media_poll_interval_ms(1);
auto* track = config.add_tracks();
track->set_track_id(7);
track->set_source_kind(config::QuicEdgeTrackConfig::SOURCE_KIND_CAMERA);
track->set_device_id("camera-test");
track->set_source_track_id(source_track_id);
track->set_enable(true);
return config;
}
config::QuicEdgeConfig validPresenceOnlyConfig()
{
auto config = validConfig("unused/video/color");
config.clear_tracks();
return config;
}
media::TrackDescriptorPtr videoDescriptor(const std::string& id)
{
media::TrackDescriptor::Config config;
config.id = id;
config.source_id = "camera-test";
config.kind = media::MediaKind::VIDEO;
config.codec = media::Codec::H264;
config.payload_format = media::PayloadFormat::ANNEX_B;
config.time_base = {1, 90000};
config.width = 640;
config.height = 480;
config.nominal_rate = 30;
config.generation = 0x0000000200000003ULL;
return media::makeTrackDescriptor(std::move(config));
}
bool waitUntil(const std::function<bool()>& predicate)
{
const auto deadline = std::chrono::steady_clock::now() +
std::chrono::seconds(2);
while (std::chrono::steady_clock::now() < deadline) {
if (predicate()) return true;
std::this_thread::sleep_for(std::chrono::milliseconds(2));
}
return predicate();
}
bool testControlFraming()
{
const std::vector<std::uint8_t> payload{1, 2, 3, 4, 5};
std::vector<std::uint8_t> encoded;
std::string error;
CHECK_TRUE(quic_edge::ControlFrameEncoder::encode(
payload, 64, &encoded, &error));
quic_edge::ControlFrameDecoder decoder(64);
std::vector<std::vector<std::uint8_t>> decoded;
CHECK_TRUE(decoder.push(encoded.data(), 2, &decoded, &error));
CHECK_TRUE(decoded.empty());
CHECK_TRUE(decoder.push(encoded.data() + 2, encoded.size() - 2,
&decoded, &error));
CHECK_TRUE(decoded.size() == 1 && decoded.front() == payload);
return true;
}
bool testPacketizer()
{
auto descriptor = videoDescriptor("camera-test/video/color");
media::MediaFrame::Config frame_config;
frame_config.descriptor = descriptor;
frame_config.payload.resize(2500, 0x5a);
frame_config.sequence = 11;
frame_config.capture_time_ns = 1234567000ULL;
frame_config.key_frame = true;
auto frame = media::makeMediaFrame(std::move(frame_config));
quic_edge::DatagramPacketizer packetizer(100);
std::vector<quic_edge::DatagramPacket> packets;
std::string error;
const std::uint32_t token =
quic_edge::descriptorGenerationToken(descriptor->generation);
CHECK_TRUE(packetizer.packetize(*frame, 7, token, 99, 11, true, 1200,
&packets, &error));
CHECK_TRUE(packets.size() == 3);
std::size_t payload_bytes = 0;
for (std::size_t index = 0; index < packets.size(); ++index) {
quic_edge::DatagramHeader header;
CHECK_TRUE(quic_edge::DatagramPacketizer::decodeHeader(
packets[index].bytes, &header, &error));
CHECK_TRUE(header.track_id == 7 && header.session_epoch == 99);
CHECK_TRUE(header.fragment_index == index && header.fragment_count == 3);
CHECK_TRUE((header.flags & quic_edge::DATAGRAM_FLAG_KEY_FRAME) != 0);
CHECK_TRUE((header.flags & quic_edge::DATAGRAM_FLAG_DISCONTINUITY) != 0);
payload_bytes += header.payload_size;
}
CHECK_TRUE(payload_bytes == frame->size());
return true;
}
bool testServiceWithSharedHub()
{
const std::string track_id = "camera-test/video/color";
media::MediaSourceHub hub;
media::MediaSourceHub::FrameSink sink;
std::mutex sink_mutex;
std::atomic<bool> source_started{false};
std::atomic<std::uint64_t> keyframe_requests{0};
media::MediaSourceHub::SourceCallbacks callbacks;
callbacks.start = [&](const media::MediaSourceHub::FrameSink& value,
const media::MediaSourceHub::CancelPredicate&) {
std::lock_guard lock(sink_mutex);
sink = value;
source_started.store(true);
return true;
};
callbacks.stop = [&]() { source_started.store(false); };
callbacks.request_key_frame = [&]() {
++keyframe_requests;
return true;
};
auto descriptor = videoDescriptor(track_id);
CHECK_TRUE(hub.registerSource(descriptor, std::move(callbacks), 8));
auto transport = std::make_unique<FakeTransport>(true, true, true, 1U);
FakeTransport* transport_view = transport.get();
quic_edge::QuicEdgeService service(
validConfig(track_id), std::move(transport), hub);
std::string error;
CHECK_TRUE(service.initialize(&error));
CHECK_TRUE(service.start(&error));
CHECK_TRUE(waitUntil([&]() { return source_started.load(); }));
CHECK_TRUE(waitUntil([&]() {
return service.stats().registrations_accepted == 1U &&
service.stats().heartbeats_acknowledged >= 1U;
}));
CHECK_TRUE(transport_view->lastRegisteredNodeId() == "test-node");
CHECK_TRUE(transport_view->lastGrpcEndpointPort() == 50052U);
CHECK_TRUE(transport_view->connectCount() == 1U);
media::MediaFrame::Config frame_config;
frame_config.descriptor = descriptor;
frame_config.payload.resize(1800, 0x11);
frame_config.sequence = 1;
frame_config.capture_time_ns = 1000000;
frame_config.key_frame = true;
media::MediaSourceHub::FrameSink publisher;
{
std::lock_guard lock(sink_mutex);
publisher = sink;
}
CHECK_TRUE(static_cast<bool>(publisher));
publisher(media::makeMediaFrame(std::move(frame_config)));
CHECK_TRUE(waitUntil([&]() { return transport_view->datagramBatchCount() == 1; }));
CHECK_TRUE(service.stats().frames_queued == 1);
CHECK_TRUE(keyframe_requests.load() != 0);
// Codec initialization bytes may be learned at the first encoder IDR
// without changing the source generation. The reliable descriptor must be
// refreshed rather than treating this legal enrichment as a source error.
auto enriched_descriptor_config = media::TrackDescriptor::Config{};
enriched_descriptor_config.id = track_id;
enriched_descriptor_config.source_id = "camera-test";
enriched_descriptor_config.kind = media::MediaKind::VIDEO;
enriched_descriptor_config.codec = media::Codec::H264;
enriched_descriptor_config.payload_format = media::PayloadFormat::ANNEX_B;
enriched_descriptor_config.time_base = {1, 90000};
enriched_descriptor_config.width = 640;
enriched_descriptor_config.height = 480;
enriched_descriptor_config.nominal_rate = 30;
enriched_descriptor_config.generation = descriptor->generation;
enriched_descriptor_config.codec_config = {0, 0, 0, 1, 0x67};
auto enriched_descriptor =
media::makeTrackDescriptor(std::move(enriched_descriptor_config));
media::MediaFrame::Config enriched_frame;
enriched_frame.descriptor = std::move(enriched_descriptor);
enriched_frame.payload.resize(256, 0x22);
// Simulate a source restart that resets its device-local sequence. The
// QUIC wire sequence must remain monotonic inside the media epoch.
enriched_frame.sequence = 1;
enriched_frame.capture_time_ns = 2000000;
enriched_frame.key_frame = true;
enriched_frame.discontinuity = true;
publisher(media::makeMediaFrame(std::move(enriched_frame)));
CHECK_TRUE(waitUntil([&]() { return transport_view->datagramBatchCount() == 2; }));
const auto wire_sequences = transport_view->datagramFrameSequences();
CHECK_TRUE(wire_sequences.size() == 2U &&
wire_sequences[0] == 1U && wire_sequences[1] == 2U);
CHECK_TRUE(transport_view->controlCount() >= 3);
CHECK_TRUE(transport_view->controlSequencesStrictlyIncreasing());
CHECK_TRUE(service.state() != quic_edge::QuicEdgeServiceState::FAILED);
service.stop();
return true;
}
bool testMissingInjectedSourceRetriesSafely()
{
media::MediaSourceHub hub;
auto transport = std::make_unique<FakeTransport>();
FakeTransport* transport_view = transport.get();
quic_edge::QuicEdgeService service(
validConfig("missing/video/color"), std::move(transport), hub);
std::string error;
CHECK_TRUE(service.initialize(&error));
CHECK_TRUE(service.start(&error));
CHECK_TRUE(waitUntil([&]() { return service.stats().source_errors >= 2; }));
CHECK_TRUE(service.state() == quic_edge::QuicEdgeServiceState::ONLINE);
CHECK_TRUE(service.status().registered);
CHECK_TRUE(service.status().active_media_tracks == 0U);
CHECK_TRUE(transport_view->connectCount() == 1U);
service.stop();
return true;
}
bool testPresenceOnlyWithoutMedia()
{
media::MediaSourceHub hub;
auto transport = std::make_unique<FakeTransport>();
FakeTransport* transport_view = transport.get();
quic_edge::QuicEdgeService service(
validPresenceOnlyConfig(), std::move(transport), hub);
std::string error;
CHECK_TRUE(service.initialize(&error));
CHECK_TRUE(service.start(&error));
CHECK_TRUE(waitUntil([&]() {
return service.stats().registrations_accepted == 1U &&
service.stats().heartbeats_acknowledged >= 1U;
}));
const auto status = service.status();
CHECK_TRUE(status.registered);
CHECK_TRUE(status.session_id == "test-session");
CHECK_TRUE(status.observed_source_ip == "203.0.113.11");
CHECK_TRUE(status.active_media_tracks == 0U);
CHECK_TRUE(transport_view->datagramBatchCount() == 0U);
CHECK_TRUE(transport_view->connectCount() == 1U);
CHECK_TRUE(service.state() == quic_edge::QuicEdgeServiceState::ONLINE);
service.stop();
return true;
}
bool testHeartbeatTimeoutReconnectsWithoutTaskFailure()
{
media::MediaSourceHub hub;
auto transport = std::make_unique<FakeTransport>(true, false);
FakeTransport* transport_view = transport.get();
auto config = validPresenceOnlyConfig();
config.set_control_response_timeout_ms(20U);
quic_edge::QuicEdgeService service(config, std::move(transport), hub);
std::string error;
CHECK_TRUE(service.initialize(&error));
CHECK_TRUE(service.start(&error));
CHECK_TRUE(waitUntil([&]() {
return service.stats().heartbeat_timeouts >= 1U &&
transport_view->connectCount() >= 2U;
}));
CHECK_TRUE(service.state() != quic_edge::QuicEdgeServiceState::FAILED);
service.stop();
return true;
}
bool testRegistrationRejectionBacksOff()
{
media::MediaSourceHub hub;
auto transport = std::make_unique<FakeTransport>(false, true);
FakeTransport* transport_view = transport.get();
auto config = validPresenceOnlyConfig();
config.mutable_reconnect()->set_initial_delay_ms(20U);
config.mutable_reconnect()->set_maximum_delay_ms(80U);
quic_edge::QuicEdgeService service(
config, std::move(transport), hub);
std::string error;
CHECK_TRUE(service.initialize(&error));
CHECK_TRUE(service.start(&error));
CHECK_TRUE(waitUntil([&]() {
return service.stats().registrations_rejected >= 3U &&
transport_view->connectCount() >= 4U;
}));
CHECK_TRUE(service.stats().heartbeats_sent == 0U);
const auto intervals = transport_view->connectIntervals();
CHECK_TRUE(intervals.size() >= 3U);
CHECK_TRUE(intervals[0].count() >= 15);
CHECK_TRUE(intervals[1].count() >= 35);
CHECK_TRUE(intervals[2].count() >= 70);
CHECK_TRUE(service.state() != quic_edge::QuicEdgeServiceState::FAILED);
service.stop();
return true;
}
bool testHeartbeatAckRequiresSessionId()
{
media::MediaSourceHub hub;
auto transport = std::make_unique<FakeTransport>(true, true, false);
FakeTransport* transport_view = transport.get();
quic_edge::QuicEdgeService service(
validPresenceOnlyConfig(), std::move(transport), hub);
std::string error;
CHECK_TRUE(service.initialize(&error));
CHECK_TRUE(service.start(&error));
CHECK_TRUE(waitUntil([&]() {
return transport_view->connectCount() >= 2U &&
service.stats().heartbeats_sent >= 2U;
}));
CHECK_TRUE(service.stats().heartbeats_acknowledged == 0U);
CHECK_TRUE(service.state() != quic_edge::QuicEdgeServiceState::FAILED);
service.stop();
return true;
}
bool testSlowMediaStartDoesNotBlockHeartbeat()
{
const std::string track_id = "slow-camera/video/color";
media::MediaSourceHub hub;
std::atomic<bool> start_entered{false};
std::atomic<bool> start_exited{false};
std::atomic<bool> release_start{false};
media::MediaSourceHub::SourceCallbacks callbacks;
callbacks.start = [&](const media::MediaSourceHub::FrameSink&,
const media::MediaSourceHub::CancelPredicate& cancelled) {
start_entered.store(true);
while (!release_start.load() && !cancelled()) {
std::this_thread::sleep_for(std::chrono::milliseconds(2));
}
start_exited.store(true);
return !cancelled();
};
callbacks.stop = []() {};
CHECK_TRUE(hub.registerSource(
videoDescriptor(track_id), std::move(callbacks), 8U));
auto transport = std::make_unique<FakeTransport>();
FakeTransport* transport_view = transport.get();
quic_edge::QuicEdgeService service(
validConfig(track_id), std::move(transport), hub);
std::string error;
CHECK_TRUE(service.initialize(&error));
CHECK_TRUE(service.start(&error));
const bool entered = waitUntil([&]() { return start_entered.load(); });
const bool heartbeat_while_blocked = entered && waitUntil([&]() {
return service.stats().heartbeats_acknowledged >= 1U;
});
const bool registered_while_blocked = service.status().registered;
auto stop_future = std::async(std::launch::async, [&service] {
service.stop();
});
const bool stop_while_start_blocked =
stop_future.wait_for(std::chrono::milliseconds(500)) ==
std::future_status::ready;
release_start.store(true);
stop_future.get();
const bool start_cancelled = waitUntil([&]() { return start_exited.load(); });
CHECK_TRUE(entered);
CHECK_TRUE(heartbeat_while_blocked);
CHECK_TRUE(registered_while_blocked);
CHECK_TRUE(stop_while_start_blocked);
CHECK_TRUE(start_cancelled);
CHECK_TRUE(transport_view->connectCount() == 1U);
CHECK_TRUE(transport_view->controlSequencesStrictlyIncreasing());
return true;
}
bool testLifecycleStateGuards()
{
media::MediaSourceHub hub;
{
auto transport = std::make_unique<FakeTransport>();
quic_edge::QuicEdgeService service(
validPresenceOnlyConfig(), std::move(transport), hub);
service.stop();
CHECK_TRUE(service.state() ==
quic_edge::QuicEdgeServiceState::UNINITIALIZED);
std::string error;
CHECK_TRUE(!service.start(&error));
}
{
auto transport = std::make_unique<FakeTransport>();
quic_edge::QuicEdgeService service(
validPresenceOnlyConfig(), std::move(transport), hub);
std::string error;
CHECK_TRUE(service.initialize(&error));
CHECK_TRUE(service.start(&error));
CHECK_TRUE(waitUntil([&]() {
return service.stats().registrations_accepted == 1U;
}));
error.clear();
CHECK_TRUE(!service.initialize(&error));
CHECK_TRUE(!error.empty());
service.stop();
}
return true;
}
} // namespace
int main()
{
if (!testControlFraming() || !testPacketizer() ||
!testServiceWithSharedHub() || !testMissingInjectedSourceRetriesSafely() ||
!testPresenceOnlyWithoutMedia() ||
!testHeartbeatTimeoutReconnectsWithoutTaskFailure() ||
!testRegistrationRejectionBacksOff() ||
!testHeartbeatAckRequiresSessionId() ||
!testSlowMediaStartDoesNotBlockHeartbeat() ||
!testLifecycleStateGuards()) {
return 1;
}
std::cout << "quic_edge_protocol_test: PASS\n";
return 0;
}

View File

@ -0,0 +1,58 @@
# Simulate 模块开发指南
`simulate/` 保存仿真引擎适配和可视化辅助能力。当前实现主要是 MuJoCo viewer模拟摄像头、电机和机械臂仍通过 `devices/` 中相同的设备抽象接入系统。
返回[项目总览](../../README.md)。
## 职责边界
`simulate/` 负责:
- 仿真引擎上下文、模型和 viewer
- 引擎事件循环、渲染和 UI adapter
- 仿真资源加载的通用辅助能力。
`devices/` 负责:
- 将仿真传感器或执行器实现为 `AbstractDevice` 子类;
- 对外提供与真机一致的设备语义;
- 参与 DeviceFactory、DeviceManager 和 gRPC/媒体接入。
不要让 service 或 task 直接调用 MuJoCo API它们应通过设备抽象工作。
## 当前 CMake
- [`CMakeLists.txt`](CMakeLists.txt) 加载 `mujoco/`
- [`mujoco/CMakeLists.txt`](mujoco/CMakeLists.txt) 加载 viewer
- target `cmvr_es::mujoco_viewer` 链接 MuJoCo、GLFW 和 OpenGL。
## 新增仿真引擎
1. 新建 `simulate/<engine>/`
2. 将 engine context 和资源所有权封装在独立类中;
3. 提供明确 `initialize/start/step/stop` 生命周期;
4. 通过 adapter 向模拟设备暴露最小能力;
5. 在 `devices/` 中实现对应模拟后端;
6. 在设备 config proto 的 `oneof backend` 增加配置;
7. 更新两侧 CMake
8. 提供 headless 测试,并将图形测试单独标记。
## 线程和资源
- 明确由哪个线程调用 engine step 和 render
- 不在多个线程无锁访问引擎状态;
- viewer 关闭不能悬挂设备回调;
- 设备停止后不得继续读取已释放的仿真模型;
- 模型路径通过 `ConfigHelper::resolveResourceFile()` 解析;
- headless 环境不能强制创建 OpenGL 窗口;
- 固定或记录仿真 timestep避免控制周期与仿真周期混淆。
## 测试建议
- 模型加载失败和资源路径错误;
- headless 初始化、step 和停止;
- 重复启动/停止;
- 模拟设备与真机抽象的行为一致性;
- 时间步、单位、关节顺序和坐标系;
- viewer 关闭与后台设备线程并发;
- 测试结束后无线程、窗口和引擎资源泄漏。

184
cmvr-es/task/README.md Normal file
View File

@ -0,0 +1,184 @@
# Task 模块开发指南
`task/` 保存由 TaskManager 管理的可运行模块。任务分为周期调度任务和自持线程/事件循环的服务任务。
返回[项目总览](../../README.md)。
## Task 契约
所有任务实现 [`task.h`](task.h) 中的接口:
- `id()`:配置和管理器使用的稳定唯一 ID
- `runMode()`:周期任务或服务任务;
- `init()`:配置校验和本地资源准备;
- `start()`:启动执行,必须快速返回;
- `step(dt)`:周期任务的一次计算;
- `stop()`:幂等停止并回收任务拥有的资源;
- `state()` 和状态辅助接口:线程安全地暴露状态;
- `detailStatusString()`:提供可诊断状态,不包含密钥。
析构函数应安全调用 `stop()`。异步状态、错误和统计必须使用 mutex/atomic 保护。
## 运行模式
### PERIODIC_STEP
- 由 TaskManager scheduler 调用 `step()`
- 配置 `control_period_s` 必须是有限正数;
- 只有 task state 为 `RUNNING` 时执行 step
- 所有周期任务共享一个 scheduler 线程;
- step 不能做阻塞网络 I/O 或无界计算;
- step 返回失败时应更新任务状态和可诊断错误;
- 不要在 step 内同步停止 TaskManager。
### BLOCKING_SERVICE
- TaskManager 不调用其 `step()`
- start 必须创建自己的 server/worker 后快速返回;
- stop 必须关闭 server、唤醒等待并 join 线程;
- 不能把 `BLOCKING_SERVICE` 理解为 start 可以永久阻塞。
`GrpcServerTask``QuicEdgeTask` 是当前服务任务参考实现。
## 新增任务的完整链路
### 1. 配置 Proto
`protos/cmvr/config/` 新增:
```protobuf
message ExampleTaskConfig {
string id = 1;
}
message ExampleTaskRootConfig {
ExampleTaskConfig example_task = 1;
}
```
`TaskConfigEntry::TaskType` 中使用新的、稳定的枚举值。当前 `2``4` 已 reserved不得复用。
### 2. 实现目录
```text
task/example_task/
├── CMakeLists.txt
├── include/example_task.h
├── src/example_task.cpp
└── tests/example_task_test.cpp
```
实现 `Task` 全部接口。持续运行任务的典型状态转换为:
```text
UNINITIALIZED -> IDLE -> RUNNING -> STOPPED
└-------> FAILED
```
持续周期任务必须在 start 后进入 `RUNNING`,否则 scheduler 不会调用 step。命令驱动任务可以在 start 后保持 `IDLE`,收到外部命令后再进入 `RUNNING`,当前 TouchScreenTask 就采用这种方式。
init、start 或 step 都可能进入 `FAILED`,一次性任务也可以进入 `SUCCEEDED`。失败后是否允许重新 init/start 必须在类注释和测试中明确。
### 3. Creator 与注册
creator 应:
1. 校验 manager entry ID 和 config file
2. 使用 `ConfigHelper` 加载 root config
3. 校验子配置 ID 与 manager entry ID 一致;
4. 只创建对象,不进行耗时外部连接;
5. 返回 `nullptr` 并记录明确错误。
提供:
```cpp
void registerExampleTaskFactory();
```
通过 [`task_factory.h`](task_factory.h) 的 `TaskFactory::registerCreator()` 注册。
`registerCreator()` 对相同 TaskType 会覆盖旧 creator当前不会报错。不要重复注册。creator 在 registry mutex 持有期间执行,因此不能从 creator 递归调用 register/create。
### 4. main 注册
`TaskManager::getInstance()` 之前显式调用注册函数。当前位置见 [`../main.cpp`](../main.cpp)。
TouchScreen 是 TaskFactory 内硬编码的历史实现;新任务优先使用显式 registry 模式。
### 5. TaskManager 映射
在 [`../manager/task_manager/src/task_manager.cpp`](../manager/task_manager/src/task_manager.cpp) 的 TaskType 字符串映射中增加新类型,否则计划日志会显示 unknown。
### 6. 默认配置
新增:
```text
cmvr-es/config/tasks/example_task/example_task.pb.txt
```
并在 `config/manager/task_manager.pb.txt` 增加 entry。依赖硬件、网络或证书的新任务默认 `enable: false`
配置 `run_mode` 必须和 task 的 `runMode()` 一致,否则 TaskManager 会跳过该任务。
### 7. CMake
- 为任务建立独立 library target
- 添加项目命名空间 alias
- 在 [`CMakeLists.txt`](CMakeLists.txt) 或 [`../CMakeLists.txt`](../CMakeLists.txt) 加入子目录;
- 根 `cmvr_es` 必须链接注册函数所在 target避免静态库未被带入
- 测试放在 `if(BUILD_TESTING)`
- 使用 `add_test()` 登记到 CTest。
参考 [`quic_edge_task/CMakeLists.txt`](quic_edge_task/CMakeLists.txt)。
## TaskManager 当前语义
- init 按配置顺序执行;
- init 失败只跳过该任务,进程继续;
- ID 重复会跳过后加入项;
- run mode 不匹配会跳过;
- `TASK_RUN_MODE_UNKNOWN` 当前会记录错误并退化为 `PERIODIC_STEP`,配置不得依赖该历史行为;
- start 阶段任一任务失败会停止此前已启动任务;
- 返回 false 的 task 必须自行清理此次 start 的部分资源;
- 任务保存在 unordered_map不能依赖 start/stop 顺序;
- manager 处于 running 状态时stop 先停止 scheduler再调用每个任务 stop
- 未启动或已经停止时,`stopRunTask()` 会直接返回;
- TaskManager 单例已存在时传入新配置不会热更新。
任务之间有依赖时,应由一个协调 task 显式管理,或增加明确依赖模型;不能依赖配置顺序碰巧变成启动顺序。
## 测试最低要求
- 缺失配置、空 ID 和 ID mismatch
- run-mode mismatch
- init/start/stop 状态转换;
- stop 重复调用;
- start 失败后的资源清理;
- 周期 dt、调度延迟和 step 失败;
- service worker 正常 shutdown
- stop 发生在阻塞等待期间;
- 析构时仍处于 RUNNING
- fake 依赖下的无设备运行。
单项测试:
```bash
cmake --build build --target quic_edge_task_test
ctest \
--test-dir build \
-R '^quic_edge_task_test$' \
--output-on-failure
```
## 提交检查
- [ ] 使用新的 TaskType 枚举号
- [ ] 配置 root message、默认配置和 ID 一致
- [ ] creator 与 main 注册均已完成
- [ ] run mode 与配置一致
- [ ] start 快速返回
- [ ] stop 幂等并 join 所有线程
- [ ] 状态查询线程安全
- [ ] CMake target 被根程序链接
- [ ] 测试已登记到 CTest

View File

@ -0,0 +1,27 @@
add_library(quic_edge_task STATIC src/quic_edge_task.cpp)
target_compile_features(quic_edge_task PUBLIC cxx_std_17)
target_include_directories(quic_edge_task PUBLIC ${PROJECT_SOURCE_DIR}/cmvr-es)
target_link_libraries(quic_edge_task
PUBLIC
cmvr_es::task
cmvr_es::quic_edge_service
PRIVATE
cmvr_es::proto
cmvr_es::logging
cmvr_es::device_manager
)
add_library(cmvr_es::quic_edge_task ALIAS quic_edge_task)
if(BUILD_TESTING)
add_executable(quic_edge_task_test tests/quic_edge_task_test.cpp)
target_compile_features(quic_edge_task_test PRIVATE cxx_std_17)
target_link_libraries(quic_edge_task_test PRIVATE cmvr_es::quic_edge_task)
add_test(NAME quic_edge_task_test COMMAND quic_edge_task_test)
if(UNIX AND NOT APPLE)
get_property(_quic_task_test_library_dirs DIRECTORY PROPERTY LINK_DIRECTORIES)
list(PREPEND _quic_task_test_library_dirs "${CMAKE_BINARY_DIR}/cmvr_compiler_runtime")
list(JOIN _quic_task_test_library_dirs ":" _quic_task_test_library_path)
set_tests_properties(quic_edge_task_test PROPERTIES
ENVIRONMENT "LD_LIBRARY_PATH=${_quic_task_test_library_path}")
endif()
endif()

View File

@ -0,0 +1,49 @@
#ifndef CMVR_ES_QUIC_EDGE_TASK_H
#define CMVR_ES_QUIC_EDGE_TASK_H
#include <memory>
#include <mutex>
#include <string>
#include "cmvr/config/quic_edge_config/quic_edge_config.pb.h"
#include "service/quic_edge/include/quic_edge_service.h"
#include "task/task.h"
namespace cmvr::task {
class QuicEdgeTask final : public Task {
public:
explicit QuicEdgeTask(const config::QuicEdgeConfig& config);
~QuicEdgeTask() override;
const std::string& id() const override { return id_; }
TaskRunMode runMode() const override { return TaskRunMode::BLOCKING_SERVICE; }
bool init() override;
bool start() override;
bool step(double dt) override;
void stop() override;
TaskState state() const override;
bool isBusy() const override;
bool isFinished() const override;
bool isFailed() const override;
std::string stateString() const override;
std::string detailStatusString() const override;
private:
TaskState mappedState() const;
config::QuicEdgeConfig config_;
std::string id_;
std::unique_ptr<quic_edge::QuicEdgeService> service_;
mutable std::mutex mutex_;
TaskState state_{TaskState::UNINITIALIZED};
std::string last_error_;
};
void registerQuicEdgeTaskFactory();
} // namespace cmvr::task
#endif // CMVR_ES_QUIC_EDGE_TASK_H

View File

@ -0,0 +1,200 @@
#include "task/quic_edge_task/include/quic_edge_task.h"
#include <sstream>
#include <utility>
#include "cmvr/config/task_manager_config/task_manager_config.pb.h"
#include "common/base/logging/logger.h"
#include "common/config/config_files.h"
#include "manager/device_manager/include/device_manager.h"
#include "task/task_factory.h"
namespace cmvr::task {
namespace {
std::shared_ptr<Task> createQuicEdgeTask(const config::TaskConfigEntry& entry)
{
if (entry.id().empty() || entry.config_file().empty()) {
CMVR_LOG(ERROR) << "[QuicEdgeTask] Task id or config_file is empty";
return nullptr;
}
config::QuicEdgeRootConfig root;
if (!ConfigHelper::loadConfigFile(entry.config_file(), root)) {
CMVR_LOG(ERROR) << "[QuicEdgeTask] Failed to load config: "
<< entry.config_file();
return nullptr;
}
config::QuicEdgeConfig config = root.quic_edge();
if (config.id().empty() || config.id() != entry.id()) {
CMVR_LOG(ERROR) << "[QuicEdgeTask] Task ID mismatch: manager=" << entry.id()
<< ", config=" << config.id();
return nullptr;
}
if (!config.tls().ca_file().empty()) {
config.mutable_tls()->set_ca_file(
ConfigHelper::resolveConfigFile(config.tls().ca_file()));
}
if (!config.tls().certificate_file().empty()) {
config.mutable_tls()->set_certificate_file(
ConfigHelper::resolveConfigFile(config.tls().certificate_file()));
}
if (!config.tls().private_key_file().empty()) {
config.mutable_tls()->set_private_key_file(
ConfigHelper::resolveConfigFile(config.tls().private_key_file()));
}
if (config.software_version().empty()) {
config.set_software_version(device::DeviceManager::getInstance().version());
}
return std::make_shared<QuicEdgeTask>(config);
}
} // namespace
QuicEdgeTask::QuicEdgeTask(const config::QuicEdgeConfig& config)
: config_(config), id_(config.id())
{
}
QuicEdgeTask::~QuicEdgeTask()
{
stop();
}
bool QuicEdgeTask::init()
{
std::lock_guard lock(mutex_);
if (id_.empty()) {
last_error_ = "QUIC edge task id is empty";
state_ = TaskState::FAILED;
return false;
}
service_ = std::make_unique<quic_edge::QuicEdgeService>(config_);
std::string error;
if (!service_->initialize(&error)) {
last_error_ = std::move(error);
state_ = TaskState::FAILED;
return false;
}
last_error_.clear();
state_ = TaskState::IDLE;
return true;
}
bool QuicEdgeTask::start()
{
std::lock_guard lock(mutex_);
if (state_ == TaskState::RUNNING) return true;
if ((state_ != TaskState::IDLE && state_ != TaskState::STOPPED) || !service_) {
last_error_ = "QUIC edge task is not initialized";
state_ = TaskState::FAILED;
return false;
}
std::string error;
if (!service_->start(&error)) {
last_error_ = std::move(error);
state_ = TaskState::FAILED;
return false;
}
last_error_.clear();
state_ = TaskState::RUNNING;
CMVR_LOG(INFO) << "[QuicEdgeTask] Started, id=" << id_
<< ", enabled=" << config_.enable();
return true;
}
bool QuicEdgeTask::step(const double dt)
{
(void)dt;
return !isFailed();
}
void QuicEdgeTask::stop()
{
std::lock_guard lock(mutex_);
// Keep ownership stable for the complete stop. init() may replace the
// unique service instance and therefore must not race a raw pointer here.
if (service_) service_->stop();
if (state_ != TaskState::FAILED) state_ = TaskState::STOPPED;
}
TaskState QuicEdgeTask::mappedState() const
{
if (!service_ || state_ != TaskState::RUNNING) return state_;
return service_->state() == quic_edge::QuicEdgeServiceState::FAILED
? TaskState::FAILED : state_;
}
TaskState QuicEdgeTask::state() const
{
std::lock_guard lock(mutex_);
return mappedState();
}
bool QuicEdgeTask::isBusy() const
{
return state() == TaskState::RUNNING;
}
bool QuicEdgeTask::isFinished() const
{
return state() == TaskState::STOPPED;
}
bool QuicEdgeTask::isFailed() const
{
return state() == TaskState::FAILED;
}
std::string QuicEdgeTask::stateString() const
{
return taskStateToString(state());
}
std::string QuicEdgeTask::detailStatusString() const
{
std::lock_guard lock(mutex_);
std::ostringstream output;
output << taskStateToString(mappedState());
if (!config_.enable()) {
output << " disabled";
return output.str();
}
if (service_) {
const auto stats = service_->stats();
const auto service_status = service_->status();
output << " service=" << quic_edge::toString(service_->state())
<< " target=" << config_.server_host() << ':' << config_.server_port()
<< " node_id=" << service_status.node_id
<< " registered=" << service_status.registered
<< " connections=" << stats.successful_connections
<< " registrations=" << stats.registrations_accepted
<< " heartbeat_sequence=" << service_status.heartbeat_sequence
<< " heartbeat_acks=" << stats.heartbeats_acknowledged
<< " media_tracks=" << service_status.active_media_tracks
<< " frames=" << stats.frames_queued
<< " datagrams=" << stats.datagrams_queued;
if (!service_status.session_id.empty()) {
output << " session=" << service_status.session_id;
}
if (!service_status.observed_source_ip.empty()) {
output << " observed_ip=" << service_status.observed_source_ip;
}
if (!service_status.last_media_error.empty()) {
output << " media_error=" << service_status.last_media_error;
}
const std::string service_error = service_->lastError();
if (!service_error.empty()) output << " error=" << service_error;
} else if (!last_error_.empty()) {
output << " error=" << last_error_;
}
return output.str();
}
void registerQuicEdgeTaskFactory()
{
TaskFactory::registerCreator(
config::TaskConfigEntry::TASK_TYPE_QUIC_EDGE,
createQuicEdgeTask);
}
} // namespace cmvr::task

View File

@ -0,0 +1,28 @@
#include <iostream>
#include "task/quic_edge_task/include/quic_edge_task.h"
int main()
{
cmvr::config::QuicEdgeConfig config;
config.set_id("quic-disabled-test");
config.set_enable(false);
cmvr::task::QuicEdgeTask task(config);
if (!task.init() || task.state() != cmvr::task::TaskState::IDLE) {
std::cerr << "disabled QUIC task failed to initialize\n";
return 1;
}
if (!task.start() || task.state() != cmvr::task::TaskState::RUNNING ||
task.detailStatusString().find("disabled") == std::string::npos) {
std::cerr << "disabled QUIC task failed to start safely\n";
return 1;
}
task.stop();
if (task.state() != cmvr::task::TaskState::STOPPED) {
std::cerr << "disabled QUIC task failed to stop\n";
return 1;
}
std::cout << "quic_edge_task_test: PASS\n";
return 0;
}

186
protos/README.md Normal file
View File

@ -0,0 +1,186 @@
# Protobuf 与协议开发指南
`protos/` 是 CMVR-ES 配置、gRPC API、共享消息和 QUIC 控制面的契约源。生成代码由 CMake 创建,不应手工编辑或提交。
返回[项目总览](../README.md)。
## 目录职责
| 目录 | 职责 |
| --- | --- |
| `cmvr/api/` | gRPC service 和 command DTO |
| `cmvr/msgs/` | 设备、状态和错误等共享消息 |
| `cmvr/config/` | Proto Text 运行配置 Schema |
| `cmvr/common/` | 通用几何等跨领域消息 |
| `cmvr/quic_edge/v1/` | QUIC reliable control message 和 v1 wire 文档 |
| `rbk/` | 第三方/兼容协议 Schema |
## C++ 生成流程
根 [`CMakeLists.txt`](../CMakeLists.txt)
1. 递归收集 `protos/**/*.proto`
2. 使用 `protoc` 生成 `.pb.h/.pb.cc`
3. 使用 `grpc_cpp_plugin` 生成 `.grpc.pb.h/.grpc.pb.cc`
4. 输出到 `build/_protobuf/`
5. 打包为 `cmvr_es::proto`
`build/_protobuf/` 是构建产物,不手改、不提交。
当前使用 `file(GLOB_RECURSE ...)` 且没有 `CONFIGURE_DEPENDS`。新增 Proto 文件后必须重新执行 CMake configure
```bash
cmake -S . -B build \
-DCMAKE_BUILD_TYPE=Release \
-DCMVR_ARCH=x86
cmake --build build -j"$(nproc)"
```
`protoc``grpc_cpp_plugin` 当前固定从 `output/bin/` 使用。干净环境首次构建先执行根 README 的工具引导步骤。
## 通用兼容规则
已发布协议中禁止:
- 修改或复用字段号;
- 修改 enum 数值含义;
- 修改 oneof tag
- 修改字段 wire type
- 修改 gRPC package、service 或 method 全名;
- 将原本可选的字段改成必填语义;
- 把同一字段单位从 m 改成 mm 等隐式破坏。
删除字段时同时 reserved 字段号和名称:
```protobuf
message Example {
reserved 2;
reserved "old_field";
string id = 1;
string new_field = 3;
}
```
新增字段使用新 tag。需要区分“缺失”和“零值”时使用 proto3 `optional`,并验证目标语言工具链支持。
破坏性 gRPC 变化应创建版本化 package/service而不是原地改变旧方法。
## 配置 Proto
配置也必须考虑已部署 `.pb.txt`
- 新 scalar 的零值不能自动成为危险默认值;
- C++ 必须做范围和跨字段校验;
- enum unknown、oneof 未设置应明确失败;
- root message 命名保持 `XxxRootConfig`
- 字段注释写明单位、范围和安全语义;
- Schema 变更同步 [`../cmvr-es/config/README.md`](../cmvr-es/config/README.md) 与样例配置。
TextFormat 使用字段名,因此随意重命名字段同样会破坏旧配置。
## gRPC API
推荐一个领域拆分为:
```text
cmvr/api/example_command.proto
cmvr/api/example_service.proto
```
service 文件 import command 文件。公共 header、错误和时间戳复用 `common.proto`,不要复制出多个略有差异的定义。
新增 API 后还需要:
1. 实现 C++ service
2. 加入 service CMake target
3. 在 GrpcServerTask 中构造和注册;
4. 生成 Java client
5. 增加 handler、reflection 和 grpcurl 测试。
完整流程见 [`../cmvr-es/service/README.md`](../cmvr-es/service/README.md)。
`cmvr/api/test_service.proto` 当前没有实现或注册,且包含历史拼写 `TestReqeust`,不要把它作为新 API 模板。
## QUIC Edge v1
当前固定契约:
- package`cmvr.quic_edge.v1`
- `protocol_version = 1`
- ALPN`cmvr-quic-edge/1`
- reliable stream`uint32_be length + EdgeControlEnvelope`
- media DATAGRAM固定 64 字节非 Protobuf header + payload
完整字段和偏移见 [`cmvr/quic_edge/v1/README.md`](cmvr/quic_edge/v1/README.md)。
破坏 framing、固定头、状态机或时序时必须建立新协议版本、package 和 ALPN不能只修改 Proto。
新增 control envelope payload 时:
- 使用未占用的新 oneof tag
- 同步 edge 发送/dispatch 和状态机;
- 同步 message/session/heartbeat sequence 校验;
- 同步 Java Gateway
- 更新 wire 文档;
- 增加 golden vector 和 fake transport 测试。
Edge 当前入站只实现 RegisterResponse、HeartbeatAck 和 ProtocolError。Proto 中存在的消息不等于两个方向都已实现。
每次 QUIC 连接建立后Edge 的出站 control `message_sequence``0` 重新开始,因此首个 `NodeRegisterRequest.message_sequence` 当前为 `0`。Gateway 必须接受该起始值,不能假设从 `1` 开始;后续只要求同一发送方向严格递增,允许出现间隔。
QUIC stream 与 DATAGRAM 没有跨通道到达顺序保证。Gateway 不能假设 session/descriptor 一定先于媒体到达。
## Java 生成
仓库只自动生成 C++。Java 平台应在自己的 Gradle/Maven 工程中锁定:
- protoc
- protobuf-java
- protoc-gen-grpc-java
- grpc-java
并以 `protos/` 作为 import root。
QUIC Gateway 只需普通 Protobuf Java message
```bash
output/bin/protoc \
-I protos \
--java_out=platform-gateway/src/main/java \
protos/cmvr/quic_edge/v1/quic_edge.proto
```
gRPC Java client 还需要 grpc-java plugin。当前 Proto 没有统一 `java_package``java_multiple_files`,增加这些 option 时应同步两端生成结果和平台 import。
浏览器不能直接消费自定义 QUIC ALPN不应从这些 Proto 推导“浏览器可直连 Edge”。
## Review 与验证
```bash
# 重新生成并编译 C++
cmake -S . -B build \
-DCMAKE_BUILD_TYPE=Release \
-DCMVR_ARCH=x86
cmake --build build -j"$(nproc)"
# 验证当前自动测试
ctest --test-dir build --output-on-failure
# 验证运行时 reflection
/tmp/grpcurl -plaintext 127.0.0.1:50052 list
```
评审清单:
- [ ] 新字段只使用新 tag
- [ ] 删除字段同时 reserved number 和 name
- [ ] enum 数值和语义兼容
- [ ] 旧 pb.txt 仍可解析
- [ ] C++ 重新 configure 并生成
- [ ] Java/其他语言生成成功
- [ ] service 已真正注册
- [ ] grpcurl reflection 与预期一致
- [ ] QUIC wire 文档和测试同步更新
- [ ] 破坏性变化采用新版本

View File

@ -35,6 +35,7 @@ message AudioData {
MP3 = 1; MP3 = 1;
AAC = 2; AAC = 2;
WAV = 3; WAV = 3;
OPUS = 4;
} }
bytes data = 1; bytes data = 1;
int32 sample_rate = 2; int32 sample_rate = 2;

View File

@ -0,0 +1,88 @@
syntax = "proto3";
package cmvr.config;
message QuicEdgeTlsConfig {
string ca_file = 1;
string certificate_file = 2;
string private_key_file = 3;
string server_name = 4;
// Development-only escape hatch. Production configurations should keep
// this false and provide ca_file plus server_name.
bool allow_insecure = 5;
}
message QuicEdgeReconnectConfig {
uint32 initial_delay_ms = 1;
uint32 maximum_delay_ms = 2;
double multiplier = 3;
uint32 jitter_percent = 4;
uint32 connect_timeout_ms = 5;
}
message QuicEdgeTrackConfig {
enum SourceKind {
SOURCE_KIND_UNSPECIFIED = 0;
SOURCE_KIND_CAMERA = 1;
SOURCE_KIND_MICROPHONE = 2;
}
uint32 track_id = 1;
SourceKind source_kind = 2;
string device_id = 3;
bool enable = 4;
// Zero uses QuicEdgeConfig.maximum_frame_bytes.
uint32 max_frame_bytes = 5;
// Protocol-neutral MediaSourceHub track ID. When empty, the service derives
// the default ID from source_kind and device_id (for example
// "right_hand_cam/video/color"). The Hub's descriptor is the authority for
// codec metadata and descriptor generation.
string source_track_id = 6;
}
message QuicEdgeConfig {
string id = 1;
bool enable = 2;
string server_host = 3;
uint32 server_port = 4;
string alpn = 5;
string node_id = 6;
QuicEdgeTlsConfig tls = 7;
QuicEdgeReconnectConfig reconnect = 8;
// Local safety limits. The effective DATAGRAM size is the minimum of this
// value and the peer/path value reported by the QUIC implementation.
uint32 maximum_datagram_bytes = 9;
uint32 maximum_control_frame_bytes = 10;
uint32 maximum_frame_bytes = 11;
uint32 datagram_send_queue_depth = 12;
uint32 media_poll_interval_ms = 13;
// Node-presence fields migrated from the former gRPC edge agent. node_id may
// be "auto" when the implementation supports hostname-based resolution.
string software_version = 14;
// Existing edge gRPC server endpoint advertised to the gateway. Empty or
// "auto" host selects a usable address from the current interface snapshot.
string grpc_endpoint_host = 15;
uint32 grpc_endpoint_port = 16;
bool grpc_endpoint_tls = 17;
// The receiver may override heartbeat_interval_ms in NodeRegisterResponse.
// control_response_timeout_ms applies while waiting for registration and
// heartbeat acknowledgements on the reliable stream.
uint32 heartbeat_interval_ms = 18;
uint32 control_response_timeout_ms = 19;
// An empty list, or a list with no enabled entry, is valid: node registration,
// IP reporting and heartbeat continue without a camera or microphone.
repeated QuicEdgeTrackConfig tracks = 20;
bool include_loopback_interfaces = 21;
}
message QuicEdgeRootConfig {
QuicEdgeConfig quic_edge = 1;
}

View File

@ -6,8 +6,9 @@ message TaskConfigEntry {
TASK_TYPE_UNKNOWN = 0; TASK_TYPE_UNKNOWN = 0;
TASK_TYPE_TOUCH_SCREEN = 1; TASK_TYPE_TOUCH_SCREEN = 1;
TASK_TYPE_GRPC_SERVER = 3; TASK_TYPE_GRPC_SERVER = 3;
reserved 2; TASK_TYPE_QUIC_EDGE = 5;
reserved "TASK_TYPE_ARM_CONTROL"; reserved 2, 4;
reserved "TASK_TYPE_ARM_CONTROL", "TASK_TYPE_GRPC_EDGE_AGENT";
} }
enum TaskRunMode { enum TaskRunMode {

View File

@ -0,0 +1,160 @@
# QUIC edge v1 wire format
## Scope and topology
QUIC edge v1 carries two functions over one authenticated QUIC connection:
- a reliable bidirectional control stream for node registration, heartbeat,
current local IP addresses, the advertised gRPC endpoint and media metadata;
- QUIC DATAGRAM payloads for lossy, real-time audio and video.
Robot arm, AGV and other device-control APIs remain on the existing cmvr-es
gRPC server. The QUIC node descriptor advertises that server's current endpoint;
it does not move robot-control RPCs onto QUIC.
```text
cmvr-es -- custom QUIC edge v1 --> Java receiver / edge gateway
|
+-- WebTransport/HTTP3 --> browser
```
A browser cannot connect to this custom QUIC ALPN directly. The gateway must
terminate v1, register the node, reassemble media fragments and expose
WebTransport (or another browser-supported media API). Browser sessions do not
participate in edge-node registration or heartbeat.
## Build and host-safe defaults
MsQuic is optional at configure time. It can be installed under
`dependency/<arch>/third_party/msquic/<version>` or supplied explicitly:
```bash
cmake -S . -B build \
-DCMVR_ENABLE_MSQUIC_BACKEND=ON \
-DCMVR_MSQUIC_ROOT=/absolute/path/to/msquic
cmake --build build -j2
cmake --install build
```
Without MsQuic the project still builds, but enabling the QUIC task fails with
an explicit error. The repository default keeps `quic_edge` disabled. The
existing inbound gRPC server remains enabled independently.
To enable node presence without media:
1. Configure the gateway address, ALPN `cmvr-quic-edge/1`, TLS trust, node ID,
advertised gRPC endpoint and heartbeat values in
`cmvr-es/config/tasks/quic_edge_task/quic_edge_task.pb.txt`.
Certificate paths are relative to the cmvr-es configuration root, for
example `certs/quic_gateway_ca.pem`.
2. Leave `tracks` empty, set `quic_edge.enable: true`, and enable the
`quic_edge` TaskManager entry.
To add a media uplink, also enable the camera/microphone in DeviceManager and
add a matching `tracks` entry. A zero-track configuration is valid: unavailable
media must not suppress node registration, IP reporting or heartbeat.
`maximum_frame_bytes` must fit in one atomically admitted DATAGRAM batch; reduce
it or increase `datagram_send_queue_depth` when changing the DATAGRAM size.
## Reliable control stream
The edge opens one bidirectional reliable stream after the QUIC handshake. Both
directions carry a sequence of length-prefixed protobuf messages:
```text
uint32_be protobuf_length
protobuf EdgeControlEnvelope
```
The length excludes the four-byte prefix. Each receiver must reject a frame
larger than its configured control-frame limit. `protocol_version` is `1`.
`message_sequence` increases independently in each direction; gaps are allowed,
but duplicates and reordering are rejected. The edge resets its outbound
control sequence on every connection, so its first `NodeRegisterRequest`
currently has `message_sequence=0`; gateways must not assume the sequence starts
at 1. Heartbeat uses a separate sequence so its acknowledgement remains
explicit.
The legal session order is:
1. The edge sends `NodeRegisterRequest` as its first application message after
every QUIC connect or reconnect. It includes `node_id`, `boot_id`, software
version, the current IPv4/IPv6 interface snapshot and advertised gRPC endpoint.
2. The gateway replies with `NodeRegisterResponse`. Media and heartbeat must not
start until `accepted=true` and a non-empty `session_id` are received.
3. The edge sends `NodeHeartbeat` at the negotiated interval. The gateway returns
`NodeHeartbeatAck` with the same `session_id` and exact
`acknowledged_sequence`. A missing, rejected or mismatched ACK causes the edge
to reconnect and register again after its configured backoff.
4. If media tracks are available, the edge sends `MediaSessionOpen`, binding its
`session_epoch` to the accepted `session_id`, followed by track descriptors.
The edge calls reliable descriptor send before DATAGRAM send for a new
generation. QUIC does not guarantee arrival order across a reliable stream
and DATAGRAMs, so a gateway must boundedly buffer or drop media whose session
or generation metadata has not arrived yet.
5. The gateway may send `ProtocolError`; a fatal error closes the connection.
The current edge receive path otherwise accepts only `NodeRegisterResponse`
and `NodeHeartbeatAck`. Although `MediaSessionClose` is present in the v1
schema, the current edge neither sends it nor accepts it inbound. Gateways
must not depend on that message until both sides implement and test it.
The edge sends a fresh local-interface snapshot in every heartbeat, so an IP
change is reported without a second protocol. These addresses are edge claims.
`observed_source_ip` is authoritative for the public/NAT-facing address and must
be derived by the gateway from the authenticated QUIC peer, never copied from a
client field. `heartbeat_interval_ms=0` in the registration response means the
edge retains its configured interval.
DATAGRAM negotiation is required only when at least one media track is enabled.
The reliable registration and heartbeat path remains valid for a zero-track
node or while all media sources are unavailable.
Media source registration, device start/keyframe callbacks and DATAGRAM
packetization run on a dedicated media worker. Reliable registration and
heartbeat therefore continue while a media source is slow or unavailable;
the reliable send path serializes heartbeat and media metadata so envelope
sequence order remains strict.
`MediaSourceHub` is protocol-neutral and gives each adapter an independent,
single-consumer subscription cursor. Its source-start callback receives a
cancellation predicate and must check it around potentially blocking device
startup. QUIC reconnect/stop and gRPC client cancellation can therefore abandon
startup without blocking presence or process shutdown. Ring overflow keeps the
newest bounded set of immutable frames, advances a slow cursor to the oldest
retained frame, and reports the exact dropped count so protocol adapters can
mark a discontinuity and request a new video keyframe.
## Media DATAGRAM wire format
Every media DATAGRAM starts with this fixed 64-byte, network-byte-order header:
| Offset | Size | Field |
|---:|---:|---|
| 0 | 4 | magic `CMQD` (`0x434d5144`) |
| 4 | 1 | protocol version (`1`) |
| 5 | 1 | media kind (`1` video, `2` audio) |
| 6 | 2 | flags |
| 8 | 2 | header size (`64`) |
| 10 | 2 | fragment index |
| 12 | 2 | fragment count |
| 14 | 2 | payload size |
| 16 | 4 | track ID |
| 20 | 4 | codec generation token |
| 24 | 8 | media session epoch |
| 32 | 8 | DATAGRAM packet sequence |
| 40 | 8 | media frame sequence |
| 48 | 8 | monotonic capture timestamp in microseconds |
| 56 | 4 | complete frame size |
| 60 | 4 | fragment byte offset in the complete frame |
Flag bit 0 marks a video keyframe, bit 1 is reserved and must currently be
zero, and bit 2 marks a discontinuity. Codec initialization bytes are carried
reliably in `MediaTrackDescriptor`. QUIC already authenticates each DATAGRAM,
so the application header has no redundant checksum.
Receivers must key reassembly by `(session_epoch, track_id, frame_sequence)`,
drop incomplete frames at their playback deadline, and reject fragments whose
offset plus payload exceeds `frame_size`. A new session epoch invalidates all
fragments retained from a previous connection. `frame_sequence` is generated
by the QUIC edge service and remains strictly increasing per track throughout
the media epoch, including after a device source restarts.

View File

@ -0,0 +1,152 @@
syntax = "proto3";
package cmvr.quic_edge.v1;
// Every application message on the edge-opened reliable bidirectional stream
// is carried by this envelope. message_sequence is strictly increasing per
// sender (gaps are allowed) and is independent from the heartbeat sequence used
// for liveness acknowledgement.
message EdgeControlEnvelope {
uint32 protocol_version = 1;
uint64 message_sequence = 2;
oneof payload {
NodeRegisterRequest node_register_request = 10;
NodeRegisterResponse node_register_response = 11;
NodeHeartbeat node_heartbeat = 12;
NodeHeartbeatAck node_heartbeat_ack = 13;
MediaSessionOpen media_session_open = 20;
MediaTrackDescriptor media_track_descriptor = 21;
MediaSessionClose media_session_close = 22;
ProtocolError protocol_error = 30;
}
}
message NetworkInterfaceAddress {
enum AddressFamily {
ADDRESS_FAMILY_UNSPECIFIED = 0;
ADDRESS_FAMILY_IPV4 = 1;
ADDRESS_FAMILY_IPV6 = 2;
}
string interface_name = 1;
string ip_address = 2;
AddressFamily family = 3;
bool loopback = 4;
}
// The existing cmvr-es gRPC server remains the robot-control endpoint. The
// edge advertises its current reachable address through the QUIC control plane.
message GrpcEndpoint {
string host = 1;
uint32 port = 2;
bool tls = 3;
}
message NodeDescriptor {
string node_id = 1;
string boot_id = 2;
string software_version = 3;
repeated NetworkInterfaceAddress local_interfaces = 4;
GrpcEndpoint grpc_endpoint = 5;
}
// This must be the first application message sent after each QUIC connection
// is established. A reconnect always creates a new registration session.
message NodeRegisterRequest {
NodeDescriptor node = 1;
uint64 sent_at_unix_ms = 2;
}
message NodeRegisterResponse {
bool accepted = 1;
string session_id = 2;
string message = 3;
// Zero tells the edge to keep its locally configured interval.
uint32 heartbeat_interval_ms = 4;
// Derived by the receiver from the authenticated QUIC peer address. It is
// not copied from a client-supplied local interface.
string observed_source_ip = 5;
}
// A heartbeat carries a fresh network snapshot so address changes are reported
// without opening a second protocol or connection.
message NodeHeartbeat {
string node_id = 1;
string boot_id = 2;
string session_id = 3;
uint64 sequence = 4;
uint64 sent_at_unix_ms = 5;
string software_version = 6;
repeated NetworkInterfaceAddress local_interfaces = 7;
GrpcEndpoint grpc_endpoint = 8;
}
message NodeHeartbeatAck {
bool accepted = 1;
uint64 acknowledged_sequence = 2;
string message = 3;
uint64 server_time_unix_ms = 4;
string observed_source_ip = 5;
string session_id = 6;
}
message MediaSessionOpen {
string node_id = 1;
uint64 session_epoch = 2;
// Binds the media epoch to the accepted node registration on this QUIC
// connection. Media must not start before this session is assigned.
string session_id = 3;
}
enum MediaKind {
MEDIA_KIND_UNSPECIFIED = 0;
MEDIA_KIND_VIDEO = 1;
MEDIA_KIND_AUDIO = 2;
}
message MediaTrackDescriptor {
uint32 track_id = 1;
MediaKind kind = 2;
string device_id = 3;
string codec = 4;
uint64 codec_generation = 5;
// The exact MediaSourceHub track and the 32-bit token repeated in every
// DATAGRAM header. The full generation remains on the reliable stream.
string source_track_id = 6;
uint32 codec_generation_token = 7;
string payload_format = 8;
// Video fields. They are zero for audio tracks.
uint32 width = 10;
uint32 height = 11;
uint32 frames_per_second = 12;
// Audio fields. They are zero for video tracks.
uint32 sample_rate = 20;
uint32 channels = 21;
// Decoder initialization bytes, for example AVCC/HVCC or AudioSpecificConfig.
// Existing cmvr-es sources may leave this empty when configuration NAL units
// are carried in-band.
bytes codec_config = 30;
}
message MediaSessionClose {
string reason = 1;
string session_id = 2;
uint64 session_epoch = 3;
}
message ProtocolError {
uint32 code = 1;
string message = 2;
uint64 related_message_sequence = 3;
bool fatal = 4;
}