标签: 输入契约

  • joystick 输入生产者:100 Hz 发布与 0.2 秒失联门的缺口

    结论:joystick_control 是一个输入生产者:它要么读终端键盘、要么读游戏手柄,然后由一个独立的守护线程以 100 Hz 目标节拍发布 testJoystick。三个事实决定它的边界:轴值是运行期估计(极值动态扩展,未标定);发布线程不检查输入年龄与 cancel,采集阻塞时旧轴值仍能被封装成新的 valid=True 消息;下游 joystickd 的约 0.2 秒帧差失联门只感知消息停发,识别不了「重复旧输入的新消息」。要注意这描述的是软件路径,不是实车会保持执行器动作的结论——selfdriveState、card 和 panda 安全策略仍独立约束输出。

    两个实现:终端键盘与游戏手柄

    入口带一个容易被误读的开关:直接执行脚本时会检查 IsOffroad,但设置 ZMQ 环境变量会跳过该入口检查,而 main() 本身直接构造游戏手柄。这是开发工具的启动约束,不是强制的进程级安全策略,ZMQ 的存在也不能视为网络身份认证。

    组件 / 函数 具体行为 边界与含义
    Keyboard.update() 阻塞读取一个字符;W/S 与 A/D 分别按 0.05 累积,R 归零,C 将本地 cancel 置真,下一次输入又先置假。 轴值不自动归中;输入停止不触发归零,长期保持的值由发布线程继续发送。
    KBHit.__init__()、set_kbhit_terminal()、set_normal_term() 从 sys.stdin 取 fd,保存 termios,清除 ICANON/ECHO 并以 TCSAFLUSH 应用;注册进程正常退出时的恢复回调。 非 TTY 的 tcgetattr() 会失败;异常/信号强杀不保证 atexit 执行,终端状态不是按对象生命周期恢复。
    KBHit.getch()、getarrow()、kbhit() 单字符阻塞读、三字符方向键读取、select(..., 0) 非阻塞就绪查询分别作为静态方法暴露。 Keyboard.update() 只调用 getch(),未先查询 kbhit();getarrow() 假设读满三字符,畸形序列可抛异常。
    Joystick.update() get_gamepad()[0] 处理首个事件,按 PC/车载不同映射转换纵横轴;采集到的极值动态扩展区间,再插值、死区与三次曲线。 极值是运行期估计,不是经标定的设备物理量;只按首个事件更新。已处理的拔出异常会归零,其他异常或采集线程停止不进入该分支。
    send_thread() 独立守护线程以 100 Hz 目标节拍发布 testJoystick,把外层 valid 置真,只复制 axes_order 对应的两轴值。 不检查采集线程是否阻塞、上次真实输入时间或 cancel。
    Joystick schema 结构有 axes 和 buttons;服务表频率为 0。 当前生产者只填 axes;cancel 不是该生产者消息的一部分,下游也只取轴值。

    发布线程不看输入:100 Hz 与约 0.2 秒失联门

    send_thread() 周期性复制 axes_values。采集线程没有为每次输入维护时戳,发布线程也不检查 joystick.cancel。因此在主采集循环阻塞、设备静默但不抛已处理异常时,生产端仍会产生新的 valid=True 两轴消息。joystick_control_thread() 先同步写入 JoystickDebugMode=True,启动发布线程,主线程无限调用 update(),没有显式 stop/join/模式撤销协议。

    下游 joystickd 的约 0.2 秒帧差失联门只感知消息停发,不会识别这类「重复旧输入的新消息」。探针也印证了这一点:send_thread() 在轴值不变且 cancel=True 时仍连续发送相同的两轴,每条 valid=True 且不填 buttons。这里要重复一次边界——这描述的是软件路径,selfdriveState、card 和 panda 安全策略仍独立约束输出,不能据此断言实车会保持执行器动作。

    生命周期缺口 源码事实 后果
    输入年龄门 采集线程无每次输入时戳,发布线程不查上次输入时间 采集阻塞仍发布旧轴为「新消息」
    取消传播 C 只改本地 cancel,且下次按键即清除;消息不含取消字段 取消不会作为消息传给下游
    发布时钟 发布按 100 Hz 目标节拍,与采样时钟无关 发布时钟不能代表采样新鲜度
    关闭握手 无显式 stop/join/模式撤销协议 进程退出才停;不能推断采集卡住时会停止发布

    模式参数与下游 joystickd:两种进程切换方式

    JoystickDebugMode 在 manager 启动/下线时切换清理:车载调试模式用它把控制权在 controlsd 与 joystickd 之间切换。这里要区分两件事——参数清理不等于 testJoystick 发布生命周期监测,模式选择与输入有效性需要分开验证。下游 joystickd 只取轴值并设置帧数超时门,而 card 与 panda 各自还有独立的安全门。

    游戏手柄一侧的映射也不是只有一套:Joystick.update() 按 PC 与车载两套映射转换纵横轴,采集到的极值动态扩展区间后,再经插值、3 百分比死区与三次曲线。探针确认 PC 扳机翻转后会再归一化,已处理的拔出异常会清零轴值。源文档还列出若干待验证项:不同游戏手柄 inputs 事件顺序与极值、真实拔出/静默行为、GIL 调度下两个线程的读写时序、消息停发后的实际时间,以及车辆安全钩子的响应;当前归档目录没有针对这些边界的专用原仓库单元测试,本篇的隔离探针不能替代真实设备验证。

    输入契约:采样时钟不能由发布时钟代替

    源文档对插件化的要求很明确:输入插件输出应包含设备身份、采样单调时戳、序号、校准版本、轴语义/单位以及明确的取消事件,发布时钟不能代替采样时钟;宿主按真实输入年龄与设备健康授予短期租约,过期、拔出、采集线程失活或异常时撤销租约,并且发布无效/零值不能伪装成新鲜的真实操纵;测试控制与生产控制分属隔离命名空间,即使测试输入有效,也只形成受监督的候选指令,必须继续通过唯一控制仲裁和独立硬件安全门。

    还要留意终端资源这块的实测边界:Keyboard() 构造时才创建 KBHit,但 joystick_control_thread() 没有 finally 或显式调用 set_normal_term();正常 Python 退出时 atexit 尝试恢复,被强制终止则不能保证。此外该类注释里「Windows no-op」的说法与模块顶层 import termios 不一致,不能把它当跨平台终端插件接口。

    常见问题

    为什么「消息还在发」不代表输入还新鲜?

    因为 send_thread() 按 100 Hz 目标节拍复制 axes_values,而采集线程没有为每次输入维护时戳,发布线程也不检查上次真实输入时间或 cancel。采集阻塞但进程仍在时,旧轴值仍会被封装成新的 valid=True 消息。

    终端里按 C「取消」会传给下游吗?

    不会。C 只是把本地 cancel 置真,且下一次输入又会先置假;cancel 并不是该生产者消息的一部分,下游 joystickd 也只取轴值。因此不能把终端说明中的取消键理解为已传播的取消命令。

    游戏手柄拔出后轴值会归零吗?

    对已处理的 OSError/UnpluggedError 会归零并返回假;但源文档指出其他异常或采集线程停止并不进入该归零分支,而且极值是运行期估计,不是经标定的设备物理量。

    设置 ZMQ 环境变量是否等于更安全?

    不是。设置 ZMQ 只是跳过 CLI 入口的 IsOffroad 检查,改变的是本地检查,不能被当作网络身份认证或进程级安全策略。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/JOYSTICK_INPUT_PRODUCER_DEEP_DIVE.md(取用了键盘与游戏手柄两种输入实现、100 Hz 发布与约 0.2 秒失联门缺口、六项隔离探针结果与插件化输入契约)。

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

    更新记录

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

  • 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

  • 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

  • 路段来源发现的缓存陷阱: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

  • 蜂窝模组六态状态机:CREG 与 CGREG 的漫游门为何不对称

    结论:蜂窝数据链的核心问题不是「有没有连上」,而是一组被代码门控范围和发布相位放大的信任缺口:漫游许可只门控 CREG=roaming、并不单独拒绝 CGREG=roaming;ICCID 只校验字符集、不校验长度;connected=True 只表示看到了 ppp0 的接口 IP,不等于外网端到端连通或 DNS 已成功。以下基于固定源码快照 c8fb906 的静态调用链与三组假依赖探针,未接 Quectel 模组、SIM 或运营商网络。

    进程与接口边界

    蜂窝链是一个常驻 Python 进程加三个对外接口:控制口、数据口与状态文件。它以「模组可用」为目标,而不是以 onroad 为前提;宿主还会在启动时临时接管系统网络服务,这一接管本身也带状态恢复风险。

    层 入口 行为
    生命周期 process_config.py、Modem.run()、stop() modem 是常驻进程,不以 onroad 为前提。启动时若服务文件存在则尝试临时屏蔽/停止 ModemManager;正常执行到 stop() 末才尝试解除屏蔽/启动,不记录其原状态,也不检查命令返回码
    控制口 Modem._at() /dev/modem_at0 以 9600 波特率打开;抢不到 /dev/shm/modem.lock、收到错误或超时均返回空响应。该锁也由 qcomgpsd 和 LPA 使用
    数据口 PPPSession.start() pppd 使用 /dev/modem_at1 拨号 CID 1;connected 不是由进程启动直接确认,而是在 ppp0 发现 IP 后更新
    配置 _do_initializing()、_params_changed() 从持久参数读取 GsmApn、GsmRoaming;SIM 身份变更、APN/漫游变更引发重新初始化
    状态输出 _publish_state()、HardwareComma.get_modem_state() 状态先写 /dev/shm 临时文件再原子替换为 /dev/shm/modem;硬件抽象读取 JSON 后由 hardwared 发布 deviceState

    六态状态机与关键函数

    状态机在 INITIALIZING → SEARCHING → CONNECTING → CONNECTED → DISCONNECTING 之间流转。_do_initializing() 清理旧 PPP/路由、配置 AT、读取 IMEI/ICCID/IMSI/版本,对 EG25 应用专用设置并写入 APN;身份不完整会留在初始化态。部分 AT 配置调用没有逐条检查返回值,因此不能仅凭进入 SEARCHING 判定所有模组配置成功——探针也确认,AT+CGDCONT 返回空列表仍会发布身份并进入搜索态。

    _poll_iface() 从 ppp0 提取 IP/peer,并调用 maybe_install_routes() 配置默认路由与策略路由表 1000,成功后调用 maybe_install_dns()。所以 connected=True 表示看到了接口 IP,不是外网端到端连通或 DNS 已成功的证明。

    注册门:CREG 与 CGREG 的不对称

    搜索态先读 CREG,没有响应则不查询 CGREG;拨号要求两者均报告 home 或 roaming。但漫游禁止分支只在 CREG 为 roaming 时阻断:当 CREG 为 home、CGREG 为 roaming 时,代码仍进入 CONNECTING。这是代码门控范围的问题——它不等于运营商实际会建立数据链,也不证明资费策略正确。

    此外,_check_iccid() 周期性发现换卡,ICCID 查询空响应不会触发换卡,收到非空且不同的值才设置标记。PPP 退出后的处理是:前两次失败各执行一次数据口 DTR 复位并再次启动 pppd,第三次转入断开、不再复位/重拨;若此时已有 SIM 变更标记则直接转断开,不增加失败计数。

    身份与遥测的宽边界

    身份解析与遥测采样的接纳规则普遍宽松,且错误常常以「整组不更新」而不是「逐字段报错」的形式出现。

    字段 接纳规则 边界
    IMEI 要求全数字且长度为 14 至 17 长度有门,格式受限
    ICCID 只检查字符是否属于十六进制集合 没有长度门,单字符 A 也可作为非空身份穿过初始化门
    _atv() 前缀 用子串判断前缀并按第一个冒号切分 示例 noise:+QCCID:12345 得到 +QCCID:12345,非精确解析所请求响应
    mcc_mnc 仅取全数字 IMSI 的前六位 不单独判定 MCC/MNC 的实际长度
    采样项 边界行为
    CSQ CSQ=99 视为缺测,却未拒绝 -1 或大于 31 的值;-1 产生负质量,40 的质量被上限钳成 100
    COPS 制式码 网络制式码无效时仍可更新运营商名
    频段通道号 无法解析时整组频段/通道都不更新
    温度 列表中的 255 被跳过,但任一非数字项会令整组返回空增量
    流量计数 tx_bytes 或 rx_bytes 其中一个读取/解析失败,整组返回空增量

    结合增量发布,这些「整组返回空增量」会让旧值继续保留,因此这些状态字段不是逐字段实时状态。

    启动接管与退出恢复

    run() 只以 ModemManager 单元文件是否存在决定执行 mask --runtime、stop,不检查两个命令的退出码;stop() 无条件尝试 unmask --runtime、start,不回忆服务接管前是否已停止或屏蔽。恢复顺序为杀 PPP、清路由、删除状态文件、恢复服务,若中途清理抛异常,后续恢复不会执行。main() 在 run() 与 stop() 之间没有 finally,所以循环外启动异常会跳过 stop()。

    另一个值得记录的相位问题是:run() 的异常捕获只修改内存中的枚举态,不在该分支发布 DISCONNECTING;随后正常的断开处理器先发布带 INITIALIZING 字段的初始快照,返回时又发布 INITIALIZING。因此若没有其他正常转移,消费者可能完全看不到这次异常的 DISCONNECTING 阶段。

    AT 初始化同样是「逐条调用、宽验收」:最终只依据 AT+CGMI 的首行是否有回显决定通过,前面 AT_INIT 的空/错误应答不会阻止前进;EG25 专用六条命令也不检查应答,非 EG25 直接跳过。此外,特定 Webbing ICCID 前缀会直接允许漫游而不读取 GsmRoaming——这是代码识别规则,不是实卡资费保证。

    断开处理的顺序也值得注意:它先发布 connected=False、state=INITIALIZING 的初始快照,随后才杀 PPP、清路由和复位数据口,因此状态发布与资源撤销不是同一事务——消费方可能先看到「已断开」,而旧 PPP 与路由仍在。

    常见问题

    connected=True 就代表设备能上网吗?

    不代表。它只表示在 ppp0 上看到了接口 IP,不是外网端到端连通,也不是 DNS 已成功的证明;路由与 DNS 是后续独立步骤。

    漫游被禁止时为什么仍可能进入拨号?

    因为漫游允许判断只门控 CREG=roaming,没有单独拒绝 CGREG=roaming。当 CREG 为 home、CGREG 为 roaming 时,代码仍进入 CONNECTING。

    ICCID 的解析有什么风险?

    它只检查字符是否属于十六进制集合,没有长度门,单字符 A 也能作为非空身份穿过初始化门;这属于代码接纳规则,不表示模组会给出这种异常应答。

    为什么模组状态字段会显示旧值?

    温度、流量等采样项在任一子项解析失败时会让整组返回空增量,结合增量发布机制,旧值可能继续保留,因此这些字段不是逐字段实时状态。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/MODEM_DEEP_DIVE.md(蜂窝模组六态状态机、身份/遥测解析与生命周期接管,含源码行号索引)
    • 项目 02 —— openpilot-research/probes/test_modem_state_machine_contract.py(十一项注册门与发布相位隔离探针)
    • 项目 02 —— openpilot-research/probes/test_modem_identity_telemetry_contract.py(八项身份与遥测字段解析探针)

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

    更新记录

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

  • selfdrived 事件生产者:53 个直接事件与两道提前返回

    结论:SelfdriveD.update_events() 在同一帧内直接引用 53 个 EventName,但它们只是在「生产者」一侧被加入;加入事件之后仍要经过状态门与告警仲裁,才能变成驾驶员听到的告警或执行器退出。两道提前返回(未初始化、CP.passive)会让后续平台健康检查整体跳过,而 cruiseMismatch/noGps 在事件类型映射中为空,不能凭名字推断会触发禁入或退出。以下基于固定源码快照 c8fb906 的静态分析,未运行端到端时序或实车验证。

    每帧入口与两道提前返回

    update_events() 每帧先执行 clear():它保留静态事件并更新计数,随后处理调试、大模型与一次性启动提示。若 initialized=False,函数添加 selfdriveInitializing 后立即返回,此时不运行后续的驾驶员监测、车况、硬件与通信故障检查。也就是说「初始化中」这一种状态会掩盖其他所有平台健康判定。

    初始化本身在 data_sample() 中等待 canValid && sm.all_checks(),或约 6 秒超时;超时只结束等待,不保证订阅数据有效。初始化完成后可以处理书签,但 CP.passive 又会使函数提前返回。carUnrecognized/dashcamMode 是构造时添加的静态事件,startupNoCar/startupNoControl 等则通过 startup_event 变量在首轮间接加入,所以不能从 53 个直接名称的计数推断它们缺席。

    53 个直接事件按来源分组

    下表按各来源首次直接引用的顺序,列出相应的 EventName 与生产条件的中文摘要。同一帧可能追加多个事件,Events 的有序插入并不等于去重。

    来源 直接 EventName(摘要) 条件及边界
    调试与大模型 joystickDebug、bigModelLoading、bigModelFailed、lateralManeuver、longitudinalManeuver、selfdriveInitializing 大模型失败是有条件的软退出(失败/失联/外设消失边沿),非每帧无条件触发;机动实验按接收帧选择,横向优先
    书签与巡航恢复 userBookmark、userBookmarkNotPaired、resumeBlocked 非 PCM 巡航、设定速度 vCruise > 250 且加速/恢复按键出现时阻止恢复
    驾驶员监测 tooDistracted、driverDistracted1~3、driverUnresponsive1~3 非 notCar 才检查;lockout 禁止使能;alertLevel 1/2/3 与 activePolicy 是否 vision 决定两套提示
    CAN 与踏板 pcmEnable、pedalPressed CS.canValid 后才转发 CarEvents;油门上升沿需开启 DisengageOnAccelerator
    设备健康 overheat、outOfSpace、lowMemory、fanMalfunction 热状态达 overheated;非仿真空闲空间 <7% 或内存 >90%;风扇见下文数值门
    标定与车道偏离 calibrationIncomplete、calibrationRecalibrating、calibrationInvalid、ldw 标定状态非 calibrated 时三选一;IsLdwEnabled 且左右偏离任一成立才产生 ldw
    panda 安全 controlsMismatch、relayMalfunction 与 safetyConfigs 逐项比较;额外 panda 必须 silent/noOutput;mismatch_counter >= 200 可直接触发
    系统/总线/通信 processNotRunning、cameraMalfunction、canBusMissing、canError、commIssue 等 进程故障优先并跳过相机检查;总通信问题仅在 all_checks() 失败且大模型不在缓冲期时产生
    定位/参数/传感器 posenetInvalid、locationdTemporaryError、paramsdTemporaryError、sensorDataInvalid 主题曾被收到才看有效位;加速度计或陀螺仪任一主题距最近接收帧超过约 10 秒
    提示与副作用 fcw、steerSaturated、noGps、modeldLagging、personalityChanged FCW 只生产提醒不下发制动;转向饱和看目标/实际横向加速度比值;驾驶风格在按键释放时循环

    车辆侧的更多事件由 CarEvents.update() 转发,它还会产生车门、安全带、挡位、定速、转向和品牌专属事件,并由 CS.canValid 门控;车辆接口上报的 CarState 信号真假、车型安全钩子和线束电气行为不由这一层证明。

    几个容易被名字误导的事件值得单列。excessiveActuation 只在非 notCar 且有校准姿态时调用 ExcessiveActuationCheck.update(),首次触发会写驻车告警并把内存标志置真,之后才持续添加,构造时还会从已有的 Offroad_ExcessiveActuation 恢复该标志。变道提示同理:模型处于预变道且对应方向盲区被占用时阻止提示,进入开始或结束阶段时才发 laneChange——这些是模型状态消费,不代表真实盲区已被验证。

    数值门与告警边界

    这些条件的触发都绑定具体数值门,且多数在「严格超过某段时间对应的帧数」之后才成立。下表汇总关键阈值。

    项目 阈值 / 时间门
    空闲空间 / 内存 非仿真下空闲空间 <7%,或内存使用 >90%
    风扇故障 已知 panda、风扇目标 >50% 且缓存转速 <500 rpm,严格超过 15 秒对应帧数;注释称「5 秒」,数值实现为 15 秒
    未初始化超时 约 6 秒(只结束等待,不保证数据有效)
    panda 配置不匹配 严格超过 10 秒对应帧数;或 safetyRxChecksInvalid / mismatch_counter >= 200 直接触发
    传感器新鲜度 加速度计或陀螺仪任一主题距最近接收帧超过约 10 秒
    转向饱和 横控活动且最近约 2 秒未检测到驾驶员转向,并满足比值与控制器 saturated
    原车巡航一致性 / GPS cruiseMismatch 约 6 秒;noGps 约 2 秒且累计无 GPS 行驶距离 >1500 m(映射为空)
    模型频率 modelV2.frameDropPerc > 1 即添加 modeldLagging

    需要特别记住两处:风扇反馈故障「注释 5 秒、实现 15 秒」,并且不检查反馈新鲜度;cruiseMismatch 与 noGps 在本快照的 EVENTS 映射中为空,而 Events.contains() 只看类型映射,所以它们不会据此生成禁入或退出类型。

    生产条件不等于告警或退出

    update_events() 对某些系统故障有意设置了优先级与缓冲:总通信问题仅在 all_checks() 失败、无其他新增系统禁入/退出故障、且大模型不在加载或就绪后 5 秒缓冲期时才产生;进程故障优先,alive 优先于平均频率。这些都是「生产条件」层面的取舍,不是最终告警时刻。

    系统与通信故障内部也排了优先级:manager 指出应运行而未运行的进程时先报进程故障并跳过相机检查,recv_frame=0 的首次消息不通过该门,缓存故障可以持续触发事件;车况的 canTimeout 优先于 !canValid,且只有 openpilot 纵控才检查雷达错误,雷达 CAN 错误归入 canError。

    因此插件化时,传感器、模型与车型插件必须提供时间戳、数据有效位和失联证据,核心才保留事件归一化、状态机与 panda 安全约束。下一步应逐事件追到上游生产消息及回调告警,配合进程回放、SIL/HIL 和封闭场地验证;本文没有运行测试或控制车辆。

    常见问题

    未初始化时为什么很多故障检查根本不运行?

    因为 clear() 后若 initialized=False,函数会添加 selfdriveInitializing 并立即返回,后续的驾驶员监测、车况、硬件与通信故障检查全部被跳过。

    这 53 个直接事件就是全部事件吗?

    不是。这只是 update_events() 中直接引用的名称;启动事件通过变量间接加入、车辆事件由 CarEvents 转发,全部 99 个名称与类型组合另见静态事件索引。

    风扇故障到底是 5 秒还是 15 秒?

    注释称「5 秒」,但数值实现为 15 秒对应帧数;并且该检查不核对反馈新鲜度,只按「从最后一次健康处理帧起严格超过 15 秒」成立。

    cruiseMismatch 或 noGps 触发后会退出吗?

    不能这样推断。这两个 EventName 在本快照的 EVENTS 映射中为空,而 Events.contains() 只看类型映射,所以不会据此生成告警/退出类型。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/SELFDRIVE_EVENT_PRODUCERS_DEEP_DIVE.md(53 个直接事件的生产条件、两道提前返回与数值门)
    • 项目 02 —— openpilot-research/SELFDRIVE_EVENT_CATALOG.md(全部 99 个名称与类型组合的静态事件索引)

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

    更新记录

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

  • UBX 分帧的 65535 字节阻塞:GNSS 二进制解析器的三类失效边界

    结论:u-blox UBX 接收链的主要风险不在「字段解不出来」,而在三个输入契约缺口:跨块的前导前缀可能被整段丢弃、16 位长度字段可让一条未完成的长帧长期挡住后续合法帧、字段 DSL 解析完成后不检查尾随字节。三者都不会让进程崩溃,只会让上层在「看起来一直在发布数据」时误判。以下基于源码快照 c8fb906 的静态阅读与隔离探针,未接模组、未做路测。

    从字节块到完整帧:分帧器怎么切

    链路起点是串口:pigeond.run_receiving() 把读取到的字节块发布为 ubloxRaw,ubloxd.main() 按消息接收后交给 UbxFramer.add_data()。分帧器查找前导 B5 62,按头部小端长度取出 6 + payload_len + 2 字节,再用 _checksum_ok() 核对 UBX 的 CK_A/CK_B。坏校验只丢当前第一个字节并重新搜索;好帧完整消费;长度不够则暂存。

    真正微妙的是跨块边界。如果缓存里只剩单字节 B5,add_data() 会在 len(buf)<2 时保留它,下一块可以正常接上。但如果当前块是 00 B5——长度已至少为 2,又找不到完整的 B5 62——find()==-1 分支会清空缓存;下一块即使以 62 接上,合法帧也无法识别。隔离探针用一条校验正确的 B5 62 01 07 00 00 08 19 遍历所有单处切块点均能还原,但 00 B5 后接余下字节返回零帧。

    缓存尾部情形 代码行为 后果
    单字节 B5 len(buf)<2 时保留等待拼接 下一块可正常接出完整帧
    00 B5 等带杂字节前缀 找不到完整前导,find()==-1 清空缓存 下一块的合法帧前缀被丢掉,无法识别
    坏校验帧后接好帧 逐字节丢弃并重新搜索 能滑动一字节并恢复到好帧

    长度阻塞:一条 FF FF 的长帧能挡住什么

    若缓存以 B5 62 01 07 FF FF 开始,长度字段声明了 65535 字节 payload,分帧器在收到总计 65543 字节之前,既不会验证校验,也不会重新搜索前导。此时即使随后追加一条完整合法的短帧,该帧也仍留在缓存里、没有立即输出——隔离探针确认了这个状态。由于长度字段是 16 位,单条候选等待有限,但串口异常或丢字节仍能让后续帧长期被挡住,直到足够字节到达或上层重置。这里没有按消息类型限制长度、等待时间或缓冲年龄的门。

    字段 DSL:解释器契约不等于协议一致

    UBX 报文的字段用一套声明式 DSL 描述,注册、读取、组合、约束各在不同的执行路径上:

    层次 关键入口 输入约束及边界
    schema 注册 BinaryStruct.__init_subclass__() 按类注解声明顺序收集字段并安装逐字段 _read;示例 schema 在 Ubx、Gps、Glonass。
    基础读取 _parse_field() 整数/浮点/字节按 struct 读取并检查剩余长度;位字段按高位先读,切回字节字段时对齐到下一字节。
    组合字段 _parse_field() Switch 用已解析字段选分支;Array 用前序计数逐项读;Substream 先按声明长度切片再解析内部对象。
    类型约束 _parse_field() ConstType 不符即拒绝;EnumType 遇未知枚举值返回原始整数。

    BinaryStruct.from_bytes() 返回 _read() 结果,没有检查 reader 是否恰好耗尽输入;SubstreamType 也不检查内部 reader 的剩余字节。探针 Demo(u8).from_bytes(bytes([7, 99])).field 返回 7,证实尾部多余的 99 不会导致失败。另有几处边界值得记录:未知枚举保留整数;未匹配且无默认值的 Switch 返回 None 且不消费其后输入;数组计数为 2 而第二个 u16 不完整时抛 EOFError;BinaryReader.read_bytes(-1) 会返回切片并把游标从 0 移到 -1——这是通用 DSL 自定义输入的边界,UBX 顶层 length 是无符号 u16,未证明生产可触达。

    还有一层容易误读:真实 Ubx 顶层 schema 的 from_bytes() 会拒绝错误 magic,但 checksum 只是读作 u16、并不计算比对;而生产路径的 parse_frame() 直接截取 payload 按消息类型解析,根本不调用顶层 Ubx.from_bytes()。所以不能把顶层 ConstType 当成接收链的校验依据。SFRBX 是特例:手工检查 len(payload)==8+4*num_words,未知 GNSS ID 会抛异常并被主循环静默跳过。

    星历缓存与时间语义

    GPS 星历路径要求十个 32 位字,把每字右移六位去掉奇偶/填充后得到 30 字节子帧,再按 TLM/HOW 子帧号选择内容。缓存按 sv_id 收集 1/2/3 子帧,三者齐全后检查 IODC 低字节与两个 IODE 相等,才发布并清空;该 GPS 子帧缓存没有时间过期,跨重启/掉线混合只由 IOD 检查部分阻断,不能当作完整的新鲜度证明。GLONASS 路径要求四个字,转 16 字节字符串后按 freq_id 缓存 1-5 号字符串,对已知 superframe 号检查一致性,号未知时用 last_log_time 做约 10 秒窗口相关性判断。

    UbxFramer.last_log_time 在每次 add_data() 开始时更新为当前 ubloxRaw.logMonoTime,一次返回多个帧时仍是同一个最近块时间,它不是逐帧硬件采样时间。而 ubloxd.main() 对解析异常直接 continue 且不记录原因,外层若只看发布频率,无法区分坏校验、schema 错误、未知消息和缓存未齐。

    常见问题

    UBX 分帧器为什么可能丢掉合法的后一帧?

    当缓存里已经有字节却找不到完整前导 B5 62 时(例如块以 00 B5 结尾),find()==-1 分支会清空缓存;下一块即使以 62 接上,也拼不出完整前导,合法帧无法被识别。只有精确剩下单个字节 B5 时才会保留待拼。

    16 位长度字段会带来什么风险?

    若缓冲以 B5 62 01 07 FF FF 开头,长度声明为 65535 字节 payload,分帧器要等累计 65543 字节才验证校验或重搜前导;期间追加完整合法短帧也不会立即输出,且代码没有按类型限制长度、等待时间或缓冲年龄。

    字段解析成功就说明报文版本匹配吗?

    不是。from_bytes() 不检查 reader 是否恰好耗尽输入,尾部多余字节不会导致失败;EnumType 未知值会以原始整数保留。帧层核对的是 UBX 头部声明长度与校验,不代表具体消息 schema 严格匹配 payload 版本或长度。

    星历缓存能证明数据新鲜吗?

    不能。GPS 子帧缓存处没有时间过期,只靠 IODC 与两个 IODE 的检查部分阻断跨重启/掉线混合;GLONASS 在未知 superframe 号时以约 10 秒窗口做相关性判断。last_log_time 是最近块时间,不是逐帧采样时间。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/GNSS_UBX_BINARY_PARSER_DEEP_DIVE.md(UBX 分帧、字段 DSL 与星历缓存,含源码行号索引)
    • 项目 02 —— openpilot-research/probes/test_ubx_framer_stream_boundaries.py(分帧跨块与长度阻塞的四项隔离探针)
    • 项目 02 —— openpilot-research/probes/test_ubx_binary_struct_contract.py(字段 DSL 六项契约探针)

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

    更新记录

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

  • Quectel DIAG 记录契约:7E 分帧、双层长度与时间语义

    结论:Quectel DIAG 接收链的真实风险不在「能不能解出卫星数」,而在三处宽松的输入契约:AT 文本用子串判断状态、7E 分帧没有单帧长度上限与结束符超时、内外两层长度校验只保证封装边界而不保证 payload 与版本/星数匹配。这些缺口都不会让进程崩溃,只会让上层在「日志仍在发布」时误判。以下基于固定源码快照 c8fb906 的静态阅读与隔离探针,未打开 AT/DIAG 端口、未写 NV、未做实板定位测试。

    从 AT 端口到 DIAG 日志:初始化顺序与文本解析

    链路入口先等 AT 端口。qcomgpsd.main() 创建 ModemDiag 并调用 setup_quectel();AT 命令通过 at_cmd() 锁住 /dev/shm/modem.lock,与 modem、LPA 共享所有权。at_cmd() 先取进程间锁再打开串口,写入以 CR 结尾的命令、丢弃回显并返回数据行。DIAG 串口是 /dev/ttyUSB0,以 115200 波特率、RTS/CTS 与 DSR/DTR 硬件流控独占打开。初始化会依次请求 NV 写/读、日志掩码、可选关闭已有 GNSS、DPO/自启配置、可选辅助时间、NMEA 输出、GNSS 启动与 OEMDRE 命令;外层 main() 在 setup_quectel() 返回后才初始化并拉高 GNSS_PWR_EN,信号清理则先拉低该脚再调用 teardown_quectel()。

    这一层的文本解析相当宽松:ERROR 与 +CME ERROR 只作为终止行,不会转成异常;若没有数据行,函数会像收到 OK 一样返回空字符串。wait_for_modem() 只要求端口路径出现并在 AT 文本中找到 +QGPS:,因此它接受 +QGPS: 0 的关闭状态,也没有整体 deadline;gps_enabled() 用 "QGPS: 1" in response 判断,连 +QGPS: 10 都会返回真。这些是文本解析语义,不代表真实模组会给出该状态码。

    重试也是嵌套的:try_setup_logs() 的十次重试嵌在 setup_quectel() 的五次重试内。隔离探针用始终失败的假 setup_logs() 观察到 50 次调用与 55 次一秒睡眠,并且每轮外层重试前都会重复发送 NV 写/读请求。它证明的只是主机调用顺序与软件重试上限,真实串口阻塞、NV 写入效果与总耗时都未验证;两层通用异常还会替换故障报告,无法从最终异常反推底层 DIAG 失败原因。

    层次 字段/机制 核对内容
    分帧 7E / 7D / CRC16 发送附 CRC16/X-25,转义 7D/7E 并以 7E 结束;解码用断言检查结尾与 CRC
    外层封装 <BH> 外层长度须等于内层字节数
    内层封装 <HHQ> 内层长度须等于内层总字节数,并取出 log_type 与模组 log_time

    7E 分帧与一个没有上限的接收循环

    接收端 recv() 以结束符从串口块拼接,并把未配对部分留在 pend,hdlc_decapsulate() 解转义后用断言核对结尾和 CRC。真正的问题是 recv() 没有单帧最大长度,也没有结束符等待超时:连续不含 7E 的输入会持续累积在局部列表中,脚本无法从中判断它是「正在等一帧」还是「已经失控积压」。

    同样要注意容错的边界:CRC、短帧、外层长度或日志版本异常没有在 main() 循环内转成「坏帧计数并继续」,多数会抛出异常或断言,需要由进程监督重启。send_recv() 在等待命令应答时会跳过遇到的 DIAG_LOG_F,所以设置/清理日志期间不能假定每个测量都能被发布。另有一处不对称:setup_logs() 对首个「查询日志 ID 范围」响应只解析内层 operation/status,没有检查外层 opcode,而后续逐类别设置 mask 的响应则显式断言 opcode;这证明的是主机解析门缺失,而非模组会发出这种交错回包。

    三条测量记录的字节边界

    DIAG 帧取到操作码后只处理 DIAG_LOG_F,随后用 parse_struct() 把声明字符串转成无隐式对齐的小端 struct 格式;名字含 _Flt 时即使声明为 uint32 也按 IEEE-754 float 读,含 _Dbl 则按 double 读。dict_unpacker() 使用 unpack_from,只要求输入长度至少为 schema 大小,不校验恰好耗尽;因此插件若沿用 C 结构体的默认 padding,或只看类型名,会错读浮点与后续字段。

    记录 头部 / 单星长度 主循环中的检查与边界
    OEMDRE 测量 95 / 109 字节 检查版本 2,按 svCount 切片并逐星 unpack_from;切片前不验证 95+109*svCount == payload_len,短片抛解包异常、尾随字节可被忽略
    GPS 测量 28 / 70 字节 检查版本 0;svCount>0 时用 len(sats)//svCount == 70(非精确总长度,两星多 1 字节仍通过);svCount==0 时不检查剩余长度
    GLONASS 测量 29 / 70 字节 与 GPS 共用星数检查,两者状态位扩展不同

    上述长度来自对 dict_unpacker() 的纯 Python 探针:OEMDRE (95,109)、GPS (28,70)、GLONASS (29,70);同一探针确认头部末尾额外 1 字节仍可解出字典,且 (2*70+1)//2 == 70。这证明了解码器的具体边界行为,但不证明模组实际会发送异常帧。

    时间、定位与有效性语义

    qcomGnss 的 union 承载 DR 测量、SV 多项式与 GPS/GLONASS 测量等。主循环把 DIAG 内层的 log_time 原样写入 logTs,没有在此处转换成 Unix 或 Linux 单调纳秒;发布消息的 logMonoTime 是消息构造时间,不能替代模组的测量时间。

    gpsLocation 映射只接受 u_PosSource==2 且 GPS week 非 0xFFFF 的位置报告,把弧度经纬/航向转成角度、ENU 速度变为 NED;hasFix 以 verticalAccuracy != 500 判定,而该字段取自 q_FltVdop(垂直精度因子),能否当作以米计的垂直精度需要模组协议与实测核对,不能靠字段名推断。Unix 时间由 GPS 周与周内毫秒减固定 18 秒生成,随后由 timed 根据定位主题的新鲜度、hasFix 与时间范围决定是否校时。

    常见问题

    DIAG 接收为什么会持续积压?

    因为 recv() 没有单帧最大长度,也没有结束符等待超时;连续不含 7E 的输入会持续累积在局部列表中,脚本无法区分「正在等一帧」与「已经失控积压」。

    外层与内层长度校验通过就说明 payload 匹配吗?

    不是。两层长度只保证封装边界,不保证该类型 payload 与版本/星数匹配。OEMDRE 在切片前不验证 95+109*svCount == payload_len,尾随字节可被忽略。

    GPS 测量两颗星时多 1 字节为什么仍能通过?

    因为主循环用的是 len(sats)//svCount == 70,而不是精确总长度校验;整数除法让多出来的 1 字节被容忍,svCount==0 时甚至不检查剩余长度。

    verticalAccuracy 能当作以米计的垂直精度吗?

    不能直接这样用。它取自 q_FltVdop,其声明注释是垂直精度因子;是否等于以米计的垂直精度,需要模组协议与实测核对,不能仅凭 hasFix 的判定条件推断。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/GNSS_QUECTEL_DIAG_RECORD_CONTRACT_DEEP_DIVE.md(AT/DIAG 调用链、7E 分帧与三条记录的字节边界,含源码行号索引)
    • 项目 02 —— openpilot-research/probes/test_quectel_modemdiag_contract.py(CRC16/X-25、转义往返、分块保留与 send_recv() 跳帧六项探针)
    • 项目 02 —— openpilot-research/probes/test_quectel_nested_setup_retry_contract.py(内层十次嵌套外层五次的调用次数与睡眠统计)

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

    更新记录

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