标签: openpilot

  • locationd 的四种健康位:20 Hz 输出为什么不能压缩成一个布尔值

    结论:locationd 的健康状态是四个独立维度——deviceMotion.valid、inputsOK、sensorsOK、posenetOK,它们不能压缩成一个「正常」布尔值。顶层 valid 只等于 filter_initialized && isfinite(kf.t),不直接包含另外三项;而 filter_initialized 一旦置真,不会在消息异常时自动变回假。名义频率下,约第 2 个相机坏输入或第 10 个 IMU 坏输入才会让 critical_service_inputs_valid 变假。

    调度与启动门:先看名义频率

    main() 以 cameraOdometry 为 SubMaster 轮询主题,另开独立 socket 批量读取 IMU 消息。服务表配置 加速度计/陀螺仪名义 104 Hz,相机里程计和 deviceMotion 20 Hz。

    主题 名义频率 作用
    加速度计 / 陀螺仪 104 Hz IMU 观测源,经独立 socket 批量读取
    相机里程计 20 Hz 轮询主题,同时触发 deviceMotion 发布
    deviceMotion 20 Hz 仅在 sm.updated['cameraOdometry'] 时发布

    启动门的细节很关键:初始化阶段只在 sm.all_checks() 与 sensor_all_checks() 同时满足时把 filter_initialized 置真;之后此布尔值不会在消息异常时自动变回假。输出侧,deviceMotion 仅在 sm.updated['cameraOdometry'] 时发布——单有 IMU 更新不触发输出。

    循环把 IMU 与 SubMaster 更新的消息按 logMonoTime 排序,仅把顶层 valid=True 的消息交给 handle_log()。这是当前批次内排序,不能保证跨批次全局严格有序;历史观测由滤波器最大回绕窗口约束。进程还可读取持久的 LocationFilterInitialState 作为初始状态/协方差来源,但本快照常规运行代码未定位到写入该键的对应路径——不能假定每次启动都有历史姿态。

    十八维状态与四类观测

    States 把状态分为 NED 姿态、设备速度、角速度、陀螺偏置、设备加速度、加计偏置,共 18 维。generate_code() 定义姿态旋转、速度积分以及 IMU/相机四类观测方程,运行时通过 rednose 生成的 EKF 执行——不是简单对四种速度取平均。四类观测的门各不相同:

    观测 生产条件与变换 拒绝 / 降权边界
    加速度计 msg.which() == acceleration,硬件数组重排取负为 [-v[2], -v[1], -v[0]],用传感器纳秒时间戳 时间戳不能为零,与 logMonoTime 差严格不超过 0.1 秒,不能早于滤波状态 0.8 秒以上;bmx055 作为第二 IMU 源被忽略;范数须 <100 m/s²
    陀螺仪 同样重排取负,先减滤波器陀螺偏置,再与相机偏航率分布交叉比较 除时间/来源门外,角速度范数须 <10 rad/s,偏航差须低于相机偏航标准差的 30 倍;只有通过才作为 PHONE_GYRO 观测
    相机旋转 以 timestampEof - 0.1 秒作为视觉姿态时刻,用 device_from_calib 旋到设备系 受 0.8 秒回绕门;旋转范数 ≤10 rad/s;标准差各分量须 >1e-5,范数不可超对应上限的 10 倍;入滤波前标准差乘 10
    相机平移 与旋转同一延迟时间和标定旋转;视觉平移写入 CAMERA_ODO_TRANSLATION 平移范数 ≤200 m/s;标准差须 >1e-5 且范数不超过 10*200,入滤波前标准差乘 4;方差旋到设备系后给 EKF

    两处时间语义差异必须记住:carState 在此只更新 abs(vEgo) 供 posenetOK 门使用,不是这只姿态滤波器的直接速度观测;extrinsicsCalibration 在 rpyCalib 非空且分量位于 ±0.5 rad 内时更新 device_from_calib。更关键的是:相机观测没有像 IMU 那样检查自身时间戳与 logMonoTime 的 0.1 秒差,而是按 timestampEof-0.1 与滤波器时刻比较——新硬件不能把两种时间语义混为一谈。

    坏输入计数:第 2 个相机、第 10 个 IMU

    HandleLogResult 区分成功、时间非法、值非法和传感器来源非法。主循环只对关键主题的时间/值非法增加坏输入计数,成功时乘以按频率计算的 calculate_invalid_input_decay();SENSOR_SOURCE_INVALID 不进入这两个分支。

    名义频率下,相机 input_invalid_limit=2、IMU 各为 round(2*104/20)=10,输出门使用比它们各低 0.5 的阈值——所以约第 2 个相机坏输入或第 10 个 IMU 坏输入,才会让 critical_service_inputs_valid=False。源文档特别澄清:实际恢复取决于后续成功样本数量和消息速率,不是固定墙上 10 秒。

    四种健康含义各不相同

    输出字段 源码计算 不代表什么
    deviceMotion.valid filter_initialized && isfinite(kf.t);同样设置四组 XYZMeasurement.valid 不直接包含当前 inputsOK/sensorsOK/posenetOK;某传感器掉线时顶层 valid 不一定同步变假
    inputsOK sm.all_valid() 与关键观测坏输入计数门同时成立 all_valid() 只检查消息有效位,不等于全部主题当前 alive/频率正常
    sensorsOK 对两路 IMU 各保存最近消息的 valid 位;非仿真要求距接收单调时间 <0.1 秒 不等于加速度/陀螺数值一定通过 handle_log() 的物理合理性与时间检查
    posenetOK 对相机平移标准差的前/后各 20 项均值作比,后均值是前均值 4 倍以上且 >7、车速 >5 m/s 时置假(低速放行) 它是模型运动估计的波动门,不是整条视觉链的真实性证明

    复位逻辑同样要分清:_finite_check() 在滤波返回的状态或协方差出现非有限值时调用 reset(t);但它不自动重置 filter_initialized 或所有输入计数。上游 selfdrived 对 posenetOK/inputsOK 另产生事件,controlsd 消费设备姿态与车辆参数——健康位与事件门是分层契约。

    测试与硬件插件边界

    test_locationd_scenarios.py 使用路段回放构造 IMU 缺失、数值峰值和 150 ms 时间偏移场景;其测试依赖路段数据下载/缓存,本轮未执行,不据测试文件存在而宣称当前设备已验证。

    自制 IMU/相机插件至少要保持轴序、符号、单位、硬件时间戳、logMonoTime 对齐、噪声标准差及顶层 valid 语义;对异步/乱序观测要验证 0.1 秒传感器门、0.8 秒回绕窗口和视觉 0.1 秒模型延迟。算法层允许回退或过滤观测,独立执行器安全限制仍在 panda 固件。

    常见问题

    顶层 valid 为真,能否说明所有传感器都正常?

    不能。deviceMotion.valid 只等于 filter_initialized && isfinite(kf.t),不直接包含 inputsOK/sensorsOK/posenetOK。由于 filter_initialized 一旦置真不会在消息异常时自动变回假,首次初始化后某传感器掉线,顶层 valid 不一定同步变假。

    为什么相机和 IMU 的坏输入阈值差这么多?

    因为阈值按名义频率换算:相机 20 Hz 对应 input_invalid_limit=2,IMU 104 Hz 对应 round(2*104/20)=10,输出门再各低 0.5。所以约第 2 个相机坏输入或第 10 个 IMU 坏输入才触发,恢复则取决于后续成功样本数量和消息速率,不是固定时长。

    posenetOK 变假是否等于视觉定位失效?

    不等于。它是对相机平移标准差前后各 20 项均值作比得到的波动门(后半均值是前半 4 倍以上且 >7、车速 >5 m/s 时置假,低速放行),只反映模型运动估计的波动,不是整条视觉链的真实性证明。

    相机观测和 IMU 观测的时间门一样吗?

    不一样。IMU 检查自身时间戳不能为零、与 logMonoTime 差严格不超过 0.1 秒、且不早于滤波状态 0.8 秒以上;相机观测没有检查自身时间戳与该 0.1 秒差,而是按 timestampEof-0.1 与滤波器时刻比较。两种时间语义不能混用。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/LOCATIOND_SENSOR_FUSION_HEALTH_DEEP_DIVE.md(取用了 18 维状态、四类观测门、坏输入计数与四种健康含义)。
    • 项目 02 —— openpilot-research/CAR_PROTOCOL_IMPLEMENTATIONS.md(仅用于确认 selfdrived/controlsd 与健康位事件的分层关系)。

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

    更新记录

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

  • road/device/view 三套轴向:相机与 IMU 接入的几何契约

    结论:坐标变换链里的矩阵和常数是当前设备配置,不是新摄像头或 IMU 的通用标定结果。三套轴向必须分清:road 为前/左/上,device 为前/右/下,view 为右/下/前;euler2rot_single() 用 Rz(yaw) @ Ry(pitch) @ Rx(roll),角度是弧度。更隐蔽的是「什么时候接受标定值」这件事,模型与 UI 两道门并不一样;此外接入未登记的新设备/传感器键会在静态路径上直接抛 KeyError。以下基于源码快照 c8fb906 的静态阅读与独立探针,未取得实机镜头、安装位姿或模型实体做数值验证。

    坐标系与单位

    坐标系 源码定义 接口要求
    地理坐标 geodetic2ecef_single() 接收纬度、经度、海拔 输入纬经度是度,函数内转弧度;海拔按 ECEF 公式使用长度单位;输出 x/y/z 为同一长度单位(通常米)。
    ECEF / NED LocalCoord 以一处地理或 ECEF 坐标定原点 NED 的 z 朝下;ned2ecef 加回 ECEF 原点,ecef2ned 先减原点。不能把 GNSS 纬经度直接当车身坐标。
    路面/设备/视图 camera.py 轴向定义 road 前/左/上,device 前/右/下,view 右/下/前;view_frame_from_device_frame 是固定轴重排矩阵。
    姿态 euler2rot_single() 使用 Rz(yaw) @ Ry(pitch) @ Rx(roll) 角度用弧度,欧拉角排列为 roll/pitch/yaw;rpyCalib 与 IMU 输出不可擅自交换轴或度/弧度。

    五个文件与关键入口

    几何能力集中在 common/transformations 的五个文件里,各自有明确职责与边界:

    文件与关键入口 实际功能及边界
    camera.py: CameraConfig、DeviceCameraConfig 每路相机用宽、高、单个焦距构造 K(fx=fy、主点在画面中心);此对象不含真实镜头畸变系数或在线外参优化。
    DEVICE_CAMERAS 以 (deviceType, sensor) 选三路相机配置,含部分 unknown 兼容键;AR/OX 的 fisheye 焦距在源码注释里被标为可能不准。
    get_view_frame_from_road_frame()、get_view_frame_from_calib_frame() 按旋转角和高度构造 3x4 外参;模型预处理使用的高度为 0,其他调用不应隐去平移项。
    normalize()、denormalize() 用 K^-1 与 K 在像素坐标与归一化针孔坐标间变换;都不是带畸变的鱼眼矫正。
    device_from_ecef()、img_from_device() 前者依位姿四元数把 ECEF 点转设备系;后者转 view 后除以深度,负深度置 NaN,零深度无显式安全分支。
    model.py: get_warp_matrix() 计算 intrinsics @ view_frame_from_device_frame @ rot_from_euler(...) @ calib_from_model;bigmodel_frame=True 选 sbig 逆矩阵。

    运行链:模型与 UI 的标定门不一样

    modeld 在 extrinsicsCalibration 更新且已见设备/窄路相机状态时,用 (deviceType, sensor) 从 DEVICE_CAMERAS 取配置,按主/辅相机选内参并计算两套 warp。该访问没有默认配置分支——若接入新设备/传感器组合而未登记键,静态代码路径会抛 KeyError。而且这个触发条件没有显式要求 extrinsicsCalibration.valid 或 calStatus=calibrated,所以不能把 modeld 的矩阵更新等同于标定已通过全部质量门。

    行车叠加的 AugmentedRoadView._update_calibration() 同样选择 DEVICE_CAMERAS,但更新外参前显式检查消息更新、valid、三元素 rpyCalib 和 calStatus=calibrated;宽相机还有 wideFromDeviceEuler 路径。也就是说,模型与 UI 的「何时接受标定值」门不同;单凭叠加图像看起来对齐,不能证明模型输入几何或相机身份正确。Calibrator 用姿态估计累积筛选标定块、依 pitch/yaw 范围更新状态,并在文件头明确假设 roll 为零,模型输入不校正 roll。

    退化输入与边界

    独立探针给出了几处具体边界:零角度且输入 med 模型内参时 get_warp_matrix() 为单位矩阵;normalize([2000, 600]) 在宽 1928 的内参下仍返回有限值,因为 normalize() 只把带负值的点置 NaN、不限制图像上界;denormalize() 未给边界时也返回有限值,传入 width=1928, height=1208 后返回 NaN,但它只拒绝 x>width,x==width 仍被接受,所以该边界不是有效像素索引判据。img_from_device() 的批量 (1,3) 背向点返回 NaN,但 (3,) 单点输入会因末尾二维切片触发 IndexError——注释称支持单点,实际不支持。未知传感器键则直接抛 KeyError。

    测试现状方面:test_coordinates.py 含 ECEF/NED 数值样本、单点/批量和错误输入检查,test_orientation.py 含姿态转换一致性;但当前 transformations 测试目录未见独立的镜头畸变、DEVICE_CAMERAS 新键、img_from_device() 单点或 get_warp_matrix() 标定回归测试。用隔离运行器加载两份仓库原测试文件、仅把 OpenpilotTestCase 临时替换为标准 unittest.TestCase 后,12 个原测试方法全部通过——它执行的是原测试方法与原变换函数,但不运行项目测试夹具,不能替代镜头/IMU 实机标定。

    新硬件接入至少需要什么

    1. 为真实相机/传感器身份登记尺寸和内参,避免行车路径上的 KeyError;
    2. 以标定板或实景观测测量焦距、主点、鱼眼畸变、主宽相机相对姿态与安装高度;
    3. 确认 device/road/view 轴向与 rpyCalib 弧度定义,不要让度/弧度或轴序在链路中被悄悄替换;
    4. 对相机 SOF、IMU、GNSS 使用可比较时间基;
    5. 在回放中同时检查模型输入 warp、UI 叠加、定位残差与异常输入。

    应由受控平台层发布标定结果及质量状态,普通插件不得在行车中任意替换几何矩阵或伪造校准完成标志。实机误差、长期漂移与车辆级安全影响仍待台架和封闭场地验证。

    常见问题

    模型输入几何能用 UI 叠加看起来对不对来验证吗?

    不能。模型与 UI 的「何时接受标定值」门不同:UI 显式检查 valid、三元素 rpyCalib 与 calStatus=calibrated,而 modeld 的触发条件没有显式要求这些。叠加图像对齐不等于模型输入几何或相机身份正确。

    接入新的相机或传感器组合为什么会直接抛 KeyError?

    因为 DEVICE_CAMERAS 按 (deviceType, sensor) 取配置,代码没有默认配置分支。未登记键的设备/传感器组合会在静态路径上直接抛 KeyError,必须先登记尺寸与内参。

    img_from_device() 支持单点输入吗?

    注释称支持,但当前实现在单点 (3,) 输入时会因末尾二维切片抛 IndexError,批量 (N,3) 才可用。零深度也没有显式安全分支,调用者须自行处理非有限投影。

    几何常量可以直接搬到新摄像头上吗?

    不能。本文所述矩阵与常数是当前设备配置,不是新摄像头或 IMU 的通用标定结果;AR/OX 的 fisheye 焦距在源码注释里也被标为可能不准。新硬件应重新测量并规定轴向与弧度约定。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/GEOMETRY_TRANSFORMS_HARDWARE_DEEP_DIVE.md(坐标变换与相机几何接口契约,含源码行号索引)
    • 项目 02 —— openpilot-research/probes/run_transformations_source_tests.py(加载原测试文件的隔离运行器,12 个原测试方法通过)
    • 项目 02 —— openpilot-research/probes/test_camera_geometry_plugin_contract.py(相机几何四项隔离探针)

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

    更新记录

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

  • 车载硬件状态:300 秒驻车、11.8 V 与 45 W 的关机链

    结论:硬件状态的信任缺口集中在两处:缺测被静默当成 0(ThermalZone.read() 在温度文件不存在时返回 0,HardwareBase 多数方法是空操作或零值默认),以及慢速链没有采样时间戳(hw_state_thread() 消费端无采样时间,旧网络/USB 状态可能继续出现在 deviceState)。此外驻车容量是估算值而非车载 BMS 实测电量。以下基于固定源码快照 c8fb906 的静态分析与离线探针,不是实板验证。

    平台抽象与 hardwared 线程

    平台选择在导入时就固定:有 /AGNOS 文件则用 HardwareComma,否则用 HardwarePc,并非运行时可插拔发现。HardwareBase 除 get_device_type() 外多数方法有空操作或零值默认实现——移植时不能把「继承成功」等同于「能力可用」。

    组件 / 函数 输入与输出 实际职责
    HARDWARE 选择 /AGNOS 文件 → HardwareComma 或 HardwarePc 导入时固定平台实例,并非运行时可插拔发现
    ThermalZone.read()、ThermalConfig.get_msg() sysfs 温区 → *TempC 字段 按温区名称查找并读取;温度文件不存在返回 0,不是显式失效标志
    hw_state_thread() 网络、调制解调器、USB → 容量 1 的队列 每约 10 秒或 USB 拓扑变化时抓取慢速状态;队列满或采集失败会消耗本次触发,主线程沿用旧值
    hardware_thread() pandaStates、peripheralState、温度、Params → deviceState 点火边沿和约 2 Hz 周期更新,处理启动门、热状态、风扇目标、功耗与关机请求
    FanController.update() 温度、点火 → 目标风扇百分比 积分 PID 加前馈,点火变化重置
    PowerMonitoring.calculate() 车端电压、点火 → 累计耗电 / 剩余容量估计 驻车按设备功率积分,点火时按固定 45 W 充电率估计;约 10 秒保存 CarBatteryCapacity

    点火、热状态与行车资格

    pandaStates 中任一已知 panda 的点火线或 CAN 点火信号可建立点火;超过 5 秒未收到状态则清除点火,但线束连接状态 in_car 仅在收到 panda 状态时更新。行车资格分两层:已有行车状态要求点火、无 onroad cycle、未达到 critical;从驻车新启动还要求条款/培训、空间、设备已启动、温度低于 overheated、非卸载等启动条件。

    因此热状态迟滞允许「过热但未严重」的在途状态暂时保持,不能把「禁止进入行车」误写成「立即退出行车」。温度源分两组:驻车危险检查用内存、CPU、GPU 的最大值,行车热状态再加入 PMIC,两者都经 5 秒一阶滤波;mici 与其他设备采用不同热带阈值,驻车高温可直接升到 critical。风扇目标在温度告警时被强制为 100%,随后经 pandad 发给 panda;panda 回报的转速另由 selfdrived 检查。

    风扇 PID 与温度带

    风扇与温度构成一条闭环:温度决定目标百分比,panda 执行并回报转速,故障事件则由 selfdrive 侧产生。关键参数如下。

    项目 参数 / 行为
    风扇控制 积分 PID 加前馈,点火变化时重置
    行车风扇范围 30–100%
    驻车风扇范围 0–30%
    温度告警 风扇目标强制 100%
    温度滤波 5 秒一阶滤波
    温度源 驻车用内存/CPU/GPU 最大值;行车再加入 PMIC

    电源估算与关机条件

    普通关机不是单条件触发,而是「前置状态 + 多个触发之一」的组合;ForcePowerDown 能绕过普通条件,但仍受末尾约束。

    条件 阈值 / 机制
    普通关机前置 驻车、连接车辆、未禁用关机、驻车超过 300 秒
    触发之一 驻车超过 30 小时
    触发之二 低通电压低于 11.8 V
    触发之三 估计容量耗尽
    点火充电率 按固定 45 W 估计
    ForcePowerDown 绕过上述普通条件,但仍受末尾的 started_seen / 最短开机时间约束

    hardwared 只写 DoShutdown;真正调用关机的是 manager.main()。

    缺测的零值风险与插件契约

    两个具体风险值得单独记住:hw_state_thread() 消费端无采样时间戳,旧网络/USB 状态可能继续出现在 deviceState;ThermalZone.read() 把缺失温区当 0。这类慢速状态之所以能继续沿用旧值,正是因为消费端没有采样时刻可供判断新鲜度;新平台应给每条状态附上采样时间与失效哨兵。这些是移植设计风险,需要在目标平台上显式处理,而不是沿用零值默认。

    因此新平台要显式声明能力与缺测状态:标定 sysfs 温区名、单位、采样频率、失效哨兵;定义电压测点、功率方向与关机电气动作;点火、温度、关机和风扇属于可信任设备服务,需要时效性、故障降级和唯一发布者。硬件插件不得自行宣布 deviceState.started 或绕开独立 panda 安全门。

    外置计算模块与触摸记录

    同一条 hardwared 线程里还挂着外置计算模块与触摸记录两条支线。Chestnut.update() 在驻车时限次刷固件,并对外置 GPU 的丢电、链路、过热、模型失败分别生成不同告警;chestnut_state_thread() 读取供电与 PCIe 状态,并按有效位合并 GPU 状态。也就是说「外置模块可用」本身也是一组需要区分失效类型的判定位,而不是一个布尔值。

    触摸记录则只属诊断路径:touch_thread() 仅在 comma 硬件启动,按事件帧分组发布到原始 touch 主题,UI 交互另由 raylib 采集,两者不应混为一谈。此外,hardware_thread() 里的 ign_edge 是「已启动状态」与 onroad 条件的持续差异,不一定是单次点火边沿:启动条件受阻而 onroad 条件仍真时,它会持续绕过约 2 Hz 的周期门,device_booted 一旦为真也会在本进程锁存。

    常见问题

    温度读不到会怎样?

    ThermalZone.read() 在温度文件不存在时返回 0,这不是显式失效标志。缺测不能被静默当作 0 °C,新硬件平台必须明确失效处理。

    普通关机需要满足哪些条件?

    前置是驻车、连接车辆、未禁用关机且驻车超过 300 秒;再由超过 30 小时、低通电压低于 11.8 V、或估计容量耗尽三者之一触发。

    驻车容量是车载 BMS 实测电量吗?

    不是。它是估算值:无点火时按平台报告的设备功率积分,有点火时按固定 45 W 充电率估算,并非测得的真实电量。

    过热会立即退出行车吗?

    不一定。热状态迟滞允许「过热但未严重」的在途状态暂时保持;「禁止进入行车」与「立即退出行车」是两条不同的判定。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/HARDWARE_STATE_DEEP_DIVE.md(硬件状态、供电与热管理,含源码行号索引)
    • 项目 02 —— openpilot-research/probes/test_power_monitoring_contract.py(电源估算与关机条件的离线探针)
    • 项目 02 —— openpilot-research/probes/test_fan_controller_contract.py(风扇 PID 上下限与积分的离线探针)

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

    更新记录

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

  • 1.52 米偏置与 3 m/s 门:雷达轨迹与视觉前车的融合边界

    结论:radard 把硬件雷达轨迹与视觉前车融合成两个前车,但它的匹配是「先选最高分、再验合理性门」:一旦最高分目标被距离或速度门拒绝,不会改试次高分目标,即使后者能通过。它依赖三个固定几何/速度常量——距离减 1.52 m 的相机-雷达偏置、距离差门 max(视觉距离×0.25, 5 m)、速度门「差 <10 m/s 或雷达前车绝对速度 >3 m/s」。自制安装位姿或坐标符号不同时,不能只改 CAN 解码而不重验这些常量。以下基于源码快照 c8fb906,未做回放或台架测试;这些阈值是该提交的实现,不等于传感器硬件规格。

    消息链与调度:20 Hz 与可能的缓存复用

    card 在雷达接口返回数据时发布 radarTracks;若雷达错误结构有任一真值,顶层 valid=False,但错误与点仍在消息内。radard.main() 等待 CarParams,以 modelV2 为 SubMaster 轮询主题,读取 radarTracks/carState 后调用 RadarD.update() 与 publish()。这里有个细节:循环没有再用 sm.updated['modelV2'] 包住更新,因此轮询超时也可能沿用缓存数据再发 radarState,不能把每条输出都当作一帧新视觉预测。

    组件与关键函数

    组件/函数 实现功能 边界
    KalmanParams 根据固定表插值 KF1D 增益,建立速度/加速度模型 构造要求雷达步长严格在 (0.01,0.2) s;实际传入 DT_MDL,不是逐帧读取雷达硬件时间差
    Track.update() 保存相对距离/横向位置/相对速度,并更新前车绝对速度及加速度滤波 新 track 第一帧不做 kf.update();track ID 消失即删除对象,重现会重置滤波历史
    Track.potential_low_speed_lead() 判定低速近距离同车道候选 要求 vEgo<4 m/s、|yRel|<1 m、0.75<dRel<25 m;不依赖视觉概率
    match_vision_to_track() 对每条雷达轨迹算纵向/横向/速度拉普拉斯评分并取最大者 选中后还查距离与速度合理性;最高分也可能被拒绝
    get_lead() 优先匹配雷达;高视觉概率但无匹配时回退视觉;可再被低速雷达目标覆盖 leadOne 启用低速覆盖,leadTwo 不启用;视觉入门为滤波概率 >0.5
    RadarD.update() 对齐车速、维护轨迹 ID、平滑视觉概率、组装两个前车和错误 即使输入 sm.all_checks() 不通过,仍执行轨迹/前车组包

    几何、速度与来源选择

    RadarD.__init__() 用 CP.radarDelay 构造自车速度历史队列;RadarD.update() 仅在 carState 接收帧变化时推进队列,以历史首元素加雷达相对速度得到前车速度。原类方法探针确认:0.1 s 延迟配置下队列在启动时先含 0,未收到新 carState 帧即使当前对象的 vEgo 改变也不推进;第三个不同接收帧才取到此前的非零车速。这是按接收帧计数的延迟,不是按每个雷达点的硬件时间戳做精确插值。radarTracks.points 按 trackId 转字典,本轮未出现的 ID 被删;若上游对同一物体频繁换 ID,速度/加速度估计会反复从初值开始。

    match_vision_to_track() 把视觉距离减固定 RADAR_TO_CAMERA=1.52 m,横向位置取负号,速度比较 vRel+vEgo 与视觉预测 lead.v[0]。评分尺度使用模型给的 xStd/yStd/vStd,但最终还要求距离差小于 max(视觉距离×0.25, 5 m),速度差 <10 m/s 或雷达前车绝对速度 >3 m/s。探针确认:速度差即使很大,只要雷达前车绝对速度严格大于 3 m/s,速度合理性门仍放行;且它先选最高分再验门,最高分目标被拒时不会改试次高分目标。

    常量/门 数值 作用
    RADAR_TO_CAMERA 1.52 m 视觉距离减去的固定相机-雷达偏置
    距离合理性门 max(视觉距离×0.25, 5 m) 限制视觉与雷达的距离不一致幅度
    速度合理性门 差 <10 m/s 或绝对速度 >3 m/s 后者会大幅放行不匹配的速度差
    低速覆盖门槛 vEgo<4 m/s、|yRel|<1 m、0.75<dRel<25 m 仅 leadOne 启用,等距不覆盖

    有效性、故障与下游

    RadarD.update() 的顶层有效位取 sm.all_checks(),包含 modelV2、carState、radarTracks 的存活/频率/消息有效检查。ready 只取 sm.seen['modelV2'],不是「当前模型仍新鲜有效」的证明;radarErrors 直接来自上游。探针确认:输入检查失败时仍能组装 leadOne.present=True,而 publish() 将顶层 valid=False 随前车和错误一起交给发布器——硬件错误不应靠 present 掩盖。

    模型概率采用上升立即、下降经一阶滤波的非对称策略:下降按 dt=0.05 s、rc=0.2 s 逐次衰减,探针从 0.8 突降到 0 时观察到 0.64→0.512→0.4096,因此低于原始视觉阈值后的两轮仍可保留视觉前车。若 leadsV3 少于两条,原函数会跳过两个前车字段的赋值。下游 selfdrived 在允许纵向控制的车型上,把 canError、临时不可用与其它雷达错误分别映射为事件;LongitudinalPlanner.update() 把 radarState 交给 MPC,计划输出的 hasLead 使用首个前车的 present。雷达数据最终仍要过计划、使能状态机、车辆控制限幅与独立执行网关——radard 不直接发 CAN。

    插件化时应保留什么

    雷达输入插件应先只读地影子发布候选轨迹,保留唯一且不复用的 ID、硬件时间戳、雷达延迟、车辆坐标与安装偏置、单位及四种错误位。权威 radarTracks 与 radarState 各只能有一个生产者;输入边界须拒绝或隔离 NaN/Inf,缺席、无效或过期轨迹须明确失效,不得用最近一次 present=True 无限延续。ID 连续性会影响滤波历史,候选排序与合理性门的组合也会影响回退,因此设备跟踪策略与前车选择语义都属于插件契约。应按路段回放、误报/漏报故障注入与目标车型 SIL/HIL 顺序验证,再考虑上车。

    常见问题

    视觉前车和雷达前车匹配不上时会怎样?

    get_lead() 优先匹配硬件雷达;高视觉概率但无匹配时回退视觉前车,标 radar=False、radarTrackId=-1,之后还可能被低速雷达目标覆盖。但仅 leadOne 启用低速覆盖,leadTwo 不启用。

    为什么最高分匹配目标被拒绝后,不会换下一个?

    因为函数先对每条轨迹评分并取最大者,再验证距离与速度合理性门;一旦最高分目标被拒,实现不会改试次高分目标,即使它能通过门。这是该提交的确定性行为。

    radarState 的顶层 valid 和前车 present 是一回事吗?

    不是。valid 取输入检查的聚合结果,present 与 radarErrors 各自独立。探针确认输入检查失败时仍能组装 leadOne.present=True,由发布器带着 valid=False 一起发出。

    低速下第一个前车一定会被雷达覆盖吗?

    不一定。低速候选要求 vEgo<4 m/s、|yRel|<1 m、0.75<dRel<25 m。首个前车即使 ready=False 也可被选中,但已有前车时只允许更近的低速点覆盖,等距不覆盖,此时 modelProb 会回到默认 0。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/RADARD_LEAD_FUSION_DEEP_DIVE.md(雷达轨迹与视觉前车融合,含源码行号索引)
    • 项目 02 —— openpilot-research/probes/test_radar_lead_selection_contract.py(坐标/速度匹配与回退的隔离探针)
    • 项目 02 —— openpilot-research/PLUGIN_REFACTOR.md(源文档指向的插件化蓝图)

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

    更新记录

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

  • 两款车载相机传感器:三帧延迟与 g-1..g+1 的曝光闭环

    结论:原生相机链同时描述了 OS04C10 与 OX03C10 两套传感器,每路先探测 OS04C10、失败再回退 OX03C10。曝光闭环按 20 Hz、固定 dt=0.05 平滑目标灰度,检索范围只有当前模拟增益索引的 g-1..g+1,并且用三帧前的 EV 做反馈——所以状态消息里的曝光字段与随后提交的下一组寄存器值,不能不加帧延迟地视为同一时刻的物理测量。

    两套传感器描述:不只是「读曝光的接口」

    SensorInfo 同时承载探测地址、启动/I2C 表、RAW 格式、曝光范围/增益表、读出时间以及 ISP LUT;它不是仅有 getExposureRegisters() 的驱动接口。openSensor() 在每一路先尝试 OS04C10,再尝试 OX03C10,两个 sensors_init() 都失败才禁用该路。两种实现的 getSlaveAddress() 都把端口 0/1/2 映射为 0x6c/0x20/0x6c——新板的 I2C mux/线序不能只靠改传感器 ID 解决。

    契约 OS04C10 OX03C10
    分辨率/格式 2688×1520,RAW12,BGBG Bayer,out_scale=2 1928×1208,RAW12,GRGR Bayer,stride 另加 4 字节,顶部/底部额外行及 frame_offset=2
    MCLK / 探测 ID 24 MHz,ID 0x5304 24 MHz,ID 0x5803
    曝光范围 / EOF 偏移 2..2352 行,11 ms 2..2016 行,14.697 ms
    模拟增益索引 0..40 0..54
    ISP 黑电平、色彩矩阵、gamma、线性化、暗角 对应 OX 配置

    探测都读寄存器 0x300a。两个必须注意的点:EOF 是 SOF + readout_time_ns 的推算值,不是独立硬件捕获;OS04C10 的 11 ms 在源码中注明来自日志估计。模拟增益方面,两者的物理增益表虽相同,寄存器编码不同,索引必须受各自 analog_gain_max_idx 限制。ISP 侧,prepare_gamma_lut() 将 65 个采样点打包为 64 项;这些校正表与具体镜头/传感器组合绑定。

    探测供电、CSI 与 ISP 的真实耦合

    sensors_init() 组装一次 Qualcomm CAM_SENSOR_PROBE_CMD:I2C 采用 fast mode(源码注释为 400 kHz),16 位寄存器地址和 16 位 ID 数据。探测包内嵌固定上电/下电序列:先设置电源/复位项,等待 1 ms;给 MCLK 写入 24 MHz,再等待 1 ms;释放复位后等待 34 ms,才执行探测。源文档提醒,源码对某些 power_seq_type 仍保留「clock??」等不确定注释,不能把整数直接当作可投板的稳压器拓扑,必须用目标内核驱动定义、原理图和示波器确认引脚极性与上电斜率。

    ISP 输入口固定 D-PHY、4 条数据 lane、映射 0x3210、VC0;路面相机经 IFE 输出 NV12,座舱相机走 RAW RDI 再交 BPS。configCSIPHY() 同样固定 lane_mask=0x1f、4 lane、lane_assign=0x3210、MIPI_SETTLE_CNT=33 和 data_rate=48000000——这些值没有来自 SensorInfo 的逐传感器能力协商,更换 lane 数、走线、时钟或 PHY 时,只增加一个传感器类是不够的。

    座舱 BPS 初始化直接以 sensor->num() 索引 bps_cfg、bps_settings 和 bps_striping_output 三组固定二进制表。表声明仅有 4 个槽,前两个为占位,实际表与 ImageSensor 枚举值 2/3 绑定。新增枚举或热插拔传感器若不同步提供 BPS 表及索引验证,会在座舱路径出现无效配置或越界风险。

    自动曝光的实际计算

    CameraState::init() 从板级焦距、像素尺寸与输出缩放计算像素焦距,设置各路固定参考测光矩形,并初始化最近三帧的 EV。calculate_exposure_value() 在 NV12 Y 平面按 x=2, y=2(路面)或 y=4(座舱)抽样;从 255 向下累计直方图,取让高亮端累计至少达到样本数一半的亮度,再除以 256。它是抽样亮度的中位数近似,不是平均照度,也不是传感器原生统计直方图。

    set_camera_exposure() 按 20 Hz 固定 dt=0.05 平滑目标灰度;根据三帧前的 EV 与当前测得灰度计算 desired_ev,约束到 min_ev..max_ev。随后只搜索当前模拟增益索引的 g-1..g+1,为每个候选求受限积分时间,并调用传感器的 getExposureScore() 评估 EV 误差、推荐增益代价和增益切换代价——这不是全增益表的全局最优搜索。转换增益在目标灰度跨越阈值时切换,并通过 dc_gain_weight 平滑增益因子。

    曝光寄存器编码差异

    两个传感器的寄存器写入方式不同,且有一处共同的静态边界:

    实现 曝光寄存器编码 静态边界
    OS04C10 积分时间写 0x3501/02,同一模拟增益分别写 0x3508/09 与 0x350c/0d dc_gain_enabled 参数未参与此函数的寄存器生成
    OX03C10 HCG/LCG、SPD 与 VS 时间分别写入;SPD 有下限/上限,VS 为积分时间约 1/40 并限定 1..34 行,模拟增益写 0x3508/09 dc_gain_enabled 同样未参与此函数的寄存器生成

    两行的「静态边界」含义一致:软件的转换增益模型不能被理解为这里每帧必有对应寄存器切换;多曝光段的图像效果仍取决于初始化表和 ISP 配置。

    输入风险与插件重构

    源文档列出三类输入风险。第一,调试覆盖开关 CTRL_EXP_FROM_PARAMS 允许从 CameraDebugExpGain / CameraDebugExpTime 读取字符串并调用 std::stoi;在覆盖分支中未见范围检查,随后 sensor_analog_gains[new_exp_g] 与寄存器表直接按索引访问,负数、过大值和非数字分别可能造成越界或解析异常。第二,desired_ev 计算中直接除以测得 grey_frac,而亮度函数可返回零,标准浮点环境下暗帧会产生非有限中间值——该路径的最终值及 std::clamp 行为需测试确认。第三,测光 ROI 与 NV12 stride 的寻址差异:缓冲 Y 行步长是 128 字节对齐值,测光代码却按可见宽度换行,而固定的两种模组宽度都未对齐——后续算法描述是代码意图,不能据此认为它采样了光学 ROI 中对应的每个像素。

    因此新「相机插件」至少要声明:探测/供电、I2C 与 MCLK、CSI 格式/步长、Bayer/ISP LUT、曝光实际生效延迟、SOF/EOF 时间域、镜头内外参和失效状态。源文档的定位很明确:插件可以提供传感器参数与采集能力,但时间戳真实性、帧有效性、模型输入契约和车辆执行许可应由受控平台层校验。对自制板,调试接口应与行车模式隔离并做显式限幅。

    常见问题

    为什么说曝光状态和寄存器值不是同一时刻的?

    因为 set_camera_exposure() 用三帧前的 EV 计算 desired_ev,并按 20 Hz 固定 dt=0.05 平滑;最终参数通过 sensors_i2c() 排队写入下一帧配置。状态消息的曝光字段与随后提交的寄存器值之间存在帧延迟,不能不加延迟地视为同一时刻的物理测量。

    传感器探测为什么不能只改 I2C 地址?

    因为两种实现的 getSlaveAddress() 都把端口 0/1/2 映射为 0x6c/0x20/0x6c,而且探测还依赖固定上电/复位序列(1 ms、1 ms、34 ms)和 MCLK 24 MHz。新板的 I2C mux 与线序不能只靠改传感器 ID 解决,需用内核驱动定义、原理图和示波器核对。

    为什么换一种 lane 数或走线不能只加一个传感器类?

    因为 ISP 输入口与 configCSIPHY() 的参数是固定值(4 lane、lane_assign=0x3210、MIPI_SETTLE_CNT=33、data_rate=48000000),没有来自 SensorInfo 的逐传感器能力协商。更换 lane 数、时钟或 PHY 时,需要同步修改这些固定配置。

    自动化曝光是「全增益表最优搜索」吗?

    不是。它只搜索当前模拟增益索引的 g-1..g+1,为每个候选求受限积分时间,并由 getExposureScore() 评估 EV 误差、推荐增益代价和增益切换代价。因此它接近局部搜索,而非全局最优。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/CAMERA_SENSOR_EXPOSURE_ISP_DEEP_DIVE.md(取用了两套传感器契约、探测供电与 CSI/ISP 固定配置、曝光闭环与寄存器编码差异)。
    • 项目 02 —— openpilot-research/CAR_PROTOCOL_IMPLEMENTATIONS.md(仅用于确认车型配置与相机索引在整体链路中的位置)。

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

    更新记录

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

  • pandad 的 100 Hz 主循环:connected 不等于链路健康

    结论:原生 pandad 用 100 Hz 主节拍派发约 20/10/2 Hz 的低频任务,但它暴露的「存活证据」必须分层看:进程存活、句柄 connected、CAN 更新、控制读成功、MCU 安全模式读回是五个不同层级。PandaSpiHandle::connected 在构造时为真,而在此 SPI 实现中未见出错后置假,因此 check_connected() 不是可靠的链路探针;comms_healthy 则在 bulk 负返回时锁存为假,即使随后成功也不自动恢复。本文为对本地 source/ 快照 c8fb906 的静态追踪,未启动进程、未接入实板、未发送车辆控制帧。

    构建、进程所有权与运行开关

    SConscript 在非 Darwin 平台构建原生程序;但 SCons 构建出二进制不等于 manager 直接管理它——进程表把原生 _pandad 设为 enabled=False,常驻的是 Python pandad 包装器。包装器通过签名门后 Popen(["./pandad"]),等待其退出再恢复,原生进程的退出码不作为包装器的成功判据。macOS 不构建此目录的原生程序,但进程表仍定义 Python 包装器,两者是独立的构建/运行条件。

    开关 源码入口 对运行态的影响
    BOARDD_LOOPBACK connect() 只要变量存在就请求 panda CAN 回环,不是只读诊断
    BOARDD_SKIP_FW_CHECK connect() 跳过原生 up_to_date() 签名门;不跳过外层 Python 包装器的刷写/最终签名检查
    FAKESEND pandad_run() 阻止发送线程调 Panda::can_send(),但主循环仍可下发安全模式、心跳、风扇与 IR 命令
    STARTED send_panda_states() 在本地 health 副本中强制点火线位,影响点火/省电/NO_OUTPUT 分支与发布值,不改变真实引脚
    NO_FAN_CONTROL process_peripheral_state() 跳过风扇目标下发,仍处理 IR 与其他设备命令
    PANDAD_MAXOUT can_receive() 收 CAN 后追加一次向 0xab 端点的丢弃缓冲读取,返回值不单独作为健康判据
    SPI_ERR_PROB lltransfer() 正值时随机改 SPI 长度、TX 或 RX 数据,属实传输注错开关,不可在行车部署

    这些开关不是可分配给普通插件的能力:FAKESEND 只约束候选 CAN 发送,STARTED 与回环/注错会改变网关控制流或真实总线行为。设备配置应由受信任启动器使用固定清单生成,并在上车前核对实际进程环境。

    调度和观测语义

    主循环先收 CAN,然后每 5 帧处理风扇/IR,每 10 帧更新 selfdriveState、deviceState 并处理 panda 健康/心跳/安全模式,每 50 帧发布外设状态;每轮还持续消费 MCU 串口日志。RateKeeper 名义基频是 100 Hz,首次 frame=0 即运行低频分支。

    节拍 帧模数 任务
    100 Hz 每帧 先接收 CAN 并发布,逐轮读取串口日志;RateKeeper 调整帧计数与睡眠
    约 20 Hz 每 5 帧 处理风扇目标与 cabinCameraState IR 目标
    约 10 Hz 每 10 帧 更新 selfdriveState、deviceState,处理 MCU/CAN 健康、心跳与 PandaSafety 配置
    约 2 Hz 每 50 帧 发布 peripheralState(电压、电流、风扇)

    若 SPI 交易、串口读取或消息处理超期,实际频率由运行时决定,RateKeeper 不能保证硬实时或补发错过的状态帧。engaged 每个 10 Hz 周期由 selfdriveState 的 alive/valid 与 enabled 联合计算;is_onroad 仅在 deviceState 本次 updated 时更新,否则沿用上次布尔值。因此「设备消息未更新」与「确认仍在行车」不能等同。

    send_panda_states() 对 health 或任一路 CAN health 返回空值时直接跳过发布,process_panda_state() 随之返回,本轮不发心跳,也不执行它的通信故障退出门;主循环随后仍调用 configureSafetyMode(is_onroad)。若读到了结构体但 comms_healthy=false,事件标无效;只在 ignition_local=false 时设置 do_exit,点火真时继续运行并依赖其他安全层。

    失联、退出与故障路径

    PandaSpiHandle::connected 构造时为真,在此 SPI 实现中未见出错后置假,因此 check_connected() 不是可靠的链路探针——进程存活、句柄 connected、CAN 更新、控制读成功、MCU 安全模式读回是不同层级的证据。默认 SPI 交易重试还可能没有总时限。退出主循环后,只有 is_onroad && !engaged && connected 才尝试发 NO_OUTPUT,且控制写结果没有回执;接着 join() 发送线程——若线程正阻塞于无总截止期的 SPI 交易,退出也可能被推迟。

    Python 包装器会在原生进程退出后进入自己的恢复循环,但不能代替 MCU 固件的独立心跳/安全钩子。固件过期异常在 connect() 的构造 try 之外抛出,原生内部 100 ms 重试只覆盖构造失败,外层包装器才处理进程退场。

    自制硬件和插件验收

    应把网关状态拆成 process_alive、transport_available、can_rx_fresh、health_snapshot_complete、mcu_safety_mode_confirmed 和 heartbeat_age,每项带设备 UID、采样时间、错误原因与独立阈值。插件只能订阅这些观测或提交受约束候选帧,不能自行改变心跳/安全模式;退出和断电路径应由 MCU 侧默认安全态兜底,而非依赖主机退出时的一次控制写。

    相邻的 test_pandad_spi.py 在实板注入小概率 SPI 错误,检查 CAN、pandaStates、peripheralState 的数据与平均/最大间隔;它没有覆盖永久 SPI 失败、health 只有短读、点火真假两种退出分支或 join() 被无限交易阻塞。本轮未运行该硬件测试,新平台应先在无车台架做这些故障注入。

    常见问题

    check_connected() 能证明 SPI 链路正常吗?

    不能。PandaSpiHandle::connected 在构造时为真,而在此 SPI 实现中未见出错后置假,所以 check_connected() 不是可靠的链路探针;进程存活、句柄状态、CAN 更新、控制读成功与 MCU 安全模式读回是不同层级的证据。

    comms_healthy 变假后会自动恢复吗?

    不会。comms_healthy 只在 bulk 返回负值时被置假,本句柄内未见恢复为真,即使随后调用成功也不自动恢复;控制读写本身也不更新该位。

    health 读失败时会发心跳吗?

    不会。send_panda_states() 对 health 或任一路 CAN health 返回空值时直接跳过发布,process_panda_state() 随之返回,本轮不发心跳,也不执行它的通信故障退出门。

    pandad 退出时一定会发 NO_OUTPUT 吗?

    不一定。退出主循环后只有 is_onroad && !engaged && connected 同时成立才尝试发 NO_OUTPUT,且控制写结果没有回执;join() 若阻塞在无总截止期的 SPI 交易,退出也可能被推迟。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/PANDAD_NATIVE_MAIN_LOOP_LIVENESS_TELEMETRY_DEEP_DIVE.md(原生 pandad 入口、主/发送线程、调度分频与失联降级)
    • 项目 02 —— openpilot-research/PANDAD_NATIVE_SPI_RESPONSE_BOUNDS_RETRY_CONTRACT_DEEP_DIVE.md(源文档指向的原生 SPI 响应边界与重试专题)
    • 项目 02 —— openpilot-research/PROCESS_SUPERVISION_DEEP_DIVE.md(源文档指向的进程监督与设备恢复包装器专题)

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

    更新记录

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

  • 横向目标曲率有三道限幅:jerk 5、加速度 3、曲率 0.2 各拦什么

    结论:从目标曲率到车轮,controlsd 要经过「来源仲裁 → 变化率/加速度/绝对曲率三道限幅 → 选定的横向控制器 → 车型编码 → panda 固件」这几层。两个容易被误读的边界是:来源选择只看 lateralManeuverPlan 的消息 valid 位,不看 updated、alive 或消息年龄,因此发布者停发而进程不重启时,最后一条有效计划可能被持续选为目标;另外变化率限幅本身不会置 curvature_limited 标志,不能用它判断「是否发生过裁剪」。以下为固定源码快照 c8fb906 的静态追踪。

    两条目标来源与 valid / alive 的差别

    正常目标来自 modelV2.action.desiredCurvature;若 controlsd 看到 lateralManeuverPlan 的消息 valid 位为真,则优先采用该计划;横向未激活时两条来源都改用实测曲率,以降低再使能时的阶跃。模型侧 get_action_from_model() 有两条版本兼容路径:没有 action 输出时从预测轨迹的 yaw/yaw rate 估算曲率,有 action 时把输出横向量除以 max(1,vEgo)^2;纵向加速度作 0.3 s 平滑,横向平滑常量为 0,但 vEgo<=0.3 m/s 时保持上一帧曲率。

    实验来源 lateral_maneuversd 只有机动 active 且未完成才把计划标为有效,曲率以基线曲率加「目标横向加速度 / max(vEgo,1)^2」形成;进程表只在横向机动条件下管理它,生产运行不应把它当作常驻规划器。

    失效语义的核心在消息健康位:SubMaster.update_msgs() 只在收到新消息时更新 valid;alive 按收包时间重新计算;all_checks() 才同时检查存活、频率与有效位。而 controlsd 的来源选择只检查 sm.valid['lateralManeuverPlan'],未检查该主题的 updated、alive 或消息年龄。因此曾收到有效实验计划后,若发布者停发而进程不重启,最后一条计划仍可能被持续选为目标——其后仍经过限幅、latActive、车型与 panda 门,不等于可以任意发送转向。

    controlsd 的逐层处理

    Controls.__init__() 读取 CarParams,按转向接口类型优先选 angle/curvature,否则按横向调参选 PID/torque;update() 以 selfdriveState 为轮询主题更新异步输入与姿态数据。state_control() 更新车辆模型、活动门、目标曲率与执行器,并对数值字段做有限性检查。VehicleModel.calc_curvature() 把扣除在线角偏置的方向盘角换成实测曲率,考虑转向比、速度与路面横滚;controlsd 在调用前取负号,方向约定需用实车校验。

    函数/组件 源码作用 契约或边界
    Controls.__init__() 按转向接口类型优先选 angle/curvature,否则按横向调参选 PID/torque。 不同输出单位不能在插件间直接互换。
    state_control() 更新车辆模型、活动门、目标曲率与执行器,做有限性检查。 生成的是 CarControl,不是 CAN 或执行许可。
    VehicleModel.calc_curvature() 扣除在线角偏置的转角换算实测曲率,考虑转向比/速度/横滚。 调用前取负号,方向约定需实车校验。
    clip_curvature() 顺序限制变化率、考虑 roll 的横向加速度范围、绝对曲率。 curvature_limited 只由后两道限幅标记,变化率限幅不置位。
    Controls.publish() 发布 controlsState 与 carControl,比较请求与 carOutput。 顶层 valid=CS.canValid,不是 sm.all_checks()。
    Car.controls_update() carControl 存活且非被动/已初始化时调用车型 apply() 并发布 sendcan。 仍须通过车型编码与 panda 固件安全约束。

    三道限幅与标志位陷阱

    clip_curvature() 按顺序做三件事:用至少 1 m/s 的速度计算 5 m/s^3 的近似横向 jerk 限幅;再按 ±3 m/s^2 加 roll 补偿限加速度;最后按 ±0.2 1/m 限绝对曲率。源码明确标注 jerk 公式为近似,这些常量不是可以脱离车型和硬件直接复用的认证约束。

    隔离探针给出一个典型陷阱:当变化率单独生效时,输出已从 0.1 改成 0.0005,但 curvature_limited=False。也就是说这个标志不能作为「无裁剪」的诊断依据。探针只验证纯数值函数,不代表 controlsd 消息时序、车型控制器或板端限幅已通过。

    限幅 阈值 是否置 curvature_limited
    变化率(近似横向 jerk) 5 m/s^3,速度下限 1 m/s 否
    横向加速度(含 roll 补偿) ±3 m/s^2 是
    绝对曲率 ±0.2 1/m 是

    输出单位与有限性清理

    控制器输出按 steerControlType 分流:通用第一返回值写 actuators.torque,angle/普通 PID 的第二返回值写 steeringAngleDeg,curvature 型写 actuators.curvature,扭矩控制的第二返回值为零。LatControlAngle 与 LatControlCurvature 都返回 steer=0,有效输出分别是角度与曲率字段;LatControlPID 在施力/低速/安全限幅时冻结积分;LatControlTorque 把目标曲率换横向加速度后返回时会翻转符号,不能把 pid_log.output 与 actuators.torque 直接同号比较。

    state_control 对 Actuators 的数值字段逐个检查非有限值并清零,但这只是末端值清理,不是上游模型输入、车辆参数或中间 PID 状态的完整非有限传播防护。publish() 在 selfdriveState.active 时比较本次请求与已收到的 carOutput,把差异留给后续循环的控制器饱和判断,这不是同步的本帧硬件确认。

    控制器 有效输出字段 边界
    LatControlAngle steeringAngleDeg(steer=0) 曲率反算目标方向盘角并加角偏置。
    LatControlCurvature actuators.curvature(steer=0) 曲率误差 PID 或前馈,驾驶员施力时用前馈。
    LatControlPID steeringAngleDeg 或扭矩 施力/低速/安全限幅时冻结积分。
    LatControlTorque actuators.torque 返回时翻转符号;不能把 pid_log.output 与扭矩直接同号比较。

    自制硬件与插件契约

    源文档给出的约束是:候选规划插件应输出带来源、时间戳、年龄上限、曲率单位 1/m、车辆坐标/正方向、适用车速范围与显式失效状态的目标;仲裁层独占来源选择与 TTL,失效时回退模型或安全降级。车辆学习参数、转角编码与 carOutput 的实际执行反馈也应按车型/硬件版本记录。

    不能让普通插件修改 latActive、车型极限或 panda 固件;CarControl 及其 valid 位也不能替代板级安全门。现有横向饱和测试覆盖部分车型与控制器,扭矩缓冲测试覆盖停用后缓冲清零,但未证明四类控制器全部车型、计划抢占超时、模型丢帧、车况异步或实车符号正确,仍需补齐来源有效→停发/无效切换的回放测试与 NaN/Inf 注入。

    常见问题

    实验计划停了不发,为什么车还在用它?

    因为 controlsd 的来源选择只检查 sm.valid['lateralManeuverPlan'],未检查 updated、alive 或消息年龄。曾收到有效计划后,若发布者停发而进程不重启,最后一条计划仍可能被持续选为目标;但它仍经过限幅、latActive、车型与 panda 门。

    curvature_limited=False 就代表没有裁剪吗?

    不代表。这个标志只由横向加速度与绝对曲率两道限幅设置;变化率限幅本身不置位。探针显示变化率单独生效时输出已从 0.1 改成 0.0005,但标志仍是 False。

    三道限幅的阈值分别是多少?

    按顺序是:用至少 1 m/s 计算 5 m/s^3 的近似横向 jerk、±3 m/s^2 的横向加速度(含 roll 补偿)、±0.2 1/m 的绝对曲率。源码标注 jerk 公式为近似,不能直接当认证约束复用。

    扭矩控制器的输出符号可以直接对比吗?

    不能。LatControlTorque 返回时会翻转符号,不能把 pid_log.output 与 actuators.torque 直接同号比较;angle 与 curvature 两类控制器则返回 steer=0,有效输出在角度或曲率字段。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/LATERAL_TARGET_ACTUATION_DEEP_DIVE.md(两条来源仲裁、controlsd 逐层处理、三道限幅与四类控制器)。
    • 项目 02 —— openpilot-research/ 同目录探针记录:五项 clip_curvature() NumPy 数值契约隔离探针结论。

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

    更新记录

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

  • 音频告警与录音: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

  • 路段下载缓存: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