标签: openpilot

  • 路段标识与切片: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

  • 离线与实时两条数据管线: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

  • 100 Hz 的 card 网关:sendcan 不按 valid 拦截

    结论:card 是「车型发现 → 车况发布 → 控制帧网关」的中央进程,但它发出的东西按语义要分成四层:控制请求已生成、sendcan 已发布、panda 接受、车端确认动作。最容易踩的坑是:card 把 sendcan.valid 赋为 CS.canValid,而 pandad.can_send_thread() 本身并没有根据 Event.valid 字段筛掉 sendcan——这个元数据不是板端的发送阻断。以下基于源码快照 c8fb906 的静态阅读与假依赖探针,未构建、未做车型回放、未做实车验证。

    初始化与车型身份

    Car.__init__() 创建 can 订阅、pandaStates/carControl/onroadEvents 的 SubMaster,以及 sendcan/carState/carParams/carOutput/radarTracks 发布端。正常路径等首个非空 CAN,读取 AlphaLongitudinalEnabled 与 CarParamsCache,调用 get_car() 做识别、固件查询与接口构建,再按指纹取得 RadarInterface;识别失败在 get_car() 内回退 MOCK,这不是允许未知车发送通用控制帧。

    阶段 关键函数/参数 含义
    固件查询复用 obd_callback() 写 ObdMultiplexingEnabled 请求,先删 ObdMultiplexingChanged,再阻塞等待 pandad 写确认位;四项假 Params 探针确认假确认返回假仍记成功,且无超时或板端模式读回。
    指纹完成 FirmwareQueryDone get_car() 返回后写真;pandad 在该位之前不取最终车型参数。
    被动模式 OpenpilotEnabledToggle、CI.CC、CP.dashcamOnly 没有控制器、用户关闭或车型只支持 dashcam 时,CP.passive=True,并把 safetyConfigs 改为 noOutput。
    SecOC secOcRequired、SecOCKey 可从指定缓存文件导入 32 个字符,再把十六进制字符串解析为 16 字节;源码只检查长度与解析结果,不等于密钥来源或权限已验证。
    参数缓存 CarParamsCache、CarParamsPersistent、CarParamsPrevRoute 缓存用于本次 manager 生命周期的快速指纹;不能拿旧缓存代替当前 CAN/固件匹配。

    SecOCKey 解析处调用 bytes.fromhex(),没有局部捕获非法十六进制的 ValueError;静态上长度正确但内容错误的键可令初始化异常。此处尚未构造故障注入测试,量产硬件应验证密钥导入、轮换、无效格式与清除时的安全默认态。

    一次 card 步进的消息顺序

    把单次 step() 展开,顺序是固定的:

    1. pandad.can → Car.state_update();
    2. 调用 CI.update(can_list) 与 RI.update(can_list)(雷达可返回 None);
    3. VCruiseHelper 更新 CS.vCruise、CS.vCruiseCluster;
    4. Car.state_publish() 依次发布 carOutput、carState、radarTracks;
    5. onroadEvents 不含 selfdriveInitializing 时进入 Car.controls_update();
    6. 首次调用 CI.init() 后写 ControlsReady;
    7. CI.apply(carControl) → sendcan → pandad.can_send_thread → panda 固件安全门。

    can_comm_callbacks() 把 cereal 的 can/sendcan 消息与 CanData 列表互转,供指纹、接口初始化与诊断查询使用。state_update() 等待并批量读取原始 CAN,再分别调用车型与雷达 update();空 CAN 轮次累加 can_rcv_cum_timeout_counter,回放时使用 CAN 消息中的单调时间戳。三项原 CAN 回调探针确认:接收回调保留 Event 批次但不返回外层有效位/时间戳;诊断发送回调沿编码器默认值生成 valid=True 的 sendcan——这些回调没有按 carControl 活性或 CS.canValid 过滤。

    车况发布:四个 valid 位各有定义

    state_publish() 约每 50 秒发布一次 carParams;每步先发布 carOutput(上次 CI.apply() 的执行器输出回报),再发布 carState。四个有效位不能合并成一个「整车健康」标志:

    字段 取值来源 边界
    carState.valid CS.canValid 只反映车况信号是否有效
    carOutput.valid carControl 的 SubMaster.all_checks() 与车况有效性各自独立
    radarTracks.valid 雷达错误字段;None 时不发布该主题 错误与点仍在消息内
    carParams 节拍 sm.frame 对 50/DT_CTRL 取模 不是独立墙钟定时器

    六项原 state_update()/state_publish() 探针还确认:空原始接收批次仍会调用 CI.update()、RI.update() 并累加超时计数;只要原始事件列表非空,即使替身解码成空 CAN 列表也不累加;巡航 helper 先更新当前 CS,控制使能上升沿再用 CS_prev 初始化设定值;carOutput/carState/radarTracks 的有效位可分别为假/真/假。

    控制门链:谁真正拦住了发送

    step() 在发布车况后,要求 CP.passive=False、已收到 onroadEvents、且其中不含 selfdriveInitializing,才进入控制更新。首次 controls_update() 会调用 CI.init() 做车型接口/ECU 初始化,然后写 ControlsReady;pandad 的 configureSafetyMode() 只有在 FirmwareQueryDone 与 ControlsReady 两个门都满足后才读取 CarParams 配置安全模式。若 carControl 仍活跃,CI.apply() 产出请求执行器回报和 CAN 帧并发送 sendcan;否则本分支不发新帧。

    七项原 controls_update()/step() 探针确认:carControl 活跃而 CS.canValid=False 时,仍会编码并发布带 valid=False 的候选帧;initialized_prev=True 时跳过重复初始化;回放模式给 CI.apply() 使用记录的 CAN 时间。这就直接指向前述结论——sendcan.valid 只是元数据,车型 CarController、panda 固件的 TX 安全钩子和 controlsAllowed 才是另外的强制约束,硬件插件不能绕过这些层。

    故障或差异 当前代码可证实的处理 待验证
    无非空 CAN 初始化持续等待;运行期空批次累加超时计数 线束断开、总线睡眠与线程退出时序
    未识别车型或禁用控制 回退 MOCK/被动 noOutput 实板继电器与线束默认态
    OBD 复用确认未到 obd_callback() 阻塞等待 Params 确认 固件重连/断电恢复、超时策略
    carControl 丢失 all_alive 不满足时不再调用 CI.apply() 最后一帧、panda 超时与原厂系统恢复时延
    CAN 无效但已有控制帧 发布 sendcan.valid=False;发送线程未按 valid 拦截 车型控制器与 panda 安全钩子的拒绝/回退

    时序上,card 由 CAN 输入驱动,配置 100 Hz Ratekeeper 监测节拍,实际迭代频率取决于总线输入与处理时间;params_thread() 约每 100 毫秒刷新单位与实验模式,cumLagMs 来自 Ratekeeper 剩余时间。不同进程不是原子事务:不能假定最新 carState、最新 carControl 和本轮 CI.apply() 精确一一对应。

    常见问题

    sendcan.valid=False 会阻止 panda 发送吗?

    不会自动阻止。pandad.can_send_thread() 只检查时间戳是否小于 1 秒,本身没有根据 Event.valid 字段筛掉 sendcan。真正的强制约束在车型 CarController、panda 固件的 TX 安全钩子和 controlsAllowed。

    收到 carState 就说明整车数据都健康吗?

    不能。carState.valid、carOutput.valid、radarTracks.valid 各自有定义,可以分别为假/真/假。它们不能合并成一个「整车健康」标志。

    OBD 复用握手为什么会卡住?

    obd_callback() 写请求后阻塞等待 pandad 写确认位,而这个握手没有显式超时,也没有板端模式读回。四项假 Params 探针确认假确认返回假仍记成功,因此固件重连/断电恢复与超时策略需要额外设计。

    识别失败回退 MOCK 会不会乱发控制帧?

    不会直接发送。回退只发生在识别阶段,仍由后续参数与安全配置决定输出;被动路径会把 safetyConfigs 改为 noOutput,而不是允许未知车发送通用控制帧。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/CARD_VEHICLE_GATEWAY_DEEP_DIVE.md(card 车型发现、车况发布与控制帧网关,含源码行号索引)
    • 项目 02 —— openpilot-research/CARD_OBD_MULTIPLEXING_HANDSHAKE_DEEP_DIVE.md(源文档指向的 OBD 复用握手专题)
    • 项目 02 —— openpilot-research/PANDAD_PYTHON_CAN_CAPNP_ADAPTER_VALIDITY_DEEP_DIVE.md(源文档指向的 CAN 适配与有效位专题)

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

    更新记录

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

  • 变道意图与 LDW:20 mph 与 31 mph 两道阈值各管什么

    结论:变道意图和车道偏离预警是两条不同的辅助链路。变道意图靠转向灯新沿、驾驶员同向扭矩与盲区位,在车速不低于 20 mph 时从 off 进入准备态;车道偏离预警则要求车速严格大于 31 mph、近 5 秒无转向灯且横向未激活。两者都只产生提示或候选,不直接授权转向或制动执行器。本文为固定源码快照 c8fb906 的静态分析。

    消息与时序:本帧状态、下一帧脉冲

    modeld.main() 在本帧推理前读取上一次 DH.desire,编码到 desire_pulse;ModelState.run() 只将 desire 从低到高的变化送入模型。推理后才从本帧 desireState 左右变道项之和计算 lane_change_prob,调用 DH.update(),并把更新后的状态/方向写入本帧 modelV2.meta。因此状态提示可在本帧出现,新意图要到下一帧才进入模型;不能把它们当同一时刻的闭环输出。

    plannerd.main() 以 modelV2 轮询,只有该主题更新时计算 LDW 并发布 driverAssistance;消息顶层 valid=sm.all_checks(),所以计算出的左右布尔值与可供消费的有效性是两回事。服务表配置 driverAssistance 为 20 Hz,这不是端到端时延保证。desirePrediction 是 LDW 使用的另一组模型值,不能与 desireState 混写。

    组件/入口 主要逻辑 下游或边界
    DesireHelper.get_lane_change_direction() 从左转向灯决定左,否则决定右;调用者先要求仅单侧灯亮。 准备态方向可随灯变化。
    DesireHelper.update() 转向灯新沿、速度、横向活动、驾驶员同向扭矩、盲区和模型完成概率推进状态。 只设置意图/状态,不直接写转向执行器。
    ModelState.run() 在推理输入中把非零 desire 的上升沿转为脉冲。 持续保持同一意图不会每帧重发脉冲。
    LaneDepartureWarning.update() 根据车速、灯冷却、横向活动、线可见度/距离及预测概率设置左右偏离。 仅产生布尔结果,不制动或转向。
    Controls.state_control() laneChangeState!=off 时设置 carControl 转向灯请求,按有效位设置 HUD 偏离方向。 车型是否实现灯控需逐车型检查。

    变道意图的状态门

    DesireHelper.update() 只有横向激活、单侧转向灯出现新沿、速度不低于 20 mph,才从 off 进入 preLaneChange。准备态要求 steeringPressed 且扭矩方向与转向灯一致,并且该侧无盲区报警,才进入 laneChangeStarting。熄灯或降到门槛以下会关闭准备态;已启动后源码不再以盲区位直接撤销,而是依赖模型概率和超时等分支,因此不能把盲区位当成持续的安全许可。

    完成门要求启动至少 0.5 s 且左右变道 desireState 概率和严格小于 0.02,之后若灯仍亮回准备态,否则关闭。横向失活或累计计时超过 10 s 关闭。laneChangeFinishing 虽在 log.capnp 中定义、selfdrived 也接受,本版本 helper 没有赋值该状态。五项原 DesireHelper 类假消息枚举探针确认:20 mph 恰好允许新沿进入准备态;横向失活会把上一灯沿记忆清零,再激活时即使灯一直亮也可视为新沿;同向扭矩在盲区亮时不能启动,但进入 laneChangeStarting 后熄灯/盲区亮且模型完成概率仍高时,意图继续输出。十秒上限在每次调用开头检查,首次越过十秒的该帧仍处于启动态,下一次调用才复位。

    车道偏离预警的判定条件

    LaneDepartureWarning.update() 要求车速严格大于 31 mph、按模型帧估算的近 5 s 内没有转向灯、carControl.latActive=False,且 desirePrediction 非空。左/右车道线各自概率大于 0.5;以相机横向偏移 0.04 m 修正后的近线条件分别是左侧 y[0]>-1.12 m、右侧 y[0]<1.04 m;对应方向的模型变道预测概率还须大于 0.1。last_blinker_frame 初值为零,冷却按 SubMaster.frame * DT_MDL 估算,不是单独的墙钟计时。四项原 LDW 类假模型字段探针确认:初始帧号 99 仍在五秒冷却内,帧号 100 恰好结束;31 mph 等值不允许 LDW,模型概率/线可见度/近线距离的等值也不触发;空 desirePrediction 会清左右状态,但非空且短于所需索引的向量直接抛 IndexError。

    维度 变道意图 车道偏离预警
    车速门槛 不低于 20 mph 才能进入准备态。 严格大于 31 mph。
    转向灯 需要单侧灯的新沿。 近 5 s 内没有转向灯。
    横向活动 要求横向激活。 要求 latActive=False。
    驾驶员输入 要求 steeringPressed 且扭矩同向。 不依赖驾驶员扭矩,依赖线可见度与模型预测。
    输出 变道状态/方向、转向灯请求与提示。 左右偏离布尔值、HUD 方向与声音提示。

    提示与执行边界

    selfdrived 仅在 IsLdwEnabled 和 driverAssistance 有效时生成 ldw;开关由参数轮询更新。该事件是 PERMANENT 小型提示及声音,不是 disengage 或执行器指令。准备变道、盲区阻挡和变道中分别是 WARNING 提示;其中盲区阻挡带提示音。controlsd 也会在 driverAssistance 有效时把左右偏离送入 HUD。

    两项原消费者条件组合探针确认了旧值消费的边界:先收 driverAssistance.valid=True,随后假时钟推进到超出 20 Hz 服务的十倍存活窗口且无新消息时,updated=False、alive=False,但 valid=True 与旧左右字段保留,隔离执行 SelfdriveD.update_events() 的 LDW 分支仍会添加 ldw;若收到新消息且外层 valid=False,两个分支都跳过。转向灯、驾驶员扭矩/按压、盲区都来自车型解码,不是模型自己「看见了」即可替代,新硬件接口必须逐车型证明同向扭矩和盲区位的确切含义。

    常见问题

    变道是车辆自己判断安全后完成的吗?

    不是。这里的变道是驾驶员参与的 L2 辅助行为,DesireHelper 只设置意图/状态,不直接写转向执行器;车型是否实现灯控还需逐车型检查。它不自动判断周围交通安全后自行变道。

    为什么变道用 20 mph、LDW 用 31 mph?

    两条链路的门槛是独立定义的:DesireHelper.update() 要求速度不低于 20 mph 才进入准备态;LaneDepartureWarning.update() 要求车速严格大于 31 mph。源码没有把两者合并为同一策略函数。

    盲区位会一直阻止变道吗?

    不会。准备态要求该侧无盲区报警,但一旦进入 laneChangeStarting,源码不再以盲区位直接撤销,而依赖模型概率和超时等分支,因此不能把盲区位当成持续的安全许可。

    LDW 的 5 秒冷却按墙钟算吗?

    不是。冷却按 SubMaster.frame * DT_MDL 估算,last_blinker_frame 初值为零;探针确认灯亮后的第 99 帧仍冷却、第 100 帧可再次触发。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/LANE_CHANGE_LDW_DEEP_DIVE.md(DesireHelper 状态机、LDW 阈值、消息发布与提示边界)。
    • 项目 02 —— 同目录探针记录:五项 DesireHelper 状态、四项 LDW 阈值、两项 plannerd 发布、两项消费者分支隔离探针结论。

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

    更新记录

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

  • UIState.update() 在绘制之后:告警时延与 5 Hz 刷新

    结论:车载 UI 是呈现与交互层,不裁决自动驾驶使能,权威状态来自 hardwared、selfdrived、card 与模型进程。决定告警时延的是一条容易被忽略的顺序:GuiApplication.render() 的生成器在绘制顶层 Widget 之后才结束本帧绘制,本轮 UIState.update() 因此发生在绘制之后,新消息通常在下一帧才用于 Widget——所以 uiDebug.frameTimeMillis 不能当成「消息发布到屏幕显示」的端到端时延。本文为对本地固定源码快照的静态阅读,逐个像素、全部语言与 GPU 合成均未验证。

    入口、数据与帧顺序

    ui.main() 初始化窗口后,按屏幕尺寸创建 MainLayout 或 MiciMainLayout,每次从 GuiApplication.render() 的生成器取得帧状态再调用 UIState.update()。屏幕关闭时生成器仍周期性 yield False,让 UIState 有机会更新并重新唤醒屏幕。触摸事件经 MouseState 由顶层 Widget 分发到子 Widget 或导航栈。

    组件/关键函数 实现作用
    UIState._initialize() 建立单例与大量 cereal 主题订阅,初始化 started、status、车型、实验模式和 Chestnut 状态
    UIState.update() 非阻塞 SubMaster.update(0),刷新状态/告警枚举,再调用屏幕设备管理;约 5 Hz 后台线程读取较慢的 Params
    UIState._update_state() / _update_status() 从 pandaStates、deviceState、selfdriveState 派生点火/行车/使能状态并通知边沿回调;started 要求 deviceState.started 且自身缓存点火为真
    UIState._update_chestnut_state() 根据设备发现、模型编译产物、新鲜度与 Params 显示断开/加载/激活/失败,与固件刷写状态不是同一状态机
    Device._update_brightness() 行车时按相机曝光估算环境亮度并平滑,后台线程设置背光;触摸、点火、交互超时控制屏幕电源
    Widget.render() / _process_mouse_events() 先更新状态再绘制,然后处理触摸;只接受从自身命中区域开始的触摸,长按达 0.45 秒后清除点击跟踪

    需要单独记住的是:UIState.update() 里的非阻塞订阅只刷新枚举与屏幕设备管理,较慢的参数项由约 5 Hz 的后台线程承担,这两者的节奏并不相同。

    页面与关键交互

    MainLayout._set_mode_for_state() 管理首页、设置、行车三态;行车切换会自动进入摄像头画面,侧边栏可由行车画面触摸切换。SettingsLayout 分为 Device、Network、Toggles、Software、Firehose、Developer 六面板,其中 TogglesLayout._update_toggles() 会根据车型纵向能力、分支和 engaged 状态限制开关。

    页面/组件 关键入口 行为
    大屏主导航 MainLayout._set_mode_for_state() 首页、设置、行车三态;行车切换自动进入摄像头画面,body/notCar 特殊处理
    大屏首页/侧边栏 HomeLayout._update_state() / Sidebar._update_state() 显示更新/驻车告警、网络/温度/连接/设备状态,可进入设置或发布书签
    大屏设置 SettingsLayout Device、Network、Toggles、Software、Firehose、Developer 六面板,开关受车型与状态限制
    首次引导 OnboardingWindow 及其 mici 版本 条款版本与培训版本写 Params;mici 另延长交互超时并将亮度设为 100,退出后恢复
    mici 主导航 MiciMainLayout._handle_transitions() 横向滚动驻车告警、首页、行车/Body;点火后约 2.5 秒切换行车,静止时返回行车

    行车画面与告警

    AugmentedRoadView._render() 先绘制摄像头,再按顺序叠加模型路径、HUD、告警和驾驶员状态,且仅在 ui_state.started 时执行。CameraView._render() 非阻塞读取 VisionIPC:没有新帧时保留上一帧,连接丢失时清空并绘制占位;comma 硬件走 EGL 直接缓冲,PC 走 Y/UV 纹理拷贝。ModelRenderer._render() 要求模型和标定至少在本次行车之后收到过。

    相机画面的连接管理也不简单:_ensure_connection() 对重连做了节流,_handle_switch() 要等目标流有帧才完成宽/窄切换,而宽/窄流的选择取决于实验模式与速度门限;_update_calibration() 只消费有效标定矩阵。屏幕上的路径只是视觉呈现,不是控制输出的独立证明。

    告警侧的两个时间门值得单列:AlertRenderer.get_alert() 在 10 秒未收到首次行车 selfdriveState 时显示等待;在 comma 硬件上,已收过状态但断流超过 5 秒时先显示接管/系统失联,再显示重启提示;大屏实现还会拒绝前一行车周期的旧告警。mici 的 AlertRenderer.get_alert() 有相同超时分支,但正常告警路径没有大屏的 recv_frame < started_frame 过滤——这是一个需要回放验证的旧告警可见性风险,不是已实测问题。语音失联告警由 soundd 负责,不能由普通 UI 插件替代。

    硬件与插件化边界

    显示平台适配需提供可靠触摸、屏幕电源、背光、GPU/EGL 或纹理路径和相机图像零拷贝能力,并监测触摸到反馈、消息到危险告警、屏幕唤醒与画面丢帧的真实时延。装饰性页面与指标可以插件化,但接管告警、失联占位、条款/培训、驾驶员监测提示及设置权限应由受信任核心约束;插件不能覆盖 selfdriveState 权威主题,也不能在未完成车型和安全校验时直接开启控制选项。

    现有 test_raylib_ui 只检查 UI 进程初始化;截图差异回放覆盖若干页面/告警视觉状态,但本次未运行。两种屏幕、触摸竞争、前后帧时延、长期断流与各语言文本溢出均尚未实测。

    因此插件化要区分「装饰」与「权威」:装饰性页面与指标可自由替换,而告警呈现、失联占位、条款培训与设置权限必须留在受信任核心;插件也不得在未完成车型与安全校验时开启任何控制选项。

    常见问题

    uiDebug.frameTimeMillis 是端到端显示时延吗?

    不是。生成器在绘制顶层 Widget 之后才结束本帧,本轮 UIState.update() 发生在绘制之后,新消息通常在下一帧才用于 Widget;因此该指标只是帧数据,不等于从消息发布到屏幕显示的端到端时延。

    UIState 的状态刷新是实时的吗?

    不是。状态订阅用非阻塞 SubMaster.update(0),而较慢的 Params 项由约 5 Hz 的后台线程刷新;屏幕关闭时生成器仍周期性 yield False 让 UIState 有机会更新并重新唤醒屏幕。

    UI 的 started 状态是怎么定的?

    UIState._update_state() 要求 deviceState.started 为真且自身缓存的点火为真,同时从 pandaStates/deviceState/selfdriveState 派生状态并通知边沿回调。

    行车告警的等待与失联阈值是多少?

    AlertRenderer.get_alert() 在 10 秒未收到首次行车 selfdriveState 时显示等待;comma 硬件上已收过状态但断流超过 5 秒时先显示接管/系统失联,再显示重启提示。mici 缺少大屏那层旧告警过滤,尚需回放验证。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/UI_RUNTIME_DEEP_DIVE.md(UI 入口、UIState 聚合、页面导航与行车告警时延)
    • 项目 02 —— openpilot-research/CHESTNUT_USB_FIRMWARE_DEEP_DIVE.md(源文档指向的 Chestnut 固件刷写状态专题)
    • 项目 02 —— openpilot-research/UI_SIDEBAR_STATUS_FRESHNESS_ACTION_CONTRACT_DEEP_DIVE.md(源文档指向的侧栏状态新鲜度与动作门专题)

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

    更新记录

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

  • 设置页写了参数不等于生效:ADB、alpha 纵向与总开关的下游链差在哪

    结论:车载设置页的开关只做一件事——往 Params 写值并(在少数情况下)请求一次行车周期;参数写进去不等于硬件行为已经验证,UI 显示门也不是安全授权。软件更新页的「检查/下载」是发给更新器的信号请求,安装按钮只写 DoReboot;开发者页的 ADB/SSH、动作模式与 alpha 纵向涉及不同下游消费者;驾驶开关则通过 card、selfdrived 影响使能。大屏与 mici 两套界面在时间门、异步调度与锁键语义上并不完全对齐。以下为固定源码快照 c8fb906 的静态分析。

    软件更新页:请求不是直接安装

    大屏 SoftwareLayout 从 Params 显示当前描述/发行说明、UpdaterState、失败次数、可拉取/可安装状态与目标分支。_update_state() 只在 offroad 显示「检查/下载」和「安装」,且安装可见还要求 UpdateAvailable。按钮点击后的约 10 秒等待窗只处理「更新器未响应」的 UI 状态,不保证下载成功。

    _on_download_update() 依据按钮文案选择 SIGUSR1 检查或 SIGHUP 拉取;_on_select_branch() 写 UpdaterTargetBranch 后发送检查信号。WaitTimeHelper 把 SIGUSR1 转成 CHECK、SIGHUP 转成 FETCH,由更新器负责分支解析与 staging/finalizing,再写 UpdaterFetchAvailable、UpdateAvailable 等 Params。两屏卸载都经确认后写 DoUninstall;manager_thread() 检出 DoUninstall/DoReboot/DoShutdown 后退出循环,由 main() 调用平台卸载或重启。实际固件切槽/卸载动作仍需平台级验证。

    开发者页:参数到运行链

    开发者页的控件覆盖 ADB/SSH 开关、SSH 公钥拉取、joystick 与纵横向动作模式、alpha 纵向控制确认以及 UI FPS/触摸调试。它们大多只写一个 Params 键,是否会真正改变硬件行为取决于设备系统层或进程表:例如 ADB 控件 offroad 禁用,而 SSH 开关未使用相同的 offroad 门;动作模式三者在 UI 回调中互斥写 Params,但参数可由其他进程直接修改,UI 互斥不是进程表的全局不变量。

    SSH 公钥走 SshKeyFetcher:请求 GitHub 的 <username>.keys,把用户名与返回文本写 GithubUsername/GithubSshKeys,出错时清理;这是双键非原子写,系统级 authorized_keys 生效链仍需平台审计。

    控件 下游或边界
    ADB / SSH 写 AdbEnabled、SshEnabled;服务实际启停取决于设备系统层。ADB 控件 offroad 禁用,SSH 未使用相同门。
    SSH 公钥 请求 GitHub <username>.keys,写 GithubUsername/GithubSshKeys;双键非原子写,错误时清理。
    joystick / 纵向 / 横向动作 三者在 UI 回调中互斥写 Params;process_config.py 据参数选调试进程,参数可被其他进程改。
    Alpha 纵向控制 非 release、车型 alphaLongitudinalAvailable、确认页之后写 AlphaLongitudinalEnabled 并请求行车周期;可能改变原车 ACC/AEB 组合。
    UI 调试 写 ShowDebugInfo 并在本 UI 进程切换触摸点/FPS 叠加,不影响 CAN 安全许可。

    行车周期与控制模式

    restart_needed_callback() 写 OnroadCycleRequested。hardwared 消费后清零,并在一段时间内使 not_onroad_cycle 条件为假,迫使 started 周期重建——这不是操作系统硬重启。新设备需要测试「参数写入 → deviceState.started 边沿 → manager 进程重选 → card 车型参数重建 → panda 安全模式重新配置」的顺序。

    这里有一个两屏不一致点值得注意:大屏动作模式回调写互斥参数,但并未像 mici 那样对纵/横向动作模式调用 restart_needed_callback(),两屏的即时生效路径可能不同。因此不能从「UI 控件已改变」推断当前控制进程已安全切换,需要通过回放或实机验证 manager 对参数变化的读取节奏。

    驾驶开关与消费关系

    TogglesLayout 定义 OpenpilotEnabledToggle、ExperimentalMode、加速踏板退出、LDW、Always-On DM、座舱摄像头/麦克风记录及单位切换。它按车型 CarParams 是否有 openpilot 纵向控制动态限制实验模式与驾驶风格;实验模式首次启用要求确认,涉及重启的开关在 engaged 时禁用。

    消费侧,card 在初始化时结合 OpenpilotEnabledToggle、控制器是否存在与车型 dashcamOnly 决定 CP.passive;selfdrived 与 card 的运行状态读取实验模式。LongitudinalPersonality 还可通过车辆距离按钮变化并反映到 UI,因此 UI 选项不是唯一写入者。两套页面的限制也不等价:大屏遍历各开关的 *Lock 参数,mici 在当前页面只显式处理 RecordFrontLock,移植配置策略时须逐键核对。

    对比项 大屏 SoftwareLayout / TogglesLayout mici 对应界面
    更新请求发送 直接发 SIGUSR1/SIGHUP,约 10 秒等待窗 后台线程发送,系统时间无效时阻止按钮请求
    动作模式重启 写互斥参数,未调用 restart_needed_callback() 对纵/横向动作模式调用 restart_needed_callback()
    锁键处理 遍历各开关的 *Lock 参数 当前页面只显式处理 RecordFrontLock

    自制硬件与插件化建议

    源文档给出一组建议:把设置描述为「能力 + 策略 + 执行者」,UI 插件只呈现能力,更新器/进程管理器/平台服务执行请求;给更新与危险操作定义结构化请求及结果,而非让多个 UI 复制 pkill、布尔 Params、10 秒本地计时;保留 offroad/engaged 二次校验,确保从外部 API 调用也无法绕过。

    对 alpha 纵向、动作模式、openpilot 总开关,应采用车型能力声明和独立安全审核;插件不得替换 panda 的 TX/RX 安全策略,硬件安全默认态仍为失效安全。所有这些端到端行为(ADB/SSH 真实启停、公钥更新原子性、下载中断与升级回滚、断电恢复)都需在目标板上验证,本文只证明代码路径存在。

    常见问题

    点了「安装」是不是就完成更新了?

    不是。两屏的安装按钮最终都写 DoReboot,并非在 UI 里直接把 overlay 写入当前分区;真正的前台切换由启动脚本完成。UI 的检查与下载也只是请求语义。

    改了 ADB 或 SSH 开关就一定能连上设备吗?

    不一定。源码快照只能确认参数被写入 AdbEnabled/SshEnabled,服务实际启停取决于设备系统层;而且 ADB 控件 offroad 禁用,SSH 开关未使用相同的 offroad 门。

    动作模式切换为什么有时要重启?

    因为 restart_needed_callback() 会写 OnroadCycleRequested,由 hardwared 触发一次行车周期重建(不是操作系统硬重启)。大屏动作模式未调用该回调,mici 调用了,两屏即时生效路径可能不同。

    UI 里的实验模式和驾驶风格为什么有时不显示?

    因为 TogglesLayout 按车型 CarParams 是否有 openpilot 纵向控制动态隐藏实验模式与驾驶风格;没有纵向能力的车型看不到这两项,实验模式首次启用还要求确认。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/SOFTWARE_DEVELOPER_SETTINGS_DEEP_DIVE.md(软件/开发者/驾驶开关三页的 UI 函数、Params 与下游消费者)。
    • 项目 02 —— openpilot-research/ 同目录探针记录:更新请求信号、行车周期重建与两屏锁键差异的静态核对结论。

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

    更新记录

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

  • LSM6DS3 采样契约:104 Hz 与第一条 IRQ 的取舍

    结论:LSM6DS3 采集链的风险不在「读不到数」,而在三处静默丢样本与一处未校准语义:一次 os.read() 可能带回多条 GPIO 事件却只处理第一条;启动有效性门从第一次有效性检查起计 0.5 秒,期间样本读取成功但不发布;source 在 ID 核对前是 velodyne 占位值。以下基于固定源码快照 c8fb906 的静态分析与假 SMBus/GPIO 探针,未打开 GPIO/I2C、未在真实芯片上执行自测。

    实例、总线与启动顺序

    sensord.main() 实例化加速度、陀螺仪、温度三个传感器对象,都指向 I2C bus 1,并以 0x6A 地址访问同一颗 LSM6DS3。每个对象通过 Sensor.__init__() 独立打开 SMBus,寄存器访问经 Linux ioctl,最多读 32 字节块。main() 先逐对象 reset、再逐对象 init;温度类不覆盖 reset,所以真正写复位的是加速度和陀螺仪两个对象。

    init() 接受 WHO_AM_I 0x69/0x6A,据此把 source 设为 LSM6DS3 或 LSM6DS3TR-C;加速度设置 104 Hz、默认 ±2g、自动地址递增和 INT1 数据就绪,陀螺仪设置 104 Hz、默认 ±250 dps 和 INT1 数据就绪。三个对象共享芯片寄存器,后续适配不能把它们当作彼此独立的物理器件热插拔。

    初始化异常只记录日志,不从 sensors_cfg 中移除该对象,IRQ 线程仍会尝试读取它。自测也不一致:加速度仅在 LSM_SELF_TEST == "1" 时运行,陀螺仪在变量存在时运行、即使值是 0 也会触发;自测中的 _wait_for_data_ready() 没有总超时,芯片不产生 data-ready 时初始化可长时间停在忙等。

    中断读取:只处理第一条事件

    gpiochip_get_ro_value_fd() 请求 GPIO chip 0、line 84 的双边沿事件;interrupt_loop() 轮询 100 ms,并尝试把 IRQ 336 或 335 绑定到 CPU 1。代码把 time.time_ns() - time.monotonic_ns() 视为内核 GPIO 事件时间到单调时间的偏移,每次中断重新估计,偏移跳变超过 10 ms 时跳过本次事件并更新基准——这个换算依赖内核事件时间戳的时钟域,不能在新内核/驱动上未经核对就复用。

    关键取舍在 os.read():它一次请求最多 16 * sizeof(gpioevent_data) 字节,但随后只调用一次 from_buffer_copy(dat) 并处理第一条事件。若内核实际返回多条完整记录,其余事件已从 fd 读走却不进入逐传感器处理,可造成样本稀疏或时间间隔变大。IRQ 还是双边沿,代码没有按 gpioevent_data.id 区分上升/下降沿。

    构造输入 观察到的原函数路径 未验证范围
    一次读取串接两个 GPIO 事件,时间戳 9100/9200 ns,假时钟偏移 9000 ns 传给传感器的时间只有首条的 100 ns,发布一条消息;第二条未进入处理循环 真实 GPIO 驱动是否一次返回多条、积压率及时间戳时钟域
    返回不足一条 gpioevent_data 的字节 from_buffer_copy() 抛 ValueError,异常发生在逐传感器 try 外 真实内核短读条件与线程监督/重启行为
    温度传感器连续三次未过启动有效性门 三次读取均执行,但 Ratekeeper.keep_time() 调用数为零 假 Ratekeeper 不测 CPU 占用或 I2C 物理总线频率

    样本时间与 0.5 秒有效性门

    Sensor.is_data_valid() 从第一次调用有效性检查起计 0.5 秒,而不是从供电或 init() 开始;在此期间样本读取可能成功但不发布。温度由 polling_loop() 按 SERVICE_LIST 的 2 Hz 轮询,LSM6DS3_Temp.get_event() 在读取时以单调时钟自行打时间戳,并按芯片变体使用 16 或 256 LSB/°C。

    启动 0.5 秒内温度循环的 continue 位于 Ratekeeper.keep_time() 之前,所以该短阶段可能密集轮询 I2C;不能把它的初期调用频率视为稳定 2 Hz。

    数值换算与上下游健康门

    每次合格 GPIO 事件用同一个 ts 依次查询加速度和陀螺仪:加速度先检查 XLDA,再读 6 字节三轴有符号 16 位值,按 9.81*2/32768 转 m/s²;陀螺仪检查 GDA 后按 8.75 mdps/LSB 转 rad/s。两者都输出 [y, -x, z],并把换算后的中断时间填入 SensorEventData.timestamp;DataNotReady 被静默跳过,其他 I2C/消息异常只记录。

    构造输入 原类观察结果 边界
    16 位原始值 0xFFFF、0x7FFF、0x8000 分别解析为 -1、32767、-32768 只验证主机有符号转换,不验证芯片实际量程
    首次有效性检查从 t=10.0 开始 恰好 t=10.5 仍为假,超过 0.5 秒才为真 假单调时钟,不证明 IRQ 与初始化时序
    WHO_AM_I 正确、错误与空块 正确 ID 返回;错误 ID 触发断言;空块在 [0] 抛 IndexError 若 Python 以 -O 运行,assert 保护可被移除
    加速度状态未就绪与三轴原始字节 未就绪抛 DataNotReady;就绪后按 [y,-x,z] 输出,示例 [-9.81,-9.81,0] m/s² 假总线提供完整六字节,短块与真实安装方向未验证
    陀螺仪 +1000,-1000,0 与温度原始 16 轴序为 [y,-x,z];两芯片来源分别得 26.0 与 25.0625 °C 假消息只确认计算路径,不验证温度标定

    下游 locationd.handle_log() 对加速度/陀螺仪先比较事件内时间与消息 logMonoTime,相差超过 100 ms 时忽略,再核对来源与数值一致性;sensor_all_checks() 要求两路消息有效且最近 0.1 秒有接收。source 在 ID 核对前默认是 velodyne 占位值,而 locationd._validate_sensor_source() 仅排除 bmx055,因此初始化失败后的来源健康不能仅凭消息 valid=True 或该来源门判定。

    几处探针细节补充了这一层的取舍:不足一窗时仍会发送原始音频,两个半窗合成一次分析,一次超大回调只留下尾部。Sensor.is_data_valid() 的 0.5 秒是时间门而非数值门,而主机 SMBus 在回包长度为零时会回退请求长度、传感器层不会独立检查数据是否来自新的物理样本——因此传输成功、数据就绪、样本新鲜度与数值有效性应分成不同状态。

    常见问题

    为什么一次中断可能丢掉样本?

    因为 os.read() 一次最多请求 16 条事件结构,但代码随后只处理第一条;其余完整记录已从 fd 读走却不进入逐传感器处理,表现为样本稀疏或时间间隔变大。

    启动后前 0.5 秒为什么没有样本发布?

    Sensor.is_data_valid() 从第一次有效性检查起计 0.5 秒,而不是从供电或 init() 开始;在此期间样本读取可能成功,但不会发布。

    加速度就读绪了,为什么还可能是无效来源?

    source 在 ID 核对前默认是 velodyne 占位值,而 _validate_sensor_source() 只排除 bmx055;初始化失败后的来源健康不能只靠消息 valid=True 或该来源门判断。

    自测失败会自动恢复芯片寄存器吗?

    不一定。探针确认:差值低于阈值且两轮读取正常时,实现会先写寄存器关闭自测/采样再抛异常;但若中途 I2C 读取异常,关闭写入没有执行,且实际芯片是否保留测试状态尚未验证。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/IMU_IRQ_I2C_SAMPLE_CONTRACT_DEEP_DIVE.md(LSM6DS3 中断、I2C 样本与定位健康契约,含源码行号索引)
    • 项目 02 —— openpilot-research/probes/test_sensord_loop_contract.py(中断/温度循环三项假依赖探针)
    • 项目 02 —— openpilot-research/probes/test_imu_sensor_conversion_contract.py(数值换算与异常五项隔离探针)

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

    更新记录

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

  • 横向限幅四种算法:主机候选与 panda 判决为什么不是一件事

    结论:横向限幅分成扭矩、转角、曲率三条路径,共享默认横向加速度约 3.6 m/s²、jerk 约 3.6 m/s³(含道路横滚容差)。它们只是主机侧的候选值计算,不能等同于 panda 的发送许可;同一场景下主机函数输出并不保证每个瞬时输入同时满足加速度与 jerk 两项物理限值。插件可以输出带单位的候选扭矩/角度/曲率,但固件必须保留独立最终许可。

    三类配置与四条算法路径

    AngleSteeringLimits、AngleSteeringLimitsVM、CurvatureSteeringLimits 分别对应速度表转角、车辆模型转角和曲率。它们的共同特点是:配置类不检查车型身份、传感器健康或 CAN 权限,具体值由车型提供。下面是四条主要路径:

    入口 主机行为 主要消费者
    apply_driver_steer_torque_limits() 据驾驶员力矩与 allowance/factor/multiplier 算上下限,再对上次请求作非对称上升/下降速率限制,最后取整 Hyundai、GM、Subaru、Mazda、MG、Rivian、Volkswagen 等扭矩车型
    apply_dist_to_meas_limits() / apply_meas_steer_torque_limits() 先约束命令与实测量的误差带和绝对上限,再按上次请求限制每帧变化;误差带以 STEER_ERROR_MAX 给零附近保留范围 Toyota、Chrysler 等 EPS 电机力矩测量
    apply_std_steer_angle_limits() 按请求是否同向增大绝对值,在两张车速-角速度表间选择;插值后限单步变化,停用横向时改用实测转角,再限绝对角度 Nissan、PSA、Toyota 角度路径
    apply_steer_angle_limits_vm() 用 MAX_LATERAL_JERK/v² 反算每次可变转角,用 MAX_LATERAL_ACCEL/v² 反算绝对角;车速至少取 1 m/s Tesla、BYD 以固定安全车型参数建模

    注意 apply_driver_steer_torque_limits() 里的 steer_max 可动态覆盖静态上限。VM 路径还会用 STEER_STEP*DT_CTRL 将角速度换算为每次发送增量——调用周期由车型控制器决定,不是函数自己假设的常量。

    非扭矩路径的数值顺序

    曲率路径 CurvatureSteeringLimits.apply_limits() 的裁剪顺序固定,且这个顺序会产生反直觉的结果:

    步骤 动作
    1 按 a_max/v² 限曲率
    2 按 jerk_max/v² * steer_step*DT_CTRL 限相对上次请求的增量
    3 停用时以调用方传入的当前曲率替换
    4 最后限固定 CURVATURE_MAX

    当上次命令在当前车速下已经超出新的加速度界限时,jerk 区间可能与加速度区间不相交;后一道 np.clip 会把先前的加速度限值「拉回」上次命令附近。源文档给了一个可复算的例子:用 Ford 量级的 CURVATURE_MAX=0.02 1/m、MAX_LATERAL_ACCEL=MAX_LATERAL_JERK=3、车速 20 m/s、上次与本次请求均为 0.02 1/m、steer_step=1,本地原函数输出 0.019925 1/m——它高于当前加速度导出的 3/20²=0.0075 1/m,但仍低于固定绝对曲率上限。

    这说明了什么?主机此函数不保证每个瞬时输入同时满足两项物理限值。源文档很克制地补了一句:这并非证明真实 Ford 配置或 panda 固件会放行该帧;实际车型的速度、加速度配置、帧步长及固件独立检查需另核对。停用时的行为也值得记住:曲率和两种转角函数先前的动态限幅结果会被当前测量值替换,再只受最终绝对上限约束。

    主机不等于固件:同一场景的 safety helper 对照

    板端 steer_curvature_cmd_checks() 对启用的曲率命令分别 OR 累积固定绝对曲率、按板端车速推导的横向加速度、每帧 jerk 和实时发报频率等违例——加速度检查不会被后续 jerk 检查覆盖。这与主机的顺序差异,正是「主机候选」和「固件判决」不是同一件事的根源。

    Ford FORD_STEERING_LIMITS 将 0.02 1/m 映射为 1000 CAN 单位,20 Hz 发报;主机 CarController 每五个 100 Hz 周期调用一次共享曲率限幅。跨层探针的对照结果是:车速 20 m/s、上次曲率 0.02 1/m 的候选值被拒;同速度下界内的 0.008 1/m 候选通过。源文档强调这是共享函数与独立板端 helper 的对照证据,不是实际整车拒帧率或驾驶性能证明。

    辅助逻辑:故障规避与摩擦前馈

    common_fault_avoidance() 只在连续 request && fault_condition 时累计;超过阈值暂时拉低请求位,到 max_above_limit_frames+max_mismatching_frames 重置计数。Hyundai 的大转角、Toyota/Subaru 的高转角速度等复用此逻辑。它有两条边界:返回的是新计数和请求位,不清除同帧扭矩,也不自行核验 MCU 对请求位不匹配的容忍度。

    摩擦侧,apply_center_deadzone() 在死区内把误差归零,get_friction() 在 +/-FRICTION_THRESHOLD 之间线性插值摩擦前馈、区间外饱和。摩擦输出按 friction*latAccelFactor 缩放,不是原始扭矩命令——把它当作扭矩值会算错整整一个量纲。

    插件与硬件建议

    源文档给出的插件契约是:把横向算法插件限制为输出带单位的候选扭矩/角度/曲率;宿主负责车型身份和参数绑定、消息新鲜度、活动门及主机限幅,panda 固件保留独立最终许可。每个参数包应带车型、总线布局、固件安全模式、版本和发帧周期,拒绝跨车型或不完整配置;动态标定不能自动改变固件许可。

    在验证缺口上,test_lateral_limits.py 只对非 mock、非 notCar 的扭矩平台做静态估算,且明确跳过非扭矩平台,不直接执行限幅函数,也不验证 CAN 帧或 panda 判决;CarController.update() 全路径、NaN、真实车辆 CAN、目标固件和实车反馈均尚未验证。硬件验证的顺序应是:离线回放/隔离台架比较主机候选值与固件判决,覆盖驾驶员抢方向、车速跳变、实测量失联、请求位切断以及停用瞬间,再讨论受控场地测试。

    常见问题

    主机限幅通过就等于车辆会执行吗?

    不等于。主机函数只产生候选值;steer_torque_cmd_checks() 与 steer_curvature_cmd_checks() 在 panda 内另有状态化检查(固件车速、双车速差、曲率绝对值、加速度、jerk、实测误差等)。源文档明确主机函数不是这些状态化检查的替身。

    为什么曲率限幅可能不满足加速度门?

    因为 CurvatureSteeringLimits.apply_limits() 的顺序是加速度 → 相对上次命令的 jerk → 停用时实测曲率 → 固定绝对曲率。当上次命令已超出新的加速度界限时,后一道 np.clip 会把先前的加速度限值拉回上次命令附近,因此不保证每个瞬时输入同时满足两项限值。

    3.6 m/s² 和 3.6 m/s³ 是通用限制吗?

    它们是三类配置共享的默认量级(含道路横滚容差),但具体值由车型提供。例如 VM 路径用固定安全车型参数,Ford 量级例子用的是 CURVATURE_MAX=0.02 1/m 与 MAX_LATERAL_ACCEL=MAX_LATERAL_JERK=3。所以不能把它当成跨车型的通用上限。

    摩擦前馈的输出是扭矩命令吗?

    不是。get_friction() 的输出按 friction*latAccelFactor 缩放,由 LatControlTorque.update() 消费,不是原始扭矩命令。混淆两者的单位会得到错误结果。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/LATERAL_LIMITS_HOST_SAFETY_PLUGIN_CONTRACT_DEEP_DIVE.md(取用了四条算法路径、曲率裁剪顺序与数值例子、板端 helper 对照与插件建议)。
    • 项目 02 —— openpilot-research/CAR_PROTOCOL_IMPLEMENTATIONS.md(仅用于确认 safety_tx_hook 与 CANPacker 在横向候选之后的分工)。

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

    更新记录

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

  • VIN 只校验 17 位格式:车型识别、ECU 固件匹配与启动边界

    结论:车型识别收集的是识别证据,不是车辆身份认证。is_valid_vin() 只检查 17 位字符格式,既不校验它与实车绑定,也不证明响应来源可信;CAN 指纹只是报文地址与长度的消元,不是对内容或 ECU 身份的认证。更关键的是,FirmwareQueryDone 只表示「固件查询这一步写完了」,此时 CarParams 尚未写入,pandad 还要等 ControlsReady 才读取它——它不等于车型可信,也不等于允许执行。以下基于源码快照 c8fb906 的静态阅读,未做诊断总线隔离、VIN 欺骗或双证据冲突验证。

    启动时序:等 CAN、识别、两个握手门

    Car.__init__() 先等首个非空 CAN、解析缓存,再调用 get_car()。can_comm_callbacks() 把 can/sendcan 消息转成识别函数需要的收发接口;obd_callback() 写参数并等待 pandad 确认,而这个握手没有显式超时。识别完成后,FirmwareQueryDone 在车型接口创建后写入,但此时 CarParams 还没写;pandad 的 fetchCarParams() 必须再等 ControlsReady 才读取它。随后 card 依据控制器是否存在、用户开关和 dashcamOnly 决定 passive,被动时改成 noOutput。

    阶段 静态行为 含义与边界
    缓存 非 mock、含固件、VIN 非未知且未设 DISABLE_FW_CACHE 时复用 CarParamsCache;仍重做 CAN 指纹 缓存是加速来源,不是当前车辆身份认证;使用时没有比对本次 VIN
    主动诊断 无合格缓存且未设 SKIP_FW_QUERY 时打开 OBD 复用,查询 VIN、ECU 地址和固件 会发诊断 CAN;结果可能因超时或局部失败不完整
    CAN FPv1 维护 bus 0/1 候选,对 11-bit 非排除地址按长度消元;frame>100 且唯一则成功,frame>200 终止 frame 是收到的 CAN 包计数,不是严格墙钟两秒
    固件结果 固件候选恰好一个时覆盖 CAN 结果;多个或零个不覆盖 exact 与 fuzzy 都可形成单一候选;不要求两类证据一致
    强制覆盖 非空 FINGERPRINT 最后覆盖所选车型,来源记为 fixed 开发便利入口,不适合作为实车执行许可
    未知车型 get_car() 将无候选回退到 MOCK 仍由后续参数/安全配置决定输出,回退不等于具备控制能力

    还有一个容易被误读的细节:FINGERPRINT 覆盖时只改车型和来源,exact_match 保留此前固件匹配阶段的值。因此 fuzzyFingerprint 也不能单独解释为「固定车型的可信等级」——来源与模糊标记是追踪元数据,不是交叉验证结果。

    VIN 与固件查询细节

    get_vin() 会在 bus 0/1 与 UDS、OBD 及品牌特例请求之间重试,找不到则返回 VIN_UNKNOWN。固件侧的查询则分几步:

    1. get_present_ecus() 按 OBD 复用模式分组,普通地址并行、子地址逐个探测;get_brand_ecu_matches() 统计可能品牌,但注释明确尚未检查响应总线。
    2. get_fw_versions_ordered() 按响应 ECU 数量及匹配比例排序;对某品牌查询后,只要该品牌固件给出单一候选就提前停止,因此查询结果未必覆盖所有品牌。
    3. get_fw_versions() 依据品牌的 FwQueryConfig 形成地址、总线、请求/响应前缀及 ECU 白名单;异常记录后继续,返回已取得的局部 CarFw。被 logging 或 extra_ecus 标记的响应不用于后续匹配。
    4. IsoTpParallelQuery.get_data() 并行发送请求,多帧接收会延长单地址超时,负响应 0x78 使用更长的 pending 超时;其余坏响应/异常/超时只影响相应地址,返回字典只含完成的响应。
    5. match_fw_to_car_exact() 要求数据库列出的必要 ECU 存在且命中版本;非必要 ECU 可缺失,但一旦出现就必须匹配。Ecu.debug 特殊跳过。
    6. 通用 fuzzy 排除摄像头、雷达、EPS 与 debug,只在至少两个不同 ECU 地址唯一指向同一车型时给出匹配;match_fw_to_car() 先 exact 后 fuzzy,最终可能多个候选,只有唯一候选才在外层覆盖 CAN。
    匹配策略 成立条件 结果
    exact 数据库列出的必要 ECU 存在且命中版本 形成候选;非必要 ECU 可缺失,但一旦出现就必须匹配
    通用 fuzzy 至少两个不同 ECU 地址唯一指向同一车型 形成候选;品牌自定义回调也可能参与
    外层覆盖 match_fw_to_car() 先 exact 后 fuzzy 后只剩一个候选 只有唯一候选才覆盖 CAN 结果

    重新设计识别时的边界

    以下为设计建议,不是现有代码已实现的保障。可以把 VIN、ECU、CAN 和硬件拓扑检测设计成只读的「证据插件」,结果携带采集时间、总线、来源、完整性及冲突信息;插件不得直接设置车型安全模式或发 sendcan。由受信任的启动策略统一裁决:缓存只作性能优化,实车执行前需本次车辆证据复核;fuzzy、环境强制车型、证据冲突或版本不明时默认只读或 noOutput。OBD 复用、诊断请求和 CAN 发送应视为能力而非普通插件 API,限定唯一所有者、允许的总线/地址/速率与生命周期。最后,要保留 panda MCU 的独立限幅、心跳与安全模式——车型识别成功不等于安全配置可执行。

    验证缺口同样明确:现有 CAN 指纹测试用离线 FPv1 样本检查识别与包计数,固件指纹测试覆盖 exact、fuzzy 与数据库一致性;但本文未运行测试,也未验证诊断总线隔离、VIN 欺骗、缓存跨车误用、双证据冲突、OBD 握手阻塞与掉电恢复。此外 fingerprintSource/fuzzyFingerprint 在本快照中主要被写入 CarParams,未见 card 或 pandad 用这两个字段实施独立许可,因此不能把它们当成现成安全门。

    常见问题

    VIN 校验通过,能说明识别到了真车吗?

    不能。is_valid_vin() 只检查 17 位字符格式,既不校验它与实车绑定,也不证明响应来源可信。识别结果应携带来源与完整性信息,由上层策略裁决。

    FirmwareQueryDone 是不是代表车型已经可信?

    不是。它只表示固件查询这一步写完,此时 CarParams 尚未写入;pandad 还要等 ControlsReady 才读取。它既不代表车型可信,也不代表允许执行。

    CAN 指纹里的 frame>100 是两秒计时吗?

    不是。frame 是收到的 CAN 包计数,不是严格墙钟两秒;frame>100 且唯一则成功,frame>200 终止。采集字典也不等于可信身份。

    固件匹配唯一,就能直接覆盖 CAN 指纹结论吗?

    只有唯一候选才会在外层覆盖 CAN 结果。exact 与 fuzzy 都能形成单一候选,当前不要求两类证据一致;多个或零个候选时不覆盖。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/CAR_FINGERPRINT_FW_MATCH_DEEP_DIVE.md(车型识别、VIN 与 ECU 固件匹配,含源码行号索引)
    • 项目 02 —— openpilot-research/ACTUATION_GATE_CHAIN_DEEP_DIVE.md(源文档指向的最终执行门专题,供识别与执行的边界延伸)
    • 项目 02 —— openpilot-research/SOFTWARE_MINDMAP.md(源文档指向的主系统结构)

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

    更新记录

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