标签: 数据管线

  • 音频告警与录音:48 kHz 播放、16 kHz 采集与失联门

    结论:音频链的风险不只是「响不响」,而是三类语义被混在一起:soundPressureWeightedDb 是未校准的近似响度、不是法规意义的绝对声压级;关闭 RecordAudio 只表示 loggerd 不再把原始音频写入日志,不是麦克风停采、也不是消息总线不再出现音频样本;get_audible_alert() 因失联设置的标志在新消息到达时不会清除。以下基于固定快照 c8fb906 的源码分析与 micd/soundd 假消息探针,未接声卡、功放或做声学校准。

    两条运行链

    告警播放与麦克风采集是两条彼此独立的链:前者从事件层把当前告警写进 selfdriveState.alertSound,再由 soundd 以 48 kHz 输出;后者从麦克风 ADC 拿到 16 kHz 样本,既发布原始音频,又计算一个供音量自适应使用的声压值。

    组件 入口 主要行为
    告警源 publish_selfdriveState() 从 AlertManager.current_alert 写 alertSound、文字、等级与状态,由 selfdriveState 同步给声音和画面
    声音资源 sound_list、load_sounds() 九种消息告警键加一种内部强告警键(CRITICAL_MAX)映射到 WAV、播放次数和标称音量;断言资源为 48 kHz、16-bit、单声道
    PCM 调度 get_sound_data()、callback() PortAudio 每次要 4096 帧,按当前游标填充、循环或补零;输出缓冲第 0 通道乘当前音量
    告警切换 update_alert()、get_audible_alert() 收到新的 selfdriveState 就更新告警;循环声在已至少播放一遍后可等到本轮末尾再停
    音量 / 输出 calculate_volume()、soundd_thread() 订阅 soundPressure,一阶滤波后调节无告警时音量;严重告警渐增;20 Hz 控制循环驱动音频流
    麦克风分析 Mic.callback()、calculate_spl() 先发布原始音频,再累积 1600 样本做 RMS 与 A 加权;update() 按约 10 Hz 发布 soundPressure
    原始音频落盘 loggerd_thread() 建立订阅时检查 RecordAudio,决定是否订阅 rawAudioData、写 rlog 与向含音轨的 qcamera 编码器传样本

    失联告警与严重告警的时间门

    声音告警的强度与是否持续都绑定明确的时间窗,下表汇总关键门限。

    项目 门限 / 机制
    失联告警 最后一次 selfdriveState 超过 5 秒、缓存消息仍标记 enabled、且超时不超过后续约 10 秒
    严重告警渐增 约 4 秒把输出音量推到 1.0
    严重告警换音效 约 8 秒把 current_sound 换为内部 CRITICAL_MAX 并把游标归零
    环境音量滤波 2.5 秒一阶滤波,仅在 current_alert == none 时更新常规音量
    音频流重试 get_stream() 最多 10 次
    控制循环 20 Hz

    普通设备以 26 dB 为最小音量参考,tizi 使用不同参考与音量底数;calculate_volume() 将范围裁剪后做指数变换。MIN_VOLUME=0.1 是指数变换前的裁剪值,低于环境阈值时实际输出约为 20^-0.9,并非直接输出 0.1。

    音频输出侧的韧性也有限:get_stream() 重试最多 10 次,持续失败后没有看到另一个独立的声音后备设备路径。新硬件在按目标麦克风与扬声器重新标定音量曲线之前,不能照搬 26 dB 这类参考值,否则在车内噪声下可能出现最小可听响度不足。

    micd 的分析窗与声压语义

    micd 的标称采样块是 800 帧、16 kHz、单声道,连续两个块组成一个 100 ms 分析窗;回调按 indata[:, 0] 的实际长度处理,不使用 frames 参数限制样本数。一次大块会处理所有完整的 1600 样本窗,保留不足一窗的尾部,声压字段只保存最后一窗的结果。

    calculate_spl() 使用 REFERENCE_SPL=2e-5 算 dB,但输入是归一化数字样本,不是经过麦克风灵敏度校准的帕斯卡值;源码注释也明确称为未校准、近似人耳响度。因此 soundPressureWeightedDb 不能当作法规意义上的绝对声压级。

    采集窗的边界由一组隔离探针确认:不足一窗时仍会发送原始音频,两个半窗合成一次分析,一次超大回调只留下尾部,frames 参数不限制实际输入数组长度;若原始音频消息发送异常,会在分析窗累积之前穿透出去。因此「发布了原始音频」与「更新了声压窗」不是同一件事。

    RecordAudio 的准确边界

    micd 不读取 RecordAudio,持续发布 rawAudioData;服务定义给该主题的默认 should_log=False;loggerd 启动时按 RecordAudio 决定是否额外订阅并录制,qcamera 编码器的 include_audio 也在构造时读取该参数。

    因此关闭录音开关的含义是「loggerd 不把原始音频写入这些日志/视频」,不是麦克风停止采集,也不是消息总线不再出现音频样本;任何能订阅本机 rawAudioData 的进程仍可能读取它。开关改变后,loggerd 进程的订阅和编码器配置也不会在本循环动态重建,需要行车周期使配置重新生效。

    一个条件性状态问题

    get_audible_alert() 因失联把 selfdrive_timeout_alert 设为真后,下一次收到新的 selfdriveState 虽更新告警,却没有清除这个标志;若再下一轮没有新消息且不满足失联窗口,旧标志会请求 update_alert(none)。新循环告警尚未播满一轮时会立即停止,已播满一轮则安排到本轮末尾停止。这里只验证了原方法在假消息状态下的控制流,未证明真实设备发生过可听见的告警中断。

    常见问题

    soundPressureWeightedDb 是法规意义的声压级吗?

    不是。它由归一化数字样本经 RMS 与 A 加权计算,源码注释明确称为未校准、近似人耳响度,不能当作法规意义上的绝对声压级。

    关掉 RecordAudio,麦克风就停止采集了吗?

    不是。micd 不读取该开关,仍持续发布 rawAudioData;关闭只让 loggerd 不把原始音频写入 rlog/qcamera 音轨,其他能订阅总线的进程仍可能读取。

    失联时声音会一直鸣叫吗?

    不会无条件持续。需要三个条件同时落在窗口内:最后一次 selfdriveState 超过 5 秒、缓存消息仍标记 enabled、且超时不超过后续约 10 秒。

    为什么告警可能被提前取消?

    因为因失联设置的旧标志在新消息到达时不会被清除;若再下一轮没有新消息且不满足失联窗口,旧标志会请求 update_alert(none),新循环告警就可能被停下。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/AUDIO_ALERT_CAPTURE_DEEP_DIVE.md(音频采集、声音告警与录音边界,含源码行号索引)
    • 项目 02 —— openpilot-research/probes/test_micd_capture_window_contract.py(采集窗与原始音频发布的六项探针)
    • 项目 02 —— openpilot-research/probes/test_soundd_thread_ramp_contract.py(严重告警渐增与换音效的假时钟探针)

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

    更新记录

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

  • 路段标识与切片:route/-1 与 route—1 为什么结果不同

    结论:路段标识不是单一校验器,而是正则族、构造器与切片函数三套契约的叠加。RouteName 只用 assert 检查两段长度 16/20;SegmentRange.seg_idxs 对有界正索引遵循 Python 的右开区间(1:4 返回片段 1、2、3),而负索引与开放区间会隐式查询远端 maxqlog。因此 route/-1 与 route---1 走的是不同分支:前者可被接受为「没有片段号」,后者在路段长度断言处失败。

    标识与切片函数

    入口 实现语义 边界
    RouteName.__init__() 选择先出现的 | 或 / 分隔设备 ID 与日志 ID,并转为 device|log 规范名。 只用 assert 检查两段长度 16/20,未验证十六进制设备 ID 或时间字段格式;不是可信身份认证。
    SegmentName.__init__() 从三种分隔形式中提取片段号,可保留本地数据目录;allow_route_name=True 用 -1 表示没有片段号。 斜杠分段的 int() 可接受负片段号;-- 加负号形成三个连字符时,rsplit('--', 1) 留下多余 - 并导致路段长度断言失败。
    SegmentRange.__init__() 用 RE.SEGMENT_RANGE 对整个字符串匹配,保存正则组。 selector 仅允许 q/r/a,但「匹配成功」不保证所选日志实际存在。
    SegmentRange.seg_idxs 单个非负片段直接返回;有界正索引切片用 range(end+1)[slice];负索引或无上界切片先查询最大片段号。 整条路线、负索引和开放区间依赖远端元数据;单个过大的正片段号不会在此处查存在性。

    有界切片遵循右开区间,例如 1:4 返回片段 1、2、3;该函数先构造完整 list 再切片,不是按需生成,大的上界会增加内存。对于单个负索引,先加 maxqlog + 1,没有进一步验证结果仍非负。隔离探针把 maxqlog 固定为 3,确认 -1 → [3]、-10 → [-6],而单个正数 50 → [50] 也不在此处检查真实上界。

    共享正则与解析器边界

    RE 是共享的文本模式集合,不是统一的路段身份验证器。同一个字符串经由不同入口,结论可能相反。

    匹配方式本身就是差异来源:RE.EXPLORER_FILE 要求本地文件名主体与扩展均为小写,大写 RLOG.zst 不匹配;Bootlog.__init__() 用 re.search 可从前后带其他文本的字符串提取路段,而 Athena createClip() 用 re.fullmatch。搜索匹配不能充当整串授权或来源证明,导入器也必须明确如何处理非预期命名。

    模式或消费者 实测契约 后续设计含义
    RE.TIMESTAMP 2023-99-99--25-61-61 符合字符形状;模式不检查日历值。 区分日志 ID 语法和有效日期。
    RE.ROUTE_NAME 与 RouteName 正则允许下划线分隔,但构造器只识别 | 或 /;反过来,构造器接受 16 个 Z 和 20 个 x,正则拒绝。 不同入口不能互相代替校验结果;应定义唯一规范格式。
    RE.SEGMENT_NAME 与 SegmentName 模式片段号只接受非负数字;构造器接受 route/-1,但 route---1 不能正常解析。 切片哨兵值与真实文件/目录名应分开类型。
    RE.BOOTLOG_NAME 它只是 ROUTE_NAME 别名;Bootlog.__init__() 的 re.search 可从前后带其他文本的字符串提取路段,Athena createClip() 则用 re.fullmatch。 搜索匹配不能充当整串授权或来源证明。
    RE.EXPLORER_FILE 本地文件名需小写字母主体及小写字母数字扩展;大写 RLOG.zst 不匹配。 导入器要明确如何处理非预期命名。

    本地与远端路段发现

    Route.__init__() 按是否给 data_dir 选择本地或远端,再取排序后最后一个片段号作为 max_seg_number;若远端返回空片段列表,取 [-1] 会失败。_get_segments_remote() 请求 v1/route/<canonical>/files,按 URL path 末四级拼出片段名;_get_segments_local() 识别 explorer 单文件、device|route--segment 目录和 device|route/segment 目录三种形态,本地缺段保留为 None。

    一个值得注意的边界:远端 API 若为请求路段返回另一设备 ID 的合法 URL,该 URL 会被解析成另一路段的 Segment,但仍进入当前 Route.log_paths();本层不核对返回 URL 的 dongle_id/log_id 是否与请求相同。此外 Segment.url 的 @cache 仅按路段名缓存,不把令牌/会话放进键,也没有 TTL;Segment.events 再 GET <url>/events.json,调用 raise_for_status(),但没有显式超时。

    归类与缓存还有几处覆盖规则:_get_segments_remote() 把 URL path 末四级拼成片段名并按文件名归类 rlog、qlog 和三路相机,同类别多 URL 时后出现的值覆盖先前值,没有 .zst 与 .bz2 的显式优先级;本地则以目录遍历顺序决定同类重名格式的首选,Route 对路径的整理不打开日志,也不检查压缩格式、schema 或文件哈希。隔离探针还确认:本地 0、2 段会保留第 1 段的 None 空位;远端同一段的 rlog.zst、rlog.bz2 由后者覆盖;空远端文件列表在读取最大段号时抛 IndexError。上述结论由十六项隔离探针核对,但未联网查询、未运行真实 LogReader 或回放。换言之,「字符串能匹配」与「文件真实存在且属于该路段」是两回事;服务端是否实际返回旧权限下的数据、请求库默认超时与网络资源行为,均不由探针证明。

    对自制硬件与插件化的要求

    1. 把「语法标识」「已授权数据集」「实际文件内容」分成三层:规范设备/日志/片段 ID、来源与选择器、固定文件哈希及 schema 分别记录。
    2. 远端返回每个文件 URL 时,先核对其中的设备 ID、日志 ID 和片段号属于所请求的路段。
    3. 离线回放不应为负索引或开放切片隐式访问 API;先解析成明确有序片段清单,再逐段验证日志/视频/相机同步。
    4. 不能用标识字符串长度或 API 返回 URL 作为真实性证明;跨来源补段、qlog/rlog 选择和文件格式须在报告中显式呈现。

    常见问题

    route/-1 和 route---1 为什么结果不同?

    route/-1 走斜杠分段,int() 可接受负片段号;route---1 是 -- 加负号形成三个连字符,rsplit('--', 1) 会留下多余 -,导致路段长度断言失败。两者与真实路段是否存在无关。

    有界切片 1:4 会选中哪些片段?

    遵循 Python 右开区间,返回片段 1、2、3。该函数先构造完整 range 列表再切片,不是按需生成,大的上界会增加内存。

    为什么负索引会访问网络?

    因为 SegmentRange.seg_idxs 对负索引或无上界切片会先调用 get_max_seg_number_cached() 查询远端 maxqlog。整条路线、负索引和开放区间都不是纯离线解析,依赖远端元数据。

    路段标识能当身份认证吗?

    不能。RouteName.__init__() 只用 assert 检查两段长度 16/20,未验证十六进制设备 ID 或时间字段;而且正则与构造器的接受集不同。标识只表示语法形状,不构成可信身份认证。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/ROUTE_SEGMENT_IDENTITY_SLICE_DEEP_DIVE.md(取用了标识与切片函数、共享正则与解析器边界、本地与远端路段发现,以及插件化要求)。
    • 项目 02 —— openpilot-research/REPLAY_INPUT_IO_DEEP_DIVE.md(仅用于确认回放输入与实际文件读取的衔接位置)。

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

    更新记录

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

  • 离线与实时两条数据管线:rlog/qlog 选择、时间归零与视窗边界

    结论:JotPluggler 把「离线看路段」和「实时看数据流」做成两条互斥管线:启动时路段与 --stream 互斥。离线路段的两次 Auto 选择可能读到不同文件——selected_log_path() 的 Auto 优先 rlog,而 detect_route_metadata() 的 Auto 优先 qlog,所以固定实验必须同时记录二者与 DBC。实时侧,Buffer (seconds) 在这条调用链上更像视窗宽度而非内存淘汰上限,长时流的内存上界尚无证据。

    启动:一条命令里的路段与实时流互斥

    main() 解析路段或 --stream(两者互斥)、窗口尺寸、布局和 PNG 输出。无输出路径且未显式 --show 时默认开窗口;路线模式可同步或异步加载。两个容易被误读的点:--output 是截图路径而非日志导出,且 save_framebuffer_png() 先写 PPM,再调用外部 convert,因此截图依赖额外程序并会产生文件。

    异步加载由 AsyncRouteLoader::Impl::start() 启动工作线程调用 load_route_data(),用原子计数发布下载/解析进度,consume() 在线程结束后交付结果或错误。它会先 join() 旧工作线程,因此切换路线可能等待旧加载完成;源文档指出这里未见取消令牌。进度数字是加载阶段信息,不是数据完整性证明。

    离线链:从选择到并行抽取

    离线链的每一步都有明确的失败模式:

    函数/组件 源码行为 需要注意
    load_route_data() 解析路线/片段选择,查询服务端或扫描本地目录,按 /r、/q、/a 选择日志;探测 carFingerprint 和 DBC,再并行抽取,构建 RouteData 与四类相机索引 两次 Auto 选择可能不同(见下节)
    load_route_series_parallel() 按路段并行加载 C++ LogReader,每段抽取曲线/CAN/日志/时间轴/缩略图;大段可再分块;最后按段序合并 任一路段缺失或加载失败会记录首个错误并整体抛出
    LogReader 对非空损坏前缀仍可能返回成功 图表「加载完成」不等于完整日志
    build_route_data() / apply_route_data() 对四类数据计算公共时间原点并归零,整理路径、时间轴、GPS;重建浏览索引、自定义曲线和相机索引 时间归零基准不等于路线首帧

    线程预算来自 load_worker_budget(),可用环境变量 JOTP_LOAD_WORKERS 调整。时间归零的细节值得单独记:build_route_data() 以可用数据算出的 x_min 为基准,不一定等于路线首帧或首段开始;跨工具比较时必须保存原始单调时间与该偏移,否则同一段路在两份数据里会「对不上」。

    rlog/qlog 的两次 Auto 选择可能不一致

    这是最容易导致复盘出错的一处:selected_log_path() 的 Auto 优先 rlog、缺失时用 qlog;但 detect_route_metadata() 的 Auto 优先 qlog 读取车型。也就是说,实际抽取的日志与读取车型的日志可能不是同一个文件。对于要复现的实验,必须同时记录这两个输入以及所用的 DBC,否则「车型识别结果」和「曲线数据」可能来自不同来源而不自知。

    实时流:四个阶段的批次交接

    实时侧从 start_stream_session() 切换模式开始,默认重置旧路线数据并启动 StreamPoller;暂停保留数据时只暂停消费,stop_stream_session() 区分保留与清空。之后是四段式的批次交接:

    阶段 组件 职责
    订阅 run_cereal_source() 本地地址固定 127.0.0.1;订阅 services,跳过编码数据、缩略图和原始音频;检查 Cap’n Proto word 对齐
    累加 StreamAccumulator 以首个事件单调时间为 offset;carParams 更新指纹/DBC;生成数值曲线与日志/告警时间轴
    交接 merge_batch() / consume() 按曲线路径和 CAN 标识合并后台待取批次;UI 线程移走批次和错误
    应用 apply_stream_batch() 追加到 AppSession::route_data,更新浏览、时间轴、CAN、GPS 与时间范围

    这里要分清职责:这是数据订阅,不是 CAN 发送网关。另外 StreamAccumulator::appendEvent() 会通过 with_parseable_event() 静默跳过本地 schema 无法解析的事件,因此 received_messages 只代表收到的消息数,不代表成功解析、提取或绘图——需要单独计数丢弃原因。

    时间与资源边界

    ensure_shared_range() 与 update_follow_range() 把 stream_buffer_seconds 用作显示窗口跨度;advance_playback() 操作的是 UI tracker,并非重新发布历史消息——不能把画面播放位置当作控制回放进度。

    当前追踪到的 apply_stream_batch() 对曲线、日志和 CAN 样本做追加,未见按 stream_buffer_seconds 裁剪旧样本;StreamPoller 保存该值用于快照而非消费端内存淘汰。所以「Buffer (seconds)」在这条调用链上更像视窗宽度,长时流的内存上界尚无证据——源文档把这列为需要长跑内存测试进一步验证的静态观察。

    还有一处进程级副作用:run_cereal_source() 在工作线程调用 setenv/unsetenv("ZMQ"),会影响同进程其他环境读取。源文档的建议是:若插件框架允许并发源,应改为显式传输配置,不能靠全局环境切换。

    插件化与验证

    可以把 RouteCatalog/LogLoader、EventExtractor、DbcResolver、StreamSource 和 ViewModel 作为只读插件分层,但必须统一 schema + 时间基准 + DBC 版本 + 来源 manifest。实时插件需独立声明网络地址、消息白名单、队列上限、后台背压和保留时长;不能因其能读 CAN/sendcan 主题就获得物理 CAN TX 权限。日志中包含位置、告警和车况,远端流应附授权与隐私策略。

    测试现状要如实标注:test_help() 只检查可执行文件帮助输出;源文档未见对路段缺口、损坏日志、实时断连、长时内存、同时间戳/乱序、DBC 热切换的同目录专门测试,也未运行编译或 GUI 验证。JotPluggler 其余 UI/图表/地图/自定义曲线组件仍待逐专题分析,不能把本篇视作整个目录已完成。

    常见问题

    为什么同一路段的车型识别和曲线数据可能对不上?

    因为 selected_log_path() 的 Auto 优先 rlog、缺失时 qlog,而 detect_route_metadata() 的 Auto 优先 qlog 读取车型,两次选择的输入文件可能不同。固定实验需要同时记录二者及 DBC。

    Buffer (seconds) 会限制内存增长吗?

    就当前追踪到的调用链而言不能这样理解。stream_buffer_seconds 被用作显示窗口跨度,apply_stream_batch() 未见按该值裁剪旧样本,StreamPoller 仅用它做快照。因此长时流的内存上界尚无证据,需要长跑内存测试验证。

    图表显示「加载完成」就代表日志完整吗?

    不代表。LogReader 对非空损坏前缀仍可能返回成功,而图表加载完成只反映流程结束;源文档明确「加载完成」不等于完整日志。此外任一路段缺失会记录首个错误并整体抛出。

    实时流的播放位置就是控制回放进度吗?

    不是。advance_playback() 操作的是 UI tracker,并非重新发布历史消息。把画面播放位置当作控制回放进度会产生误判。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/JOTPLUGGLER_DATA_PIPELINES_DEEP_DIVE.md(取用了离线链、rlog/qlog 双 Auto 选择、实时流批次交接与时间/资源边界)。
    • 项目 02 —— openpilot-research/CAR_PROTOCOL_IMPLEMENTATIONS.md(仅用于确认 DBC 与车型指纹在离线链中的探测位置)。

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

    更新记录

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

  • 路段来源发现的缓存陷阱:HEAD 2xx 与无 TTL 假值

    结论:路段读取的可靠性瓶颈不在解码,而在「来源发现」这一步:internal_source_available() 与 file_exists() 用无期限 functools.cache 保存布尔结论,端点恢复或文件删除后本进程仍会复用旧值;HTTP HEAD 只要状态为 2xx(哪怕缺 Content-Length)就判存在;auto_source() 的返回顺序取决于首次找到该段的来源,而非段号。以下基于本地 openpilot c8fb906 源码归档的静态阅读与隔离探针,未访问网络、未运行日志回放。

    标识解析:分享链接与直接输入分流

    入口 LogReader._parse_identifier() 先把 useradmin/connect 分享链接改写成路段标识,再把 HTTP(S)、cd:/ 或当前存在的本地文件视作直接输入,其余按 SegmentRange 走多来源发现。分流规则里有几处只做「字符串处理」却不做「身份校验」的地方。

    输入 原函数结果 解释
    useradmin ?onebox=<route>&onebox=other parse_qs() 解码后取第一个 onebox 值;缺键抛 KeyError 这是参数提取,不是分享链接真实性验证
    https://example.invalid/?ref=useradmin.comma.ai&onebox=<route> 仍触发 useradmin 分支并提取路段 代码检查整串是否包含域名字样,而非 urlparse(...).hostname;不能把此分支当作可信来源认证
    connect 路段 /119/120/q 秒数整除 60 后输出 /1:3/q 结束片段再加一,形成右开区间;跨边界会包含结束秒所在片段
    connect 路段 /180/0 输出 /3:1;非数字秒数抛 ValueError 此层不检查时间先后或范围非空,后续切片可能给出空清单
    不存在的 HTTP(S) URL、cd:/、本地路径 前两者仍判直接输入;本地路径只有 Path.exists() 为真才直读 直读分类不证明远端可达或文件内容完整;本地新路径会落到路段解析

    来源序列与回退

    auto_source() 默认按 internal → comma API → openpilotci → car segments 的顺序收集所需段号。RLOG 只查 rlog,AUTO 先查 rlog 再为缺段找 qlog;部分来源的结果被合并进字典,异常被记录后继续下一个来源。回退因此可以混合来源和日志类型。涉及具体函数时:eval_source() 对每个段号按候选 URL 顺序调用 file_exists()、首个存在即选中,候选优先级只反映可访问性和列表顺序,不反映版本、完整性或内容覆盖。

    一个容易忽略的实现细节是返回顺序:auto_source() 返回 list(valid_files.values()),而不按段号排序。隔离探针用内存假来源确认,请求片段 [0,1,2] 而来源依次提供 2、0、1 时,返回路径顺序为 [2,0,1],即「晚段先找到」会让输出顺序偏离段号。

    来源回退是按轮累积的:每轮保留已找到的片段,只为仍缺的段继续尝试下一个来源,因此最终清单可能同时包含 rlog 与 qlog;AUTO 会在 rlog 缺失时再为该段补查 qlog。

    无 TTL 缓存:假值与真值都会持续

    internal_source_available() 与 file_exists() 都使用无期限 functools.cache。举例来说,某段 rlog 的 HEAD 初次返回非 2xx,这个结果会在同一 Python 进程内保持为假;稍后远端上传完成,即使重新调用 LogReader.reset(),来源探测仍可复用旧假值——因为 reset() 只重建标识列表,不清这两个函数的缓存。反过来,旧真值在远端删除或权限撤销后,也会让发现阶段继续选中,直到真正读取时才失败。

    file_exists() 以 HEAD 状态为 2xx 且长度不等于 -1 判存在;2xx 且缺 Content-Length 时长度默认为 0,仍判存在。它既不验证可读取,也不核对内容摘要。这些结论是缓存键和调用链推导,未做网络时序故障注入。

    混合来源与额外段号

    混合来源隔离探针进一步显示宿主边界的缺口:AUTO 模式在缺 1 时只对 [1] 继续查询 qlog,但最终结果仍按发现顺序为 [rlog-2, rlog-0, qlog-1];若来源返回 {0: path0, 99: path99},即使只请求片段 0,auto_source() 也返回额外的 99。这里没有对来源返回的键集合做交集或身份校验。也正因如此,comma_api_source() 若被要求一个超出该路线 max_seg_number 的段号,列表索引会抛异常,字典推导式不能返回其他已找到的片段,上层只能改尝试下一来源。

    三套缓存键规则的差异

    归档里同时存在三套缓存命名规则,只有前一套是遗留辅助函数,另外两套才是活跃路径;把它们混为一谈会得出错误的「碰撞」结论。

    命名空间 键规则 备注
    遗留 tools/lib/cache.py 导入时读 CACHE_ROOT,否则 ~/.commacache;把 / 直接替换为 _,远端键忽略 scheme、端口、查询串 归档内检索只找到定义、无调用方;隔离探针确认 /a/b 与 /a_b、带不同查询串的两个 URL 会得到相同键
    URLFile.hash_url() 用各自 URL 哈希 活跃下载缓存路径
    file_downloader.cache_file_path() 用各自 URL 哈希,目录来自 Paths.download_cache_root() 的 COMMA_CACHE/OPENPILOT_PREFIX 规则 不用旧辅助函数的 CACHE_ROOT

    三种键规则都不提供内容哈希验证。迁移插件缓存时应只保留明确使用的一套命名空间,并测试规范化和版本键,不要把未调用的辅助函数误当运行事实。

    来源拼接本身也不做校验:openpilotci.get_url() 把 route 名中的 | 换成 /,再直接把 segment_num/filename 拼到固定容器 URL,函数自身不校验或 URL 编码路径片段;FileReader() 则把 cd:/ 先映射内部端点、本地路径包装 DiskFile、远端用 URLFile。再次强调,发现阶段的 HEAD 成功与读取阶段的成功是两个独立事件。

    常见问题

    为什么远端文件已上传,重试仍然失败?

    因为 internal_source_available() 和 file_exists() 使用无期限 functools.cache,旧假值会保留;LogReader.reset() 只重建标识列表,不清这两个函数的缓存。

    HEAD 返回 2xx 就代表文件可用吗?

    不代表。2xx 且缺 Content-Length 时长度默认为 0、仍判存在;它既不验证可读取,也不核对内容摘要。发现阶段成功与读取阶段成功是两个独立事件。

    auto_source() 的返回列表是按段号排序的吗?

    不是。顺序取决于首次找到该段的来源;探针确认请求 [0,1,2] 而来源依次给出 2、0、1 时,返回顺序就是 [2,0,1]。需要按段号处理时应显式排序。

    来源插件返回了额外段号会被过滤吗?

    不会。探针确认即使只请求片段 0,auto_source() 也会返回来源附带的额外段号 99,宿主没有对来源返回的键集合做交集或身份校验。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/LOG_SOURCE_AVAILABILITY_CACHE_DEEP_DIVE.md(来源发现、存在性缓存与回退契约,含源码行号索引)
    • 项目 02 —— openpilot-research/probes/test_logreader_identifier_dispatch_contract.py(分享链接与直接输入分流的五项隔离探针)
    • 项目 02 —— openpilot-research/probes/test_logreader_auto_source_contract.py(混合来源顺序与额外段号的隔离探针)

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

    更新记录

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

  • Athena 片段导出:createClip 入队不校验时间顺序、码率与倍速

    结论:离线视频片段导出由 Athena RPC 排队:createClip() 入队、getClipState()/getClipChunk() 读取、deleteClip() 取消,工作线程把起止时间映射成连续的 60 秒分段,先写到 clips/.<filename> 点号临时文件,编码成功后再用 os.replace() 原子改名。关键边界是:入队层只校验 route、摄像头文件名与输出 basename,不校验时间顺序、bitrate 或 speedup,而 PC 回退路径没有 comma 硬件那套校验;硬件工作器按 floor(start*20) 到 ceil((start+duration)*20) 选帧,输出按 20 FPS 重建。本文为对源码快照 c8fb906 的静态阅读与假依赖探针,未在 comma 设备上运行 Qualcomm V4L2 解码/编码,也未生成实际 MP4。

    从远程请求到可下载文件

    VideoClips.createClip() 在非 PC 设备上仅在 IsOffroad 时接受创建;它校验 route、摄像头文件名和输出 basename,并按输出文件名维护任务。_worker() 依据起止时间映射连续 60 秒分段,先输出到点号临时路径,编码后确认仍是同一任务对象才 os.replace() 为正式文件;异常时移除当前队列项并尝试清理临时文件。

    _encode() 负责选后端:PC 用 ffmpeg concat/libx264,comma 硬件调用 encoderd --clip,码率由 Mbps 转 bps,并传递倍速与 JSON 元数据;子进程非零退出视为失败。注意片段与 uploader 的自动 qlog 上传不同:生成并经分块读取或显式上传接口交付,不能把 clips/ 目录出现当作云端已持有视频。

    边界 入口 已核对行为
    入队 VideoClips.createClip() 非 PC 仅 IsOffroad 时接受;校验 route、摄像头文件名与输出 basename;不校验时间顺序、bitrate、speedup
    工作线程 VideoClips._worker() 按起止时间映射连续 60 秒分段,先写 clips/.<filename>,编码后仍是同一任务才原子改名
    后端选择 VideoClips._encode() PC 用 ffmpeg concat/libx264;硬件调 encoderd --clip,码率 Mbps→bps,传倍速与元数据
    状态/读取 getClipState() / getClipChunk() 成品需 ffprobe 读到元数据且 requested_at 为数值才列 ready;同名内存任务覆盖磁盘 ready
    取消/删除 deleteClip() 先从内存队列移除,再在锁内终止同名编码进程,最后删除正式输出文件

    _available_ranges() 依赖任何以 camera.hevc 结尾的文件是否存在,据此推算名义 60 秒范围,并不探测真实视频长度;这就是「名义时长」与「实际可解码时长」分离的来源。

    RPC 层契约:隐藏名、负偏移与倒置区间

    四组原函数隔离探针确认了入队层与读取层的多处不对称。PC 分支可把结束时间早于起点、负码率和零倍速原样加入内存任务;非 PC 分支则在入队前要求 IsOffroad。.hidden.mp4 会通过 createClip() 的 basename 检查,却被 getClipChunk() 的「不得以点号开头」检查拒绝,_on_disk() 也跳过隐藏名。这种「可排队却不可读取」是层间契约不一致,而非异常参数一定编码成功。

    读取侧还有两处细节:getClipChunk() 接受等于文件长度的偏移并返回空数据,同时拒绝负数与非整数偏移;状态查询用 re.search 允许带前后缀的 route 字符串提取合法路段,而创建请求用 re.fullmatch。工作器探针还确认,请求 10–130 秒会映射到三个名义 60 秒段,向 _encode() 传相对起点 10 秒、时长 120 秒;真正如何拒绝倒置区间(如 60→-1 秒)未在探针执行,PC/硬件编码器的实际拒绝行为需另行验证。

    encoderd --clip 与硬件工作器

    encoderd.main() 仅在 comma 硬件编译分支接受 --clip,解析 OUTPUT START DURATION、--bitrate、--speedup、--metadata 与输入段,转入 encode_clip()。该函数要求非空输入、有限且非负的起点、正的时长/码率/倍速,并要求请求落在「输入文件数 × 60 秒」的名义范围内;它会跳过起点之前的整段,检查首段视频编码为 HEVC 且宽高有效。

    环节 实现 边界
    参数校验 encode_clip() 要求非空输入、有限非负起点、正时长/码率/倍速 名义范围 = 输入文件数 × 60 秒,不等于每段实际可解码时长
    解码 FFmpeg 解复用 HEVC,input_frame+1 作 token 排入 V4LDecoder.queuePacket() 捕获面 token 经 handleCapture() 还原源帧序号
    抽帧 按 floor(start*20) 到 ceil((start+duration)*20) 选帧,speedup 用全局帧偏移模运算 跳过的解码面立即归还,送编码器的面由回调释放
    输出 frame_id 与 timestampSof/Eof 从零按 20 FPS 重建 倍速靠抽帧实现,输出仍标注为 20 FPS,不保留原始 SOF/EOF

    V4LEncoder::encode_frame() 提交 NV12_UBWC 面,避免解码面过早重用;工作器发 EOS、等待已排输入有对应解码结果,最多连续五次空轮询,随后关闭编码器并检查实际抽帧数。VideoWriter::write() 用微秒时间戳写 MP4,析构时写 trailer 并移除 .lock。

    长片并行与失败边界

    对不足两个剩余段或时长小于 120 秒的请求,encode_clip() 串行处理;其余请求按接近中点的段边界拆成两个工作器:第一段直接写目标,第二段先写到已打开后 unlink 的临时 spool,两个工作器完成后按第一段编码帧数换算微秒偏移,回放第二段包并拼接。并行分支的 frame_offsets 保证倍速抽帧跨分片连续,第二段 codec config 被跳过而沿用第一段输出头。

    源码显式拒绝短的中间段,以免把源视频缺口误拼成连续时间;但工作器的成功条件比较的是实际读到的 input_frame 所推导的帧数,没有强制 input_frame >= end_frame。因此最后一段提前结束而仍有部分选中帧时,静态看可能以短于请求时长的文件返回成功,_available_ranges() 也会把末段按满 60 秒展示。这需要截断末段的实板样本或硬件回放来验证,不能仅凭退出码保证时长完整。

    常见问题

    createClip 会拒绝不合理的时间区间吗?

    入队层不会。PC 分支可把结束时间早于起点、负码率和零倍速原样加入内存任务;非 PC 分支只额外要求 IsOffroad。真正校验落在硬件编码入口 encode_clip(),而 PC 回退路径没有同一套硬件校验。

    clips 目录出现文件就等于云端已持有视频吗?

    不等价。片段由 Athena RPC 排队、生成,再经分块读取或显式上传接口交付,与 uploader 的自动 qlog 上传是两条路径;目录里出现文件只说明本地已生成。

    为什么导出的片段可能比请求时长短?

    工作器的成功条件只比较实际读到的 input_frame 推导帧数,没有强制 input_frame >= end_frame;末段提前结束而仍有部分选中帧时,静态看可能以更短文件返回成功,_available_ranges() 还会把末段按满 60 秒展示。

    倍速导出会改变帧率吗?

    不会标注变化。倍速通过全局帧偏移的模运算抽帧实现,输出 frame_id 与 timestampSof/Eof 从零按 20 FPS 重建,文件仍标为 20 FPS。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/CLIP_EXPORT_DEEP_DIVE.md(Athena 片段 RPC、worker 分段与 encoderd --clip 硬件工作器)
    • 项目 02 —— openpilot-research/V4L_DECODER_RECONFIG_SURFACE_LIFECYCLE_DEEP_DIVE.md(源文档指向的硬解端口重配与外借面归还专题)

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

    更新记录

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

  • 为什么规格比品牌重要:轮胎选择的底层逻辑

    结论:选胎的正确顺序是先看规格与赛道的匹配关系,再看品牌。轮胎是赛车上唯一同时承担「唯一接地件」角色的部件,也是少数「花更多钱不一定更快」的选择——决定表现的是胎宽、扁平比与配方这三项规格与赛道条件的匹配,品牌只在这些规格确定之后才起到区分作用。对绝大多数赛道日车手,半热熔的综合表现优于全热熔。

    为什么轮胎反直觉

    在动力、避震、刹车这些部件上,「投入更多、性能更好」通常成立。轮胎不是这样:更宽的胎面带来更大接地面积与更高抓地力,但转动惯量与簧下质量也随之上升,在轻量化车辆上窄胎反而可能更快。也就是说,轮胎存在明确的最优匹配点而非单调提升曲线,越过匹配点之后投入会变成净损失。

    三个规格变量

    讨论轮胎时,人们习惯从品牌和型号开始(米其林、固特异、倍耐力等)。但真正决定表现的是下面三个规格变量与赛道的匹配关系。

    变量 提高什么 代价 适用场景
    胎宽 接地面积、抓地力上限 转动惯量与簧下质量上升,加速与转向响应变差 动力与车重匹配的大马力车;轻车上窄胎可能更快
    扁平比 越低胎壁越硬,抓地力与转向精度更好 对路面起伏的容忍度下降,颠簸路面易出现胎体损伤 平整赛道优势明显;颠簸路面需保留胎壁高度
    软硬配方 软配方提高抓地力峰值 寿命短、热衰减快 短程冲刺;赛道日建议软胎勤换而非硬胎硬撑

    原文档给出的一条赛道日实操建议是:软胎跑三圈就换,而不是用硬胎硬撑一整天。理由是硬胎在整场里勉强可用,但每一圈都工作在抓地力不足的状态;软胎虽然只能跑几圈,但每一圈都接近峰值。

    半热熔与全热熔的选择

    半热熔(slick 之外带花纹的赛道胎)的工作温度窗口比全热熔宽很多,容错也高得多。对于大多数车手——包括有几年赛道经验的车手——半热熔的综合表现优于全热熔,因为它允许你在轮胎还没到最优温度时就开始工作;而全热熔在达到窗口之前的抓地力可能还不如一条好的半热熔。

    对比项 半热熔 全热熔
    工作温度窗口 宽 窄
    未达窗口时的抓地力 接近可用 可能低于优质半热熔
    峰值抓地力 较低 更高
    容错度 高 低
    适合人群 大多数赛道日车手 能稳定控制胎温且单程圈数多的车手

    什么时候才需要全热熔

    只有当两个条件同时满足时,全热熔的峰值优势才显现得出来:一是你已经能在前三圈把胎温稳定在最优区间,二是单程跑 8 圈以上。在此之前,全热熔往往只是让你在第一圈更好看——峰值用不上,窗口外的损失却要全程承担。

    前胎比后胎更重要

    前后胎规格的选择应该向前轴偏(给前轴更多余量)。原因在前轴的工况:它同时承担绝大部分制动、转向,以及前驱车的驱动,负荷变化最剧烈。相比之下,后轴的理论抓地力上限更高,但它很少被用到极限。

    因此,在预算或规格只能偏向一侧时,给前轴更多余量,比追求后轴更强的理论抓地力更有价值。这条与直觉相反——多数人会把更好的胎装在后轴,因为「驱动轮在后」。

    怎么验证你选对了

    选胎是否正确不能靠体感,体感会被「新胎总是感觉更好」的偏差污染。可用的验证信号有两个:

    1. 圈速的圈间形态:如果前三圈最快、之后单调衰减,说明配方或胎压没有匹配到你的热管理节奏;如果圈速在中间段最稳定,说明窗口匹配良好。
    2. 胎面温度分布:用胎温计测胎面内侧、中央、外侧三点。三点温差过大,说明胎压或定位参数需要调整;这比换品牌更能解决实际问题。

    常见问题

    更宽的轮胎一定更快吗?

    不一定。胎宽提高接地面积与抓地力上限,但转动惯量与簧下质量同步上升,会拖慢加速与转向响应。在轻量化、低马力的车上,窄胎反而可能更快。是否更宽取决于车辆动力与车重的匹配,而不是越宽越好。

    赛道日应该用半热熔还是全热熔?

    对大多数车手应选半热熔。全热熔的峰值优势需要两个前提同时成立:能在前三圈把胎温稳定在最优区间,且单程跑 8 圈以上。在此之前使用全热熔,峰值用不上,却要全程承担窗口外的抓地力损失。

    前后胎应该配同规格,还是前轴更好?

    规格只能偏向一侧时,优先给前轴更多余量。前轴同时承担制动、转向与前驱工况,负荷变化最剧烈;后轴的理论抓地力上限虽高,但很少被用到极限。

    胎压该怎么定?

    不要用静态冷胎压当目标值,冷胎压只是起点。随着胎温上升胎压会明显升高,正确做法是设定一个热胎目标压,跑几圈后测量再微调。前后压的平衡比绝对值更重要——偏高的前压会让转向变轻,但推头倾向增加。

    延伸阅读

    参考资料

    • 本站车型库 —— /data/cars/(原厂轮胎规格、动力与车重参数,用于判断胎宽匹配)
    • 本站文章 —— /blog/trackday-data-review-segment-method/(圈间形态判据)
    • 说明:本文属赛道工程实践经验总结,「三圈」「8 圈」等为原文档给出的经验量级,非受控实验统计结果。

    说明:本文为轮胎选择的经验性方法总结,文中涉及的经验量级(软胎三圈、全热熔八圈门槛等)来自赛道实践惯例,非实测统计结果,不同车型、赛道与驾驶风格下差异可能很大。涉及轮胎规格变更请以厂商规格与当地法规为准。

    更新记录

    首次发布 2026-10-13(原发布于建站期,本次按本站 GEO 规范重写:补充结论段、三变量规格表与半热熔/全热熔对比表、常见问题与结构化数据)。最近核对:2026-10-13

  • 刹车温度管理:从能用到可控

    结论:决定圈速的不是峰值制动力,而是热衰减。直线末端全力踩刹车是所有人都做得到的事,真正的差距出现在连续几圈之后:刹车盘温度累积升高、摩擦系数下降,而踏板脚感几乎不变,于是你以为踩得和第一圈一样,圈速却慢了零点几秒。因此刹车管理的对象是「热」,不是「力」;硬件升级的顺序也应该按散热能力排,而不是按价格排。

    峰值制动力是个误导

    新手讨论刹车时几乎都在谈制动距离,但这个指标只在单圈、冷刹车的前提下有意义。赛道日的实际工况是连续多圈:连续三圈之后,刹车盘温度可能已经超过 600°C,此时摩擦系数会明显下降,而液压制动在这一阶段还额外承受着热衰减。

    麻烦之处在于它没有体感反馈。第三圈的重刹点踩到底,感觉上和第一圈一样;但圈速显示你晚了 0.4 秒。踏板行程与脚感由液压系统决定,摩擦系数的下降被液压放大补偿掉了,车手感知不到,只有数据看得到。

    三个可以立刻做的动作

    用「刹车占空比」而不是「刹车点」思考

    传统的思路是「在这里踩刹车」,它把刹车当成一次离散事件。更有效的思路是「从出弯到入弯,整个过程的刹车踏板开度应该是多少」——把刹车看成一段连续曲线。这个视角转换会迫使你提前规划,而不是等看到弯才反应,同时也让刹车与油门的重叠区(循迹制动)变得可分析。

    前后分配不是固定值

    多数车手习惯 70% 制动在前。实际比例应该随弯道结构变化:入弯是右弯时,车身重心向左转移,前轴负荷更高,前制动的有效权重也会上升。感知重心的漂移,比记住一个固定数字有用。

    工况 重心转移方向 前制动有效权重 调整方向
    直线重刹 向前 高 基准工况,以此为参照
    入右弯 向左 升高 可适当增加前制动占比
    入左弯 向右 升高(对称) 同上,方向相反
    带转向的制动 复合 取决于转向角 优先降低总制动强度

    让数据告诉你什么时候该收

    刹车温度是可以被记录的。装一套带温度输出的卡钳传感器,或者用记录设备同时采集刹车压力与踏板行程来配合推算,你会发现自己远比自以为的更早进入热衰减区。这是少数几个「装上传感器就能立刻改变驾驶行为」的场景——因为结论与体感冲突,没有数据几乎不可能靠自己意识到。

    硬件升级的顺序

    如果硬件要升级,顺序应当按「对散热与一致性的贡献 / 成本」排列:

    顺序 项目 解决什么 局限
    1 刹车片 提高工作温度上限 最便宜、见效最快,但只适合轻度使用
    2 刹车盘 改善散热 赛道日的关键项,成本显著上升
    3 卡钳 改善刚性、降低踏板行程、提升一致性 对散热贡献有限,主要改善脚感与重复精度
    4 避震与轮胎 改善负荷转移 不解决热衰减本身

    常见的错误是反过来做:先花大钱换避震,结果刹车该热还是热——因为避震改变的是负荷转移节奏,而热衰减是摩擦副的温度问题,两者不在同一条因果链上。

    常见问题

    怎么判断自己已经进入热衰减区?

    体感不可靠,因为液压系统会掩盖摩擦系数下降。可靠的判据是数据:同一刹车点、同等踏板力下,圈速或减速度出现单调下降;或者刹车压力不变而减速度降低。出现这种「输入不变、输出变差」的形态,基本可以判定为热衰减。

    刹车盘温度超过 600°C 就一定要停车降温吗?

    600°C 是原文档中给出的典型量级,不是通用阈值。不同刹车片配方的工作温度窗口差异很大,是否超限应当以刹车片厂商给出的工作区间为准。实际操作中,更可靠的信号是上一问提到的减速度单调下降,而不是某个绝对温度值。

    前后制动比例可以自己调吗?

    取决于车辆是否配备可调比例阀或电子制动力分配。多数原厂车的比例是固定的,车手能调的是「驾驶方式」而非「硬件比例」——即在重心转移的不同阶段分配制动强度。若确实需要调硬件比例,需确认当地法规与赛事规则是否允许。

    赛道日需要刹车导风口吗?

    如果已经出现明确的热衰减信号,导风口是一项低成本的散热改善,优先级介于换刹车片与换刹车盘之间。它的局限是效果依赖管路设计,且会引入额外的进气阻力与部件可靠性风险。

    延伸阅读

    参考资料

    • 本站车型库 —— /data/cars/(用于确认具体车型的制动系统形态与总线通道)
    • 本站文章 —— /blog/trackday-data-review-segment-method/(热衰减的逐段判据)
    • 说明:本文属赛道工程实践经验总结,温度与比例数值为常见赛道日场景的典型量级,非受控实验测量结果。

    说明:本文为赛道刹车管理的经验性方法总结,文中 600°C、0.4 秒、70% 等数值是为说明方法而引用的典型量级,非实测统计结果,不同车型、刹车片配方与环境温度下差异可能很大。涉及制动系统的任何改装与调整,请以厂商规格、当地法规与专业技师意见为准。

    更新记录

    首次发布 2026-10-13(原发布于建站期,本次按本站 GEO 规范重写:补充结论段、前后制动分配表与硬件升级顺序表、常见问题与结构化数据)。最近核对:2026-10-13