运动语义框架

QMI8658A 只是一个来源。框架对外暴露的是语义——"敲了一下"、"拿起来了"——而不是某个寄存器的某一位。本页说明契约、仲裁规则、QMI8658A 绑定细节,以及新增来源的做法。

两个平面

一条运动输入同时携带两个平面,二者都是一等公民:

平面内容产生方
数据面MotionSample:原始计数、加速度 mG、角速度 dps×10、roll/pitch/yaw驱动
语义面MotionEvents:Tap / Still / TiltEnter / Shake / Lift …QMI8658 引擎(Still)与 core 识别器(其余)

不做"输出语义 / 输出原始数据"的二选一。Android 的 SensorEvent 与 Zephyr 的 sensor_trigger 都是两条流并存:上层要原始数据做记录、标定、滤波,语义则省掉每个产品重写一遍手势阈值。MotionBatch 把两者装在同一次投递里,消费方按需取用。

语义从哪来

  • 硬件引擎:芯片自带判定器,功耗与延迟最优,参数在寄存器里。QMI8658A 如今只使能 No-Motion,Still 是出场时唯一的硬件语义。
  • core 识别器:MotionRecognizer,只看数据面,阈值全在 core,换传感器不改变业务含义。敲击、倾斜、摇动、拿起/放下、姿态全在这里——包括曾经尝试放到硬件上的 Tap(见下方死路复盘)。

两者在 MotionInput::poll 里汇合,一起仲裁后再下发。

仲裁在平面内进行:软件 Tap 与软件 Shake 同属 Action family,会收敛成一个。

MotionEvent::Tap 与 GestureEvent::Tap 则分属两个平面的不同事件,不合并。前者是加速度计测到的机械敲击(可能来自外壳、被敲的桌面,甚至设备放在振动面上),后者是手指落在玻璃上,带位置和按压时长。同一个物理动作可以同时产生两者,二者都保留。

契约

MotionSource::sample(now_ms) -> Result<Option<MotionReading>, MotionError>

MotionReading { sample, hardware } 是「一次成功轮询」的结果。

Ok(None) 表示这一拍没有新帧。在 20 ms 节拍下属正常情况,不得当故障上报。

识别器的语义在 MotionInput 里叠加,MotionSource 实现不需要知道识别器的存在。

MotionEvent 的互斥按三个 family 划分:

Family成员优先级最高者
StateStill Moving Activity StepStep
PoseTiltEnter TiltExit Portrait LandscapeTiltEnter / TiltExit
ActionTap Shake Lift PlaceShake

同 family 的两个候选描述同一个物理动作,仲裁只留一个。跨 family 的事件允许共存(「在动」且「已倾斜」是两个同时成立的事实)。

优先级让更具体的描述胜出:摇动包含了它自己引发的倾斜抖动,拿起包含了随之而来的姿态变化。

family 冷却 300 ms,抑制同族的重复抖动。

Tap(40)低于 Shake(100)与 Lift(80),所以一次既敲又拿的动作只报后者。

MotionEvents 容量为 8,必须能装下仲裁前的并集——1 个硬件引擎加 5 个 core 分类器(tap / tilt / posture / shake / lift-place)共 6 个,仲裁后才收敛到每族至多一个。

能力声明

MotionCapabilities 是位集,声明而非开关。数据面无条件下发,所以某个来源不具备某语义时只是永不抛出,而不是关掉整条流。

能力分两层,避免把软件算出来的东西记到硬件头上:

  • QMI8658_CAPABILITIES — 驱动自己算的:TELEMETRY STILL。
  • RECOGNIZER_CAPABILITIES — core 从任意数据面推出来的:TAP TILT SHAKE LIFT_PLACE POSTURE。
  • MOTION_CAPABILITIES — 两者并集,即该板对外宣传的能力。

