# Service 模块开发指南 `service/` 实现边缘端对外协议和设备抽象之间的适配。Service 负责解析请求、查找设备、转换 DTO 和返回结果,不负责创建具体设备后端。 返回[项目总览](../../README.md)。 ## 当前结构 `service/` 顶层只按传输协议保留两个子目录:`grpc/` 和 `quic_edge/`。gRPC 内部再按运行角色分层,避免把队列、停止控制、客户端和服务端实现混在同一层。 ```text service/ ├── grpc/ │ ├── action/ # ActionQueue 校验、账本和 FIFO 执行器 │ ├── client/ # 边缘端使用的 gRPC client │ ├── server/ # gRPC service、协调器和安全扩展 │ │ ├── include/ │ │ ├── src/ │ │ └── tests/ │ └── stop_all/ # 高优先级 StopAll 通道 └── quic_edge/ # QUIC client、控制状态机和媒体 packetizer ``` | 目录 | 职责 | | --- | --- | | `grpc/action/` | SystemService ActionQueue 的校验、幂等账本和边缘端 FIFO 执行器 | | `grpc/client/` | 面向边缘端内部调用的 gRPC client | | `grpc/server/` | 入站设备控制、状态查询、兼容流式接口和安全控制面 | | `grpc/stop_all/` | 不进入普通命令队列的高优先级停止通道 | | `quic_edge/` | 边缘端主动连接平台的 QUIC client、控制状态机和媒体 packetizer | | `quic_edge/tests/` | 已登记到 CTest 的 QUIC 协议测试 | 两个遗留 gRPC client test 位于 `grpc/server/tests/*_client_test.cpp`,当前没有通过 `add_test()` 登记;它们是历史可执行文件,不代表默认自动覆盖。 gRPC 和 QUIC 的职责边界: - 机械臂、AGV 等可靠控制继续使用 gRPC; - 节点注册、心跳和 IP 上报使用 QUIC reliable stream; - 实时音视频使用 QUIC DATAGRAM; - `quic_edge/` 不是平台 Gateway,也不是浏览器服务器。 ## SystemService 设备清单 `SystemService/GetDeviceList` 返回 `DeviceManager` 的当前只读快照,只包含 `enabled=true` 的设备。启用但创建、初始化、启动或健康检查失败的设备仍会返回, 并通过 `manager_state`、`health`、`has_error` 和 `error_message` 描述异常。 接口同时返回稳定的 `device_type` 和仅用于展示/诊断的具体 `type_name`;调用方 不得使用 `type_name` 做设备类别判断。 该 RPC 不修改配置、不动态注册设备,也不触发设备生命周期操作。启用 reflection 后可直接查询: ```bash grpcurl -plaintext \ -d '{}' \ 127.0.0.1:50052 \ cmvr.api.SystemService/GetDeviceList ``` ## SystemService ActionQueue `SystemService/ExecuteActionQueue` 接收一个完整的有限动作序列,在边缘端排队并 逐步串行执行,所有步骤结束后返回最终结果。平台只需要提交一次请求,因此连续机械臂 动作不会再受到每个单独 gRPC 往返和 Wi-Fi 抖动的影响。 当前 v1 仅允许以下 `ActionStep.command`: - 机械臂同步 `MoveJ`、`MoveL`; - AGV 同步 `navigateToPose`、`navigateToStation`、`followPath`; - 边缘端本地 `delay`。 机械臂和 AGV 请求复用各自已有的类型化 Request,目标设备仍由每一步的 `header.device_id` 指定。所有运动步骤必须设置 `asynchronous=false`;`MoveL` v1 仅接受 Base frame;AGV 后端还必须明确支持同步导航终态确认。`speedJ`、`speedL`、`servoJ`、 AGV `translate`、速度控制、查询和流式 RPC 都不属于 ActionQueue v1。 ActionQueue 遵循以下执行语义: - 平台先调用 `GetSystemInfo` 读取 `action_service_instance_id`,并在每次提交和重试中填入 `expected_service_instance_id`。ActionQueue 账本随服务实例重建;若断线期间边缘服务重启, 旧实例 ID 会被拒绝,平台必须先对账,不能用新 ID 自动重放不确定的动作; - `action_id` 是必填的全局唯一幂等键;同一服务实例内,相同内容的已受理请求不会重复下发 设备命令,相同 ID 但内容不同的请求必须拒绝。服务端缓存最近 4096 个完整结果,更早的 已执行 ID 由精确 retired-ID 账本 fail-closed 拒绝、不会重跑;单实例最多记录 262144 个已受理 ID,达到容量后仅拒绝新 ID,已有 ID 仍可查询; - 入队前校验全部步骤、设备、参数和同步能力,校验失败时不会执行任何步骤; - v1 每个请求最多 256 步、序列化大小最多 512 KiB、排队或执行中的 Action 最多 64 个、 同时提交或等待结果的 RPC 最多 256 个;Action 与单步超时上限均为 24 小时, `total_timeout_ms=0` 使用 30 分钟默认值,AGV 路径最多 4096 段; - `total_timeout_ms` 包含排队与执行时间,单步 `timeout_ms=0` 时继承 Action 剩余时间 或服务端默认值;所有超时值均由服务端施加上限; - 任一步失败、取消或超时后立即停止序列,不再执行后续步骤;`completed_steps` 表示此前 成功完成的步骤数,`failed_step_index` 仅在存在对应失败步骤时出现; - Action 一旦受理,不因平台连接中断而自动取消;断线只结束该 RPC waiter,边缘动作继续。 平台可用相同 `action_id` 重试并取得仍在缓存中的同一次执行结果; - `StopAll`、机械臂 `stopMotion`、AGV `cancelNavigation` 和软件急停不进入 FIFO,必须 作为高优先级安全/抢占路径执行。它们仍不具备功能安全等级。 - 机械臂步骤超时会立即走 typed `stopMotion` 并等待停车确认;若无法确认停车,设备控制权 保持隔离,不会继续后续步骤或接受新的普通控制命令;需先按设备安全流程确认状态,再 重启边缘服务恢复控制。 - AGV 的取消 ACK、零速度 ACK 均不等于停稳;ActionQueue 和安全停止 RPC 只有在导航任务 终态且底盘连续零速度采样确认后才释放控制权,否则同样保留隔离。 已知的执行完成、业务失败、取消、超时和预校验拒绝由 `ActionResultCode` 与 `CommandHeader.Feedback` 表达。`ActionDeduplicationStatus` 结构化区分新受理、合并等待、 缓存结果、已淘汰结果、账本耗尽、ID 冲突和服务实例不匹配;平台不得通过解析错误字符串 判断动作是否执行过。 `ACTION_RESULT_CODE_UNSPECIFIED` 不得作为服务端最终结果。 ## MotorService `MotorService` 将 gRPC 电机命令适配到已经由 `DeviceManager` 创建的 `MotorManager` 和 `AbstractMotor`,不直接持有现场总线或厂商驱动。 关键文件: - Proto:[`../../protos/cmvr/api/motor_service.proto`](../../protos/cmvr/api/motor_service.proto) 和 [`../../protos/cmvr/api/motor_command.proto`](../../protos/cmvr/api/motor_command.proto) - 实现:[`grpc/server/include/grpc_motor_service.h`](grpc/server/include/grpc_motor_service.h) 和 [`grpc/server/src/grpc_motor_service.cpp`](grpc/server/src/grpc_motor_service.cpp) - 注册:[`../task/grpc_server_task/src/grpc_server_task.cpp`](../task/grpc_server_task/src/grpc_server_task.cpp) 服务按单电机仲裁。同步 Profile 命令、Cyclic Position/Velocity 双向流、 `setEnabled`、状态读取和软件 `emergencyStop` 共用同一控制权状态: - 同一电机已有 owner 时拒绝新的控制调用; - cyclic 流首帧必须是 `open`,后续 setpoint sequence 必须严格递增; - reader 使用 latest-wins 邮箱,客户端必须持续并发读取反馈; - 取消、deadline、watchdog、非法帧、后端拒绝或写失败都会触发 Quick Stop; - 任何清理 Quick Stop 未确认时,服务进入 fail-closed 锁存; - 只有成功执行 `setEnabled(true)` 才解除服务内软件急停锁存; - 服务层 Quick Stop 和 `emergencyStop` 都不具备功能安全等级。 AUBO 控制柜 IO 不经过 `MotorService`,由 `ArmService/ExecuteJsonCommand` 转发到目标 `RobotArm`。厂商命令和安全约束见 [AUBO 控制柜 IO](../devices/arm/aubo_arm/README.md)。 旧的 `SystemService/ExecuteJsonCommand` 已移除;相机 PTZ 应使用类型化的 `CameraService/ControlPtz`。 ## 新增 gRPC Service 当前没有动态 service registry,必须完成以下全部步骤。 ### 1. 定义 Proto 在 [`../../protos/cmvr/api/`](../../protos/cmvr/api/) 增加或扩展: - `_command.proto` - `_service.proto` import 路径必须相对于 `protos/`。兼容规则见 [`../../protos/README.md`](../../protos/README.md)。 ### 2. 实现 Service 目录约定: ```text service/grpc/ └── server/ ├── 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 或 MediaSourceManager Subscription 释放 producer lease; - 不持有设备状态锁进行网络写; - 为 wait/read 使用有限 timeout; - 慢客户端不能阻塞设备生产线程; - H.264/H.265 丢帧后等待关键帧恢复; - gRPC RGB 流在积压超过 `camera_stream_max_pending_frames` 或帧龄超过 `camera_stream_max_frame_age_ms` 时主动丢弃旧帧,请求 IDR,并从下一个关键帧恢复。 当前仅 gRPC RGB 和麦克风流使用 MediaSourceManager;Depth/RGBD 仍直接读取设备帧。 gRPC 相机实时流默认最多保留 2 帧积压、最大允许 250 ms 帧龄。两个配置项填 0 时使用上述默认值。该策略以低延迟为目标,不保证每个视频帧都到达客户端;控制命令 仍由普通 gRPC RPC 承担。 `FrameData` 附带 `capture_utc_ns`、`source_sequence`、`pts`、`dts`、 `source_fps`、`source_timestamp` 和 `source_frame_number`。平台端可用采集时间 与接收时间的差值区分设备、网络、服务端写阻塞和客户端解码/渲染队列延迟。新增字段 保持 protobuf wire compatibility,旧客户端可以继续连接,但需要重新生成代码后才能 读取这些诊断字段。 ### 当前安全状态 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` | | DeviceManager 心跳适配 | `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,不应将其描述为已实现的双向控制能力。 ### DeviceManager 心跳快照 `QuicEdgeService` 通过可注入的 `DeviceSnapshotProvider` 获取协议无关的纯值 快照。生产构造绑定已经初始化的 `DeviceManager`,fake transport 测试则注入 合成快照,因此协议状态机不需要创建硬件对象或依赖 DeviceManager 单例。 DeviceManager 的本地快照继续保留禁用设备;QUIC wire 映射层仅序列化 `enabled=true` 的设备。已启用但创建、初始化或启动失败的设备不会被过滤。 心跳线程只读取 Manager 维护的内存状态,不能在这里同步访问厂商 SDK、网络或 设备总线。新增设备健康探针必须实现 `AbstractDevice::healthSnapshot()` 的 线程安全、无阻塞 I/O 契约;未实现时上报 `UNSPECIFIED`,不得伪造为健康。 设备异常字符串会限长,整条消息仍受 `maximum_control_frame_bytes` 约束。 ### 跨 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 超时和重连; - DeviceManager 已启用设备过滤、类型/状态映射和 provider 失败隔离; - Gateway 返回零心跳周期时采用本地 `heartbeat_interval_ms`; - 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