第一章:Go语言USB编程概述与UVC协议基础
Go语言原生不提供USB设备操作支持,需借助C语言绑定或系统级库实现底层交互。主流方案包括基于libusb的cgo封装(如github.com/google/gousb)和操作系统专用接口(Linux下通过/dev/video*节点配合V4L2 ioctl调用)。其中,gousb库提供了跨平台的USB设备枚举、配置描述符解析及控制/中断/Bulk传输能力,是构建Go USB应用的首选基础设施。
UVC协议核心机制
UVC(USB Video Class)是USB-IF定义的标准视频设备类规范,无需厂商驱动即可被主流操作系统识别。其关键特性包括:
- 设备通过标准控制请求(如
GET_CUR/SET_CUR)管理视频流参数(分辨率、帧率、曝光等) - 视频数据通过ISOCHRONOUS端点传输,保障实时性与带宽稳定性
- 所有控制能力由UVC描述符(Video Control Interface + Video Streaming Interface)静态声明
Go中获取UVC设备能力示例
使用gousb枚举并读取UVC摄像头的视频控制描述符:
// 初始化USB上下文
ctx := &gousb.Context{}
defer ctx.Close()
// 查找UVC设备(bDeviceClass=0x0e,即Video Class)
dev, err := ctx.OpenDeviceWithVIDPID(0, 0, func(d *gousb.DeviceDesc) bool {
return d.DeviceClass == 0x0e
})
if err != nil {
log.Fatal(err)
}
defer dev.Close()
// 获取视频控制接口(通常为Interface 0)
iface, err := dev.DefaultInterface()
if err != nil {
log.Fatal(err)
}
// 此处可进一步发送GET_DESCRIPTOR请求解析UVC控制单元
UVC常见控制请求类型
| 请求类型 | 请求码 | 典型用途 |
|---|---|---|
| GET_CUR | 0x81 | 读取当前亮度/对比度值 |
| SET_CUR | 0x01 | 设置自动对焦启用状态 |
| GET_INFO | 0x03 | 查询某控制项是否可读写 |
UVC设备必须在描述符中明确定义每个控制项的支持能力,Go程序需据此动态构造控制传输包,不可盲目发送未声明的请求。
第二章:Linux UVC设备通信原理与Go底层驱动建模
2.1 USB设备枚举与UVC描述符解析实践
USB设备上电后,主机通过控制传输发起标准请求(如GET_DESCRIPTOR),触发完整枚举流程:复位 → 获取设备描述符 → 地址分配 → 获取配置/接口/字符串描述符。
UVC描述符层级结构
UVC设备在标准USB描述符基础上扩展了多类类特定描述符:
cs-interface(VideoControl Interface)vs-interface(VideoStreaming Interface)format-uncompressed/frame-uncompressed
枚举关键代码片段
// 请求获取UVC视频控制接口的处理单元描述符
uint8_t req_buf[32];
usb_control_transfer(
dev, // 设备句柄
USB_DIR_IN | USB_TYPE_CLASS | USB_RECIP_INTERFACE,
UVC_GET_CUR, // bRequest: 获取当前值
(UVC_VC_PROCESSING_UNIT << 8) | 0x03, // wValue: PU ID + control selector (Brightness)
intf_num, // wIndex: VC接口编号
req_buf, 4, 1000 // wLength=4字节亮度值,超时1s
);
该请求读取处理单元(PU)的当前亮度值;wValue高字节为单元ID,低字节为控制选择器(0x03=Brightness);返回数据为LE编码的16位有符号整数。
| 字段 | 含义 | 示例值 |
|---|---|---|
bDescriptorSubtype |
描述符子类型 | 0x05(PU) |
bControlSize |
控制字段字节数 | 0x02 |
bmControls[0] |
支持的控制位掩码(LSB) | 0x01(Brightness) |
graph TD
A[主机复位设备] --> B[读取设备描述符]
B --> C[分配地址]
C --> D[读取配置描述符]
D --> E[解析UVC VideoControl Interface]
E --> F[遍历Extension Unit/Processing Unit]
2.2 UVC控制请求(SET_CUR/GET_CUR)的Go二进制编码实现
UVC设备通过标准控制请求读写视频类特定单元(如亮度、对比度),核心是构造符合USB Video Class 1.5规范的4字节请求体。
请求结构解析
每个SET_CUR/GET_CUR请求携带:
wValue: 控制ID(如VC_BRIGHTNESS_CONTROL= 0x01)wIndex: 单元ID(如Processing Unit ID = 0x03)wLength: 数据长度(通常为2字节,含符号整数)
Go二进制编码示例
func encodeUVCControlRequest(ctrlID, unitID uint16, value int16) []byte {
buf := make([]byte, 4)
binary.PutUint16(buf[0:2], ctrlID) // wValue
binary.PutUint16(buf[2:4], unitID) // wIndex
// Note: wLength is set in USB control transfer, not in payload
return buf
}
该函数生成标准UVC控制请求前缀;实际SET_CUR有效载荷需额外追加value(如binary.Write(&buf, binary.LE, value)),而GET_CUR则省略载荷,仅用此头触发读取。
| 字段 | 偏移 | 类型 | 示例值 |
|---|---|---|---|
wValue |
0x00 | uint16 | 0x0001 |
wIndex |
0x02 | uint16 | 0x0003 |
graph TD
A[Go调用encodeUVCControlRequest] --> B[填充wValue/wIndex]
B --> C[返回4字节请求头]
C --> D[USB主机发送SET_CUR/GET_CUR]
2.3 Video Streaming接口配置与帧格式协商(YUY2/MJPG/H264)
USB Video Class(UVC)设备通过GET_CUR/SET_CUR控制请求在VideoControl Interface上协商流参数,在VideoStreaming Interface上完成帧格式与帧描述符匹配。
格式协商关键流程
// 查询支持的格式:bFormatIndex = 1 (YUY2), 2 (MJPG), 3 (H264)
uvc_get_cur(dev, VS_PROBE_CONTROL, &probe, sizeof(probe));
// probe.bFormatIndex 决定后续帧描述符解析路径
该请求读取当前Probe结构体,其中bFormatIndex联动选择对应VS_FORMAT_MJPEG或VS_FORMAT_H264描述符块;dwMaxVideoFrameSize需匹配所选格式的典型码率上限。
常见格式特性对比
| 格式 | 压缩类型 | 带宽需求 | 主机解码依赖 | 典型用途 |
|---|---|---|---|---|
| YUY2 | 无损YUV422 | 高(~27 MB/s @ 1080p30) | 否 | 低延迟采集、图像处理 |
| MJPG | 有损JPEG | 中(~5–15 MB/s) | 否(硬件解码可选) | 网络传输、嵌入式终端 |
| H264 | 有损视频编码 | 低(~1–4 MB/s) | 是(需VAAPI/VideoToolbox) | 远程会议、移动端推流 |
数据同步机制
graph TD A[Host sends SET_CUR PROBE] –> B{bFormatIndex == 3?} B –>|Yes| C[Load H264_GUID, parse H264_FRAME_DESC] B –>|No| D[Parse MJPG/YUY2_FRAME_DESC] C –> E[Configure SPS/PPS via VS_COMMIT_CONTROL] D –> E
2.4 异步UVC控制传输与超时重试机制设计
UVC设备的控制请求(如曝光、白平衡设置)需通过异步控制传输实现,避免阻塞视频流路径。
核心设计原则
- 控制命令与视频数据通道物理隔离
- 所有SET/GET请求封装为异步任务,绑定唯一
request_id - 超时阈值按操作类型分级:基础参数(100ms)、耗时操作(500ms)
重试策略
- 指数退避:初始延迟 10ms,最大重试3次
- 错误分类响应:
STALL不重试,TIMEOUT触发重试,BUSY等待后重发
// 异步控制传输任务结构体
struct uvc_ctrl_req {
uint8_t req_type; // USB_REQ_TYPE_CLASS | USB_RECIP_INTERFACE
uint8_t unit_id; // 终端单元ID(如PTZ单元=6)
uint16_t ctrl_sel; // UVC_VC_* 控制选择符(如UVC_VC_EXPOSURE_TIME_ABSOLUTE)
uint8_t data[32]; // 控制值缓冲区(小端编码)
uint32_t timeout_ms; // 当前重试级对应超时值
};
该结构体支持动态超时配置,ctrl_sel决定控制域语义,data长度由UVC规范定义;timeout_ms在每次重试时按指数增长更新。
| 重试次数 | 延迟间隔 | 触发条件 |
|---|---|---|
| 0 | — | 首次发送 |
| 1 | 10 ms | TIMEOUT |
| 2 | 30 ms | TIMEOUT(二次) |
| 3 | 70 ms | TIMEOUT(终次) |
graph TD
A[发起控制请求] --> B{USB传输完成?}
B -- 是 --> C[解析响应状态]
B -- 否 & TIMEOUT --> D[启动重试计数器]
D --> E{重试<3次?}
E -- 是 --> F[指数延迟后重发]
E -- 否 --> G[上报CTRL_FAILED]
C --> H[成功/失败回调]
2.5 设备热插拔事件监听与libusb-free的纯Go轮询方案
传统 USB 热插拔依赖 libusb_hotplug_register_callback,但需 CGO 且跨平台兼容性差。Go 生态中,gousb 仍绑定 libusb;而纯 Go 方案需绕过内核事件机制,转向设备状态轮询。
轮询核心逻辑
func pollUSBDevices(interval time.Duration) <-chan []string {
ch := make(chan []string, 1)
go func() {
for {
devs, _ := listUSBPaths() // 如读取 /sys/bus/usb/devices/ 下目录
ch <- devs
time.Sleep(interval)
}
}()
return ch
}
listUSBPaths() 扫描 Linux sysfs 或 macOS IOKit 路径,返回唯一设备标识(如 busnum:devnum);interval 建议设为 500ms——兼顾响应性与系统负载。
方案对比
| 方案 | 依赖 | 实时性 | Windows 支持 |
|---|---|---|---|
| libusb 回调 | CGO | 高 | ✅ |
| 纯 Go 轮询 | 无 | 中 | ⚠️(需 WMI 查询) |
graph TD
A[启动轮询] --> B[读取当前设备列表]
B --> C[与上一次快照比对]
C --> D{新增/移除?}
D -->|是| E[触发 OnConnect/OnDisconnect]
D -->|否| B
第三章:纯Go UVC控制核心模块实现
3.1 UVCControl结构体设计与参数安全封装
UVCControl 是 USB 视频类设备控制的核心抽象,需兼顾硬件约束与线程安全。
数据同步机制
采用读写锁保护共享字段,避免控制参数在 ioctl 调用与内核回调间发生竞态:
typedef struct {
uint8_t brightness; // [0, 255],经校验后映射至硬件寄存器
uint8_t contrast; // [0, 127],带饱和截断保护
uint8_t auto_exposure; // 0=manual, 1=auto,仅接受枚举值
rwlock_t lock; // 读多写少场景下的轻量同步原语
} UVCControl;
brightness与contrast在 set 接口内强制执行范围裁剪(CLAMP(val, min, max)),auto_exposure使用位掩码校验(val & ~1非零则拒绝写入),杜绝非法状态注入。
安全封装策略
- 所有字段仅通过
uvc_control_set_*()系列函数修改 - 初始化时自动置为硬件默认值(非零初始值)
- 每次写入触发 CRC16 校验缓存更新
| 字段 | 校验方式 | 错误响应 |
|---|---|---|
| brightness | 范围检查 | -ERANGE |
| auto_exposure | 枚举校验 | -EINVAL |
| contrast | 截断赋值 | 无错误,静默修正 |
3.2 自动对焦、曝光、白平衡等标准控制项的Go接口映射
在嵌入式视觉系统中,gocv 和 v4l2go 等库将 V4L2 控制 ID 映射为类型安全的 Go 接口,屏蔽底层 ioctl 复杂性。
核心控制项映射表
| V4L2 控制 ID | Go 字段名 | 类型 | 取值范围 |
|---|---|---|---|
V4L2_CID_AUTO_FOCUS |
AutoFocus |
bool |
true/false |
V4L2_CID_EXPOSURE_AUTO |
ExposureMode |
uint32 |
1(auto), 3(manual) |
V4L2_CID_WHITE_BALANCE_TEMPERATURE |
WBTempK |
int32 |
2800–6500 |
控制参数设置示例
// 设置手动曝光时间为 10ms(单位:微秒)
ctrls := v4l2.Controls{
ExposureAuto: 3, // manual
ExposureAbs: 10000,
}
err := dev.SetControls(ctrls)
该调用最终封装 VIDIOC_S_CTRL ioctl,将 ExposureAbs 值经 v4l2_control 结构体序列化后传入内核驱动;ExposureAbs 单位为微秒,需确保设备支持 V4L2_CID_EXPOSURE_ABSOLUTE 能力位。
数据同步机制
graph TD
A[Go 应用层] -->|v4l2.Controls struct| B[v4l2go 封装层]
B -->|ioctl VIDIOC_S_CTRL| C[Linux V4L2 子系统]
C --> D[ISP 驱动/HAL]
3.3 摄像头PTZ(Pan-Tilt-Zoom)扩展命令的协议兼容实现
为支持多厂商设备统一控制,需在ONVIF核心协议基础上扩展自定义PTZ指令,同时保持与GB/T 28181、RTSP+VISCA的双向兼容。
协议适配层设计
采用命令路由表实现协议语义映射:
| ONVIF Action | GB/T 28181 CMD | VISCA Hex | 备注 |
|---|---|---|---|
AbsoluteMove |
0x0301 (云台绝对定位) |
81 01 06 02 00 00 00 00 FF |
角度归一化至0–359° |
RelativeMove |
0x0302 (相对移动) |
81 01 06 03 02 00 03 00 FF |
X/Y步长单位:0.1° |
def map_ptz_command(onvif_req: dict) -> bytes:
# 将ONVIF AbsoluteMove坐标(pan=-180~180, tilt=-90~90)转为VISCA格式
pan_visca = int((onvif_req["Position"]["PanTilt"]["x"] + 180) * 0x4000 / 360) # 16-bit unsigned
tilt_visca = int((onvif_req["Position"]["PanTilt"]["y"] + 90) * 0x2000 / 180)
return bytes([0x81, 0x01, 0x06, 0x02]) + pan_visca.to_bytes(2, 'big') \
+ tilt_visca.to_bytes(2, 'big') + b'\xFF'
逻辑分析:pan_visca将-180°→180°线性映射至VISCA 16位无符号域(0x0000–0x4000),tilt_visca同理映射-90°→90°至0x0000–0x2000;末尾b'\xFF'为VISCA帧终止符。
数据同步机制
使用状态缓存+时间戳校验避免指令冲突。
第四章:实时视频流捕获与控制联动系统构建
4.1 UVC Isochronous Endpoint数据包解析与帧重组算法
UVC设备通过等时传输通道持续推送视频数据,每个USB微帧(125μs)可携带多个等时数据包(ISO Packet),需精准识别边界并恢复原始视频帧。
数据包结构特征
- 每个ISO包含12字节头部(含PID、长度、状态)
- 有效载荷以
bDescriptorSubtype == VS_FRAME_UNCOMPRESSED起始标记帧头 - 帧内分片(Fragment)由
dwFrameInterval和bFrameIndex协同标识连续性
帧重组核心逻辑
// 伪代码:基于bFrameIndex的环形缓冲区重组
if (pkt->header.bFrameIndex != expected_idx) {
flush_current_frame(); // 帧序错乱,丢弃残帧
expected_idx = pkt->header.bFrameIndex;
}
append_payload(pkt->data, pkt->length);
if (pkt->header.bEndOfFrame) { // 标志位指示帧终结
emit_complete_frame(buffer);
}
bEndOfFrame由主机端UVC驱动在最后一片设置;bFrameIndex每帧递增(模256),用于检测丢帧与重排序。
关键状态机流转
graph TD
A[接收ISO包] --> B{bFrameIndex匹配?}
B -->|是| C[追加至当前帧缓冲]
B -->|否| D[清空缓冲,重置expected_idx]
C --> E{bEndOfFrame==1?}
E -->|是| F[提交完整YUV帧]
E -->|否| A
| 字段 | 长度 | 作用 |
|---|---|---|
bFrameIndex |
1 byte | 帧序列号,支持循环检测 |
bEndOfFrame |
1 bit | 位于头部第10字节bit7,标识末片 |
4.2 零拷贝Ring Buffer设计与Go协程安全帧队列管理
核心设计目标
- 消除内存复制开销(尤其是大帧数据)
- 支持高并发生产/消费(如视频采集 × 多路AI推理)
- 保证跨goroutine访问的原子性与缓存一致性
Ring Buffer零拷贝实现
type FrameRing struct {
buf []byte // 预分配连续内存块
mask uint64 // len(buf)-1,用于位运算取模(2的幂)
prodPos uint64 // 生产者位置(原子读写)
consPos uint64 // 消费者位置(原子读写)
}
// 获取可写帧头指针(无拷贝)
func (r *FrameRing) Acquire(size int) []byte {
// ……(省略边界检查与自旋等待逻辑)
return r.buf[r.prodPos&r.mask : (r.prodPos+uint64(size))&r.mask]
}
mask确保O(1)索引计算;Acquire返回原始底层数组切片,避免make([]byte, size)分配;prodPos/consPos使用atomic.LoadUint64同步,规避锁竞争。
协程安全帧生命周期管理
| 阶段 | 管理方式 |
|---|---|
| 分配 | Acquire() 返回裸指针 |
| 使用中 | 引用计数 + sync.Pool回收 |
| 释放 | Release() 原子递增consPos |
graph TD
A[Producer Goroutine] -->|Acquire → 写入帧数据| B(Ring Buffer)
B -->|Release → 更新 consPos| C[Consumer Goroutine]
C -->|Read → 直接访问 buf| D[AI推理模块]
4.3 控制指令与视频流时间戳同步机制(PTS对齐与延迟补偿)
数据同步机制
视频解码器与控制指令(如播放/暂停/跳转)需严格对齐 PTS(Presentation Timestamp),否则出现音画撕裂或指令响应滞后。
延迟补偿策略
- 检测当前渲染帧 PTS 与指令触发时刻的时间差 Δt
- 动态调整后续帧的解码/显示调度窗口,而非丢弃帧
- 对于快进指令,跳过 PTS 落在
[t_cmd, t_cmd + Δt_th]区间的非关键帧
PTS 对齐代码示例
// 指令到达时记录系统时间(纳秒级)
int64_t cmd_ts = av_gettime_ns();
// 获取下一待显示帧的PTS(已转为同一时间基)
int64_t next_pts = av_rescale_q(frame->pts, dec_ctx->time_base, AV_TIME_BASE_Q);
int64_t pts_diff_us = (next_pts - cmd_ts) / 1000; // 微秒级偏差
if (pts_diff_us > 50000) { // >50ms,启用预缓冲补偿
avcodec_flush_buffers(dec_ctx); // 清空旧解码状态
}
逻辑分析:cmd_ts 作为控制锚点,next_pts 经时间基统一后可直接比对;pts_diff_us 决定是否触发缓冲重置,避免累积延迟。avcodec_flush_buffers() 确保后续帧从新时间线开始解码。
| 补偿模式 | 触发条件 | 影响范围 |
|---|---|---|
| 无补偿 | |Δt| ≤ 10ms |
正常流水调度 |
| PTS偏移重映射 | 10ms < |Δt| ≤ 50ms |
调整显示队列顺序 |
| 缓冲刷新 | |Δt| > 50ms |
清空解码器状态 |
graph TD
A[指令到达] --> B{计算PTS偏差Δt}
B -->|Δt ≤ 10ms| C[直通显示]
B -->|10ms < Δt ≤ 50ms| D[PTS重排序]
B -->|Δt > 50ms| E[Flush+重同步]
4.4 基于HTTP+MJPEG的轻量级流式服务与Web控制面板集成
MJPEG(Motion JPEG)以帧为单位封装JPEG图像,通过HTTP分块传输(multipart/x-mixed-replace),无需复杂编解码器,天然适配浏览器 <img> 标签。
核心服务实现(Python + Flask)
from flask import Response, render_template
import cv2
def gen_frames():
cap = cv2.VideoCapture(0)
while True:
ret, frame = cap.read()
if not ret: break
_, buffer = cv2.imencode('.jpg', frame, [cv2.IMWRITE_JPEG_QUALITY, 75])
yield (b'--frame\r\n'
b'Content-Type: image/jpeg\r\n\r\n' + buffer.tobytes() + b'\r\n')
@app.route('/stream')
def stream():
return Response(gen_frames(),
mimetype='multipart/x-mixed-replace; boundary=frame')
逻辑分析:
gen_frames()持续捕获并压缩帧;mimetype告知浏览器按MJPEG协议解析;boundary=frame定义帧分隔符。参数IMWRITE_JPEG_QUALITY=75平衡画质与带宽(60–85为推荐区间)。
Web控制面板集成要点
- 支持异步AJAX指令(如
/api/led?state=on) - 流地址嵌入
<img src="/stream" />实现零依赖实时预览 - 前端状态同步采用 Server-Sent Events(SSE)
| 特性 | HTTP+MJPEG | WebRTC |
|---|---|---|
| 浏览器兼容性 | ✅ 全平台 | ⚠️ 需权限/信令 |
| 延迟 | 200–500ms | |
| CPU占用 | 极低 | 中高 |
graph TD
A[浏览器请求 /stream] --> B[Flask响应 multipart/x-mixed-replace]
B --> C[浏览器解析 boundary=frame]
C --> D[逐帧渲染 JPEG]
D --> E[实时画面显示]
第五章:完整代码开源说明与测试固件使用指南
开源仓库结构说明
本项目完整源码托管于 GitHub 公共仓库(https://github.com/embedded-iot/esp32-can-gateway),采用模块化组织方式。根目录下包含 firmware/(主固件工程)、test/(单元测试与硬件在环测试用例)、docs/(硬件引脚定义与通信协议文档)、scripts/(自动烧录与日志解析 Python 脚本)及 ci/(GitHub Actions 流水线配置)。所有 CMakeLists.txt 均启用 -Wall -Werror -Wextra 编译选项,确保静态检查通过率 100%。核心驱动层已通过 MISRA-C:2012 Rule 1.3 和 Rule 17.7 合规性扫描。
固件编译与烧录流程
支持 ESP-IDF v5.1.4 及以上版本。执行以下命令完成本地构建:
cd firmware && idf.py set-target esp32c3 && idf.py build && idf.py -p /dev/ttyUSB0 flash monitor
烧录前需确认 USB-to-Serial 芯片为 CP2102 或 CH340,并在 sdkconfig.defaults 中设置 CAN 波特率为 500 kbps、GPIO 引脚映射为 IO10(TX)、IO11(RX)。若使用 JTAG 调试,可启用 idf.py jtag-debug 进入 GDB 会话。
测试固件功能矩阵
| 测试类型 | 触发方式 | 验证指标 | 耗时(秒) |
|---|---|---|---|
| CAN 帧环回测试 | 发送 0x123 标准帧 | 接收帧 ID/数据/时间戳一致性 | 2.1 |
| 错误帧注入 | 手动短接 CAN_H/CAN_L | 错误计数器递增与中断触发日志 | 3.8 |
| OTA 升级压力测试 | 连续 10 次固件推送 | 升级成功率、Flash 擦写寿命监控 | 186 |
硬件在环测试(HIL)操作指南
使用 Vector CANcaseXL 连接被测设备,运行 test/hil/can_hil_test.py 脚本:
- 自动加载
test/vectors/can_fd_250kbps.csv中预定义的 127 条激励向量 - 实时比对响应帧与预期 CRC-16/CCITT-FALSE 校验值
- 生成
reports/hil_report_$(date +%Y%m%d_%H%M%S).html可视化报告,含帧延迟直方图与错误分布热力图
故障诊断辅助工具
scripts/dump_can_log.py 支持从串口原始日志中提取结构化 CAN 数据流:
# 示例输出片段(JSONL 格式)
{"ts_ms":1698765432100,"id":0x18F,"len":8,"data":"0102030405060708","type":"STD"}
{"ts_ms":1698765432105,"id":0x18F,"len":8,"data":"0102030405060709","type":"STD"}
配合 scripts/analyze_jitter.py 可计算帧间间隔标准差(实测典型值 ≤ 12μs),用于验证实时性达标情况。
安全启动与签名验证配置
固件默认启用 Secure Boot V2 与 Flash Encryption。私钥 keys/secure_boot_signing_key.pem 由硬件安全模块(HSM)生成并离线保管;签名过程通过 idf.py secure-boot-sign 自动完成,签名摘要存储于 eFuse 的 BLOCK_KEY3 区域。烧录后可通过 idf.py monitor 查看 Secure boot enabled, flash encryption enabled 启动日志。
CI/CD 流水线关键节点
flowchart LR
A[Push to main branch] --> B[Build firmware for ESP32/ESP32-C3/ESP32-S3]
B --> C[Run unit tests on QEMU]
C --> D[Deploy to physical test rig via SSH]
D --> E[Execute HIL test suite]
E --> F[Generate coverage report with gcovr]
F --> G[Upload artifacts to GitHub Releases if all pass] 