板层通过 HasMotion::motion_capabilities() 声明它自己的并集,app 启动时读一次并打进日志([MOTION] declared capabilities 0x…),现场排查时能直接看出这一版固件宣称能报什么。没有加速度计的板返回 MotionCapabilities::EMPTY。

参数速查

台架标定对着下面这两张表走。参数分两层:core 识别器(纯软件阈值)与 QMI8658A 引擎(寄存器里的硬件阈值),细节分别在下面两节。

横版面板把这一页排成两块文本加一个仪表盘:第一块是各轴读数,第二块是估计量与语义计数,第三块是姿态仪的圆盘。一块放得下十八行,而各轴读数一个人就占十五行,所以估计量那四行只能挪到第二块,而不是再加第四块。

ATTITUDE 第一块:各轴读数

标签字段单位意义对应参数
A0–A2raw_accelLSB加速度原始计数(±4 g,8192 LSB/g)—
M0–M2accel_mgmG加速度,去重力前—
G0–G2raw_gyroLSB陀螺仪原始计数(±1024 °/s,64 LSB/°/s)—
D0–D2gyro_dps_x100.1 °/s角速度—
R0–R2tilt_deg_x100.1°roll / pitch / yaw(yaw 无磁力计会漂)TILT_ENTER_DEG_X10 / TILT_EXIT_DEG_X10

ATTITUDE 第二块

前四行是估计量,不是原始轴值:它们是识别器判了什么,不是传感器测了什么。

标签字段单位意义对应参数
STstatus位图STATUS1(0x2F) 原值—
LRgravity_deviation_mgmG|测量模长 − 1 g|(静止判据的量)STILL_GRAVITY_DEV_MG
SRshake_residual_mgmG摇动估计残差(矢量长度)SHAKE_ON_MG
TRtap_residual_mgmGtap 平方和(多轴线性加速度平方和)的平方根TAP_PEAK_MAG_MG2

这四行是标定专用:原始轴值里这些量按定义就是 0(静止时模长恰落在 1 g 壳上),阈值只能在设备上读。其下十四行是每个语义的累计计数。

标签语义触发条件本板
TAP单击识别器上报 Tap{count},count=1计数
2T双击同上,count=2计数
3T三连击同上,count=3(封顶)计数
STL静止No-Motion 引擎(三轴 AND,上升沿)计数
MOV移动未宣告N/A
ACT活动未宣告N/A
STP计步未宣告N/A
TIN倾斜进入15° 带符号计数
TOX倾斜退出10° 迟滞计数
SHK摇动残差 > 2000 mG 且 800 ms 内 3 次上穿计数
LFT拿起模长离开 [850, 1150] mG 带(对 1 g 偏离 > 150 mG)持续 150 ms计数
PLC放下模长回到 [850, 1150] mG 带持续 150 ms计数
PRT竖屏X/Y 水平分量中较大者 ≥ 300 mG,且 Y 为主计数
LND横屏同上但 X 为主计数

N/A 与 0 的区别是刻意的:0 意味着阈值没触发(要调),N/A 意味着这块板根本不上报(不用调)。计数为 u16 饱和累计、每 boot 归零、无清零入口。

识别器阈值

全部在 recognizer.rs,不在驱动里。

语义判据参数
TiltEnter / TiltExit15° 进入 / 10° 退出,带符号迟滞—
Tap残差越过峰阈且上一拍在安静阈下,峰须在预算内跌回并待满见下
Shake残差矢量长度 > 2000 mG,800 ms 内 3 次上穿见下
Lift / Place测量模长偏离 1 g 超过 150 mG,持续 150 ms见下
Portrait / Landscape两轴水平分量都低于 300 mG 时无有意义姿态,两个都不报—

带符号迟滞是必需的:单阈值会在边界每拍抖出一对进出。

Tap

去重力用逐轴 300 ms 移动平均(TAP_BASELINE_TAU_MS),但基线只在静止相位 blend。

