标签: 远程车联

  • MARV 无人车的 CAN 集成层:35 报文与 115 信号

    结论:MARV 无人车的 CAN 集成层只有一份 marv.dbc,规模是 35 条 BO_ 报文、115 行 SG_ 信号,平均每条报文 3.29 条信号。它落在 marv_driver 包的 resource/ 目录里,与自定义消息包、场景包、惯导驱动并列。真正限制复用的是三处未核验:平台硬件、DBC 信号校验、以及它并非量产车型定义。

    集成层的落点:一份 DBC 与四个 ROS 包

    归档只保留了一个文件:colcon_ws/src/marv_driver/resource/marv.dbc。把它放在 driver 包的 resource/ 下,说明这个项目把报文定义当作驱动的一部分来管理,而不是外部配置文件。这个位置决定了 DBC 的更新节奏与驱动代码同步,改信号定义等于改驱动包。

    同一工作空间里并列着四个包。理解它们的分工,才能判断一条 CAN 信号从总线到控制回路之间要穿过几层。

    包 上游说明 在集成链中的位置
    marv_driver Basic system functions 归档的 marv.dbc 位于该包 resource/ 目录,是报文定义的落点
    marv_msgs Custom messages package 自定义消息类型定义,说明文档未给出消息清单
    marv_scenarios 用户实现的场景,例如 waypoint_navigation 场景层,说明文档仅举了航点导航一例
    sbg_ros2 The SBG INS driver 惯性导航数据源,与总线信号并行

    规模与统计口径

    35 条报文与 115 行信号是归档方统计 BO_ 与 SG_ 行数得出的。这个口径只说明文件里定义了多少条报文、多少行信号,不代表每一条都已被实车验证。作为对照,同为车队级归档的水冷式方程式项目通常在 50 到 115 条报文的量级,MARV 属于偏小的一侧。

    指标 数值 口径说明
    报文定义(BO_) 35 按 BO_ 行数统计
    信号定义(SG_) 115 按 SG_ 行数统计
    平均信号 / 报文 3.29 由上述两项相除得出
    归档文件数 1 marv_driver/resource/marv.dbc

    归档说明还给出了该文件的完整性校验值:Git blob 182c2fb5f6c02d8ef26c26b2adaa564174f1c2f1,SHA-256 4aab0907c9c43435b11c8729c725c092f98667deb868cc7c6356ed5eb8f3dddc,快照修订为 5b7ed5f6a5547a63407562572b31243f9c5a35a7,采集日期 2026-10-04。

    构建、运行与开机自启

    构建链路相当固定:先在 REACH 车载计算机上按「reach setup」文档装齐依赖,再 source ROS 2 galactic,最后在 colcon_ws 目录内执行 colcon build --symlink-install。这里没有给出依赖清单,意味着环境不可复现——换一台机器时,第一步的「装齐依赖」只能靠文档之外的信息完成。

    运行方式有三套,分别对应调试、常规与部署。测试用途下 ./marv_start.sh 会启动一个 screen 终端会话,适合现场看输出;常规使用则依赖服务在开机时自动拉起,需要人工干预时另有 ./marv_restart、./marv_stop、./marv_start 三个脚本。

    场景 命令 说明
    测试用途 ./marv_start.sh 启动一个 screen 终端会话,便于直接观察节点输出
    常规使用 开机自动启动;./marv_restart、./marv_stop、./marv_start 需先在 reach setup 文档中完成服务配置
    服务管理 sudo systemctl enable marv_package、start、stop、systemctl status marv_package enable 用于开机自启,start 与 stop 用于手动控制,status 查状态
    会话清理 screen -r 重新接入;killall screen 接入后用 ctrl + C、ctrl + D 逐个关闭终端

    读这份文档时有一处需要自己纠正:上游把「禁用服务」的命令也写成了 sudo systemctl enable marv_package,与启用命令完全相同。按字面执行达不到禁用效果,正确做法应是对应的 disable。这类笔误在自维护文档里很常见,抄命令之前先确认语义,不要照单全收。

    日志与仿真这两套机制合起来,覆盖了从录制到回放再到脚本自检的完整链路,但它们的边界都写在文档里而没有含糊处理。使用时最容易犯的错误是把「跑通了」当成「跑对了」。

    两套 rosbag 与 scenario_starter 的仿真边界

    日志侧提供两套 rosbag:一套记录全部 topic,另一套只记录指定 topic(如导航数据),具体 topic 可在 start_specific_ros_bah.sh 脚本中修改。这种「全量 + 定向」的双轨设计很实用——全量用于事后回溯,定向用于长时间跑车时控制数据量。

    仿真侧则由 scenario_starter 这个额外的 ROS 节点承担,用来模拟 MARV 系统其余部分的行为。它模拟 12V Auto 状态,以及 OCU 发出的 start scenario 命令。文档对它的边界写得很克制:它不模拟任何传感器数据,作用只是在正式路试前先在「陆地」上验证场景脚本能否正常启动。

    手段 覆盖 明确不覆盖
    rosbag(全量) 每一个 topic 说明文档未涉及落盘策略与容量限制
    specific rosbag 指定 topic,例如导航数据 其余 topic 不记录;需手工维护脚本内的 topic 列表
    scenario_starter 节点 12V Auto 状态、OCU 的 start scenario 命令 任何传感器数据;只能给出脚本是否启动正确的指示

    把这三行放在一起看,结论很直接:这套工具链能验证「脚本会不会崩」,但不能验证「车会不会开对」。传感器数据必须来自真实车辆或另行搭建的仿真。

    归档快照与权利状态

    归档只保留了一个文件,因此完整性校验相对简单:路径 colcon_ws/src/marv_driver/resource/marv.dbc,Git blob 182c2fb5f6c02d8ef26c26b2adaa564174f1c2f1,SHA-256 4aab0907c9c43435b11c8729c725c092f98667deb868cc7c6356ed5eb8f3dddc,快照修订 5b7ed5f6a5547a63407562572b31243f9c5a35a7,采集日期 2026-10-04。归档方确认保留字节与所钉住的 Git blob 一致,且在本地存档中没有字节相同或经 CRLF 归一化后相同的重复项。

    权利侧的表述需要读仔细:归档保留了仓库的 MIT 许可证与上游 README,同时写明各组件数据的独立来源并未确立。这两句话不矛盾,但含义差别很大——MIT 覆盖的是仓库自身携带的内容,不等于覆盖项目所引用的第三方组件数据。把它并入闭源产品或对外再分发之前,组件部分仍需单独确认。

    常见问题

    marv.dbc 的 35 条报文分别对应哪些硬件?

    归档说明没有给出逐条映射。文件位于 colcon_ws/src/marv_driver/resource/marv.dbc,规模为 35 条 BO_ 报文与 115 行 SG_ 信号;归档方同时写明具体平台硬件与 DBC 信号校验均未独立检查,且这不是量产车型定义。要确定报文与硬件的对应关系,需要结合 marv_driver 的源码自行核对。

    为什么构建步骤要固定 ROS 2 galactic?

    因为构建与运行步骤是按这个发行版写的:构建前需 source ROS2 galactic,相关脚本按 /opt/ros/galactic/setup.bash 的路径组织。换成其他发行版时这些路径与包名需同步调整,而说明文档没有提供其他发行版下的验证记录。

    两套 rosbag 应该怎么选?

    按用途分:一套记录全部 topic,适合事后完整回溯;另一套只记录指定 topic(例如导航数据),适合长时间跑车时压住数据量。定向那套的 topic 列表写在 start_specific_ros_bah.sh 脚本里,改场景时要记得同步维护,否则关键信息不会被记录。

    scenario_starter 能不能代替实车路试?

    不能。它只模拟 12V Auto 状态与 OCU 发出的 start scenario 命令,用于在正式路试前验证场景脚本能否正常启动。上游说明明确指出它不模拟任何传感器数据,因此只能给出「脚本是否启动正确」的指示,无法验证控制行为是否正确。

    这份归档的权利状态如何?

    归档方保留了仓库的 MIT 许可证与上游 README,同时说明各组件数据的独立来源并未确立。MIT 覆盖的是仓库本身携带的内容,不等于覆盖随项目引用的第三方组件数据;再分发前需要单独确认各组件部分的权利。

    延伸阅读

    参考资料

    • 项目 01 —— public-can-dbc-datasets/additional/case-lab-marv-ros/SOURCE.md(marv.dbc 的路径、35 条 BO_ 报文与 115 行 SG_ 信号、Git blob 与 SHA-256、快照修订与采集日期、平台硬件与信号校验未核验的声明、保留 MIT 许可证与上游 README 的权利说明)
    • 项目 01 —— public-can-dbc-datasets/additional/case-lab-marv-ros/UPSTREAM_README.md(colcon_ws 内四个包的分工、依赖与 galactic 环境下的 colcon build –symlink-install 构建、两套 rosbag 与 start_specific_ros_bah.sh 的 topic 配置、marv_start.sh 与 screen 会话、systemd 服务命令、scenario_starter 节点的仿真覆盖范围与不模拟传感器数据的边界)

    免责声明

    本文所述报文与信号数量、包结构、命令与运行方式均来自公开开源仓库的说明文档,属第三方工程产物,未经独立实车核验。归档方已明确声明平台硬件与 DBC 信号校验未独立检查,且该定义并非量产车型模型。不同平台版本与组件固件可能存在差异。涉及安全相关用途时请仅作参考,并以实际测量与官方资料为准。

    更新记录

    • 2026-10-14 初稿

  • Athena 日志转发:4096 字符分帧、100 秒等待与 1 小时重发门槛

    结论:Athena 诊断日志转发是在 swaglog 已落盘之后的独立一段:非 PC 会话线程每约 10 秒扫描 swaglog_root,把候选文件整份读入、组装 RPC、按 4096 字符拆成 WebSocket 帧发送,并等待最多约 100 秒的成功响应。三个数字决定重发行为:4096 字符分帧、100 秒确认等待、3600 秒重发门。要注意两个非持久化事实:写入 user.upload 的发送时间只是「已尝试」标记,不是云端交付证明;确认只依据响应 JSON 的 result.success,不核对文件哈希或服务端对象键。而响应里的 id 会被直接拼进日志根路径,log_handler() 未校验它是否为本进程已发送的普通文件名。

    转发链:从轮转文件到 WebSocket 分帧

    本地来源是 SwaglogRotatingFileHandler 按 INFO 以上轮转落盘的文件,转发线程读的是完整文件而非逐条 logMessage。下表是这一段的关键函数与职责。

    函数 / 组件 实现职责
    SwaglogRotatingFileHandler 把 INFO 以上日志轮转落盘;转发线程读取完整文件,而非逐条 logMessage。
    get_logs_to_send_sorted() 枚举 Paths.swaglog_root(),检查 user.upload,未标记或标记早于一小时的文件进入候选;排序后排除候选集末项。
    xattr_cache.getxattr()/setxattr() 按路径与属性名缓存读取;本模块 setxattr() 先清缓存再写属性。来自其他进程的属性改动不主动失效。
    log_handler() 非 PC 会话线程每约 10 秒重新扫描;每轮弹出一个候选,写发送时间、读全文件、组装 RPC,并消费确认队列。
    send_queue_push()、ws_send() 日志请求采用低优先级,队列按 (priority, seq) 排序;发送线程把消息按 4096 字符拆成 WebSocket 帧。入队不等于已写到网络。
    jsonrpc_handler()、is_response() 带 id 且含 result/error 的消息进入模块级 log_recv_queue;无专门的请求来源或连接代号关联。

    三个数字决定重发行为:4096 / 100 / 3600

    第一是分帧长度:ws_send() 把消息按 4096 字符拆成 WebSocket 帧,发送中失败时该消息已经从队列取走,源码没有针对这条发送失败即时重排,重发只能依赖一小时扫描。第二是确认等待:当前文件对应的响应最多等待约 100 次、每次 1 秒。第三是重发门:get_logs_to_send_sorted() 用当前墙钟秒数减去 xattr 里的发送时间,无标记或超过 3600 秒即视作可重发。

    阈值 / 取值 含义 边界
    4096 字符 ws_send() 的 WebSocket 分帧长度 发送中失败后消息已出队,无即时重排
    约 100 秒 等待当前文件成功响应的最长时间(约 100 次 × 1 秒) 期间收到其他 ID 的响应仍会处理其成功标记
    3600 秒 无标记或标记早于一小时才重新列为候选 失败后不会立刻重试
    2147483647 本地「收到成功响应」的哨兵值 正常当前时间下不会进入候选,但不是对象存储的签名回执

    还有一个排序细节:返回值是 sorted(logs)[:-1],排除的是当次可发送候选中排序最大的文件,而不是先在全部目录项中识别活动文件。如果最新活动文件已有哨兵值而不在候选集,另一个较新候选也会被排除——这是源码排序条件的推导,文件名与实际轮转状态的一致性仍需验证。

    「已尝试发送」标记先写,打开可能失败

    log_handler() 从候选尾部取文件,先写当前 Unix 秒数到 user.upload,后打开并完整读取文件、调用 send_queue_push()。这个顺序带来一个明确后果:文件打开失败、进程中断、发送线程尚未发送、网络中断,都可能留下「已尝试」标记;而没有成功确认时要等一小时条件满足才能重新列为候选。探针也复现了这一点——打开日志失败前已经写入了「尝试发送」时间,且没有排入发送队列。

    此外,int.to_bytes(curr_time, 4, ...) 与 open(...).read() 之间没有事务,单个轮转文件会全部进入内存及 JSON 消息,这条路径没有专属的文件大小上限。对读属性时的 ValueError/TypeError 会被当成「未标记」,其他 OSError 交由外层循环记录。

    确认只信响应 JSON,不校验文件身份

    result.success 为真时,才把响应 id 对应的文件标成哨兵值;错误响应、超时或解析异常不会直接清理原发送时间。确认只基于响应 JSON 字段,不核对文件哈希、服务端对象键或持久化状态。由于每个请求的 id 就是文件名,而消费响应时未核对当前已发送的 ID 集合,这里存在可被误触的风险:响应 id 被直接传给 os.path.join(Paths.swaglog_root(), log_entry) 并用于 setxattr(),is_response() 只检查形状。

    探针显示:在没有当前发送任务时,形如 {"id":"../outside","result":{"success":true}} 的假响应仍会使 log_handler() 尝试对拼出的 /logs/../outside 写成功属性。这只证明函数没有已发送 ID 校验,不证明生产 WebSocket 可收到该响应、外部路径可写或服务器已持久化日志。插件化时正确的做法是把关联 ID 映射为宿主持有的文件句柄或受限路径,而不是用远端字符串拼路径。

    硬件与插件化验收

    源文档给出一条清晰的验收方向:诊断转发只能在非实时服务域运行,限制单文件与单次 RPC 大小,并定义网络费用、电量与存储写入预算,不影响控制周期与录制带宽;以本地生成的不可猜任务 ID 绑定文件身份、内容摘要和连接代号,独立记录 pending/sending/accepted/retry_wait,只有服务端可验证确认才推进终态;日志收集与远端转发分别授予能力,默认脱敏设备标识、位置、令牌和用户数据。测试上应在离线服务模拟器中注入发送前崩溃、分帧中断、延迟/重复/伪造响应、墙钟跳变、xattr 不可用、活动文件轮转及超大文件。

    常见问题

    写入 user.upload 的发送时间能证明日志已上传成功吗?

    不能。它只是「已尝试」标记,源码先写这个时间再打开并读取文件,因此文件打开失败、进程中断或网络中断都可能留下该标记。真正的成功标记是收到 result.success 后写入的 2147483647 哨兵值,而它也只是本地的成功响应标记,不是对象存储的签名回执。

    发送失败后日志会立刻重试吗?

    不会立刻。get_logs_to_send_sorted() 用当前墙钟秒数减 xattr 发送时间,无标记或超过 3600 秒即视作可重发;ws_send() 发送中失败时消息已经从队列取走,源码没有即时重排,重发依赖一小时扫描。

    为什么确认只等 100 秒,而不是一直等?

    源码对当前文件对应的响应最多等待约 100 次、每次 1 秒。期间即使收到其他 id 的响应,仍会处理其成功标记,直到收到当前 id 或等待结束;log_recv_queue 位于模块级,重连时不会清空。

    响应里的 id 被直接当路径用,有风险吗?

    有校验缺口。响应 id 会被直接拼进日志根路径并用于 setxattr(),而 is_response() 只检查形状、log_handler() 未验证这个 ID 是本进程已发送的普通文件名。探针证明函数没有已发送 ID 校验,但不证明生产环境可达或被利用;插件化时应改用受限路径或文件句柄映射。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/ATHENA_DIAGNOSTIC_LOG_FORWARD_ACK_DEEP_DIVE.md(取用了转发函数的组件索引、4096/100/3600 三个阈值、发送标记先写、确认字段与路径拼接风险、插件化验收要求)。

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

    更新记录

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

  • 常规 uploader:候选选择、HTTP 结果与退避循环

    结论:常规 uploader(system/loggerd/uploader.py)每轮只上传一个对象,且它的「成功」是本地终态,不是远端交付回执。源码用同一个 upload_success 事件与 user.upload=1 标记覆盖 200/201 与 401/403,把「零字节/超限文件跳过」也一并记为成功;因此不能据事件或 xattr 推断服务端已存完整对象。它也不是 Athena 的指定 URL 队列,两套逻辑要分开看。

    函数级调用链

    基线是本地 source/ 快照 c8fb906,结合静态阅读及四项原 upload() 假依赖探针;没有发网络请求、清锁、写真实扩展属性或运行设备测试。

    函数 具体行为
    get_directory_sort() / listdir_by_creation() 路段名按末个 -- 拆成两段、右对齐并在左侧补零后排序;这是目录名排序,不是读取文件创建时间。只选日志根下一层目录。
    clear_locks() 进程启动时遍历根目录各子项,删除名称以 .lock 结尾的文件;未逐项确认对应写入者已退出。
    list_upload_files() 跳过含任意 .lock 文件的目录及 user.upload=1 文件;计费网络下限制 qcamera.ts 的路段前缀。枚举不等于会被下游选中。
    next_file_to_upload() 先物化全部候选到列表,第一轮按路径包含 crash/ 或 boot/ 选;第二轮只选 qlog、qlog.zst、qcamera.ts。没有候选则返回 None。
    do_upload() / upload() 用设备令牌获取上传 URL/headers;按 key 与原文件扩展名决定是否压缩,然后 requests.put(..., timeout=10);压缩源完整进入内存缓冲。

    排序与清锁两处都带假设。目录排序只是把路段名按末个 -- 拆开、右对齐补零后比较字符串,并非读取文件创建时间;清锁则在启动时直接删除所有以 .lock 结尾的文件,没有逐项确认对应写入者已退出。两者都只保证「本地看起来就绪」,不等于段已经写完或写入者已经停止。

    选择与标记的精确语义

    list_upload_files() 的产出是候选流,next_file_to_upload() 才决定本轮只上传一个对象。其第一优先级按完整路径中的 crash/、boot/ 子串匹配,第二优先级仅包含 immediate_priority 的三个文件名;普通路段 rlog、前后/座舱原始视频即使在候选流中,也不会被常规循环直接选中。还需注意 MAX_UPLOAD_SIZES 只含 qlog 和 qcam 文件名,不能把它当作对 qlog.zst 或 qcamera.ts 的通用大小上限。

    传输实现还有一处容易被误解的地方:get_upload_stream() 对未压缩文件返回文件句柄,对压缩文件则先完整压缩到 BytesIO 再上传,所以「边上传边压缩」并不是本路径的实现;do_upload() 还忽略返回的压缩后长度,本层没有独立校验上传字节数与对象摘要。FAKEUPLOAD 会令非 412 的 URL 响应直接返回假成功,只适用于隔离测试,不是生产交付回执。

    结果分支 源码处理 能/不能推断什么
    零字节 / 命中上限且超限 不调用上传 API,但仍置 success=True 并写 user.upload=1。 只说明本地跳过,不说明远端收到。
    412 URL API 或 PUT 的 412 记为 upload_ignored。 表示被忽略,不等于失败或成功。
    200/201 与 401/403 都走名为 upload_success 的事件和本地标记。 不能据该事件或 xattr 推断服务端已存完整对象。
    setxattr() 失败 通常只记事件,函数仍返回 True。 文件可能下轮被再次选择。

    还有一个条件性错误路径:last_exc 只在尝试 do_upload() 的分支初始化;零字节或超限文件直接进入成功标记分支。如果此时 setxattr() 抛 OSError,其异常处理日志引用未定义的 last_exc,会引发 UnboundLocalError 并退出当前调用,外层 main() 没有包住 step()。假 xattr 探针分别复现了零字节与超限文件的原函数行为,但并未执行真实文件系统故障。另外,do_upload() 的 debug 日志还会记录预签名 URL 和 headers,这些值可能含短期访问凭据,生产日志策略需单独审计。

    网络调度与测试边界

    main() 从 deviceState 读 networkType/networkMetered,SubMaster.update(0) 后没有在这里核验消息新鲜度或外层有效位。FORCEWIFI 仅把用于「是否无网」的局部 network_type 设成 Wi-Fi,传给 step() 的仍是原始 deviceState 类型及计费位。

    情形 睡眠/退避
    无网 睡 60 秒(驻车)或 5 秒(行车)。
    无候选 以同样基数加随机抖动等待。
    成功后 基数 0.1 秒。
    失败时 基数先翻倍、最大 120 秒,再睡基数到两倍之间。

    UPLOADER_SLEEP=0 会跳过睡眠,适合测试而非目标硬件常态。test_uploader.py 用 mock URL API 与 FAKEUPLOAD 覆盖正常/412、目录顺序、锁文件和 xattr;它没有验证真实 HTTP PUT、401/403、预签名 URL 解析失败、零字节且 xattr 失败、计费网络状态切换或掉电恢复。测试中的 clear_locks 确认启动时会删除锁,但不证明活动 writer 不受影响。本轮未运行原仓库测试;四项隔离探针只覆盖 upload() 局部控制流。

    自制硬件的上传插件契约

    自制硬件的上传插件应把「未尝试/待传/传输中/被服务端接受/拒绝/本地跳过/确认存储」分别持久化,并以文件摘要或对象版本核对交付结果。上传器与 loggerd 应协商段关闭与锁所有权,不能在不确定写入者状态时全局清锁;对网络费用、RAM 中整文件压缩、带宽、eMMC 写放大和重试设预算。台架验收需要覆盖以上结果分支、网络状态失联、API/PUT 各类 HTTP 码、断电与 xattr 不可用,再验证实板资源峰值。

    常见问题

    uploader 报了 upload_success,文件一定上传成功了吗?

    不一定。源码让 200/201 与 401/403 都走名为 upload_success 的事件和本地标记,零字节或超限文件也会置 success=True 并写 user.upload=1 但根本不调用上传 API。这些都是本地终态,不能据事件或 xattr 推断服务端已存完整对象。

    为什么普通路段的 rlog 和视频不会被上传?

    因为 next_file_to_upload() 第一轮只按路径包含 crash/ 或 boot/ 选,第二轮只选 qlog、qlog.zst、qcamera.ts 这三个 immediate_priority 文件名。普通路段的 rlog 与前后/座舱原始视频即使在候选流里,也不会被常规循环直接选中。

    FORCEWIFI 会改变计费网络判断吗?

    不会完全改变。源码中 FORCEWIFI 仅把用于「是否无网」的局部 network_type 设成 Wi-Fi,而传给 step() 的仍是原始 deviceState 类型及计费位。因此计费相关的限制仍按原始状态生效。

    清锁会不会影响正在写入的日志?

    clear_locks() 在进程启动时遍历根目录各子项,删除名称以 .lock 结尾的文件,但源码未逐项确认对应写入者已退出;测试里的 clear_locks 也只确认启动时会删除锁,不证明活动 writer 不受影响。因此自制硬件应让上传器与 loggerd 协商锁所有权,而不是在不确定写入者状态时全局清锁。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/UPLOADER_SELECTION_RESULT_BACKOFF_DEEP_DIVE.md(取用了候选选择与标记语义、结果分支、退避参数、条件性未定义变量路径与上传插件契约)。
    • 项目 02 —— openpilot-research/ATHENA_RECENT_ROUTES_METERED_UPLOAD_CONTRACT_DEEP_DIVE.md(仅用于确认计费网络最近路段门的接续关系,未取用其未公开内容)。

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

    更新记录

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

  • Athena 主连接:70 秒 Ping 门不是定时器,跨会话队列不清空

    结论:Athena 主 WebSocket 长连接的存活判断里有两处「看起来在保护、实际没有」。第一,70 秒 Ping 门不是独立定时器:只有当 recv_data() 抛 WebSocketTimeoutException 时才检查距上次 Ping 的年龄,业务帧持续到来时不会触发检查。第二,recv_queue、send_queue、log_recv_queue、upload_queue 都是模块级对象,新会话重建线程却不重建队列,旧请求或回复可能在断线后被新会话处理。此外 send_queue_push() 的队列未设 maxsize,4096 的单位是 Python 字符。以下为固定快照 c8fb906 的静态追踪,未连接生产服务或运行车载设备。

    关键组件与调用链

    设备到 Athena 的主连接由几个线程协作:一个接收线程、一个发送线程、一个 keepalive 线程、四个上传 worker、一个诊断日志 worker 以及 HANDLER_THREADS 个 RPC worker,全部由单个 end_event 管理。manage_athenad.main() 在子进程结束后等 5 秒再启动,这一层只观察进程退出,不验证 WebSocket 认证或 RPC 健康。

    组件 行为与限制
    manage_athenad.main() 子进程结束后等 5 秒再启动;不验证认证或 RPC 健康。
    athenad.main() 读 DongleId 与上传队列;用 Api.get_token() 连 /ws/v2/{dongle_id},create_connection(timeout=30.0),成功后清当前上传字典。
    handle_long_poll() 单个 end_event 管理收发/keepalive/四个上传/一个日志/RPC worker;exit_event 每 0.1 秒检查,逐个 join(),无统一 join 截止期。
    ws_recv() 文本解码 UTF-8、二进制入 recv_queue;收到 Ping 写本机单调时钟纳秒到 LastAthenaPingTime。
    ws_send() 从进程级优先队列取一条消息,按最多 4096 字符发首帧与 continuation;发送前已出队,异常只设结束事件不回填。
    ws_manage() 依 IsOffroad 每 5 秒轮询切换 TCP keepalive/USER_TIMEOUT;无本地异常处理。
    backoff() 返回 [0, min(128, 2**retries)) 的随机整数秒。

    70 秒门不是定时器

    Ping 写入的是 time.monotonic() 纳秒,不是 Unix 时间。ws_recv() 里的 70 秒门只在 recv_data() 抛 WebSocketTimeoutException 时检查:当底层持续返回业务帧却没有 Ping 时,循环不进入超时分支,源码不会检查 Ping 年龄;只有当底层持续超时、超过门限的那次超时才触发结束。这是控制流推导,实际帧与超时节奏取决于 WebSocket 与网络。

    LastAthenaPingTime 只在收到 Ping 时更新,并在 main() 捕获连接异常或 manager 启动清理时删除;而收发 worker 设置 end_event 后正常退场的路径不删除它。因此某段时间内该 Params 可以保留旧 Ping 值,消费者必须用同一单调时钟域计算年龄,并区分「历史最后 Ping」与「当前会话活着」。hardwared.py 只是把它复制到 deviceState.lastAthenaPingTime,没有按年龄推导实时连接健康。

    取值 含义 边界
    70 秒 Ping 年龄门 仅在 recv 超时分支检查,不是独立定时器。
    4096 字符 ws_send() 分帧长度 单位是 Python 字符,协议帧字节长度并非固定 4096。
    5 秒 守护层重启间隔;ws_manage() 轮询间隔 仅观察进程退出,不代表 RPC 健康。
    0.1 秒 exit_event 检查间隔 退出依赖各 worker 及时响应,无统一截止期。
    128 backoff() 上限 首次异常可能等 0 或 1 秒。

    状态转移与跨会话队列

    连接成功后 main() 把 conn_retries 置 0 再调用 handle_long_poll()。收发线程异常会设置 end_event;所有 worker 结束、ws.close() 返回后,该次循环通常正常结束,随后执行 backoff(0) 即 0 秒后尝试下一次连接。只有异常穿透到 main() 的两个异常分支时才增加 conn_retries、随机退避并删除 LastAthenaPingTime。

    跨会话的隐患在于队列:recv_queue、send_queue、log_recv_queue 和 upload_queue 是模块级对象,新会话重建线程却不重建这些队列,旧请求、回复或日志响应若在断线时留在队列,可能被新会话处理。cur_upload_items 在每次成功连接后清空,但上传等待队列不清空。这属于条件性时序风险,不是本轮复现结果。

    send_queue_push() 对发送项使用 (priority, next(send_seq), data),同优先级由递增序号破同分,高优先级 RPC 回复可先于低优先级日志;队列未设置 maxsize,若生产速率长期超过发送速率,内存增长需要实测并设平台预算。此外 handle_long_poll() 逐个 join() 没有超时,上传回调、日志响应等待或阻塞的 WebSocket/Params 操作若不及时响应结束事件,会延迟整个会话清理和重连。

    硬件与插件化验收

    把主连接封装为带连接代号的会话插件:每条 RPC 请求、响应和诊断确认绑定代号、deadline 与幂等键;重连时显式取消、迁移或重试,不能让旧队列消息无条件进入新连接。把「进程存活、TCP 已连、服务端鉴权成功、最近 Ping 年龄、RPC 可用、上传可用」拆成独立健康指标,deviceState.lastAthenaPingTime 只能作为其中一项时间戳输入。

    对队列长度、帧与消息字节数以及每类 worker 的执行时间设上限;所有 worker 应支持有限时间退出与可观测的清理结果;收发失败需区分未发送、部分发送、已发送未确认。台架验收应覆盖:仅业务帧无 Ping、静默链路、半开 TCP、快速反复断连、不同线程故障、Params 写失败、上传/日志等待阻塞、旧响应跨会话以及行车与驻车切换。现有硬件门控测试预期驻车约 60 秒、行车约 21 秒内触发重连并重新收到 Ping,但本轮未运行。

    常见问题

    Ping 超过 70 秒没来就一定会断开吗?

    不一定。70 秒门不是独立定时器,只有在 recv_data() 抛 WebSocketTimeoutException 时才检查;当底层持续返回业务帧却没有 Ping 时,循环不进入超时分支,不会检查 Ping 年龄。

    lastAthenaPingTime 能当作当前连接是否活着的判据吗?

    不宜单独使用。它只在收到 Ping 时更新,且收发 worker 正常退场的路径不会删除旧值;消费者必须用同一单调时钟域算年龄,并区分「历史最后 Ping」与「当前会话活着」,该值只能算一项输入。

    断线后旧队列里的消息会怎样?

    可能被新会话处理。recv_queue、send_queue、log_recv_queue、upload_queue 是模块级对象,新会话重建线程但不重建这些队列;cur_upload_items 每次成功连接后清空,但上传等待队列不清空。

    发送 4096 是固定 4096 字节吗?

    不是。ws_send() 按最多 4096 字符拆分首帧与 continuation,单位是 Python 字符,协议帧的实际字节长度并非固定 4096;队列也未设 maxsize,长期积压需要实测。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/ATHENA_WEBSOCKET_SESSION_LIVENESS_DEEP_DIVE.md(取用了组件与调用链表、70 秒 Ping 门、跨会话队列不清空、backoff 与插件化验收要求)。

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

    更新记录

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

  • 远程车联与车队遥测:四个数据入口与一条时间尺度分界

    结论:远程车联与车队遥测可按数据入口分成四类:蜂窝 OBD 设备、车企账号 API、常驻车载网关、本地桥接(自建 MQTT 等)。它们与本地诊断最大的区别是一条时间尺度分界——本地诊断是「一次会话、实时仪表」,远程平台是「连续行程、历史趋势、远程告警」。在这条分界之上,最被低估的验证项不是功能数量,而是上传延迟、断网补传、数据权限与设备订阅成本。

    四类数据入口

    公开资料显示,远程车联产品的入口形态差异很大,不能只看「能看车况」这一层:

    入口类型 典型形态 公开描述的典型输出
    蜂窝 OBD 设备 插入 OBD-II、自带联网模块与 SIM 行程轨迹、驾驶行为、车辆健康通知
    车企账号 API 车企账号授权加可选流式接口 行程、充电过程、车况历史
    常驻车载网关 常驻 OBD 接口的模块,含车型专用集成 本地网页、SD 卡记录、可选远程上报
    本地桥接 手机蓝牙 OBD 桥接到用户自建服务 自选后端内的实时与历史指标

    这四类入口的采集方式不同:蜂窝设备自行联网持续上报;车企账号接口读取的是托管数据;常驻网关可在车上保留本地记录再选择上报;本地桥接则把数据送到用户自建的服务端。因此采样频率、数据保留期限与真实覆盖率都不可能从商店描述直接读出。

    四类入口的成本结构也不同:蜂窝设备是「硬件 + 服务包」,车企账号接口以账号授权为核心,常驻网关兼顾本地与远程,本地桥接则把存储与保留责任交给用户。选型时应把成本拆到「设备、服务、数据保留」三项分别估算,而不是只看一个订阅价。

    本地诊断与远程车联:一条时间尺度分界

    公开资料给出的核心比较如下:

    维度 本地诊断(手机在车旁) 远程车联 / 车队平台
    连接 经蓝牙 / Wi-Fi 适配器直连 设备常驻车上,经蜂窝 / 网关上传
    时间尺度 一次诊断会话、实时仪表 连续行程、历史趋势、远程告警
    对象 单车与车主或技师 多车、司机、资产、规则与组织
    输出 DTC、PID、曲线、报告 位置、运营指标、车况告警、维护任务
    重要验证 适配器兼容、PID 频率、诊断准确性 上传延迟、断网补传、数据权限、设备订阅成本

    这条分界决定了评估重点:本地诊断看「一次读得准不准」,远程平台看「长期传得全不全」。把两者的功能清单直接对比,往往会误判。

    数据保留与权限是最被低估的验证项

    远程车联的第二组关键问题在数据治理。公开资料中可以看到几种不同做法:有的平台在车主授权后,用遥测接口提供实时 / 历史规范化车况,另设需额外权限的原始数据接口;有的自托管方案把数据写入用户自己的对象存储或数据库,由用户负责保留与备份;也有方案把导出能力限定在按日期请求的压缩包。

    另一个细节是「命名不等于事实」:公开资料特别指出,某些标注为「OBD 级别」的高级配置档,并不等于物理 OBD-II 连接,而是来自车企账号数据。选型时若把两者混算采样字段与成本,就会得到错误结论。

    车队层的对象是组织,不是单车

    面向车队的产品,数据组织单位是车队、司机与运营规则,而非一辆车的故障码会话。公开资料列出的车队能力包括:位置、行程、油耗、怠速、驾驶行为、维修与工时合规(HOS),以及面向司机端的检查、工时与沟通流程;有的还提供 SDK / API 供集成。

    需要注意的是,公开商店文字往往无法证明某项车况数据究竟来自 OBD、专用网关还是其他设备。因此这类平台更适合按「网关、相机、订阅方案」分别核验,而不是笼统地归为「车载数据采集」。同样的道理,一些只呈现检查、工单与保养流程的工具,在无法证明直接采集车载数据时,只能算维护工作流参考。

    另一个常被忽略的点是数据权限的组织化:车队平台面向多车、司机与资产,权限需要按角色分配;这类需求在单车诊断视角下几乎不存在,也决定了它无法用「一个应用、一次会话」的框架来评估。

    常见问题

    远程车联能替代本地诊断吗?

    不能直接替代。两者时间尺度不同:本地诊断面向一次会话的实时读数与故障排查,远程平台面向连续行程、历史趋势与多车告警。选购时应按需要解决的问题选择入口,而非比较功能清单条数。

    「OBD 级别」的数据就是来自 OBD 接口吗?

    不一定。公开资料中,某些标注为 OBD 级别的高级配置档实际来自车企账号数据,并不等于物理 OBD-II 连接。核验时应确认数据入口是设备、网关还是云端接口。

    车队平台的数据都来自 OBD 吗?

    不能假定。公开商店文字常常无法证明某项车况来自 OBD、专用网关还是其他设备,需要按网关、相机与订阅方案分别核验。

    远程数据最该验证什么?

    上传延迟、断网后的补传、数据权限范围以及设备订阅成本。这些比功能数量更能决定长期可用性,也常是商店描述不覆盖的部分。

    延伸阅读

    参考资料

    • 项目 01 —— car-scanner-research/远程车联与车队数据.md(取用四类数据入口、本地与远程的时间尺度分界、数据保留与权限口径、车队层对象;未引用任何逆向材料)。
    • 项目 01 —— car-scanner-research/电动车与性能数据.md(取用车企云端与多源接入的相邻口径)。

    说明:本文仅取用公开产品说明对远程车联与车队平台的数据入口分类与验证口径,未部署任何服务、未连接车辆或设备、未进行通信抓包,也未核验各平台宣称的采样频率、数据保留期限与覆盖率。文中所有分类、维度与边界均出自源文档,标注为未验证的结论保留其原有边界。

    更新记录

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

  • 桌面还是手机:CAN 数据记录工具的四个能力层级

    结论:桌面与手机的 CAN 数据记录工具并非同一品类,而是覆盖四个能力层级:CAN 物理 / 帧层、OBD 服务 / PID 层、厂商增强诊断层、维修决策层。桌面端多出 USB、串口与 Windows 平台上的 J2534 入口,手机端则受限于 BLE / Wi-Fi / 经典蓝牙,二者都不天然支持工程级总线分析。理解这条分层,就能解释为什么「支持 CAN」不等于「能诊断所有 ECU」,也能解释重卡诊断为何要单独看待。

    桌面与手机的入口差异

    公开资料显示,桌面诊断软件与手机应用在物理接口上并不对等。桌面端通常覆盖更完整的接口集合,手机端的连接方式则相对收敛:

    维度 桌面端(PC) 移动端(手机)
    操作系统 Windows 10 / 11、macOS 较新版本 iOS 与 Android
    接口形态 USB、串口、经典蓝牙、BLE、Wi-Fi BLE、Wi-Fi、MFi,Android 另有经典蓝牙
    专业总线入口 Windows 另支持 J2534 兼容设备 通常不提供
    典型场景 离线回放、报告与大屏分析 随车实时读数与一次诊断会话

    两个边界需要说清楚:其一,桌面端对 J2534 的支持通常限定在 Windows,不能外推到 macOS;其二,移动端一般不提供 USB 入口。因此「桌面能力更全」只在特定接口与平台组合下成立,跨平台购买时还需逐项核对授权是否分开。

    四个能力层级:帧、服务、增强、决策

    抛开平台,工具的能力可以按抽象层次排成四级:

    层级 数据表示 主要工作
    CAN 物理 / 帧层 总线、ID、时间戳、字节 采集、过滤、解码规则研究
    OBD 服务 / PID 层 服务号、PID、DTC、物理量 通用诊断与实时监测
    厂商增强诊断层 ECU、私有服务、车型包 多模块诊断、适配与保养
    维修决策层 故障解释、报告、配件 / 流程 判断下一步行动

    这个分层解释了为什么单靠通用 OBD2 适配器与故障码表,无法自然长成工程级 CAN 工具或维修厂诊断平台。原始 CAN 层先保留时间戳与原始帧,再用 DBC 或人工分析建立语义;OBD 服务层则围绕服务号与 PID 的请求和物理量换算。两者处于不同抽象层,不能把「支持 CAN」视为「已能诊断所有 ECU」。

    需要区分的是,原始帧类工具的定位是工程级分析与仿真,而不是给车主解释故障码;它们围绕时间戳、帧 ID、总线与载荷组织数据,用 DBC 把帧转成信号只是其中一步。选择时应先明确目标是「复盘一段总线」还是「读一次故障码」,两者对应的工具形态并不一样。

    原始 CAN 与诊断之外:数据可离线复盘

    桌面端的另一项结构差异是离线可复盘。公开资料中的开源 CAN 分析工具支持从多种日志格式(如 CSV、candump、Vector、PCAN)导入导出,围绕时间戳、帧 ID、总线、方向、长度与载荷组织帧,并借助 DBC 将帧 ID / 位段转成可解释信号。这类工具的定位是工程级总线分析与仿真,而不是给车主解释故障码。

    与之相对,一些桌面 OBD 工具的核心仍是「设备随附的诊断软件」定位:故障码、实时 PID、Mode 05 / 06 / 09、日志回放与 CSV。二者的差别在于:前者以原始帧为中心,后者以服务与物理量为中心。

    重卡与商用车是独立品类

    公开资料强调,重卡 / 商用车诊断的协议、故障语义与售后场景与轻型车不同,不应把轻型车排放诊断能力直接推到重卡。重卡类工具通常依赖专用蓝牙接口,面向 DPF 再生、降额等场景,并可能提供双向命令;另有相邻的维修资料类产品,提供故障码、接线图与维修资料,属于诊断之后的知识层,并不等同于直接采集车辆数据。

    对选型者而言,这意味着:如果目标车辆是商用车,通用 OBD App 的能力清单参考价值有限,需要单独核对协议与售后链路。从能力层级看,重卡工具的重心通常落在「厂商增强诊断层」与「维修决策层」,依赖专用接口而非通用适配器,其数据入口与售后链路都与轻型车不同。

    常见问题

    桌面端一定比手机端能力更强吗?

    不能一概而论。桌面端在 USB、串口与 Windows 的 J2534 入口上更完整,适合离线回放与大屏分析;但 J2534 通常限 Windows,且桌面与移动授权常需分别购买。要按具体接口与平台组合核对。

    「支持 CAN」就等于能诊断所有 ECU 吗?

    不等于。原始 CAN 层只保留时间戳与原始帧,需要 DBC 或人工分析才能建立语义;OBD 服务层则只覆盖标准服务与 PID。能否读到某个 ECU,取决于车型协议、总线拓扑与适配器能力。

    重卡诊断能用轻型车 OBD 工具替代吗?

    不建议。重卡的协议、故障语义与售后场景与轻型车不同,公开资料明确不应把轻型车排放诊断能力直接推到重卡,通常需要专用接口与面向 DPF、降额等场景的功能。

    桌面工具的离线回放有什么价值?

    它可以围绕时间戳、帧 ID、总线与载荷复盘一段采集,并用 DBC 把原始帧转成可解释信号,是对比多份日志、验证解码规则的重要手段,也是移动端一次会话不擅长的场景。

    延伸阅读

    参考资料

    • 项目 01 —— car-scanner-research/桌面与原始CAN.md(取用桌面与移动接口差异、四个能力层级、原始 CAN 分析定位与重卡品类边界;未引用任何逆向材料)。
    • 项目 01 —— car-scanner-research/逐产品拆解.md(取用移动端接口集合口径,用于对比桌面端)。

    说明:本文仅取用公开资料对桌面与移动诊断工具的能力分层与接口口径,未安装任何安装包、未连接总线、未进行通信抓包,也未核验各商品宣称的接口兼容与平台支持。文中所有层级、接口与边界均出自源文档,标注为未验证的结论保留其原有边界。

    更新记录

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

  • 网络状态到费用策略:路由 metric、RSSI 分档与计量位的回退边界

    结论:网络状态是一条「慢速快照 → 分类 → 费用策略」的链路:hardwared 约每 10 秒采集一次网络/调制解调器/USB 快照,分类函数从 /proc/net/route、wpa_supplicant 与 modem 状态文件 推导类型、强度与计费位,再由 uploader、Athena 与 updated 消费。关键边界有三处:网络接口切换本身不是立即刷新触发器;蜂窝缺 signal_quality 会落入 poor 而非 unknown;未知 Wi-Fi 计费可能被当成免费网络,不能作为上传费用授权的保守依据。本文未连接 Wi-Fi、蜂窝网络或运营商账单。

    来源与关键函数

    类型判断从 get_default_route_iface() 开始:它解析 /proc/net/route,只取目的地为 00000000 且带网关标志的条目,以 (metric, 接口名) 元组最小者为接口,不单独要求 UP 标志。默认路由名匹配 wlan* 即认作 Wi-Fi、eth* 即认作以太网,否则读取 modem 的 connected 与制式,未知返回 none。SSID 与 RSSI 来自 wpa_supplicant_cmd():它用抽象 UNIX datagram socket 发命令,跳过以尖括号开头的事件消息,收到 FAIL 前缀即返回空结果,超时异常向调用方传播。

    入口 实现语义
    wpa_supplicant_cmd() 抽象 UNIX datagram socket;跳过事件消息,FAIL 返回空字典,超时异常传播
    get_default_route_iface() 取默认目的地且带网关标志的条目,按 (metric, 接口名) 最小者;不要求 UP 标志
    get_network_type() wlan*/eth* 优先;否则读 modem connected 与制式,未知返回 none
    get_modem_state() 文件缺失或 JSON 语法错误返回 {};权限异常透传;合法 JSON 非对象不检查形状
    parse_strength() / get_network_strength() 四档阈值分类;Wi-Fi 用 RSSI,蜂窝用 signal_quality,以太网直接 great
    get_network_metered() 蜂窝取 GsmMetered;Wi-Fi 按 SSID 匹配 NetworkManager keyfile 的 metered,无明确值回退基类
    hw_state_thread() 约每 10 秒或 USB 拓扑变化构造快照;容量 1,队列满丢弃新状态
    hardware_thread() 复用最后成功快照发布 deviceState.network*,并把计费位写入持久 NetworkMetered

    路由、信号与计费的精确回退

    网络类型先按 /proc/net/route 的最低 metric 默认路由接口名判断;同 metric 时按接口名字典序选取,路由行只要求默认目的地和网关标志,不检查 UP 位。格式错误的行会让解析函数抛异常,get_network_type() 则吞掉它并尝试 modem,不会跳过坏行继续比较其余路由。若 modem 虽连接但制式不在 nr/lte/utran/umts/gsm 表内,仍返回 none。

    强度方面,get_network_strength() 对以太网固定给 great,并非链路质量实测。Wi-Fi 通过 wpa_supplicant 的 SIGNAL_POLL 读 RSSI,只有大于 -100 且小于等于 0 的 dBm 才映射为 120 + clamp(dBm, -100, -20),再按四档阈值分类;恰好 -100 dBm、缺 RSSI、非法值或 socket 异常保持 unknown。蜂窝缺 signal_quality 时默认数值 0,会进入 poor,不是 unknown;读取异常则因宽泛异常捕获返回 unknown。信号质量读取与 modem 网络分类仍不是一次原子快照。

    场景 结果 边界说明
    以太网强度 great 固定值,非链路质量实测
    Wi-Fi RSSI 有效 120 + clamp(dBm, -100, -20) 后四档 仅当 dBm 大于 -100 且小于等于 0
    恰好 -100 dBm / 缺 RSSI / 非法值 unknown socket 异常同样保持 unknown
    蜂窝缺 signal_quality poor 默认数值 0,不是 unknown
    蜂窝读取异常 unknown 由强度函数的宽泛异常捕获返回

    get_modem_state() 只把文件不存在及 JSON 语法错误转为 {},权限错误会向调用方传播;它不验证 JSON 顶层必须为对象——合法数组会原样返回,get_network_type() 在需要 modem 回退时访问 .get() 可抛 AttributeError,而 get_network_strength() 会捕获异常并返回 unknown。这是控制流差异,不能据此断言真实 modem 写入了这种文件。

    计费方面,蜂窝读取布尔参数 GsmMetered(源码默认真)。Wi-Fi 先读 wpa 的 STATUS SSID,再尝试把其转义形式转换为 NetworkManager keyfile 的文本或 byte;byte; 编码,在运行态与持久目录搜索配置。首个匹配 SSID 的配置若有 metered=1/2 分别返回真/假;若为默认或未知值,会跳出搜索并回退基类,后续同名配置不再检查。SSID 缺失、读取或解析失败也回退基类,而基类对 Wi-Fi、以太网和 none 返回非计费。因此「未知 Wi-Fi 计费」在此路径可能被当成免费网络,不能作为上传费用授权的保守依据。

    从慢速快照到上传决策

    hw_state_thread() 把类型、网络信息、强度、计费位分次调用取值,采集异常只记录、不发布该次快照。约 10 秒的采样周期可被 USB 拓扑变化提前触发,但网络接口切换本身不是立即刷新触发器;队列满时新快照会被丢弃。主线程沿用最后一次快照并以较高频率重复发布 deviceState,其消息 logMonoTime 不能代替这些网络字段的实际采样年龄。

    uploader.main() 消费 deviceState.networkType/networkMetered,Athena.upload_handler() 为禁止蜂窝的任务检查计费位,updated 则读 hardwared 写入的持久 NetworkMetered 决定自动拉取节奏。三者消费的是分类/配置状态,不是计费账单、当前传输 socket 的实际路由或全链路一致快照,状态切换窗口须靠传输层重新核验。

    自研硬件与插件接口

    网络插件至少输出 link_type、default_route、connectivity、metering 四个独立字段,并携带接口 ID、来源、采样单调时间、有效位和原因。metering=unknown 应由宿主费用策略保守处理,不应默认为免费;上传器还需在每次开始与传输过程中按实际出口接口和账单策略复核。蜂窝 eSIM/SIM 身份属于敏感信息,遥测与日志插件不应默认共享 ICCID/IMEI。

    验证层面,探针只覆盖隔离控制流;台架或隔离网络测试仍应覆盖双默认路由 metric 切换、Wi-Fi RSSI 缺失、SSID 转义、真实配置读取、蜂窝 GsmMetered 切换、慢快线程交错和队列满,并同时观察 deviceState、NetworkMetered、uploader/Athena/updated 决策与真实出站接口。

    常见问题

    蜂窝信号缺 signal_quality 时为什么不是 unknown?

    因为源文档说明默认数值为 0,会进入 poor,不是 unknown;只有读取异常才因强度函数的宽泛异常捕获返回 unknown。这一差异在费用与上传策略里很关键,因为 poor 与 unknown 的保守程度不同。

    未知 Wi-Fi 的计费状态会被当成什么?

    可能被当成免费网络。源文档指出:首个匹配 SSID 的配置若为默认或未知值,会跳出搜索并回退基类,而基类对 Wi-Fi、以太网和 none 返回非计费。因此 metering=unknown 应由宿主费用策略保守处理,不能作为上传费用授权的保守依据。

    网络接口切换会不会立刻刷新状态?

    不会。约 10 秒的采样周期可被 USB 拓扑变化提前触发,但源文档明确网络接口切换本身不是立即刷新触发器,且队列满时新快照会被丢弃;主线程会沿用最后一次快照重复发布,其消息时间不能代表网络字段的实际采样年龄。

    deviceState 的 logMonoTime 能当网络状态的采样时间吗?

    不能。主线程沿用最后一次快照并以较高频率重复发布 deviceState,其消息 logMonoTime 不能代替这些网络字段的实际采样年龄。网络插件应显式携带采样单调时间与有效位。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/COMMA_NETWORK_ROUTE_SIGNAL_METERING_SNAPSHOT_DEEP_DIVE.md(取用了网络分类、强度与计费回退、慢速快照与上传决策边界及插件接口要求)。
    • 项目 02 —— openpilot-research/ATHENA_DEVICE_NETWORK_METADATA_RPC_DEEP_DIVE.md(仅用于确认设备网络元数据接口的上下文)。

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

    更新记录

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

  • common.utils 契约:atomic_write、retry 与内存峰值

    结论:openpilot.common.utils 里的几个名字听起来很稳妥的辅助函数,其保证范围都比名字窄:atomic_write() 的「原子」只是同一文件系统内已完成写入的可见性切换,它不执行 fsync,因此不能承诺断电持久化,overwrite=False 也只在建临时文件前检查一次;get_upload_stream() 注释称实时压缩,实现却把压缩结果完整写入 BytesIO 再上传,峰值内存随压缩后大小增长;retry() 每次失败都睡 delay,最后一次失败后也睡,且抛出的是新异常、原始错误既不是抛出类型也不在 __context__ 中。以下为固定源码快照 c8fb906 并隔离运行 atomic_write() 的静态分析,未执行设备写入、上传、联网或外部进程。

    函数与调用方索引

    函数/类 关键行为 主要调用方
    Timer time.monotonic() 记录起点和 lap,fmt() 以给定媒体时长计算倍速 导出性能统计
    MovingAverage 长度为 window_size 的环形缓冲,部分窗口按当前 count 求均值;未采样返回 NaN Ratekeeper 循环间隔、FrequencyTracker 消息频率
    atomic_write() 在目标目录建立临时文件;上下文正常退出后 os.replace;异常时删除临时文件 令牌持久化、URL 文件、manager 配置
    get_upload_stream() 原文件返回文件流及字节数;压缩时把 zstd 完整结果写入 BytesIO 后返回流和压缩长度 uploader.do_upload()、athenad._do_upload()
    CallbackReader 委托底层对象,read() 后累计字节并回调 athenad 上传进度
    retry() 捕获 Exception,每次失败后睡眠并重试;耗尽后抛新异常或按设置返回 None qcomgpsd.at_cmd()、micd、soundd
    managed_proc() 上下文退出时 terminate()、最多等 5 秒、超时 kill() 当前仅见定义,未见调用
    sudo_write()、sudo_read() 设备文件读写;写失败时尝试提权命令,读错误返回空串 硬件 IRQ/CPU/GPU 配置、sensord IRQ 配置

    准确的失败与资源语义

    atomic_write() 的「原子」范围是同一文件系统中已完成写入的可见性切换:关闭临时文件之后才 os.replace,读者不会看到半写的目标文件。它没有对文件和目录执行 fsync,因此不能单凭这个接口承诺断电持久化。overwrite=False 只在建临时文件前用 os.path.exists 检查一次;并发创建目标文件时,后续 os.replace 仍可能覆盖它。上游 test_atomic_write() 只覆盖正常写入后目标文件可读;三项隔离探针进一步确认写入期间目标不可见、异常保留旧值并清理临时文件,以及默认 overwrite=False 在进入上下文后仍会覆盖并发创建的目标。

    get_upload_stream() 实现把压缩结果完整写入 io.BytesIO 然后才发起网络上传,对大日志峰值内存随压缩后大小增长;读取源文件失败或压缩失败会在上传前发生。调用方在 finally 关闭返回流;CallbackReader.read() 报告的是请求库从流中读取的字节数,并非远端已确认接收的字节数。athenad 使用返回长度构造 Content-Length,而 uploader 当前不使用这个长度。

    retry() 每次失败都睡 delay,最后一次失败后也睡;调用方最终看到通用 Exception。新异常在 except 块之外抛出,原始错误既不是抛出类型,也不在 __context__ 中。attempts=0 时循环不执行,行为是立即抛新异常或返回 None;ignore_failure=True 也会返回 None。这些语义对硬件初始化和 AT 命令尤其重要:重试五次、每次内部串口超时五秒,并不是「五秒的总 deadline」。

    接口 真正的保证 容易误解的地方
    atomic_write() 同文件系统内可见性切换 不 fsync,不承诺断电持久化;overwrite=False 只检查一次。
    get_upload_stream() 返回文件流或整块压缩结果流 压缩结果先进 BytesIO,峰值内存随压缩后大小增长。
    CallbackReader.read() 请求库已读字节数 不是远端已确认接收的字节数。
    retry() 按次数重试并睡眠 最后一次也睡;原始异常既非抛出类型也不在 __context__。
    MovingAverage 固定容量环形均值 构造不校验零窗口,第一次 add_value() 就失败。
    tabulate(html) 拼接单元格字符串 没有 HTML 转义,不能当安全 HTML 展示。

    嵌套重试与副作用放大

    嵌套使用会放大副作用:try_setup_logs() 最多执行十次,外层 setup_quectel() 最多执行五轮。两项假 DIAG 组合探针确认持续失败时共调用 50 次日志配置、睡眠 55 次,并重复五轮前置 NV 写/读请求;这是软件调用次数,不证明 NV 状态改变,也不包含真实 I/O 耗时。

    MovingAverage 要求正的窗口大小,构造时不校验,零窗口在第一次 add_value() 访问空缓冲时就失败。tabulate(tablefmt="html") 直接拼接单元格字符串,没有 HTML 转义,因此不能把不可信插件文本直接当作安全 HTML 展示。sudo_write() 的提权回退把值和路径拼接进 shell 命令,当前调用主要是平台常量和已发现的 IRQ 编号,但该函数不应成为接受插件任意路径/值的通用能力;sudo_read() 吞掉所有异常并返回空串,调用方应区分「空内容」和「读失败」。

    硬件与插件化落点

    源文档给出的是一组建议而非已实现能力:将特权文件操作封装为受信任平台服务,明确允许的设备节点、参数范围、审计记录和失败返回,第三方插件不直接调用 sudo_write();对配置文件分别规定可见性、并发覆盖、权限、断电耐久性与密钥保密要求,atomic_write() 只解决其中的可见性问题;上传插件按最大文件和压缩输出设内存预算,使用可测的流式压缩/限额方案,回调进度与服务端确认分开表示;重试契约显式声明可重试异常、退避、总 deadline、取消信号与最终原始故障,硬件命令还需考虑幂等性。

    本文没有运行硬件、网络或断电试验。atomic_write() 的上述文件状态已用隔离探针验证,其余故障推论来自当前实现;调用方及平台行为仍需继续审计。

    常见问题

    atomic_write() 能保证断电后不丢数据吗?

    不能。它只在同文件系统内切换可见性,关闭临时文件后才 os.replace,没有对文件和目录执行 fsync,因此不能单凭这个接口承诺断电持久化。

    overwrite=False 能防止并发覆盖吗?

    不能。它只在建临时文件前用 os.path.exists 检查一次;并发创建目标文件时,后续 os.replace 仍可能覆盖它。探针已确认进入上下文后仍会覆盖并发创建的目标。

    retry() 五次重试总耗时是固定的吗?

    不是。每次失败都睡 delay,最后一次失败后也睡;若每次内部串口超时五秒,五次重试并不是「五秒的总 deadline」,而是显著更长。

    上传进度回调代表服务端已收到吗?

    不代表。CallbackReader.read() 报告的是请求库从流中读取的字节数,并非远端已确认接收的字节数;进度与服务端确认应分开表示。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/COMMON_UTILS_IO_RETRY_RUNTIME_DEEP_DIVE.md(函数索引、atomic_write 探针、retry 语义与嵌套重试)。
    • 项目 02 —— openpilot-research/ 同目录上传队列与硬件配置专题(作为相邻边界参考)。

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

    更新记录

    首次发布 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

  • 4 个 worker 30 次重试:Athena 上传队列的持久化窗口漏在哪

    结论:Athena 的按远端请求上传用「4 个 worker + 优先队列 + Params 快照」实现,但它不是一张事务表:入队写盘、出队、取消、过期这些时点的快照并不一致,取消与过期分支都不会刷新快照,进程重启后已取消或已过期的任务可能重新出现。重试上限是 30 次、每次最长等待 10 秒,而 401/403/412 被归入终止分支、日志里也记为 success,不能当作远端已存储证明。以下为固定源码快照 c8fb906 的静态分析与隔离探针结论。

    队列与四个 worker

    这里讨论的是 Athena 按远端请求上传指定文件的队列,不是 loggerd/uploader.py 的常规路段上传。upload_handler() 由四个线程共享一个优先队列,取任务后依次处理取消、31 天过期、计费网络判断、PUT 响应与异常;线程数由 handle_long_poll() 固定创建四个,不受 RPC 的 HANDLER_THREADS 配置影响。main() 进程启动时只恢复一次队列,每次 WebSocket 会话创建上传线程,重连成功后清空当前任务字典。

    UploadFile、UploadItem 负责 RPC 参数映射与队列任务;UploadItem.__lt__/__eq__ 只比较 priority,不以 ID 比较。因此优先队列不会因此去重,同优先级的 FIFO 顺序也没有由任务 ID 或入队序号明确保证。

    组件 入口 职责和边界
    UploadItem athenad.py RPC 参数映射与队列任务;比较只按 priority,不按 ID,队列不去重。
    UploadQueueCache athenad.py 启动从 Params AthenadUploadQueue 恢复;只快照等待队列并排除已取消 ID。
    uploadFilesToUrls() athenad.py 校验文件名与存在性、按 URL 去查询串去重、生成 ID、入队并写快照。
    upload_handler() athenad.py 四个线程共享优先队列,处理取消、31 天过期、计费网络、PUT 响应与异常。
    retry_upload()、cb() athenad.py 计数小于 30 时重排并持久化,随后最长等待 10 秒;回调只在状态年龄大于 1 秒时检查计费。
    listUploadQueue()、cancelUpload() athenad.py 列表合并等待与当前任务;取消只标记等待项,不中止当前传输,也不立即刷新 Params。

    入队去重与任务 ID 的真实边界

    uploadFilesToUrls() 会检查文件名与存在性:拒绝空名、绝对路径与包含 .. 的名字,但没有证明符号链接与真实路径受限。去重比较的是 file.url.split('?')[0],覆盖等待与当前列表,未比较文件内容、完整 URL 或 headers。它只防止入队当时可见的相同基础 URL。

    任务 ID 用包含创建时间的 str(item) 做 SHA-1,这不是内容摘要,也不是服务端事务 ID。因此这套去重与 ID 都不能充当跨重启或并发请求的幂等键——重启后旧快照可能把任务恢复回来,而同一文件换个查询串就会被当作新任务。

    结果码、重试与等待

    上传结果被分成两类:响应码为 200/201/401/403/412 时终止任务、不再重试;其他 HTTP 状态以及 Timeout/ConnectionError/SSLError 计入重试。retry_upload() 在当前 retry_count < 30 时重新排队与持久化,随后最长等待 10 秒;计数初始 0 可重排为 1,29 可重排为 30,已经是 30 时不再重排。

    计费网络与重连中止走的是不增计数分支:调用 retry_upload(..., False),符合上限判断才重排并写盘,但每次都仍按最长 10 秒等待。_do_upload() 在请求的 .zst 不存在而扩展名去掉后的原文件存在时,会边压缩边上传,使用请求提供的 URL 与 headers,覆盖 Content-Length,PUT 超时 30 秒。总体而言,名为 success 的日志事件包含 401/403/412,不能当作远端已存储的证明。

    结果 是否重试 说明
    200/201/401/403/412 否,终止任务 HTTP 返回后写等待队列快照;success 日志含 401/403/412,非远端存储证明。
    其他 HTTP 状态 是 计数加一后重排并写盘;计数 30 时不再重排。
    Timeout/ConnectionError/SSLError 是 同可重试失败路径,计数达到上限后无额外快照更新。
    计费网络/会话退出 中止但不增计数 调用 retry_upload(..., False),仍按最长 10 秒等待;进入时已达 30 则不重排。

    持久化窗口

    UploadQueueCache.cache() 的 block=True 只要求 Params 写入调用阻塞完成,不是远端上传回执,也没有把「等待、传输中、完成、失败」作为单个原子状态持久化。listUploadQueue() 是瞬时内存视图;缓存与列表分别直接访问优先队列内部数组,不能视作同一事务快照。

    隔离探针复现了几个必须知道的窗口:等待项已有快照后调用 cancelUpload(),内存列表隐藏该 ID,但 Params 中仍是旧快照,模拟重启后会重新进入等待队列;retry_upload() 先重排与缓存、再清 cur_upload_items[tid],在缓存调用期间查询列表可暂时看到同一 ID 的「等待」和「当前」两项。探针没有并发调度、真实持久化故障或 HTTP 请求,证明的是允许这些状态的局部执行顺序。

    路径 Params 快照时机 可观察结果
    入队 循环结束后写一次 RPC enqueued 表示入队,不是上传确认;多任务入队与写盘非原子。
    出队及上传中 出队时不写快照 原快照可能仍含该项;中断后重放既不能保证也不能排除。
    成功分支 HTTP 返回后写等待队列快照 当前字典直到下轮才置空,列表可能短暂显示已结束项。
    可重试失败 先入队再写快照,HTTP 分支随后再写一次 计数可 0→1、29→30;已是 30 则不再重排。
    取消/过期 两个提前 continue 分支都不刷新快照 旧快照可能仍有该项,进程重启后可能重新出现。
    其他异常 没有统一任务终结/缓存动作 是否重新出现取决于此前快照与其他线程写盘,不应推断为可靠重试。

    自制硬件与插件重构要求

    源文档给出一组建议:把上传置于非实时服务域,与 CAN/执行器控制隔离;文件能力使用目录 FD 或真实路径约束,目标 URL、重定向与 headers 用显式策略,不能把远端 RPC 参数直接授予任意插件。任务应定义 queued / leased / transferring / accepted / rejected / retry_wait / exhausted / cancelled / expired 等可持久状态,出队先记录租约、超时回收,并用单一事务或追加日志保存状态迁移。

    幂等应以服务端任务 ID 或文件内容摘要加目标对象键实现;重试需退避、抖动、总期限与错误分类,401/403/412 应有各自的鉴权/权限/前置条件结果,不能统一归为成功。取消 API 必须覆盖等待与传输中任务并持久化取消终态。验收要用离线测试服务与故障注入:四线程并发、每个持久化时点断进程、重复 RPC、计费网络切换、重连、缺失文件、符号链接、HTTP 拒绝与重试耗尽。

    常见问题

    RPC 返回 enqueued 是不是就说明文件已上传?

    不是。入队只在循环结束后写一次 Params 快照,enqueued 表示入队成功,不代表上传确认,多任务入队与写盘也不是原子事务。

    上传重试最多几次,间隔多久?

    重试上限是 30 次:retry_upload() 在 retry_count < 30 时重排,已是 30 则不再重排;每次重排后最长等待 10 秒。计费网络或会话退出中止时不增加计数,但仍按最长 10 秒等待。

    日志里出现 success 就等于远端存好了吗?

    不等于。名为 success 的日志事件包含 401/403/412,这些是终止分支的响应码;它们不构成远端已存储的证明,需要服务端回执另行确认。

    为什么重启后已取消的任务又出现了?

    因为取消与过期这两个提前 continue 分支都不刷新 Params 快照,旧快照可能仍包含该项;模拟重启时清空内存取消集并执行 initialize(),任务会重新进入等待队列。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/ATHENA_UPLOAD_QUEUE_PERSISTENCE_RETRY_DEEP_DIVE.md(Athena 上传队列、四个 worker、结果码与持久化窗口)。
    • 项目 02 —— openpilot-research/ 同目录探针记录:四项队列快照与五项上传回调计费门隔离探针结论。

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

    更新记录

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