标签: 设备身份

  • u-blox 初始化「看似成功」:ACK 被丢弃、False 也继续接收

    结论:u-blox 初始化链路里有几处会导致「看起来配置成功、实际没有生效」的缺口:send_with_ack() 调用 wait_for_ack() 却丢弃了返回的布尔值,因此收到 NACK 时调用者仍会继续下一条配置;init_pigeon() 十轮全部超时后返回 False,而 init() 不检查这个返回值,仍进入接收循环;wait_for_ack() 的默认前缀只比对 UBX 头的前 6 字节,不核对目标 CFG class/id 或校验和。串口以 9600 波特率打开,切换 460800 的那步不等待 ACK。以下为固定快照 c8fb906 的静态研究,未访问串口、未驱动 GPIO、未下载辅助数据、未执行清备份的复位。

    启动与串口所有权

    process_config.py 把 pigeond 配置为 u-blox 路径的受管 Python 进程,它提供原始串口流,ubloxd 再解析定位。TTYPigeon.__init__() 在设备上的 GNSS 串口以 9600 波特率打开共享的 Serial 封装,读超时为 0;由于未使用该封装的 exclusive=True 选项,串口独占并不由此保证。

    receive() 以 64 字节块累计到本次最多约 4096 字节,遇到空读就返回,输出的是任意字节块、不是完整 UBX 帧,分帧由 ubloxd 完成。init() 注册 SIGINT 清理、关闭再打开 GNSS 电源并等待,然后 init_baudrate() 在 9600 发送 $PUBX,41 并把主机端切到 460800——这一步不等待协议 ACK,若模组未完成切速,后续 init_pigeon() 的失败会表现为 ACK 超时。

    阶段 源码可见行为 边界
    串口打开 9600 波特、读超时 0、共享 Serial 未用 exclusive=True,独占无保证。
    原始读取 64 字节块累计到约 4096 字节 输出任意字节块,不是完整 UBX 帧。
    电源控制 set_power() 驱动 SAFEBOOT_N / GNSS_PWR_EN / RST_N gpio_set() 写异常被捕获并打印,调用方不立即收到异常。
    切速 9600 发 $PUBX,41,主机切 460800 不等 ACK;未完成切速表现为后续超时。
    配置 init_pigeon() 最多十轮 CFG 端口/速率/导航/里程计与六种 UBX 输出 定长配置包是当前模组固件契约,不可移植到另一模组。

    ACK 状态与失败传播

    wait_for_ack() 在累计的串口字节里先查 ACK 前缀再查 NACK 前缀:ACK 返回 True,NACK 返回 False,0.5 s 超时抛 TimeoutError。它的默认前缀只含 UBX 头的前 6 字节,没有核对目标 CFG class/id、长度后续字段或校验和——多个应答或异步导航消息交错时,不能把「发现某个 ACK 前缀」当成指定配置已生效的证明。

    函数 可见行为 插件边界
    wait_for_ack() 先查 ACK 前缀,再查 NACK 前缀;ACK 返回 True、NACK 返回 False、0.5 s 超时抛错 默认前缀只含头 6 字节,未核对 class/id 与校验和。
    send_with_ack() 发命令后调用 wait_for_ack(),但丢弃返回的布尔值 NACK 不抛异常,调用者继续下一条配置。
    init_pigeon() 十轮 try 仅捕获 TimeoutError 部分 NACK 不触发本轮重试;全超时返回 False。
    init() 不检查 init_pigeon() 的 False 仍进入接收循环,需要显式设备就绪状态与错误码。
    save_almanac() 直接检查特定 SOS ACK/NACK 的布尔值,超时吞掉 与 CFG 的 send_with_ack() 失败语义不同。

    停止路径也有边界:常规停止由 manager 默认发 SIGINT,deinitialize_and_exit() 先发受控停机命令再关闭 GPIO 电源;若五秒内未退出,manager 可转 SIGKILL,清理处理器未必完成。现有 test_pigeond.py 覆盖原始消息频率、启动后首消息与停止后供电引脚,但本轮未运行,也不能证明每条 CFG 都被正确 ACK 或辅助数据全量装载。

    原始数据以 ubloxRaw 发布,供 UbxFramer.add_data() 处理。这一层只表明串口有字节,不能单独证明配置成功、定位已 fix 或时间可信;UBX 分帧与星历缓存属于另一条链,系统校时门又是第三条链。

    辅助定位与备份

    get_assistnow_messages() 优先用 AssistNowToken 直连 u-blox,退而用 DongleId 请求代理;HTTP 设置 5 秒超时并检查状态码。随后按 UBX 的两字节长度切分响应,但只断言当前片段以 B5 62 开始,不校验每片长度是否足够、也不校验 CK_A/CK_B;短或损坏的响应可能在索引或发送时失败,因此 HTTP 成功不等于辅助数据有效。

    run_receiving() 的后台线程等待系统时间有效且网络类型非 none,每 10 秒重试下载直到 assist_messages is not None——注意空列表也会结束下载重试。主线程看到列表后先置 assist_attempted=True,再逐条 send_with_ack();空列表会直接走到「sent」日志。若发送中抛异常,只记录「failed」且不在同一次运行中重试;若收到 NACK,由于 send_with_ack() 忽略 False,仍可能记录「sent」。只有收到首字节为 0x00 的串口块时才会重新 init() 并把 assist_attempted 清零,才有再次发送机会。

    自制硬件与插件验收

    平台适配器应独占 GNSS 的供电/复位/UART、配置版本、ACK class/id 关联、重试预算与断电顺序,并提供分层可观测状态:power_on、uart_ready、cfg_acked、raw_stream_alive、has_fix、time_trusted。辅助定位插件只产出经 UBX 长度与校验验证的消息,不直接操作供电或修改系统时间。台架需逐项注入 NACK、错命令 ACK、ACK 与数据交错、9600→460800 切速失败、下载损坏、发送中断、无串口字节与 SIGKILL,再验证真实天线、TTFF、UTC/PPS 与电源波形;本轮这些均未完成。

    常见问题

    收到 NACK 会阻止后续配置下发吗?

    不会。send_with_ack() 在发出命令后调用 wait_for_ack(),但丢弃了返回的布尔值,因此 NACK 不抛异常,调用者会继续下一条配置;这是源码可直接判定的控制流,不是模组实测。

    init_pigeon() 全部超时返回 False 后会怎样?

    init() 不检查这个 False,仍会进入接收循环。也就是说初始化失败不会挡住接收路径,插件需要显式的设备就绪状态与错误码。

    wait_for_ack() 会校验是目标配置的应答吗?

    不会。它的默认前缀只比对 UBX 头的前 6 字节,没有核对目标 CFG class/id、长度后续字段或校验和,因此在多个应答交错时不能把「发现某个 ACK 前缀」当成指定配置已生效。

    AssistNow 下载返回 HTTP 成功,辅助数据就一定有效吗?

    不一定。切分响应时只断言片段以 B5 62 开始,不校验长度是否足够、也不校验 CK_A/CK_B;短或损坏响应可能在索引或发送时失败。再加上空列表也会结束下载重试,HTTP 成功不等于辅助数据有效。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/GNSS_UBLOX_DEVICE_LIFECYCLE_DEEP_DIVE.md(取用了启动与串口所有权、ACK 状态与失败传播表、AssistNow 与备份边界、以及插件验收分层状态要求)。

    说明:本文为对固定源码快照的静态结构分析,未运行完整测试套件、未连接硬件、未验证目标车型的真实参数组合。文中所有阈值、行号索引与配置均出自源文档,标注为未验证的结论保留其原有边界。

    更新记录

    首次发布 2026-10-11 最近核对:2026-10-11

  • 二维码编码只到版本 20、纠错固定 L:openpilot 编解码两端并不对称

    结论:openpilot 的二维码模块编码端与解码端能力并不对称:编码只覆盖版本 1 到 20、只构造 byte mode,并固定纠错等级 L 与 mask 0;解码端则支持版本 1 到 40 与四个纠错等级,还会尝试相邻版本、alignment 与转置等候选。更重要的产品边界是:二维码只是载体不是授权——配对令牌取不到时会退化成空令牌 URL,而这个 URL 仍能成功编码成图形,所以「屏幕上有二维码」不证明令牌有效;eSIM 扫码的前置检查也只校验字符串形状。以下为固定源码快照 c8fb906 的静态分析与隔离测试结论。

    编码与屏幕呈现

    make_texture() 把输入字符串编码为 UTF-8,按 _capacity() 从版本 1 到 20 选择首个可容纳的码型,超过上限抛 ValueError。_data_codewords() 只构造 QR 的 byte mode,写入 8/16 位长度、终止位与交替填充字节;_codewords() 按 _EC 表分块、计算 GF(256) Reed-Solomon 余数并交织。当前编码固定纠错等级 L 和 mask 0,并不搜索视觉质量最佳的 mask,编码版本范围也比解码范围窄。

    _Qr 绘制 finder/timing/alignment、格式位与版本位,再以两列之字形填入数据。普通模式外加 4 模块 quiet zone,反色模式不加边框;两者都把每个模块放大为 10×10 RGBA 像素后通过 raylib 上传纹理。make_texture() 的注释认为上传会复制像素,因此中间 NumPy 数组无需保持存活,但实际 GPU 驱动资源生命周期仍由 UI 调用方负责。

    项 编码端 解码端
    版本范围 1 到 20 1 到 40
    纠错等级 固定 L 四个等级均支持
    字符模式 仅 byte mode(UTF-8) 数字、字母数字、byte、Kanji 与受支持 ECI
    mask 固定 mask 0,不搜索 从格式位恢复级别/mask

    配对 QR 的展示边界

    大屏 PairingDialog._generate_qr_code() 先释放旧纹理,再把 DongleId 与配对 JWT 拼入 URL 制作新纹理,每 300 秒刷新,失败时记录日志并清空纹理。二维码中可能有配对令牌,视觉截图、投屏与诊断日志应视为敏感路径;生成纹理只负责表示 URL,不能证明 URL 仍有效或账户已授权。

    mici 的 QR 控件用反色纹理,析构时卸载;__init__() 立即生成纹理,失败时 _texture=None,_render() 改画错误标签。显示缩放只按矩形高度计算,不按宽度取较小比例:若插件给非方形且宽度更窄的矩形,二维码可能横向溢出(当前控件默认建为正方形,配对页还会把它设为屏幕高度的正方形,因此这是组件复用边界而非已实测故障)。

    未配对时 PrimeScroller.__init__() 先为 QR 调用一次 _get_pairing_url() 并生成纹理,但 _last_pairing_qr_generation 初始化为负无穷,首次 _render() 会再次请求令牌、释放初始纹理并生成新纹理,之后按 300 秒周期刷新;这意味着首次展示有两份请求/纹理工作,并可能在两个请求之间取到不同令牌。刷新的时间戳即使 _generate_qr_code() 失败也会更新,错误标签将持续到下一次刷新或重建。若令牌 API 抛异常,_get_pairing_url() 返回 https://connect.comma.ai/?pair=;该字符串仍可成功编码为二维码,故有 QR 图形并不证明配对令牌有效。

    图像到文本的解码链

    顶层 decode() 先定位 finder,再尝试估计版本及相邻两个版本、是否使用 alignment、矩阵是否转置;候选均失败返回 None。它仅捕获 QRError,并非对任意形状、任意 dtype 或超大数组都提供「失败返回 None」的保证。二值图与游程/透视运算会分配与输入像素数相关的内存,外部插件传入图像前必须限制形状、大小与处理预算。

    阶段 关键函数与限制
    灰度二值化 _binarize() 分块取样、算局部对比度并参考邻块/全局阈值;边长小于 21 像素抛 QRError,假定二维灰度数组。
    finder 定位 _find_patterns() 以 1:1:3:1:1 横向候选、纵向复查去重,至少约 2 像素/模块;_pick_finders() 从前十个候选中选几何最接近直角等边的三点。
    透视采样 _perspective() 解单应矩阵;_sample() 可用 alignment pattern 修正右下角,采样越出图像抛 QRError。
    矩阵恢复 decode_matrix() 只接受 21..177 且边长满足 4v+17 的方阵;从两份格式位选汉明距离不超过 3 的级别/mask,再反掩码、去交织。
    纠错与文本 _rs_correct() 做 syndrome、Berlekamp-Massey、错误定位/幅值求解并复验;无 ECI 的 byte 段先试 UTF-8,失败退回 Latin-1。

    eSIM 扫码调用与信任边界

    QRScannerDialog 使用座舱 CabinCameraView:显示时打开驾驶员视角并关闭红外,隐藏时反向恢复;它最多约每 0.25 秒启动一次扫描,上一扫描线程仍活跃时不再启动新线程。取帧时只复制 NV12 的 Y 平面有效宽度,避免 VisionIPC 缓冲回收后后台线程读到复用数据,随后在后台调用 qrcode.decode(gray)。扫码 UI 的轮询与相机/线程生命周期不等于二维码算法内置超时。

    识别到文本后,UI 先用 parse_lpa_activation_code() 检查 LPA: 前缀与三个非空 $ 分段,失败展示短时错误;通过则让用户输入昵称,再交给蜂窝管理器下载 profile。这个前置检查只是字符串形状校验,不认证 SM-DP+ 服务器、激活码归属或 eUICC 安装授权。插件化时应把「二维码像素识别」「业务数据格式校验」「服务端授权」分为三层:配对展示插件只接收短时令牌的受控呈现能力,扫码插件只输出有长度上限的候选字符串,不直接触发下载或持有 eUICC/网络权限。

    已有测试与待验证项

    test_qrcode.py 使用独立生成、打包的 .npz 矩阵 fixture,覆盖 1 到 40 版本、四种纠错级别、多种文本模式/ECI、噪声、旋转、镜像、透视、边缘贴码与无二维码图像,还检查自有编码器版本 1 到 20 的矩阵往返。通过隔离脚本执行原测试文件的 13 个测试方法全部通过,其中版本/纠错等级循环含 160 组矩阵与渲染图检查,纹理编码往返含 20 个版本;隔离器仅提供 pyray.Texture 的导入期类型桩。

    但该测试不覆盖 make_texture() 的 raylib 上传,也不验证真实车载相机曝光/畸变、GPU 纹理生命周期、恶意超大图像或 eSIM 服务端授权。新显示硬件需在重复开闭、令牌失败、图形上下文销毁/重建与非方形布局下检查 GPU 纹理数量与 QR 可扫性。

    常见问题

    屏幕上有配对二维码就说明令牌有效吗?

    不能。令牌 API 抛异常时 _get_pairing_url() 返回 https://connect.comma.ai/?pair=,这个空令牌 URL 仍可成功编码成二维码。生成纹理只负责表示 URL,不证明令牌有效或账户已授权。

    为什么首次进入配对页会出现两次令牌请求?

    因为 PrimeScroller.__init__() 已为 QR 调用过一次 _get_pairing_url() 并生成纹理,而 _last_pairing_qr_generation 初始化为负无穷,首次 _render() 会再次请求令牌、释放初始纹理并生成新纹理;两个请求之间可能取到不同令牌。

    扫码识别到 LPA 激活码就等于能装成功吗?

    不等于。parse_lpa_activation_code() 只检查 LPA: 前缀与三个非空 $ 分段,是字符串形状校验,不认证 SM-DP+ 服务器、激活码归属或 eUICC 安装授权。

    编码端和解码端的版本范围一样吗?

    不一样。编码端只覆盖版本 1 到 20、固定纠错等级 L 与 mask 0;解码端支持版本 1 到 40 与四个纠错等级,并会尝试相邻版本、alignment 与转置等候选。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/QR_CODE_PAIRING_ESIM_PIPELINE_DEEP_DIVE.md(编码/解码两端能力、配对 QR 展示边界与 eSIM 扫码信任边界)。
    • 项目 02 —— openpilot-research/ 同目录探针记录:原二维码测试文件的 13 个隔离测试方法与 160 组矩阵检查结论。

    说明:本文为对固定源码快照的静态结构分析,未运行完整测试套件、未连接硬件、未验证目标车型的真实参数组合。文中所有阈值、行号索引与配置均出自源文档,标注为未验证的结论保留其原有边界。

    更新记录

    首次发布 2026-10-11 最近核对:2026-10-11

  • WebRTC 直播会话:Answer 失败后直播参数为何仍为真

    结论:直播会话最值得记录的不是「能否连上」,而是「参数真值与会话存在」会脱钩:驻车路径先写 IsLiveStreaming=True,若本机服务在生成 Answer 时超时或抛异常,代码会移除会话并把错误交回 HTTP 线程,却不安排 5 秒清参数,全局布尔位可能保持为真。此外 HTTP 本机入口直接信任 Content-Length,没有请求体大小或读取 deadline 上限。以下基于本地 source/ 快照 c8fb906 的隔离探针与静态追踪,未运行真实 WebRTC 连接或 HTTP 压测。

    唤醒与接入:athenad 到 webrtcd

    驻车时 athenad.startStream() 先写 IsLiveStreaming=True,等本地 webrtcd 可用再 POST;普通汽车驻车路径由 manager 按该参数启用 webrtcd 和相关直播服务。请求侧 wait_for_webrtcd() GET /schema,仅 ConnectionError 增加重试计数并休眠;post_stream_request() POST /stream 并解析 JSON,却不检查 HTTP 成功状态,4xx/5xx 的 JSON 也能作为普通返回值到上层。

    服务侧用 ThreadingHTTPServer、本机默认端口 5001;HTTP 线程用 run_coroutine_threadsafe(...).result() 等待 asyncio loop,路由仅接收三个路径。stream_lock 串行化会话替换:若已有启用会话,禁用型预热请求返回 HTTP 200 的 busy JSON;否则通知并停止旧会话,再构建新会话,Answer 生成限时 30 秒,成功后启动 run_task。

    一个可追踪的失败窗口

    这条结论不依赖网络是否暴露:驻车 startStream() 先写 IsLiveStreaming=True;若本机服务已启动但 handle_get_stream() 在生成 Answer 时超时或抛异常,代码会移除会话并把错误交回 HTTP 线程,不会安排 5 秒清参数。正常的清参数仅由已启动 run_task 的完成回调触发。也就是说「客户端拿到 500」与「直播参数已清除」是两件独立的事。

    另一个独立边界是 wait_for_webrtcd(max_retries=10):计数只在连接异常时增加。服务持续返回非 2xx 时不会达到最大重试次数,这不是「最多十次 GET」的实现;ReadTimeout 等其他异常也不是这里专门处理的重试路径。

    时限与状态转移

    整条链路的关键时限散落在不同函数里,把它们放在一起看才能估计最坏等待。

    阶段 时限 / 机制
    Answer 生成 限时 30 秒
    会话连线 限时 15 秒
    普通会话 最长 300 秒
    testJoystick body 会话 等待断开,不适用 300 秒上限
    清参数 run_task 完成后 5 秒,若会话字典仍空才写 IsLiveStreaming=False
    重试计数 仅 ConnectionError 计数,每次休眠 0.5 秒

    StreamSession.run() 连接后启动数据桥和码率任务;普通会话 300 秒超时,testJoystick body 会话则等待断开。清理由锁和 _cleanup_done 避免重复执行;若清理中途抛错,完成标记已设置,后续调用会跳过剩余资源,而且清理自身也不清 IsLiveStreaming。

    HTTP 本机边界:Content-Length 没有上限

    内容类型必须严格等于 application/json,附 charset 的值在解析 body 前即返回 415;未知路径和错误方法在读体前分别返回 404/405;/notify 畸形 JSON 返回 400,未捕获的路由异常返回含异常类名与消息的 500 JSON。

    真正的边界在读取:_read_body() 直接信任 Content-Length,没有请求体大小或读取 deadline 的本地上限;隔离探针确认 Content-Length=-1 被传给 rfile.read(-1),十亿长度也原样传入读取。默认仅监听 127.0.0.1,但本地调用方、端口转发和自定义 --host 仍是部署权限边界。

    硬件与插件验收

    建议把直播能力改为由受信任 supervisor 授予的有租期会话 lease,包含发起者、允许摄像头/主题、最大时长和清理责任;失败、取消、替换与服务进程崩溃均需释放 lease。IsLiveStreaming 这种全局布尔位不应作为唯一所有权记录。_shutdown() 会先停 HTTP 接入、再逐会话通知并停止、最后停事件循环,但关机路径不显式写 IsLiveStreaming=False,仍依赖 manager/参数生命周期接管。

    失败路径与清理顺序

    清理只在特定路径上被正确注册:remove_finished_session() 回调只在 Answer 成功、session.start() 之后才注册,所以生成 Answer 失败或入口在此之前异常,都不走这条退场门。正常清理则按固定顺序执行。

    顺序 清理动作
    1 写 LivestreamRequestKeyframe=False
    2 停止码率任务与出站桥
    3 停止轨道
    4 停止媒体流

    若码率任务的 stop() 抛错,_cleanup_done 已为真,出站桥、轨道和媒体流都不会再执行停止,随后重试也直接返回;连接失败时 run() 的 finally 仍会尝试清理。普通会话等待超时会发送 disconnect,而 testJoystick body 分支没有同一套超时/通知处理,显式 stop() 则会取消活动任务后再清理。

    常见问题

    为什么客户端拿到 500,直播参数还可能是真?

    因为 Answer 失败分支会移除会话,但不调用 schedule_teardown();清参数只由已启动 run_task 的完成回调安排,正常退场门在此路径上没有被触发。

    wait_for_webrtcd 真的最多试十次吗?

    不是。重试计数只在 ConnectionError 时增加;服务持续返回非 2xx 时循环不递增次数也不休眠,所以这不等于「最多十次 GET」。

    服务端会限制请求体大小吗?

    不会。_read_body() 直接信任 Content-Length,没有请求体大小或读取 deadline 的本地上限;Content-Length=-1 会被原样传给读取。

    Content-Type 带了 charset 会怎样?

    内容类型必须严格等于 application/json;附 charset 的值在解析 body 之前就会返回 415。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/WEBRTC_SESSION_LIFECYCLE_HTTP_BOUNDARY_DEEP_DIVE.md(athenad→webrtcd→manager 的会话生命周期与 HTTP 边界,含源码行号索引)
    • 项目 02 —— openpilot-research/probes/test_webrtc_stream_session_replacement_contract.py(Answer 失败与会话替换的五项异步探针)
    • 项目 02 —— openpilot-research/probes/test_webrtc_http_handler_boundary_contract.py(路径、方法与 Content-Length 边界的七项探针)

    说明:本文为对固定源码快照的静态结构分析,未运行完整测试套件、未连接硬件、未验证目标车型的真实参数组合。文中所有阈值、行号索引与配置均出自源文档,标注为未验证的结论保留其原有边界。

    更新记录

    首次发布 2026-10-11 最近核对:2026-10-11

  • 路段下载缓存:SHA256 是 URL 键哈希,不是内容校验和

    结论:工具侧的路段下载链把「C++ 桥接 → API 元数据 → 下载与缓存 → 工具登录」串成一条线,核心判定只有一句:缓存文件名是「URL 去掉查询串后的 SHA256」,它是 URL 键哈希,而不是内容校验和。因此同一路径换内容、改用不同签名的查询串或缓存文件本身损坏时,缓存命中分支不会重新验证。另一处容易踩的坑是 api_call() 把很多异常序列化成 {"error": ...} 却仍以正常进程退出,调用方必须解析错误字段,不能只看退出码。本文为对源码快照 c8fb906 的静态分析与隔离探针,未登录账户、未查询云端、未下载文件、未运行网络测试。

    调用链与关键函数

    PyDownloader::runPython() 通过 posix_spawnp 启动 python3 -m,分别读取 stdout 与 stderr,以 100 ms 的 select 轮询 abort,子进程失败时返回空串。这里 stdout 是机读结果、stderr 是进度/错误,PROGRESS 不等于文件校验完成;C++ Replay 与 Cabana 共用这一桥接。

    组件/函数 具体实现 契约与边界
    PyDownloader::runPython() posix_spawnp 启动子进程,读 stdout/stderr,100 ms select 轮询 abort stdout 是机读结果、stderr 是进度/错误;PROGRESS 不等于校验完成
    api_call() / CommaApi.request() 从本地配置读令牌,请求带 JWT Authorization;成功或异常统一输出 JSON 401/403 映射未授权、404 映射 not_found;异常序列化后仍以正常退出,须解析错误字段
    cmd_route_files() / cmd_devices() / cmd_device_routes() 调用路线文件列表、用户设备与设备路段 API,查询与起止时间过滤走不同端点 返回元数据或 URL,不验证下载后字节与路线所属设备身份
    cache_file_path() / cmd_download() 去 URL 查询串后 SHA256 得缓存名;优先复用缓存,未命中则单 GET 流式落临时文件,按魔数识别 bz2/zst SHA256 是 URL 键哈希而非内容摘要;同路径换内容或缓存损坏时命中分支不重验
    decompress_file() / materialize_cached_file() 流式解压并检查压缩流 eof;缓存已有压缩文件时生成解压副本 EOF 检查可发现截断,但不证明远端内容可信;下载层未显式比较下载字节数与长度头
    auth_redirect_link() / login() 打开浏览器并临时监听 localhost 回调,换令牌后请求验证,再原子写入 auth.json 回调只按路径记录首个含 code/error 请求;login() 未在本地生成随机 state 并核对回传 state

    需要强调的是:cmd_route_files() 返回的是元数据或 URL,它不验证下载后的字节与路线所属设备身份——账户可见性和车端控制权是两个概念。

    还要区分字节流与元数据:cmd_devices() 与 cmd_device_routes() 保留用户设备与设备路段两个不同端点,返回的同样是元数据或 URL;下载后字节是否完整、路线是否属于该设备,都不在本层验证,只能由上层的内容摘要与 manifest 承担。

    生命周期与失败语义

    cmd_download() 先写下载缓存目录内的临时文件;进入内层 try/finally 后的异常会删除临时文件并调用 release_conn() 归还连接。但在该内层 try 之前,HTTP 状态/长度拒绝会直接 sys.exit(1),不经过连接释放;缓存命中则直接返回路径,不再重新读取完整内容。runPython() 在 abort 时发 SIGTERM,取消下载不应被解释成文件已缓存完成。

    两条协议要分别处理:Python 网络查询的错误可能是 JSON stdout,而下载/解压错误是 stderr 加非零退出。get_token() 读取失败返回 None,set_token() 写入 config root——工具认证文件与车端设备身份/JWT 不是同一信任域,插件不得把用户账户令牌继承为默认网络权限。

    本地认证配置与缓存的实测契约

    隔离探针把 Paths.config_root() 限定在临时目录后确认:写入/替换/清除往返成功,新 auth.json 在本机权限为 0600,JSON 损坏或缺少 access_token 均返回 None,序列化失败不会损坏旧令牌,也未留下临时文件。但 get_token() 不校验类型,{"access_token": 42} 会原样返回整数 42,随后若被当作真值传入 CommaApi.__init__() 会因与字符串拼接而抛 TypeError;宽泛的 except Exception 又把读取错误、权限错误与未登录折叠成同一个 None。

    输入情形 实际结果 工程含义
    Content-Length: 10,响应流仅给 3 字节 输出路径并缓存这 3 字节,进度停在 3:10;该分支会 release_conn() 正的长度头只是入口门,没有末尾下载字节数一致性断言
    HTTP 404 或 Content-Length: 0 SystemExit(1);假响应的 release_conn() 未被调用 头部拒绝发生在内层资源 finally 之前,连接回收不能由本函数保证
    相同路径、不同签名查询串且缓存已有旧内容 返回同一 SHA256 URL 键对应的旧文件,不发起新 GET 键哈希不是内容摘要;URL 权限/查询串更新不能保证刷新数据

    这些观察只针对当前代码的无压缩分支和受控假响应,0600 也只是当前本地临时测试结果,不是所有平台或既有配置目录权限的证明;未测真实连接池在进程退出后的清理、bz2/zst 解压、网络中断、缓存竞争或最终日志解析。

    插件重构与硬件验证建议

    应把 RouteCatalog、AuthenticatedFetcher、ContentStore 和 Decompressor 分开声明权限:路线插件只提供元数据/URL,下载插件固定允许的域名与传输策略,内容库以内容摘要而非 URL 作验证标识并保存原 URL、查询时刻、压缩类型、长度与 schema 版本,解压插件限制输出体积与资源预算。给回放实验生成不可变 manifest,在启动控制算法前逐段核对哈希、事件数、首末单调时间、相机文件包数与帧 ID。

    这条链只服务离线分析/开发回放,不应成为车端控制或安全网关的动态插件加载入口。对自制硬件,先在无物理总线的隔离环境跑损坏缓存、URL 同路径变内容、截断压缩流、缺失 qlog、401/404、取消下载和本地回调异常测试,再考虑封闭台架。

    常见问题

    缓存文件名的 SHA256 能当内容校验和用吗?

    不能。它是「URL 去掉查询串后」的 SHA256,即 URL 键哈希而非内容摘要。同一路径换内容、不同签名查询串或缓存文件损坏时,缓存命中分支都不会重新验证,必须额外记录内容哈希并支持强制刷新。

    api_call() 退出码为 0 就代表成功吗?

    不代表。api_call() 会把很多异常序列化为 {"error": ...} 却仍以正常进程退出,调用方必须解析返回里的错误字段,不能只看退出码。

    下载被取消,是否说明文件已经缓存完成?

    不是。runPython() 在 abort 时发 SIGTERM,下载任务取消不应被解释成文件已缓存完成;下载与解压错误是 stderr 加非零退出,与网络查询的 JSON stdout 是两条协议。

    auth.json 的 0600 权限是通用保证吗?

    不是。它是当前本地临时目录测试的结果,不代表所有平台或既有配置目录的权限;探针也没有运行浏览器回调、服务端令牌交换、API 请求或并发写入。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/ROUTE_DOWNLOAD_AUTH_DEEP_DIVE.md(工具侧下载桥接、API 元数据、缓存与工具登录链)
    • 项目 02 —— openpilot-research/probes/test_tool_auth_config_contract.py(认证配置读写的隔离探针)
    • 项目 02 —— openpilot-research/probes/test_file_downloader_cache_contract.py(下载缓存契约的假 HTTP 探针)

    说明:本文为对固定源码快照的静态结构分析,未运行完整测试套件、未连接硬件、未验证目标车型的真实参数组合。文中所有阈值、行号索引与配置均出自源文档,标注为未验证的结论保留其原有边界。

    更新记录

    首次发布 2026-10-11 最近核对:2026-10-11

  • 600 秒预算与三次重试:外置模块刷写为何不能只看退出码

    结论:外置计算模块的固件刷写里,脚本退出码 0 不等于升级完成。刷写脚本对镜像只做长度上限、魔数、字节校验和与 CRC32 的格式/传输完整性校验,它不是来源认证或数字签名验证;更关键的是,ROM 恢复已提交但设备没有重新出现时,脚本仍会正常返回,上层据退出码 0 记为「刷写成功」,而此时运行态镜像并未被回读校验。正确的做法是把「发现、写入、激活、运行」拆成六种状态,不让一个布尔值吞掉它们。

    它是什么,也不是什么

    该模块通过 USB/PCIe 接入外置 GPU,服务于大模型推理,属于推理设备。它不是车辆 CAN 安全网关:车辆控制输出仍须经过原有的车辆接口、进程与 panda 安全链。这条定位决定了后面所有设计约束——外置模块可以失联、可以回退,但不能绕过控制安全链。

    发现与状态:USB 快照不是同步断线检测器

    设备发现从系统总线读取 VID/PID、链路速率、产品字符串、链路错误与接口朝向,再写入设备状态;其中「模块在位」的判定只识别运行态 VID/PID,不要求产品字符串版本匹配。状态线程约每 10 秒或总线拓扑变化时更新一次快照,队列容量为 1,主线程可以沿用旧值。因此它是一份采样快照,不是同步的断线检测器——真正判断「用哪个模型」的是另外两层证据。

    刷写路径:格式校验不等于来源认证

    刷写入口读取内置封装镜像,检验封装格式、产品字符串与调用方期望版本;已匹配则跳过,并设定 600 秒全局预算。镜像校验检查的是长度上限、魔数、字节校验和与 CRC32——这些字段能发现传输或格式损坏,但不能证明镜像来源或版本内容不可替换。固件写入按 4 KiB 扇区擦除、128 B 页写入,并逐页与整扇区回读验证;配置页备份为 0x100 字节,且备份文件以主机名确定路径,已有备份会优先于当前设备配置。

    校验 / 步骤 检查什么 不保证什么
    格式与产品字符串 封装结构、目标产品名、期望版本 不证明镜像内容可信
    长度上限 / 魔数 / 校验和 / CRC32 传输与格式完整性 不是来源认证或数字签名
    页 / 扇区回读 写入范围与内存目标一致 不证明新固件已启动
    配置页备份(0x100 字节) 保留设备侧配置 备份与具体设备的绑定需台架确认

    六种状态不该合并成一个退出码

    刷写与恢复会经过多条分支,每条分支得到的「证据」并不相同:首次发现时无设备会直接正常返回(没写入、没激活);产品字符串已匹配且未强制刷写会直接返回(只核对枚举字符串,没读闪存);普通路径完成写入并回读一致后才调用激活;而 ROM 路径若已提交但仍停留在 ROM 或已离线,恢复函数返回失败,外层却正常返回,于是退出码 0 被记成「成功」。此外,激活函数的所有普通完成分支都返回空值——调用方无法从返回值判断设备到底是否已激活并重枚举。

    源码分支 脚本退出码 / 上层状态 实际得到的证据
    首次发现时无设备 正常返回,记为已刷写 仅说明发现时无设备,未写入、未激活
    产品字符串已匹配且未强制 正常返回,记为已刷写 只核对枚举字符串,未读闪存、未验证 GPU/PCIe
    普通路径完成写入并回读 调用激活,正常返回 写入范围与内存目标一致,不一定代表已启动
    ROM 已提交但仍停留 ROM 或离线 恢复返回失败,外层正常返回 未完成运行态回读校验,属「提交成功 / 验证完成」混淆
    镜像、备份、读写或预算抛异常 退出码 1,记为失败 明确失败,但不自动说明设备是否仍可恢复

    重试额度同样是状态的一部分:尝试次数在进程生命周期内不复位,产品字符串恢复匹配只清「已刷写」标志,不清尝试次数;同一进程用满三次后,即使后来版本又出现不匹配,也不会再自动重试。600 秒预算是协作式收敛预算,只在重试/恢复循环的调用点检查,不是能中断所有 USB 操作的硬截止。

    模型回退与插件约束

    推理侧的设计是分层的:先尝试加载并预热大模型,超时或失败则走小模型;运行中大模型异常也会回退,并把状态写入消息供上层生成加载/失败事件。监测主题标称 10 Hz,但 USB 不存在时监测线程不会按 10 Hz 发送有效消息。插件化时,固件维护接口应返回分离的状态:未找到、已匹配、已写入并验证、等待激活、运行态已验证、失败,并附目标设备身份、镜像摘要、采样时间与失败阶段;重试额度应绑定设备/版本/维护事务,而不是复用进程内布尔值。安全侧只有运行态重新枚举、产品版本与推理健康全部确认后,才能把模块标记为可用——这仍不能替代车辆控制安全链的验证,外置模块也不得获得发送 CAN 的权限。

    常见问题

    镜像通过 CRC32 校验,就能说明固件可信吗?

    不能。长度上限、魔数、字节校验和与 CRC32 属于传输与格式完整性校验,用于发现损坏或截断;它不是来源认证或数字签名验证,改变镜像内容并重新计算校验值后仍可通过校验,因此不能据此判断镜像来源或版本不可替换。

    刷写脚本返回成功,就代表升级完成了吗?

    不一定。ROM 恢复已提交但设备未重新出现时会返回失败,外层却正常返回、退出码 0 被记成成功,而此时还没有完成运行态镜像校验;激活函数也无法从返回值告知调用方设备是否真的已激活并重枚举。

    为什么要有三次尝试上限?

    为了限制自动刷写的次数与风险。尝试次数在进程生命周期内不复位,产品字符串恢复匹配也只清「已刷写」标志、不清次数,因此同一进程用满三次后不会再自动重试,避免在异常设备上反复写入。

    外置计算模块能接管车辆控制吗?

    不能。它只是推理设备,车辆控制输出仍须经过原有的车辆接口、进程与 panda 安全链;固件升级是驻车维护事务,模块本身不得获得发送 CAN 的权限,也不能替代独立安全网关。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/CHESTNUT_USB_FIRMWARE_DEEP_DIVE.md(USB 发现、固件刷写路径、镜像与备份、ROM 恢复、模型回退与插件约束)

    说明:本文为对固定源码快照的静态结构分析,未运行完整测试套件、未连接硬件、未验证目标车型的真实参数组合。文中所有阈值、周期与配置均出自源文档,标注为未验证的结论保留其原有边界;刷机动作未被实际执行。

    更新记录

    首次发布 2026-10-11 最近核对:2026-10-11

  • 设备身份链:有缓存 DongleId 就跳过注册

    结论:设备身份链把「本地密钥 → 注册 → 令牌签发 → 各消费方」串成一条线。关键判定很简单:有公钥且 Params 已有 DongleId 时直接写回、不联系注册 API;没有公钥时把 ID 设为 UnregisteredDevice,即使已有缓存 ID 也是如此。而 is_registered_device() 只检查 Params 中 DongleId 不为 None 或哨兵值,不重新证明密钥、后端账户状态或设备真实性。设备注册、账户配对和车辆控制安全是三个不同问题。本文为对源码快照 c8fb906 的静态分析与隔离探针,未访问注册后端、未读取真实设备密钥、未验证实板网络。

    启动与注册链

    manager_init() 先写入 HardwareSerial,再调用 register(show_spinner=True),把非空结果设为 DONGLE_ID 环境变量并绑定日志上下文。因为 UnregisteredDevice 也是非空字符串,所以它不会触发「注册失败」异常;随后 manager_thread() 依据 DongleId 值排除 manage_athenad 和 uploader。可见未注册并不等于整个系统停止运行。

    阶段 关键函数/组件 实际行为
    密钥定位 get_key_pair() / Paths.persist_root() 依次查找 /persist/comma/ 下 RSA、ECDSA 私钥和公钥,返回首个完整文件对及 RS256/ES256;两对都无则返回三个 None,只读不生成
    本地标识 register() 优先读 Params 的 DongleId,仅当其为 None 时才尝试持久分区的 comma/dongle_id;有公钥且已有 ID 时直接写回,不联系注册 API
    无密钥 register() 没有公钥就把 ID 设为 UnregisteredDevice,即使已有缓存 ID 也是如此,并切换 Offroad_UnregisteredHardware 告警(PC 除外)
    在线注册 register() / api_get() 取序列号与 IMEI,用私钥签发含 register: true、一小时到期的 JWT,向 v2/pilotauth/ POST;这些值经 params=... 作 URL 查询参数传入,而非 JSON 请求体
    注册结果 register() HTTP 402/403 视为未注册;其他状态解析 JSON 中 dongle_id;异常线性退避,最长睡眠 15 秒后无限重试,60 秒后仅更新 spinner 文案
    注册判定 is_registered_device() 只检查 Params 中 DongleId 不为 None 或哨兵值,不重新证明密钥、后端账户或设备真实性

    这里还有两处细节:HARDWARE.get_serial() 在 comma 平台读取启动命令行序列号,get_imei() 从 modem 状态取值(基类默认返回空字符串);注册循环只以 None 判断 IMEI 是否就绪,所以空字符串并不会被这一循环拒绝。私钥的出厂写入/轮换机制不在这些函数中,不能据此推断供应链安全性。

    manager_init() 把 register() 的返回值直接作为 DONGLE_ID 环境变量使用,因此注册结果既决定日志上下文,也决定哪些进程被排除;但「未注册」不是异常路径,设备仍可进入离线运行,只是对应的云端相关进程被略过。

    由此,启动链的第一判据并不是「能否联网」,而是「本机是否存在完整密钥对」:密钥决定注册走向,网络只在无缓存 ID 时被使用。

    令牌与消费者

    Api.get_token() 以本地私钥生成含 identity、nbf、iat、exp 的 JWT,默认一小时有效;缺私钥时抛 RuntimeError。api_get() 只有显式传入 access_token 才加入 Authorization: JWT ...,并始终加入版本 User-Agent;Api.get()/post() 本身不会自动附带认证令牌。

    缺私钥时 Api.get_token() 直接拒绝签发,因此依赖令牌的消费方在无密钥设备上根本拿不到授权;这也解释了为何缺密钥会走 UnregisteredDevice 分支而非带着旧 ID 继续联网。

    消费方 关键入口 使用方式
    Athena 主连接 athenad.main() 连接 WebSocket 时将新令牌放入 jwt cookie;连接与重连另由 Athena 管理
    本地代理 startLocalProxy() 另签令牌连接远端 WebSocket;本地转发还有限定端口白名单,令牌不是本地端口授权的全部条件
    上传 Uploader.do_upload() 为获取上传 URL 显式传 access_token=self.api.get_token();实际上传仍走返回的 URL 与 headers
    配对 PairingDialog._get_pairing_url() 生成附加 pair: true 的 JWT 并嵌入二维码 URL;二维码应视为敏感凭据
    状态查询 PrimeState._fetch_prime_status() 未注册时不请求;UI 辅助函数要求系统时间有效,签发两小时令牌,并以单调时间约一小时为桶缓存一个令牌
    GNSS 辅助 pigeond 向设备辅助定位 API 显式传身份令牌;不参与车辆控制许可

    注册 JWT 与常规身份 JWT 的载荷、用途不同,pair 令牌又增加配对声明。源码显示的是客户端如何签发和传送,并不显示服务端如何验证签名、声明、撤销、过期或配对授权;因此不能仅凭本地 DongleId、Prime 缓存或可生成 JWT 就推断设备已被云端接受。

    消费者侧的差别同样明显:Athena 主连接与本地代理用不同令牌连接远端 WebSocket,上传是显式传令牌换取 URL,配对令牌带 pair 声明,状态查询则缓存约一小时令牌。令牌用途不同,彼此不能替代,这也是把身份能力按用途收窄的现实依据。

    自制硬件与插件化落点

    1. 硬件身份适配器:让平台提供序列号、网络模组标识和持久密钥能力;不要把 comma 的 /persist/comma 文件布局当成所有新硬件的永久 ABI。是否接入原服务端需由服务提供方确认。
    2. 受信任身份服务:把私钥读取与签名封装在固定核心或硬件安全模块;插件只获得按用途、受限时效的令牌请求能力,不获得私钥文件路径或任意 payload_extra 权限。
    3. 云端插件隔离:Athena、上传、配对与 GNSS 分别声明所需身份能力;令牌用途/受众分离、最小权限、脱敏日志、网络超时与离线降级由核心契约统一约束,不得把云端订阅/配对状态当作执行器安全门的替代品。
    4. 启动策略显式化:把「无密钥」「有密钥但无缓存」「后端拒绝」「网络长期不可用」建成不同设备状态;当前 register() 的无限重试可能阻塞 manager 初始化,须先确定离线启动与降级策略。

    验证与缺口

    仓库已有 test_registration.py,覆盖缓存命中、缺密钥、首次注册成功及 402 拒绝;但没有覆盖 ECDSA 路径、403、IMEI 空值、长时间网络失败、损坏 JSON、密钥轮换或服务端令牌授权。隔离探针确认:缺公钥会把已有缓存 ID 覆盖为 UnregisteredDevice;空字符串 IMEI 可进入注册请求;一次后端异常使重试睡眠 1 秒后继续,NotImplementedError 不重试;Api.get_token() 对额外载荷使用 payload.update(),调用方可覆盖默认声明,缺私钥则拒绝签发。

    需要强调边界:覆盖默认声明只是「客户端可构造能力」,不代表服务端接受任意声明;探针不执行真实密钥读取、JWT 编码、HTTP 或设备串号查询,上游测试也未在本环境运行。

    常见问题

    有了 DongleId 就说明设备已在云端注册了吗?

    不能这样推断。is_registered_device() 只检查 Params 中 DongleId 是否为 None 或哨兵值,不重新证明密钥、后端账户状态或设备真实性;有缓存 ID 时 register() 甚至直接写回、不联系注册 API。

    没有公钥但有缓存的 DongleId 会怎样?

    会被覆盖为 UnregisteredDevice。只要没有公钥,register() 就把 ID 设为该哨兵值,即使已有缓存 ID 也是如此,并切换 Offroad_UnregisteredHardware 告警(PC 除外)。

    哪些 API 请求会自动带上令牌?

    不会自动。api_get() 只有显式传入 access_token 才加入 Authorization: JWT ...;Api.get()/post() 本身不会自动获取身份令牌,Uploader.do_upload() 则是显式传入。

    注册失败的设备会不会直接停车?

    不会。UnregisteredDevice 是非空字符串,不会触发 manager_init() 的注册失败异常;manager_thread() 只是依据 DongleId 排除 manage_athenad 和 uploader。不过无限重试可能阻塞 manager 初始化。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/DEVICE_IDENTITY_REGISTRATION_DEEP_DIVE.md(启动注册链、令牌签发与各消费方边界)
    • 项目 02 —— openpilot-research/probes/test_device_registration_contract.py(注册与 is_registered_device() 的假依赖隔离探针)
    • 项目 02 —— openpilot-research/probes/test_device_api_key_token_contract.py(get_key_pair() 与令牌签发契约探针)

    说明:本文为对固定源码快照的静态结构分析,未运行完整测试套件、未连接硬件、未验证目标车型的真实参数组合。文中所有阈值、行号索引与配置均出自源文档,标注为未验证的结论保留其原有边界。

    更新记录

    首次发布 2026-10-11 最近核对:2026-10-11

  • 配对 300 秒刷新、统计 30 秒轮询:设备页与 Firehose 上传要分清

    结论:设备设置页、配对/订阅状态页与 Firehose 统计页都是观察窗口,不是授权与执行本身。配对二维码每 300 秒刷新、订阅状态约每 5 秒轮询、Firehose 统计约每 30 秒拉取;但页面上显示「已配对」「ACTIVE」并不证明服务端已授权、也不证明数据正在上传。源码快照里,Firehose 页面甚至没有任何「启用上传」参数或上传控制调用,它只是按网络条件给出提示。

    设备页能做什么,不能证明什么

    设备页提供的是有限的本地操作:查看设备标识(来自设备 ID 与硬件序列号)、预览座舱摄像头、复位标定、重启或关机,以及查看培训与监管信息。这些入口都有前置条件——座舱摄像头预览在两种布局上都要求处于停车(offroad)状态才使能,而且预览并不校验摄像头标定精度。

    「复位标定」尤其容易被误解。它在请求时与确认时都会检查是否处于驾驶接管(engaged)状态,然后删除四个学习缓存键,并写入一次行车周期请求;被删除的四个键分别由四个后台学习进程读取并重建。关键是:这不是把传感器硬件恢复出厂,而且四个删除操作与周期请求不是事务——断电或进程并发时的一致性并未被验证。

    重启与关机同样是「请求」而非「执行」:界面做二次确认并检查非 engaged,然后写入对应的电源请求消息,最终执行由系统管理进程与平台决定。大屏在停车时才显示关机按钮,mici 布局在未点火时显示。

    配对与订阅:三套显示门不等于授权

    配对二维码由设备 ID 调用接口获取令牌后组装,指向配对地址,并每 300 秒刷新一次;订阅状态则在 UI 初始化后由后台线程在「非行车且屏幕唤醒」时查询设备与账户接口,约每 5 秒检查一次。这里有两个必须说清的边界:其一,二维码本身包含可用于配对的令牌,应按敏感凭据处理,避免日志、截图或远程投屏无意泄露;其二,接口失败时会保留旧状态并记录错误,因此离线页面可能显示的是缓存值,不能当作实时云端授权结果。

    首页还有一处容易看错的细节:订阅宣传组件用的是「是否订阅」判断,而设置入口组件用的是另一个「是否已配对」判断。两个门不同,所以一台已配对但没有订阅的设备,可能同时看到升级宣传和 Firehose 入口。此外,配对按钮的显示门只检查系统时间是否有效——失败时提示连接 Wi-Fi,成功就弹出配对界面,它并不检查 Wi-Fi 或互联网是否真的可达,也不检查设备标识或服务端令牌签发结果。

    界面元素 看起来代表 源码里的实际含义
    配对二维码 配对完成 仅生成含令牌的配对链接,每 300 秒刷新;是否授权由服务端决定
    订阅摘要 / 升级宣传 订阅有效 读取缓存的订阅类型;接口失败时保留旧状态,可为离线缓存
    首页 Firehose 入口 正在上传数据 由「是否已配对」门控制,与订阅、上传状态均无关
    Firehose 页面 ACTIVE 上传任务运行中 仅表示网络类型非空且非计费网络,是网络条件提示

    Firehose 页面只是网络条件提示

    Firehose 统计页在构造时读取本地缓存并启动后台线程,线程在非行车且屏幕唤醒时约每 30 秒从设备接口拉取统计。注意:这个线程由组件构造启动,不是用户进入页面才启动。

    页面状态的判定非常克制:只有当设备状态里的网络类型非空、且网络非计费(networkMetered=False)时显示 ACTIVE,否则显示 INACTIVE。更重要的是,源码中没有 Firehose 页面专属的「启用上传」参数,也没有上传控制调用。因此页面上写着 ACTIVE,只能说明当前网络条件符合提示规则,不能证明上传任务正在运行、远端已接收,或数据被用于训练。

    刷新对象 周期 触发条件
    配对二维码 约 300 秒 配对界面打开且尚未完成配对
    订阅 / 配对状态 约 5 秒 非行车且屏幕唤醒的后台线程
    Firehose 统计 约 30 秒 非行车且屏幕唤醒的后台线程

    真正的上传器在页面之外

    选择与发送上传文件的是后台上传器,它按已上传标记、锁文件、文件优先级与计费网络限制挑候选文件,再从设备状态读取网络条件执行上传。这里也有一个反直觉的边界:计费网络并非一概不上传,部分小文件或被请求的路段仍可能被选中。换句话说,上传策略由上传器与日志保留策略决定,Firehose 页面既不替代也不覆盖这套策略。

    插件化与台架验证要覆盖什么

    如果要做插件化,边界应这样划:设备能力插件声明屏幕、座舱相机、监管文件、系统电源与校准缓存的可用性,实际的复位/重启/关机要经独立服务验证驻车、驾驶状态与用户确认,而不是只读界面上那个 engaged 布尔值;配对/订阅插件只负责短期令牌展示与状态观察,服务端仍是授权源;上传插件以明确的队列、网络预算、隐私许可与服务端确认结果为契约,说明/统计页只能消费状态,不能越过日志保留或计费网络策略直接读取或上传车内数据。台架验证至少覆盖:确认弹窗期间由驻车变成 engaged、四个缓存删除中途断电、二维码令牌过期、订阅接口离线与重试、计费网络上的文件选择,以及重启/关机的执行与失效恢复。

    常见问题

    Firehose 页面显示 ACTIVE,是不是就在上传数据?

    不是。该状态只依据设备状态里的网络类型非空且网络非计费来判定,是网络条件提示;源码中没有 Firehose 页面专属的启用上传参数或上传控制调用,真正的上传由后台上传器按文件优先级与计费网络限制自行选择执行。

    点「复位标定」会把传感器恢复到出厂状态吗?

    不会。复位只是删除四个学习缓存键并写入一次行车周期请求,这些键由对应的后台学习进程读取并重建,属于软件层面的重新学习,不是把传感器硬件恢复出厂设置。

    配对二维码可以截图或投屏吗?

    不建议。二维码包含可用于配对的令牌,应按敏感凭据处理,避免在日志、截图或远程投屏中无意泄露;令牌的实际有效期与服务器授权规则需要由后端验证。

    设备显示「已配对」,就说明订阅有效吗?

    不能这样推断。首页的订阅宣传与设置入口使用两个不同的判断门,已配对但未订阅的设备可能同时看到升级宣传与 Firehose 入口;订阅摘要读取的是缓存状态,接口失败时会保留旧值,不能当作实时云端授权结果。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/DEVICE_PAIRING_FIREHOSE_DEEP_DIVE.md(设备页、配对与订阅状态、Firehose 页面与上传器、插件边界)

    说明:本文为对固定源码快照的静态结构分析,未运行完整测试套件、未连接硬件、未验证目标车型的真实参数组合。文中所有周期、阈值与配置均出自源文档,标注为未验证的结论保留其原有边界;页面显示状态与外部服务/硬件是否已执行,本文严格区分。

    更新记录

    首次发布 2026-10-11 最近核对:2026-10-11