手势(Peak/Quiet/Between)期间基线冻结。否则连续几下敲击会被基线吸收,过冲把「安静读数」抬高,饿死后面打击的 80 ms 峰值预算:探针实测三连击能把残差地板推到约 155 mG,而安静阈正卡在这个量级。

判据取三轴线性加速度平方和 Σlin² 直取,不做能量平滑——要量的是残差自己在峰预算内跌回安静阈的速度。

瞬时平方越过 250 mG 峰阈(TAP_PEAK_MAG_MG2 = 62_500 mG²)且上一拍低于 150 mG 安静阈(TAP_QUIET_MG2 = 22_500 mG²),才算打击上升沿。

这样一条上升沿筛掉两类假阳性。敲击是从安静一步跨到峰阈;转动的残差逐拍爬过两条阈值,永远凑不出这一跳。上一拍不在安静下的升也进不来——缓变的拿起顶到 320 mG、转动持续越线,都是。

进入峰阶段后是衰减预算。残差必须在 80 ms(TAP_PEAK_WINDOW_MS)内跌回安静阈之下,峰才成立。

预算耗尽还挂在高位就是按压或拿起(拿起在高位撑约 1 s,任何 80 ms 窗口都满足不了)、或转动,整段丢弃。成立的峰还要在安静阈下完整待满 80 ms(TAP_QUIET_WINDOW_MS)才算一记,一次敲击的余振不算第二记。

第一记生效瞬间记下锚点,之后 500 ms(TAP_DOUBLE_WINDOW_MS)内再来的打击都滚进同一次 tap,再次走峰→安静→生效,计数 1/2/3 封顶。窗结束时按 count 上报一次 Tap,单击因此天然滞后 500 ms 才定论。

最后的收尾判据是落定预算(TAP_SETTLED_MOTION_MS = 200 ms):自第一记起算,信号在安静阈之上的累计时长超过 200 ms,说明手势没有收住。

敲击后的余振会安静待在阈下,转动则持续越过又返回,所以当场丢弃不报。转动的启动瞬变在局部上与敲击同形——硬件引擎恰恰锁死在那类瞬变上——只有「整个手势窗是否收在安静阈下」能分开二者。

阈值在 mG² 域,TR 公布其平方根(峰阈约 250 mG)。

已上机标定(探针打印逐拍残差流,50 Hz):静止噪声 9–38 mG,250 的峰阈留约七倍余量;一次较用力敲击是 1–2 个样本的 364–4866 mG 瞬变(±4 g 量程剪顶),普通日常轻敲也能跨峰阈。

拿起-停顿-放回的缓慢上升在 50–350 mG 持续约 1 s,抬手一步最大约 320 mG。它能跨峰阈,但持续高位撑不过 80 ms 衰减预算。

偶发桌面耦合单样本杂散 300–988 mG 与真实敲击同形,判为敲击(可接受)。

event.rs 把 Tap 归入 Action family(priority 40),摇动(100)与拿起(80)同族可压过它。

Shake

判据是重复次数而不是持续时长。

敲桌与摇动的区别不在单次幅度而在有没有第二次:一次冲量过线只有一次上穿,一次摇动每个反转各有一次。

不能用「超阈值累计时长、跌破则等量衰减」的漏积分。正弦在一个周期里高于自身峰值附近阈值的时间占比最多只有一半(实测本配置下 5 Hz 约 10%),等量衰减后每周期净增量恒为负,任何振荡都永远攒不够确认窗。

阈值取自 W3C《Accelerometer》的参照实现(shakeThreshold = 25 m/s² ≈ 2.5 g)。该规范是加速度计规范,不是同套里的《Device Orientation and Motion》。

有两处下修。识别器每 20 ms 才看一次信号,贴着峰值的阈值会整拍漏掉;而 3 g 的摇动在 5 Hz 上只剩约 2.5 g 残差,2.5 g 恰在信号顶端。2.0 g 是能让每个反转留下 2–3 个超阈样本的值。

W3C 的例子是 60 Hz、只看单轴 sensor.x、单拍阈值;矢量长度、需 3 次过阈、限 800 ms 都是本产品的设计而非规范要求。尚未上机标定。

Lift / Place

测量模长偏离 1 g 超过 150 mG 视为静止被打断,持续 150 ms 后相信。判据在平方域比较(850² < x²+y²+z² < 1150²),纯整数无开方。

用模长方向是不背估计误差的:旧判据减去的是自身重力估计,估计漂多少判据就跟着漂多少,桌面可能离自己估计 100 mG。原始模长只受加速度计本底噪声影响。

150 mG 取自 ST 官方 FSM pick-up 示例(LSM6DSO:上桌判 0.9 g、拿起逃逸 1.1 g,即拿起只需对重力偏出 100 mG)。台架静置的模长抖动只有 8–22 mG,150 mG 留了约七倍裕量。

更宽的 350 mG 带在台架上只有甩起来(大幅加速)才越线,普通拿起被漏掉。

纯转动(翻转、拿起而不加力)不改变模长,仍判静止。这是 ST 模长逃逸法共有的盲区,不是本实现独有的。检测的是进出静止的跳变,并不声称能分辨桌面与手掌。

Portrait / Landscape

两轴水平分量都低于 300 mG 视为平躺,此时无有意义姿态,两个都不报。

去重力估计

去重力用一阶低通,写成时间常数而非每拍系数,所以轮询频率变化时拐角不动。Shake 与 Tap 各有一个估计,时间常数不同,互不干扰。

估计时间常数20 ms 轮询下的拐角谁在用
摇动估计70 ms≈ 2.0 HzShake
Tap 基线300 ms≈ 0.5 HzTap

首个有效样本定义当前朝向而不是被拿去和朝向相减——否则开机瞬间正在被拿起的设备会凭空多出一个冲量。Lift/Place 不做去重力,判据是原始模长(见上表),不依赖估计。

Shake 用残差的矢量长度,不是三轴求和。

三轴加起来等于整流,多轴振荡整流之后在反转之间回不到零,会一直挂在阈值之上,根本没有上穿可数。矢量长度在反转处归零,才有干净的边沿。

调参时 SHAKE_ON_MG 与 LFT/PLC 的 150 mG 带宽互不相干,可分别标定。

传感器 ODR 是 896.8 Hz(6DOF 模式由陀螺仪决定,见下),core 每 20 ms(50 Hz)轮询一次,也就是每 18 帧只取 1 帧。

5–10 Hz 的摇动在 50 Hz 下每振荡仍有 5–10 个样本,所以数反转数得上来。

但反转的宽度是按角度算的。一次上穿要能被采到,阈值必须低到让该段相位跨度超过一拍——这也是 SHAKE_ON_MG 从 2.5 g 下修到 2.0 g 的原因之一。

代价是 40 ms 轮询与 20 ms 轮询会在不同时刻报出同一次摇动(粗轮询可能整个漏掉一个波峰、多等一个周期凑够次数)。这是采样决定的,不是时间基准的问题。

敲击判据同样跑在这条 20 ms 平面上,与 ODR 无关。896.8 Hz 的高 ODR 只服务于 No-Motion 引擎的样本计窗与陀螺 yaw 诊断(见下)。

yaw 是积分量,无磁力计会无界漂移,仅作诊断显示,任何语义与业务判断都不得读它。

改用模长与去重力残差判据之后,没有任何语义再来自陀螺仪。它仍留在 MotionSample 里做遥测与屏幕上的 G 显示,驱动也照常读取,但 SHAKE、TAP 与 LIFT_PLACE 都只看加速度。

之所以仍使能陀螺仪,一半是遥测价值,一半是 6DOF 模式的 ODR 由陀螺仪决定:关掉它会落到加速度计单轴那一列(1000 Hz 而非 896.8 Hz),把上面 No-Motion 的窗口换算挪动。

QMI8658A 绑定

S3 板未接 INT1/INT2,所以全部靠轮询。由此产生三条硬约束:

  1. CTRL8 bit7 必须置 1。它把 CTRL9 命令的完成信号从 INT1 改道到 STATUSINT.bit7,没有它就只能盲等。改道后 CTRL8 = 0x84(No-Motion + 握手选择)。

  2. 参数配置期间 CTRL7 必须为 0。数据手册要求 aEN = gEN = 0,因此顺序是:写 CTRL1/2/3/5 → CTRL7 = 0 → 两次 CAL 写入与 CTRL9 命令(Motion 引擎的两半) → CTRL8 = 0x84 → CTRL7 = 0x03。先装参数再开引擎,否则引擎会带着复位默认值跑。

  3. 握手必须有界,但按墙钟计。await_cmd_done 最多 1000 次、每次间隔 1 ms,失败返回 EngineConfig,不能挂住输入循环——那上面还挂着触摸与按键扫描。

    用墙钟而不是紧凑轮询,是因为 CmdDone 是粘滞标志:紧凑轮询会在器件还没算完时就放弃,而这里一次误判超时会让整个运动平面永久失效。

    手册没给 CmdDone 的最坏时间,所以预算取自唯一公布了该项的独立实现(RIOT 用 1000 ms 命令超时)。这里放宽是零成本的,因为它只在 init() 里跑一次开机流程,正常路径第一次轮询就返回。

CTRL5 显式写 0x00,即 aLPF_EN 与 gLPF_EN 全关。手册表 22 中 bit4 是 gLPF_EN、bit0 是 aLPF_EN,aLPF_MODE(2:1) 为 00 即 2.66% of ODR。

加速度计低通关,保留 896.8 Hz 全带宽。曾怀疑 24 Hz aLPF 是磕击瞬变衰减的主因,上机对照(同手势、关/开 aLPF 各测一轮)证明静止噪声与敲击读数纹丝不变——衰减来自结构与耦合,不来自这条低通。关掉它只是清掉嫌疑、给敲击瞬变留全带宽,没有任何可测的代价。

陀螺仪低通保持关(bit4=0):没有语义读陀螺通路找峰。

ODR 与窗口换算:CTRL2 = 0x13、CTRL3 = 0x53,ODR 索引 0b0011。

手册表 22 的加速度计列读作 1000 Hz,但 6DOF 列是 896.8 Hz,而注 13 说明两个传感器都使能时实际生效的是 6DOF 那一列。

ODR 已经不再为敲击服务——敲击判据在 core 的 20 ms 平面上跑、与 ODR 无关——但 896.8 Hz 仍稳超 No-Motion 引擎的需求,且保持陀螺 yaw 诊断全程率。

No-Motion 引擎按样本计窗、产品按毫秒思考。窗口在代码里以毫秒表述、再换算成样本数(odr_samples),并钉在寄存器值上——改一次 ODR 就会静默缩放所有窗口。当前 80 ms → 72 样本。

每个引擎的参数分两半,由 CAL4_H 高半字节选择(0x01 第一半,0x02 第二半),Motion 引擎两次 CTRL9 命令。写入用一次从 CAL1_L 起的 8 字节突发(CAL1_L、1_H、2_L、2_H、3_L、3_H、4_L、4_H)。两半装的东西并不连续,且 16 位参数横跨一对寄存器,所以逐字节按表 34(Motion)填:

寄存器Motion 第一半Motion 第二半
CAL1_LAnyMotionXThrAnyMotionWindow
CAL1_HAnyMotionYThrNoMotionWindow
CAL2_LAnyMotionZThrSigMotionWait[7:0]
CAL2_HNoMotionXThrSigMotionWait[15:8]
CAL3_LNoMotionYThrSigMotionConfirm[7:0]
CAL3_HNoMotionZThrSigMotionConfirm[15:8]
CAL4_LMOTION_MODE_CTRLNA
CAL4_H0x010x02

这类交错填法很容易把参数填错位而不自知,所以 init_pushes_both_parameter_sets_of_the_no_motion_engine 直接钉死全部 16 个字节,对着表逐行核。

状态映射只剩一路:STATUS1.bit6 → Still。

这是电平不是事件。No-Motion 引擎只要仍认为设备静止,每一拍都置位,直接上抛会把一次落定变成帧率级的 Still 连发(台架观察到 STL 无上限自增)。驱动只在 0→1 上升沿上报一次,位保持则静默,回落再置位是一次新的上报。

Tap 位(bit1)被明确忽略、不解析。它可能仍被硅片引擎的锁死瞬变置位,但驱动不读其结果寄存器、更不把它当事件——a_tap_bit_is_no_longer_decoded 钉住的是「tap 位既不产生事件、也不转化为额外 I²C 读」。

握手靠 STATUSINT.bit7,命令后须回写 CTRL9 = 0x00 ACK。

复位必须握手,不能盲等。7.4 说复位最长 15 ms,而 5.9/7.4 都要求复位后立即读 0x4D:0x80 表示复位成功(power-on 与 soft reset 都会置位),且该位会被后续操作覆盖,所以读完就得走。

驱动改为轮询 0x4D(最多 100 次 × 200 µs = 20 ms)而不是固定延时,失败返回 ResetIncomplete 且不写任何配置寄存器。

手册对复位字节自相矛盾:表 27 说写 0xB0,7.4 正文说写 0x0B,互为比特反转。这里取表 27 的 0xB0,而 0x4D 握手正好把这个歧义变成可检出的结果——字节错了就不会置位,直接报 ResetIncomplete,而不是静默配置失败。

STATUS1 位清理语义,手册只写了一个。§12.4 的原句是 "Reading STATUS1 by the host will clear the WoM bit",说的是 bit2 的唤醒标志,不是 No-Motion(bit6)或 Tap(bit1)——位是否自清属于未定义行为。

驱动不猜,只在上升沿上报、把真实 STATUS1 随帧带上,让现场用 ST 配左列标定行验证一次物理动作对应几次上报。

台架读数(bit6 静置期持续置位)与「电平、自清」一致,所以边沿判据 1:1 还原动作次数。

注意别去读 STATUSINT 判数据就绪:syncSmpl=0 下它的 bit0 是 INT2 电平、bit1 是 INT1 电平,只有 bit7 的 CmdDone 有意义。数据就绪必须读 STATUS0(bit0=aDA,bit1=gDA)。

配置失败后驱动不自愈:init 是唯一的配置入口,轮询在未初始化时返回 MotionError::NotReady。S3 板把运动当作可选传感器,init 失败只打一条日志并继续启动,设备照常工作、只是没有运动平面;NotReady 经限流后不会刷屏。

本产品只开 No-Motion;Tap 引擎故意不使能(见下),CTRL8 的 Any-Motion / Significant-Motion / Pedometer 位保持为 0,那些语义归 core 识别器。

NoMotionX/Y/ZThr 步长 0.03125 g(当前 0x08 = 0.25 g 斜率),NoMotionWindow 以样本计(80 ms → 72)。

MOTION_MODE_CTRL = 0xF7 中 bit0-2 使能 AnyMotion 三轴、bit4-6 使能 NoMotion 三轴,bit7 决定引擎把已使能轴 AND 还是 OR。

取 0xF7(三轴全开 + AND)是「静止」的严格读法:三轴都必须在整个窗口内低于各自的斜率阈值。

0x07 的 OR 逻辑只要有一轴落定就报静止,设备正在转动时另一轴稳住就能误报——这正是 STL 早先偏多的一个来源。

AnyMotionX/Y/ZThr = 0x00 是占位值而非选定的阈值。真实的 0 会让引擎对任何偏差都触发,而这正是它保持未使能、并由测试钉住的原因。

敲击不走硅片引擎

QMI8658A 的 tap 引擎在本产品的轮询方案下不可达,敲击判据由 core 的 20 ms 流承担(见上文「识别器阈值」的 Tap),驱动只保留 No-Motion、不声明 TAP 能力。

ODR = 896.8 Hz 不是为敲击保留的,而是给 No-Motion 引擎的样本计窗与陀螺 yaw 诊断用。

排查过程、寄存器读数与两次被推翻的假设见运动实现记录。

节拍与 I²C 预算

MOTION_SCAN_MS = 20。所有来源共用 app 的 5 ms 基础 tick,即每 4 拍一次:快到不漏掉敲击和拿起,慢到给共享 I²C 留出触摸与按键。顺序轮询下单次 poll 约 0.4 ms(STATUS1 1 字节 + STATUS0 1 字节 + 12 字节数据),20 ms 有充足余量。读取无忙等。

发射做增量抑制:加速度变化 > 20 mG 或角速度 > 5 dps 才下发。但识别器每拍都看到数据,静帧不会因此漏判。

诊断显示

右列是每个语义的累计计数表(标签与判据见参数速查)。

TAP/2T/3T 由 Tap{count} 的 count=1/2/3 分别触发,都计(count 封顶 3,第三记之后的手势即三连击)。当 count=3 也取得到之后,3T 不再像芯片时代那样恒 0。TIN/TOX 把四个方向的进入/退出合并计数。

本板未宣告的能力显示 N/A(当前 S3 宣告 0x3CB,不含 STP MOV ACT)——RECOGNIZER_CAPABILITIES 与驱动能力都没启用它们,不是阈值问题。

计数是 u16 饱和累计、每 boot 归零、没有清零入口(与触摸计数器语义一致)。选 u16 而非触摸侧的 u8 是因为摇动在 300 ms 不应期下约 3 次/秒,255 不到一分半就溢出,不够标定。

一拍里仲裁后可能同时带着多个跨 family 的事件(摇动和它引发的倾斜同批抵达),这些全部计数而非只留优先级最高的一个,否则倾斜会从统计里消失。

新增来源

  1. 在 iot-bsp-esp 写一个只依赖 embedded_hal 的驱动,实现 MotionSource。
  2. 返回 MotionReading,硬件引擎的判定填进 hardware,不要在驱动里跑 core 阈值。
  3. 声明该驱动自身的能力并集;core 派生的那部分不重复声明。
  4. 板层 take_motion 挂上 MotionInput::new(driver)。

业务映射是另一层。InputEvent::Motion 目前映射为 BusinessIntent::Invalid——框架已就位,把语义接到具体产品意图是业务决策,不在驱动层做。

多 IMU 时按来源独立采样、独立健康检查、独立识别;每个来源一份 MotionCapabilities。50 Hz 语义总线不适合飞控/平衡车那类需要高率惯性流的场景,那是另一条路。

测试

moon run iot:test      # core:契约、识别器、仲裁、状态机
moon run iot:test-bsp  # 驱动:寄存器序列、握手、状态映射

test-bsp 依赖 Cargo.toml 里对 esp-hal 的 target 隔离(cfg(target_os = "none"))。QMI8658A 与 PCA9557 源码只依赖 embedded_hal,因此把这层 ESP 依赖挡在裸机目标之外,就能在工作机上直接跑驱动单测,不必为芯片 HAL 付出构建代价,也不需要新建 crate。

驱动测试覆盖:使能顺序(引擎必须在全部 CTRL9 命令之后才开)、两次参数集写入内容、握手超时不挂死、tap 位不再解码、引擎事件在数据面未更新时仍上报、身份不匹配、量程换算、yaw 环绕与步长上界、能力归属。