BNO086 · CYD ST7789 · V1.7

BNO086:接线、代码与LCD读屏

先认准CYD ST7789,再核六线与完整V1.7工程。按启动现象排错,读懂屏幕与真实日志里的测量值、更新状态和测试范围。

Quick Facts

  • 主板:ESP32-2432S028R / ST7789
  • 固件配置:0x4B · SDA27 / SCL22
  • 控制:INT35 / RST19 · 3.3V
  • 固件:V1.7,上电自动独立会话
  • 范围:三类报告;完整 XYZ 仍待测
  • 下载:源码已备,网盘待真实链接

第一次用,从这里开始 · V1.7

先配对接线与代码,再看屏幕读数

这套方案让 CYD 屏幕显示 BNO086 的加速度、转动快慢和朝向。先认准主板和屏幕,按这版代码接六根线,再打开完整工程编译。接线和代码版本要成对,不能只照一张相似板子的图。

遇到无数据,先读 按顺序排查;跑起来后读 LCD 11 区图解、动作对应的变化 和 真实日志解读。解释按 2026-10-05 核对的 V1.7 源码,没有读取此刻板上的实时状态。

第1步 · 先认板,再找接头

这页对应哪块主板、哪种屏幕?

唯一推荐组合:CYD ESP32-2432S028R + ST7789 + BNO086 + V1.7 / cyd_st7789。本项目按用户确认的这块板制作;屏幕已经有实测记录。外观相似或也叫“小黄板”的产品可能换过屏幕、接头和板修订,不能靠黄色PCB认定是同一配置。

看主板型号与屏幕

先核 ESP32-2432S028R 和 ST7789。源码里另有 cyd_ili9341,但它是其他屏变体备用,不是本项目推荐环境。需要确认实际驱动,再选环境,不用“两个都刷一下”判断板型。

看GPIO编号,不只看接头次序

GPIO 是ESP32引脚的电气编号,不是排针第几根。翻到板背面辨认 P3;GPIO35 用作 INT。GPIO21 是屏幕背光,不能拿它代替本程序的 RST19。

RST19引出方案已由用户确认:采用SKU02753 microSD Sniffer的DAT0/DO连接BNO086 RST,利用CYD TF接口的GPIO19信号。不插存储卡,也不启用SD功能;不按TF触点顺序猜焊脚。
对照同版背面整板:P3 / CN1 在哪里
同版双USB CYD背面参考照片,USB在右、TF上中、P3与CN1在左上
同版产品参考图;按这个拍摄方向USB在右,TF在上方中部,P3/CN1在左上。不是用户本轮实物接线照片,不能用来证明GPIO19某个焊点已核实。打开原尺寸背面图

模块图只用于对照信号名;用户模块为其他/自制品牌,按用户说明参考SparkFun同电路,不能由图片保证品牌或逐项电气一致。按模块实际丝印找 3V3/VCC、GND、SDA、SCL、INT、RST。

Six connections

断电逐根接线:CN1四线、P3 INT和RST19

第2步:先断电,按下面的信号名逐根核。本程序固定 SDA27/SCL22/INT35/RST19,不用常见ESP32示例的默认SDA21。移除SD卡,六根线都按3.3V系统连接,先核模块电源端的实际丝印。

双USB版CYD背面P3和CN1参考近照,照片左侧P3右侧CN1,按下方丝印辨认
同版背面接头参考照片,不是本轮实物接线照片。保持图中方向:左P3下方丝印从照片左→右是 GND / IO35 / IO22 / IO21;右CN1是 GND / IO22 / IO27 / 3V3。GPIO35对应P3照片左起第二焊点。翻转、换到插头正视或换板修订,不能照搬左右次序。打开原尺寸近照
  1. 电源:BNO086 的 3V3/VCC → CYD CN1 3V3。本方案用3.3V;不能只按模块图片颜色辨电源。
  2. 地线:BNO086 GND → CYD CN1 GND,必须共地。
  3. 数据:BNO086 SDA → CYD CN1 IO27。SDA不是IO22;程序明确 Wire.begin(27,22,400000)。
  4. 时钟:BNO086 SCL → CYD CN1 IO22。P3也有IO22,但不需要另接第二根SCL。
  5. 数据提醒:BNO086 INT → CYD 背面P3 IO35,按丝印核对。GPIO35只能输入、无内部上拉;是否加偏置要按实际模块电路核,不能只因没有另加电阻就断言故障。
  6. 复位:BNO086 RST → SKU02753 microSD Sniffer DAT0/DO(GPIO19),该引出方案已由用户确认。GPIO21供背光,不能代替RST19;本程序不使用SD卡。
CN1四线、P3 IO35与SKU02753 DAT0/DO(GPIO19)构成本方案六线连接。改接先断电,Sniffer上不插SD卡,不把复位线当SD数据功能。
软件逻辑接线已修正并核对。 最新 JSON / FZZ 的六端点六线重开验证已通过,映射为 SDA→GPIO27、SCL→GPIO22、INT→GPIO35、RST→GPIO19,与本 V1.7 固件一致。此前软件自动识别造成的 SDA / SCL 反向状态已修正;这次修正没有改接或验证实物,也没有刷写固件。
图中的GPIO19端点仅为逻辑示意。 实际RST使用已确认的SKU02753 DAT0/DO引出,不按图中位置或TF触点顺序焊接;GPIO35 已有背面 P3 位置证据,但前视投影和虚拟点不代表实际焊点坐标。使用前按手头板型核对实际连接。
依据 V1.7 配置绘制的六线示意,SDA27 SCL22 INT35 RST19;软件映射已核对,GPIO19实体焊脚未确认
本图解释 V1.7 固件的目标连接;最新软件映射与配置一致,不能作为实物接线已经验收的证明。GPIO19采用已确认的SKU02753 DAT0/DO引出;图上GPIO19仅为逻辑端点。
查看最新软件逻辑接线图(供核对,不是焊点指南)

来源图的六线连接已经与项目映射核对。原图中竖排文字在小屏上较小,请同时看本节接线表和下面的说明。

已修正的六线软件逻辑图:SDA27、SCL22、INT35、RST19;GPIO19和GPIO35为虚拟逻辑端点,不是实物焊点位置
逻辑图,不是焊点指南。 GPIO19/RST 实体焊脚未确认;GPIO35/INT 的前视虚拟点不能代替背面 P3 实物位置。模块图片仅作参考形象,不保证用户实物为 SparkFun 原品牌。

打开原尺寸图 · 下载不含本机路径的逻辑映射

模块端CYD 端用途 / 易错点
3V33.3V本方案供电与逻辑使用 3.3V;不能据此推定其他模块耐 5V
GNDGND共地
SDAGPIO27数据线;不要与 SCL 交换
SCLGPIO22时钟线
INTGPIO35,背面 P3低有效输入;GPIO35 无内部上拉。背面 P3 有位置证据,图中前视虚拟点不是焊点。实际电平与输出结构仍需按模块电路核对
RSTGPIO19;TF DAT0 / DO 的电气身份低有效复位;使用用户确认的SKU02753 microSD Sniffer DAT0/DO引出;不能按逻辑图虚拟点焊接。本方案不使用 SD 卡
GPIO21 是屏幕背光,RST 使用 GPIO19。 GPIO19 用于模块复位时不要插 SD 卡;改变接线先断电。GPIO35 没有内部上拉这一事实,不足以证明“没加电阻就是故障原因”。用户记录为 INT 直连且未另加上拉;10kΩ 到 3.3V 仅是可选排查项,需要核实际电路并保留加装前后独立记录。

本项目按 INT 配合读取。SparkFun 库接线流程同时要求 INT / RST,Qwiic 四线接口本身只带电源、GND、SDA、SCL。官方接线指南、ESP32 GPIO 文档。

在线烧录已提供 · 页面更新2026-10-07 · 固件V1.7(2026-10-04)

只适用BNO086 SKU01954 + CYD ESP32-2432S028R / ESP32 classic / 4MB / ST7789。浏览器会核芯片与Flash容量、下载SHA-256及写后MD5。烧录会覆盖原程序、分区及写入范围内的数据,请自行备份。串口由你主动选择。

下载固件地址与哈希清单

复用已验证ST7789 V1.7应用;本次只做离线导出和浏览器检查,没有刷写实际板卡。

第3步 · 接线和这份工程一起用

打开整包 V1.7:从下载到编译、烧录

当前推荐:BNO086_MODULE_TEST_V1.7本页统一源码包;默认 cyd_st7789;带项目内依赖和修补库。
下载完整工程
  1. 解压完整 ZIP。打开 BNO086_I2C_Test_V1.7 目录,应同时看到 platformio.ini、src 和 lib。不是只下载 main.cpp,也不是在 Arduino IDE 里新建一个空草图。
  2. 用 VS Code + PlatformIO 打开项目文件夹。安装VS Code,在扩展里安装官方 PlatformIO IDE并等待初始化完成,它自带Core。通过“文件 → 打开文件夹”选择含 platformio.ini 的目录;确认默认环境 cyd_st7789。项目使用Arduino框架,构建入口是PlatformIO;命令可在PlatformIO Core CLI终端执行。
  3. 保留本包依赖与配置。platformio.ini 固定 espressif32@6.13.0 和 Arduino-ESP32 2.0.17,目标 esp32dev。lib 已带 SparkFun BNO08x 1.0.6修补副本、TFT_eSPI 2.5.43、U8g2_for_TFT_eSPI 1.7.0;首次构建需下载对应平台和工具链。不要先换成网上同名BNO库,或重配默认SDA/SCL。
  4. 先 Build。在PlatformIO Project Tasks展开 cyd_st7789 → General → Build,或执行下方只编译命令。成功应出现该环境 SUCCESS;失败先保留完整报错,检查打开目录、完整lib和工具链下载,不先改硬件接线。
  5. 再由你确认目标串口并 Upload。接好已核对的六线、移除SD卡,用可传数据的USB线连接CYD。确认目标端口,关闭占用它的串口监视器/日志工具,再执行上传命令。COM9只是本项目历史端口,不保证是你电脑上的端口。
  6. 上传完成后按下一节核启动。V1.7上电自动开始AUTO会话,不需要先发START才有读数。打开串口时以 INFO 核对固件标识、ST7789和地址0x4B;永久记录另用日志工具保存。
在含 platformio.ini 的根目录 · 只编译和列端口
pio run -e cyd_st7789
pio device list
确认物理目标后 · 先把 COMx 改为实际端口
pio run -e cyd_st7789 --target upload --upload-port COMx
pio device monitor --port COMx --baud 115200

上传会写入目标板固件,命令中的 COMx 是必须替换的占位符。本次网页制作只核对文件,没有执行上传或串口操作。公开ZIP已核验290份文件哈希与原快照一致;原项目曾编译、刷写并实测,本次没有对公开ZIP重新做编译或硬件测试。

想看程序内容或复制整份文件,见 代码查看与复制;日志工具、校验清单和说明在 下载区。独立 main.cpp 不包含完整依赖。

安装与命令参考:PlatformIO官方VS Code说明、pio run与上传参数;项目实际设置以包内platformio.ini为准。

第4步 · 核对是否真正开始读数据

成功启动时,屏幕和日志应该出现什么?

  1. 屏幕亮起并显示标题。“BNO086 模块测试”说明显示程序运行到了绘制界面;仅此还没有证明传感器通信成功。
  2. 初始化完成。ID 出现 AUTO_…,阶段进入 WARMUP,再STILL;地址为0x4B,出现 SH2 OK。串口 INFO 对应 BNO086_MODULE_TEST_V1.7。若只显示 Waiting for START,先核版本与STATE,而不是直接认定模块坏了。
  3. 确认持续报告。A/G/RPY 三行有值,Valid 的三个累计数持续增长,Hz 随最近数据更新。静止时数字很稳定也可以正常工作;OLD 则表示该类数据未到或已超过500ms没有效更新。
  4. 先完成静置,再轻缓动作。预热5秒、静置15秒期间放稳,等 READY 再观察拿起/转动响应。G显示转得多快,RPY显示朝向;完整XYZ结果要按串口命令逐轴记录。
  5. 保存并阅读本轮结果。I2Cerr等异常应结合完整日志;超过60秒也不会自动FINISH。通信有数据、静置满足窗口、三轴完成是分别判断的事,不能因有SH2 OK就宣称全功能通过。
本项目确实观察到的启动链:受控重启后出现 V1.7 → 自动 START → 0x4B 应答 → 产品信息响应 → 三类报告启用 → 连续 SAMPLE。该轮68.390秒读错误/断流/缺序号为0,整体仍为INCOMPLETE,因为XYZ未做。原始片段在 日志解读。

每次重启产生新的 boot / run / AUTO 标签并清零,不恢复旧模块标签或结论。未连主机工具时的串口启动输出不能事后补回;屏幕实时数据显示本身不是永久日志存储。

没有数据:按顺序排查 数据出来了:逐项读懂LCD

Troubleshooting

没有数据时,按这个顺序查

  1. 先记录现象。拍下LCD标签/阶段/OLD/异常;保存INFO、STATUS和启动日志。问题可能涉及接线、供电、版本、依赖或器件,不凭一句“没数据”判定原因。
  2. 核板型与代码是否配对。这页是CYD ST7789 + V1.7 + cyd_st7789;确认INFO固件身份、地址0x4B和SDA27/SCL22/INT35/RST19。旧V1.6可能等待START,不能按V1.7启动行为判断。
  3. 断电核六线和实物接头。CN1 3V3/GND/IO27/IO22,P3 IO35,已确认的GPIO19引出;检查接触、共地、模块电源/协议设置、SD卡未插、GPIO21仍用于背光。RST使用SKU02753 DAT0/DO,不试猜TF触点。
  4. 把地址应答与数据报告分开。I2C_PROBE wire_result=0只有地址应答;还需PRODUCT、REPORT_ENABLE成功和SAMPLE。只有地址没有SH2或Valid不增长,继续核INT/RST、实际模块协议与本包修补库。
  5. 按现象处理显示或刷新。屏幕不亮/颜色错/布局异常,先核ST7789环境、供电和GPIO21背光;有正常LCD但数值稳定,先看Valid是否增长、是否OLD,再判断是不是只是静止。串口与日志工具一次只让一个程序占用同一端口。
  6. 改动后新建独立记录。保留旧结果,断电改接后重启,记录新boot/run。阶段CHECK/!或REVIEW要看动作、环境、原始计数与窗口,不能抹掉错误后写成功。
编译报错发生在电脑端,先核项目目录、lib与工具链;上传报错先核端口、连接和端口占用;已上传但无传感数据再核版本、六线、协议和报告链。这几种现象应分开处理。
上传成功,但屏幕不亮或显示异常?

上传成功只说明写入完成。先核cyd_st7789环境、板型、供电和GPIO21背光没有被拿去接RST;串口INFO是否显示V1.7/ST7789。触摸CS未配置对应本项目不启用触摸,不直接等于传感器通信故障。保留屏幕现象和串口输出再排查。

扫到 0x4B,为什么还不能说工作正常?

ACK 只说明地址有应答。还需确认 SH-2 产品响应、报告订阅成功、INT / RST 时序以及连续有效数据。保存本轮标签、run、异常,断电核六线,再另开轮复测,不能用扫描结果代替测试结论。

重启后如何自动显示,原来的测试记录在哪里?

本 V1.7 每次上电自动建立新的 AUTO 会话,实时显示;旧版本 V1.6 需要 START。请先 INFO 核固件。自动会话在 RAM 和串口上更新,永久文件由主机工具保存,未连接期间的启动行不能补回。新 run 不继承旧计数或标签。

没有另外加 INT 上拉,是不是接错了?

GPIO35 无内部上拉,但实际是否需要外加偏置还取决于模块输出电路。SparkFun v10 的 INT 网未画单独上拉,用户同电路说明尚未电气测量。先核电源、连线和电平,10kΩ到3.3V只作为可选排查,记录改动前后,不能单独归因。

零通信异常为什么仍然 REVIEW 或 INCOMPLETE?

通信、静置和阶段完成度分别记录。最后 AUTO 快照通信计数为0,但静置没有满足窗口,保留 REVIEW;68.390秒轮次静置满足粗测窗口但三轴未做,整体为 INCOMPLETE。原因应结合动作、环境和原始记录复核,不能直接推定模块损坏。

偏航变化或漂移,应检查什么?

先核所用报告、轴向、单位与报告 Status,再记录附近磁性物体和电流回路。Game RV 不用磁力计,偏航可能随时间漂移;Rotation Vector 使用磁北参考也受磁场环境影响。本工程没有独立磁北 / 精度验证,需用受控参考另测。

屏幕和日志里的角速度单位如何对照?

库值为 rad/s,屏幕与 SAMPLE.g_dps 乘以 180/π,单位为 °/s;静置 gyro_norm_peak_rads 和运动 axis_gyro_peak_rads 统计保留 rad/s。对照阈值或报告时按字段写单位,例如0.6 rad/s约为34.4°/s,不能把两个数字直接比较。

能用旧 BNO055 或普通扫描仪的程序吗?

BNO086 使用 SH-2 / SHTP 报告协议,BNO055 与 MPU6050 的库和寄存器不能直接替代。使用本包的修补库和默认 ST7789 环境,核对应六线;本项目不使用 SD 卡或触摸功能。

按当前程序逐项读屏

LCD 从上到下:11 区读懂每个字段

屏幕标题是“BNO086 模块测试”。真实 V1.7 是白字黑底,约每半秒重画一次读数;没有按数值自动变红、变绿的报警颜色。固件版本在串口 INFO 输出中确认,标题本身不显示版本。

V1.7横屏字段位置复刻示意,11个蓝色编号对应下文;读数留空,非实机截图
按 main.cpp:355–394 的绘制顺序复刻;数字位置用横线或方括号留空,不是实机截图,也没有填造实测读数。蓝色编号是网页注释,真实 LCD 没有编号。打开原尺寸图
1

ID:… run:… · 本轮是谁

ID 是 AUTO_… 自动标签或你输入的 M001 等人工标签。run 是这次上电内的会话序号。两者都不是芯片出厂序列号;完整 boot-run 在日志里,同为 run:0001 也可能来自不同上电。

2

阶段 · 倒计时 · 0x4B SH2 OK

WARMUP 预热 5 秒 → STILL 静置 15 秒 → READY 等你开始三轴动作;发送 X/Y/Z 后进入 MOTION,对应轴测试 8 秒。倒计时是当前阶段还剩几秒,READY 的 0s 不是故障。0x4B 是程序选择的 I²C 通信地址;SH2 OK 表示产品响应与三类报告订阅已完成,不能当作整机测试通过。

3

A m/s2 · X / Y / Z 加速度

三个数依次是模块坐标的 X、Y、Z,单位 m/s²,显示两位小数。包含重力,放在桌上也不应该期待三项全为 0;倾斜时重力会分配到不同轴。正负号表示沿哪个方向,不能只凭画面“往左”猜符号。

4

G d/s · X / Y / Z 转动快慢

三个数依次是绕 X、Y、Z 的角速度,显示一位小数。屏幕 d/s 指度/秒(°/s),不是距离,也不是转过的总角度。转动时会变化,停止转动后通常回到接近 0。库内部用 rad/s,程序显示时换算为 °/s。

5

RPY · Roll / Pitch / Yaw 朝向

依次是横滚、俯仰、偏航,单位度(°),屏幕取整。可以把它们理解为左右倾、前后仰、朝向转了多少,但要先对照模块轴向和安装方向。它来自 BNO086 的融合四元数,再由 ESP32 换算;不是直接照抄 G,也不是移动距离。停止转动后角度可以保持非零。复杂倾斜时可能多个角一起变化,接近角度边界的跳变不等于器件瞬间乱转。

6

Hz A/G/Q · 最近数据更新有多快

依次是 A 加速度、G 角速度、Q 融合姿态的最近约一秒有效报告次数,不是屏幕刷新次数。Q 行虽然叫 Q,LCD 用 RPY 显示该报告的朝向,并没有显示四元数四个数。程序每类请求 50 Hz;实测有效速率另看日志,不能强求三项始终等于 50.0。

7

Valid · 本轮读到了多少条有效报告

三个累计数顺序也是 A / G / Q。持续采集时应该不断增长;不是校准分数或合格率。新 AUTO 或新贴签会话从头计数,FINISH 后停止增长。三项不必相等,因为实际报告速率不同。

8

I2Cerr · Bad · Gap · Rst · 异常线索

I2Cerr:初始化和运行时读写出错次数;Bad:无效数值/重复报告及解码错误;Gap:A/G/Q 断流事件和 INT 等待超时,持续断流通常先计一次事件;Rst:意外器件复位和运行期主机硬复位,不含初始化的计划复位。这些数是诊断次数,不是测量值。四项为 0 仍应看完整日志:缺序号、过大包、启用失败等并没有逐项列在这行。

9

Still · XYZ · …s/60s · 粗测做到了哪一步

Still:WAIT 还没完成静置;OK 是静置粗测满足窗口;CHECK 是没满足,需复核动作与环境。XYZ 三个位置按 X/Y/Z 排列:- 没做,字母 O 做完且满足该轴窗口,! 做完但没满足。拿起模块不会自动把“---”变成“OOO”,需要 READY 后通过串口启动各轴阶段。…s/60s 是有效流开始后的累计秒数,60 秒为最低观察要求,不是自动结束的倒计时,超过 60 秒可继续增长。

10

大字状态 · TESTING / REVIEW / OBSERVED / INCOMPLETE

TESTING 表示会话还在进行,没有因通信/数据异常提前进入 REVIEW;它不是“已通过”。活动时可以同时出现 Still:CHECK 与 TESTING,因为阶段结果在 FINISH 后才汇总。REVIEW 表示有异常或结束时某项粗测没满足,需要检查;INCOMPLETE 是初始化、时长或阶段未完成;OBSERVED 是结束后满足本程序的观察窗口,仍不是计量精度或所有芯片功能的合格证明。

11

底部操作提示

AUTO 会话一直显示 AUTO label; START M001 to assign,意思是可以给模块贴一个人工标签;不是让你必须发送 START 才能看到数据。操作员会话 READY 时显示 Serial X/Y/Z; FINISH after 60s;其他阶段提示 Serial STATUS / FINISH。Serial 是串口命令,当前程序没有屏幕触摸按钮来执行它们。

行尾 OLD 怎么读?该类报告还没收到,或距最后有效更新超过 500 ms。屏幕会保留旧数字并标 OLD,旧数不能当作此刻新测量。FINISH 后保留结束时的画面,计数停止;即使没有 OLD,也不能把这张结束画面当作仍在采集。
如果看到 INIT INCOMPLETE,没有正常数据区

初始化没完成时,LCD 显示 Starting sensor…、Waiting for START 或 INIT INCOMPLETE,并显示 SDA27/SCL22/INT35/RST19、GPIO21 背光、3.3V/不使用TF卡及串口提示;不会显示正常 A/G/RPY 三行。先保存 INFO/STATUS 和启动日志,再断电核对接线。V1.7 上电 INIT 失败会保留 INCOMPLETE,不循环重试。GPIO19从SKU02753 DAT0/DO引出,不按逻辑图虚拟点焊接。

屏幕上哪个是校准等级?

这版 LCD 没有校准等级一栏。Still OK、Valid、SH2 OK 都不是校准完成。程序把报告状态低两位记为串口 SAMPLE 的 accuracy,0/1/2/3 表示报告估计的不可靠/低/中/高;它没有在 LCD 显示,也没有作为 Valid 的校准门槛。订阅“校准角速度”是输出类型的名字,本程序没有显式执行交互校准或保存校准命令。

布局与逻辑来源:公开 main.cpp 的 349–394 行;刷新 419–420 行,阶段 284–301 行;test_config.h;evidence_rules.h。数据类型定义见 SH-2 手册 §6.5.9 / §6.5.13 / §6.5.18。

Model & purpose

BNO086 做什么,本方案实际读了哪些报告

A · 受到什么加速度

含重力的X/Y/Z三个分量;静止也有读数,单位m/s²。

G · 正在转得多快

绕X/Y/Z的角速度;停转后通常接近0,屏幕单位°/s。

RPY · 现在朝向哪里

融合朝向换算成横滚/俯仰/偏航;停止时可以保持非零,单位°。

BNO086 把三轴加速度计、陀螺仪、磁力计和运行 SH-2 的处理器集成在一个器件内。片内融合把多种传感输入整理成朝向、重力等报告,主机按需要订阅。I²C 负责传输,本项目用 CYD 屏幕显示读数并记录每轮状态。功能背景见 CEVA 官方产品说明。

对象本页采用的范围尚待确认
BNO086 实物模块用户说明采用与 SparkFun 同样电路;本页按型号组织专用购买链接、逐项电路一致性和电气测量;模块SKU01954已确认。品牌不能写为已经保证的 SparkFun 原装实物
CYD 测试方案ESP32-2432S028R / ST7789,PlatformIO + Arduino 框架,V1.7其他屏幕和主控需要核引脚与驱动,不能直接沿用本板的端点位置
输出含义 / 单位本 V1.7 状态
Accelerometer(A)包含重力的加速度,m/s²;不是原始 ADC 数值已启用,SH2_ACCELEROMETER
Calibrated Gyroscope(G)角速度,库值为 rad/s;屏幕 / SAMPLE.g_dps 为 °/s;峰值统计字段 *_rads 为 rad/s已启用,SH2_GYROSCOPE_CALIBRATED
Rotation Vector(Q / RPY)九轴融合的四元数,源码转换为横滚、俯仰和偏航已启用,SH2_ROTATION_VECTOR
Game Rotation Vector六轴姿态,不用磁力计修正航向芯片支持,本工程未订阅
独立磁力计、Gravity、Linear Acceleration、步数、Interactive Calibration分别处理磁场、重力、运动加速度和其他应用支持项;本工程没有独立启用 / 验证这些报告或流程

三类已启用报告均请求 20000 μs,即 50 Hz。请求速率、400 kHz 总线时钟与主机实际有效报告速率分别记录,不能混为一个“采样率”。

已有 BNO055 九轴资料 和 MPU6050 六轴 STM32 方案 可对照阅读。它们的协议、库和接线不同,不能将一个型号的代码直接套到另一个。

看动作,认数据

放稳、拿起、转动:先观察这些变化

动作前先读阶段:WARMUP / STILL 时放稳,不要为了让数字变化去晃动。等到 READY,再做轻缓动作;如果要留下三轴阶段结果,应按测试步骤通过串口开始 X、Y、Z,而不是只随手转一下。下面讲的是预期关系,不是为你的模块预填读数。

放稳,保持不动

G 通常接近 0;A 仍有重力,分量取决于摆放方向,模长通常在约 9.8 m/s² 附近;RPY 可以是非零角度。看 Valid 继续增长,确认“数值稳定”与“停止更新”不是一回事。

拿起或平移

起步、加速、减速时 A 可能变化;若朝向没改变,G/RPY 不应只因为换了位置而大幅变化。移到别处再放稳,它不会显示“走了多少厘米”。

缓慢倾斜

A 的重力分量会在三个轴之间重新分配,RPY 也随朝向变化。A 某个数改变不一定意味着你在平移,可能只是板子倾斜了。

绕模块某轴转动

相应 G 角速度与朝向会变化;停止时 G 通常回近 0,RPY 保留新的朝向。按模块丝印与安装核轴向;有倾斜时可能多个 RPY 数一起变,不能只按录像左右方向猜正负。

区分“快慢”和“角度”:G 说此刻转得多快,RPY 说此刻朝向哪里。转完停住,G 接近 0,RPY 仍可非零。绕重力方向转时 A 可能变化很少,朝向仍在改变。

LCD 约每秒更新 2 次,而传感报告远快于它,短促动作的峰值可能没恰好显示出来。需要比较数值或排查断流时,看串口原始日志;不要把某一帧的整数 RPY 当作精度证据。

已经在这版程序中观察什么本程序没有给出什么
A:含重力的三轴加速度;G:三轴角速度;Q换算的RPY:融合朝向;数据是否连续、异常和粗测阶段空间位置、移动距离、线速度;独立磁场读数、单独去重力加速度、温度、电压;屏幕校准等级;绝对角度精度与长期可靠性证书

完整三轴步骤见 一轮测试怎么做。四元数、六轴/九轴原理可继续读 融合说明 和 朝向示意;网页交互没有连接你的模块。

真实日志 · 原结论保留

后续成功日志:读到了什么,证实到哪一步?

这是保存于2026-10-04的V1.7真实串口记录,2026-10-05复查没有找到更晚的新轮硬件实测。每段保留自己的run,不把不同会话拼成一次通过;下方原始正文只去除了主机前缀。既有实拍视频尚未与这些run核对配对,不作为同步证据。

① 上电自动开始,模块应答并订阅成功

7012DC1D-0001 · 启动原文
FW,BNO086_MODULE_TEST_V1.7,title=BNO086 module test,board=ESP32-2432S028R,display=ST7789,address=0x4B,boot=7012DC1D
EV,boot_ms=718,test_ms=25,module=AUTO_7012DC1D,run=7012DC1D-0001,stage=INIT,event=START,fw=BNO086_MODULE_TEST_V1.7,address=0x4B,SDA=27,SCL=22,INT=35,RST=19,I2C_Hz=400000,id_source=automatic_boot_label,unique_serial=not_read
EV,boot_ms=728,test_ms=35,module=AUTO_7012DC1D,run=7012DC1D-0001,stage=INIT,event=I2C_PROBE,address=0x4B,wire_result=0
EV,boot_ms=1307,test_ms=614,module=AUTO_7012DC1D,run=7012DC1D-0001,stage=INIT,event=REPORT_ENABLE,acc=1,gyro=1,quat=1,request_us=20000
EV,boot_ms=1327,test_ms=634,module=AUTO_7012DC1D,run=7012DC1D-0001,stage=WARMUP,event=STAGE_BEGIN,duration_ms=5000,instruction=place_module_still

V1.txt 原行 36、39、40、47、49 的选摘;非连续行没有拼成一条新记录。

FW确认V1.7、ST7789;START是自动上电标签。address=0x4B是本配置地址,wire_result=0表示地址探测成功。acc=1,gyro=1,quat=1说明三类报告的启用请求成功,request_us=20000是每类请求间隔20ms。还需要下一段持续SAMPLE才能证明实际有更新。

② 静止也在更新:看计数,不只看数字跳动

同一run相隔1秒 · SAMPLE原文
EV,boot_ms=21647,test_ms=20954,module=AUTO_7012DC1D,run=7012DC1D-0001,stage=READY,event=SAMPLE,a_ms2=-8.312/-4.453/0.258,g_dps=-0.11/-0.11/-0.11,rpy_deg=-86.5/61.8/-14.9,hz=64.9/50.0/50.0,valid=1308/1012/1012,age_ms=7/3/0,sh2_estimated_us=21637239/21638639/21639639,accuracy=2/3/0,anomalies=0
EV,boot_ms=22647,test_ms=21954,module=AUTO_7012DC1D,run=7012DC1D-0001,stage=READY,event=SAMPLE,a_ms2=-8.309/-4.473/0.277,g_dps=-0.11/0.34/0.34,rpy_deg=-86.5/61.8/-15.0,hz=64.0/50.0/50.0,valid=1372/1062/1062,age_ms=16/3/0,sh2_estimated_us=22628239/22639244/22640739,accuracy=2/3/0,anomalies=0

V1.txt 原行 73、74 的选摘;非连续行没有拼成一条新记录。

这里 test_ms从20954到21954,相隔1000ms;valid从1308/1012/1012增至1372/1062/1062,分别新增64/50/50条。A/G/RPY读数变化很小,报告仍持续抵达。

这条真实SAMPLE字段怎么读
boot_ms / test_ms分别是本次启动后毫秒数、本轮会话开始后毫秒数;不是日期,也不等于有效流时长。
a_ms2=-8.312/-4.453/0.258A的X/Y/Z,m/s²,含重力;这是当时摆放方向的真实分量。
g_dps=-0.11/-0.11/-0.11G的X/Y/Z,°/s;接近零不是没收到数据。
rpy_deg=-86.5/61.8/-14.9当时的横滚/俯仰/偏航,°;LCD取整显示,串口保留一位小数。
hz / valid最近约一秒报告率 / 本轮累计有效数,顺序A/G/Q。
age_ms=7/3/0距各类最后有效更新7、3、0ms;超过500ms或未收到,LCD相应行标OLD。
accuracy=2/3/0报告状态等级,顺序A/G/Q;不是LCD的Still或大字结果。本程序仍接收状态0的有效数值,不能据此证明姿态精度。
sh2_estimated_us / anomalies主机接收与报告延迟推算的时间 / 程序汇总异常。推算时间诊断另记,见下文,不能把0解释成所有诊断都为0。

③ 68.390秒结束记录:通信观察成立,整轮未完成

同一run · FINISH / SUMMARY / TRANSPORT原文
EV,boot_ms=69717,test_ms=69024,module=AUTO_7012DC1D,run=7012DC1D-0001,stage=FINISHED,event=FINISH,reason=REPLACED_BY_OPERATOR_LABEL,test_duration_ms=69024
EV,boot_ms=69738,test_ms=69024,module=AUTO_7012DC1D,run=7012DC1D-0001,stage=FINISHED,event=SUMMARY,overall=INCOMPLETE,communication=PRODUCT_RESPONSE,stream=OBSERVED,stream_ms=68390,anomalies=0,still=COARSE_WINDOW,X=NOT_RUN,Y=NOT_RUN,Z=NOT_RUN
EV,boot_ms=69831,test_ms=69024,module=AUTO_7012DC1D,run=7012DC1D-0001,stage=FINISHED,event=TRANSPORT,read=21044,read_error=0,write=0,write_error=0,int_wait_timeout=0,host_hw_reset=0,device_reset_event=0,decode_or_frame_error=0,oversize=0,report_enable_error=0

V1.txt 原行 135、137、141 的选摘;非连续行没有拼成一条新记录。

read=21044是I²C读取次数,read_error=0是读取错误次数,两者不能混淆。该摘要的写错误、INT等待超时、解码/结构异常、过大包、启用错误、运行复位均为0;三类报告的无效、断流、缺序号、重复也均为0。

test_duration_ms=69024包含初始化;stream_ms=68390是68.390秒有效流观察。stream=OBSERVED满足该通信观察窗口,但overall=INCOMPLETE和X/Y/Z=NOT_RUN明确说明三轴没有做。FINISH原因是人工标签接管,不能写成三个模块或全部功能通过。

展开完整报告诊断:有一个诊断计数并不是零
A/G/Q及初始化诊断原文
EV,boot_ms=69759,test_ms=69024,module=AUTO_7012DC1D,run=7012DC1D-0001,stage=FINISHED,event=REPORT_SUMMARY,type=0,valid=4406,invalid=0,timeout_episodes=0,sequence_missing=0,duplicates=0,sh2_time_nonmonotonic=49,max_gap_ms=61,mean_hz=64.42,last_accuracy=2
EV,boot_ms=69779,test_ms=69024,module=AUTO_7012DC1D,run=7012DC1D-0001,stage=FINISHED,event=REPORT_SUMMARY,type=1,valid=3413,invalid=0,timeout_episodes=0,sequence_missing=0,duplicates=0,sh2_time_nonmonotonic=2,max_gap_ms=87,mean_hz=49.90,last_accuracy=3
EV,boot_ms=69800,test_ms=69024,module=AUTO_7012DC1D,run=7012DC1D-0001,stage=FINISHED,event=REPORT_SUMMARY,type=2,valid=3413,invalid=0,timeout_episodes=0,sequence_missing=0,duplicates=0,sh2_time_nonmonotonic=2,max_gap_ms=87,mean_hz=49.90,last_accuracy=0
EV,boot_ms=69852,test_ms=69024,module=AUTO_7012DC1D,run=7012DC1D-0001,stage=FINISHED,event=INIT_TRANSPORT,read=10,read_error=0,write=5,write_error=0,int_wait_timeout=0,planned_host_hw_reset=1,decode_or_frame_error=0,oversize=0

V1.txt 原行 138、139、140、142 的选摘;非连续行没有拼成一条新记录。

type=0/1/2依次为A/G/Q;sh2_time_nonmonotonic=49/2/2不是零。源码把SH-2时间视作主机接收时间与延迟的估计,单独记录倒退次数,没有加进 anomalyCount,所以原来的 anomalies=0可以与它同时出现。保留这项诊断,不据此断言器件时钟坏了。初始化 planned_host_hw_reset=1是计划复位,不是LCD的意外Rst错误。

④ 最新保留会话:仍有数据,但静置需要复核且未收尾

4B9E7573-0001 · 原静置结果、摘要及最后SAMPLE
EV,boot_ms=21330,test_ms=20637,module=AUTO_4B9E7573,run=4B9E7573-0001,stage=STILL,event=STILL_RESULT,result=REVIEW,acc_n=967,gyro_n=749,g_mean_ms2=9.4523,g_std_ms2=0.3056,gyro_norm_peak_rads=3.3680
EV,boot_ms=21802,test_ms=21109,module=AUTO_4B9E7573,run=4B9E7573-0001,stage=READY,event=SUMMARY,overall=REVIEW,communication=PRODUCT_RESPONSE,stream=INCOMPLETE,stream_ms=20475,anomalies=0,still=REVIEW,X=NOT_RUN,Y=NOT_RUN,Z=NOT_RUN
EV,boot_ms=23649,test_ms=22956,module=AUTO_4B9E7573,run=4B9E7573-0001,stage=READY,event=SAMPLE,a_ms2=-8.320/-4.367/0.766,g_dps=0.00/0.00/0.00,rpy_deg=-80.1/62.0/-6.0,hz=64.9/49.9/49.9,valid=1437/1112/1112,age_ms=6/3/0,sh2_estimated_us=23638738/23639938/23640338,accuracy=2/3/0,anomalies=0

V1.txt 原行 216、221、228 的选摘;非连续行没有拼成一条新记录。

最新摘要是 stream_ms=20475,即20.475秒,静置与整体都是REVIEW,XYZ未做。静置标准差0.3056m/s²与角速度模长峰值3.3680rad/s未满足粗测窗口;具体动作/环境原因未确定,不能据此直接归因模块损坏。

之后最后一条SAMPLE的 test_ms=22956表示会话运行到22.956秒,Valid增至1437/1112/1112,证明摘要后仍有更新;它不是新的stream_ms摘要或新的通过结果。采集器最后记录“Closed without FINISH”,没有完整结束记录。该轮保留REVIEW/INCOMPLETE范围。

原V1.txt第229行为采集器说明:Closed without FINISH; final new AUTO session left streaming. 关闭的是采集工具;未连接之后的数据无法事后补回。

“成功”在这里具体指自动启动、地址/产品响应、连续报告和相应时间内指定通信计数为0。没有三轴完成、三只全部通过、校准完成或计量精度合格结论。

下载38条真实记录选摘 下载行号与来源哈希

原始V1.txt共229条,项目原文件未改;SHA-256:72a71b77bdc8615ca3114ccfe42dd2d2e2d8c2629d811be8824ebcbaf88706d8。公开附件只包含选摘正文、原行号和审核边界,不含本机用户名、目录或原Flash。

Observed scope · 2026-10-04

实机记录:通信已观察,完整阶段仍待完成

本项目实测 当前固件为 V1.7,主板是用户确认的 CYD ESP32-2432S028R / ST7789。上电自动创建新会话,屏幕实时更新;操作员开始贴签测试时,旧 AUTO 先结束,再清零建立新的 run。AUTO 和 M001 都是软件或人工标签,不是芯片唯一序列号。

68.390 sV1.7 已保存结束记录的流时长
0该轮通信 / 报告序列异常
NOT_RUN该轮 X / Y / Z 均未执行
REVIEW最后 AUTO 快照的静置结果
版本 / 独立 run实际保存的观测原结果与限制
V1.7 / 79C93EE4-0001直接 INFO 时已经自动采集,71.951 s 快照异常 0流 OBSERVED,整体 INCOMPLETE;连接前的启动输出未补造
V1.7 / 7012DC1D-0001流 68.390 s;A/G/Q 有效报告 4406/3413/3413,均值 64.42/49.90/49.90 Hz;I²C 读取 21044 次,读错误、无效、断流、缺序号、重复及其他传输异常均 0静置 COARSE_WINDOW;流 OBSERVED,整体 INCOMPLETE;三轴未执行。被人工标签接管时有 FINISH
V1.7 / 7012DC1D-0002HANDOFF_CHECK 接管后计数独立;4.188 s 快照,异常 0随后受控重启,缺 FINISH;静置和三轴未做,未完整收尾
V1.7 / 4B9E7573-0001新 AUTO 的 20.475 s 摘要,指定传输 / 序列异常 0;摘要后最后 SAMPLE test_ms=22956、Valid1437/1112/1112,未出现新 SUMMARY 或 FINISH;静置加速度模长标准差 0.3056 m/s²,角速度模长峰值 3.3680 rad/s静置 REVIEW、整体 REVIEW;流 INCOMPLETE、三轴未做、未 FINISH。具体原因未确定
V1.6 / 9B1EA1B9-0001(历史)流 186.755 s;A/G/Q 12040/9327/9327,64.47/49.94/49.94 Hz,通信和序列异常 0静置 COARSE_WINDOW,流 OBSERVED、整体 INCOMPLETE;三轴未做,保留独立历史记录
指定零通信计数只描述相应会话和已保存时间范围;SH-2估计时间非单调诊断另记,68.390秒该项A/G/Q为49/2/2,不能写成所有计数均0。V1.1–V1.5 的历史 REVIEW 仍保留;本页没有三轴完成、绝对姿态精度、磁场、温漂或长期可靠性合格结论。
软件 / 硬件当前项目记录
目标显示 / 通信ST7789;地址 0x4B,SDA27 / SCL22 / INT35 / RST19,400 kHz
构建环境PlatformIO espressif32 6.13.0 / Arduino-ESP32 2.0.17;ST7789 与 ILI9341 备用环境均编译成功,实际刷写 ST7789
软件验证原项目主程序 62 + HAL 41,共 103 项断言 0 失败;日志归属检查 5 项通过。软件回归不替代硬件观测
已刷 V1.7 应用 SHA-256d0ddd940a04b922dfff1a24245cc6f5b08cebe7e3e038cc6aaf62ccaa3e4142f

Fusion · concept only

六轴、九轴融合:姿态参考从哪里来

报告使用传感输入朝向参考和限制
Game Rotation Vector · 六轴加速度计 + 陀螺仪横滚、俯仰参考重力;偏航没有磁北参考,时间长了可能漂移
Rotation Vector · 九轴加速度计 + 陀螺仪 + 磁力计参考重力和磁北;受周边磁场、安装、校准条件影响,不能视作任意环境下的精确指南针

概念动画 · 非实测数据

  1. 加速度:重力方向
  2. 陀螺仪:转动变化
  3. 磁力计:磁北参考
  4. 融合:输出四元数

九轴报告使用磁力计修正航向;本 V1.7 订阅的是这一类。

Accelerometer 含重力;Gravity 与 Linear Acceleration 分别描述重力分量和去重力后的运动分量。必须在同一坐标系里理解它们,静止时加速度三轴不一定全为零。当前工程没有独立订阅后两项,下面动画中的运动和数值只用来讲解。

60 秒概念片为示意,未合成旁白、没有模拟实测曲线。采用项目交付的修订版,已将早期轮询说法限定为本 V1.7 的 INT 读取流程;新版轮询边界参考 CEVA v1.17。

Quaternion → RPY

四元数表示朝向,RPY 便于人读

项目写作 q=(w,x,y,z):w 为实部,x/y/z 对应虚部 i/j/k。它表示模块整体的旋转姿态,不是空间位置。RPY 是横滚 Roll、俯仰 Pitch、偏航 Yaw;源码将四元数按固定公式转换成度。实物轴向应根据板上丝印和安装方向核对,不能根据录像里“向左转”猜正负号。

交互示意 · 仅绕示意 X 轴 · 非实测数据

XY

q=(1.000, 0.000, 0.000, 0.000)

RPY=(0°, 0°, 0°)

纯 X 轴旋转:q=(cos(θ/2), sin(θ/2), 0, 0)。二维板形只是朝向示意,没有接入传感器。

例如 θ=90° 时,q≈(0.707, 0.707, 0, 0),对应本约定下横滚约 90°。q 和 −q 表示同一个朝向;欧拉角接近特定姿态时会遇到表达歧义,因此做连续旋转计算通常保留四元数。三张录像关键帧不能补成“实测连续姿态曲线”。SH-2 参考手册。

Three different states

校准状态、静置粗测、测试完成度要分开读

状态具体含义不能推导什么
报告 Status 0 / 1 / 2 / 3芯片估计当前报告的准确性:不可靠 / 低 / 中 / 高;源码读取 status 的低两位等级 3 不是计量证书,也不证明所有报告或整个模块都已校准
StillCHECK / COARSE_WINDOW / REVIEW本测试程序检查静置时加速度模长和角速度的粗测窗口它不是 SH-2 的校准等级;静置满足窗口不等于全性能合格
XYZ--- / NOT_RUN / TESTING程序阶段完成度:三轴尚未完成、会话仍测试中看到实时数值变化不等于已执行三轴测试

动态校准用于补偿偏置等因素,动作与配置要按报告类型和安装场景执行。每只实物分别记录;“calibrated gyroscope”是报告类型的名字,不能据此宣称这只模块通过外部精度校验。本工程没有启用或完成 BNO086 的 Interactive Calibration,也没有提供保存校准数据的操作步骤。

校准动作参考 CEVA / Hillcrest 校准说明;BNO086 专有流程以 v1.17 数据手册 为准。磁性物件、电流回路和安装变化可能影响带磁北参考的姿态,应保留环境说明并重新核验。

INT / RST / SHTP

INT 提醒主机,RST 建立启动边界

INT 低有效,提醒主机处理芯片数据;RST 低有效,用于启动复位。本工程依次初始化、订阅报告,再按 INT 配合读取 SHTP 头部和载荷,通过通道、报告类型与序号解码。ACK 只说明某地址应答,不能代替有效报告和持续流测试。

流程演示 · 不按真实时序比例

  1. 复位 / 启动
  2. 配置三类报告
  3. INT 低:提醒处理
  4. 读头部 / 完整载荷
  5. 解码 / 显示 / 日志

INT 可在识别器件地址后撤销,不能把它当成“剩余字节数”。

版本提醒:CEVA v1.17 已修改早期 v1.16 的“不支持轮询”备注,描述 BNO086 被轮询而没有数据时返回零长度包、主机应忽略。本项目 V1.7 仍采用 INT 配合读取,并未验证省略 INT 的方案。下载源码的历史注释保留原样,此处按新手册说明其适用边界。

依据 CEVA v1.17 §1.2.2.2 / I²C 时序、SHTP 协议,以及本工程已修补的 HAL;本页没有实测波形或响应延迟承诺。

PlatformIO · V1.7 source

查看与复制代码:完整工程按下载包构建

默认环境是 cyd_st7789,备用 cyd_ili9341 只对应其他屏幕。程序使用项目内修补库:每包全部报告都参与计数,INT 下降沿 epoch 配合有界读取,显示和串口摘要之间继续服务数据。不要用未修补的同名库替换后再套用本页测试记录。

下面保留原固件源文件内容。只复制 main.cpp 不能得到完整工程,还需要两个头文件、platformio.ini 和下载包里的库。源文件 SHA-256 与已刷应用二进制 SHA-256 是不同对象。

main.cpp SHA-256:33c583346621097bf7f342dfedc59cac4b4b4c53c29aac42585ba75d96be572b

main.cpp · 原文件
#include <Arduino.h>
#include <Wire.h>
#include <TFT_eSPI.h>
#include <U8g2_for_TFT_eSPI.h>
#include <SparkFun_BNO08x_Arduino_Library.h>
#include <esp_system.h>
#include <cstdarg>
#include "test_config.h"
#include "evidence_rules.h"

TFT_eSPI tft;
U8g2_for_TFT_eSPI chinese;
BNO08x imu;
enum class Stage { Idle, Initializing, Warmup, Still, Ready, Motion, Finished };
struct Report {
  uint32_t good=0, bad=0, gaps=0, missing=0, duplicate=0, maxGap=0, timestampBack=0;
  uint32_t lastMs=0, rateBase=0;
  uint64_t sensorUs=0;
  uint8_t seq=0, accuracy=0;
  bool seen=false, timedOut=false;
  float hz=0;
};
struct Transport {
  uint32_t reads=0, readErrors=0, writes=0, writeErrors=0;
  uint32_t intTimeouts=0, hardwareResets=0, decodeErrors=0, oversized=0;
};
Report report[3];
Transport transport, initTransport;
Stage stage=Stage::Idle;
bool active=false, initialized=false, productResponded=false, armed=false, fault=false;
bool automaticSession=false;
bool inSensorCallback=false;
bool stillDone=false, stillOK=false, motionDone[3]={}, motionOK[3]={};
uint32_t bootId=0, sessionNo=0, started=0, streamStart=0, stageStart=0;
uint32_t ended=0, unexpectedResets=0, enableErrors=0, lastTick=0, lastDraw=0;
char module[25]="UNASSIGNED", runId[32]="", lineBuffer[96];
size_t lineLength=0;
bool lineOverflow=false;
int motionAxis=-1;
float accel[3]={}, gyro[3]={}, quat[4]={}, rpy[3]={};
float stillSum=0, stillSumSq=0, stillGyroPeak=0, stillMean=0, stillStd=0;
uint32_t stillAccelN=0, stillGyroN=0;
float motionPeak=0, motionAngle=0, motionBaseQuat[4]={};
uint32_t motionGyroN=0, motionQuatN=0;
bool motionHasBase=false;
void drawScreen();

const char *stageName() {
  switch(stage) {
    case Stage::Idle:return "IDLE"; case Stage::Initializing:return "INIT";
    case Stage::Warmup:return "WARMUP"; case Stage::Still:return "STILL";
    case Stage::Ready:return "READY"; case Stage::Motion:return "MOTION";
    default:return "FINISHED";
  }
}
void logEvent(const char *event, const char *fmt="", ...) {
  char detail[640]; va_list args; va_start(args,fmt);
  vsnprintf(detail,sizeof(detail),fmt,args); va_end(args);
  const uint32_t now=millis();
  Serial.printf("EV,boot_ms=%lu,test_ms=%lu,module=%s,run=%s,stage=%s,event=%s,%s\n",
    (unsigned long)now,(unsigned long)(active?now-started:stage==Stage::Finished?ended-started:0),
    module,runId,stageName(),event,detail);
  // Serial summaries may span many lines; drain data after each line, not after
  // the entire summary. Never reenter SH2 from a sensor callback's diagnostic log.
  static bool draining=false;
  if(active&&initialized&&!draining&&!inSensorCallback) {
    draining=true;
    for(int n=0;n<8&&digitalRead(BNO_INT)==LOW;++n) {sh2_service();yield();}
    draining=false;
  }
}
// Local vendor hooks count every decoded event, including multiple events in one SHTP packet.
// They do no serial IO. This avoids silently retaining only the last event in a packet.
void bno08xDiagnosticTransport(uint8_t code) {
  Transport &t=armed?transport:initTransport;
  switch(code) {
    case 1:++t.reads;break; case 2:++t.readErrors;break;
    case 3:++t.writes;break;case 4:++t.writeErrors;break;
    case 5:++t.intTimeouts;break;case 6:++t.hardwareResets;break;
    case 7:++t.decodeErrors;break;case 8:++t.oversized;break;
  }
  if (armed && (code==2||code==4||code==5||code==6||code==7||code==8)) fault=true;
}
void bno08xDiagnosticEvent(const sh2_SensorValue_t *v) {
  if(!armed||!active) return;
  int k=-1; float values[4]; int count=3;
  if(v->sensorId==SH2_ACCELEROMETER) {
    k=0;values[0]=v->un.accelerometer.x;values[1]=v->un.accelerometer.y;values[2]=v->un.accelerometer.z;
  } else if(v->sensorId==SH2_GYROSCOPE_CALIBRATED) {
    k=1;values[0]=v->un.gyroscope.x;values[1]=v->un.gyroscope.y;values[2]=v->un.gyroscope.z;
  } else if(v->sensorId==SH2_ROTATION_VECTOR) {
    k=2;count=4;values[0]=v->un.rotationVector.real;values[1]=v->un.rotationVector.i;
    values[2]=v->un.rotationVector.j;values[3]=v->un.rotationVector.k;
  } else return;
  Report &r=report[k]; const uint32_t now=millis();
  bool valid=finiteVector(values,count,k==0?160.0f:k==1?36.0f:1.15f);
  float norm=0;
  if(k==2) {
    for(int i=0;i<4;++i) norm+=values[i]*values[i];
    norm=sqrtf(norm);valid=valid&&norm>=0.85f&&norm<=1.15f;
  }
  if(r.seen) {
    const uint8_t step=(uint8_t)(v->sequence-r.seq);
    if(step==0) {++r.duplicate;valid=false;}
    else if(step!=1) {r.missing+=(uint8_t)(step-1);fault=true;}
    // SH2 timestamps are estimates from host receive time and report delay, not a sensor clock.
    if(v->timestamp<=r.sensorUs)++r.timestampBack;
  }
  if(!valid) {++r.bad;fault=true;return;}
  const uint32_t gap=now-(r.seen?r.lastMs:streamStart);
  if(gap>r.maxGap)r.maxGap=gap;
  if(gap>GAP_MS&&!r.timedOut) {++r.gaps;fault=true;}
  r.timedOut=false;r.lastMs=now;r.sensorUs=v->timestamp;r.seq=v->sequence;
  r.accuracy=v->status&3;r.seen=true;++r.good;
  if(k==0) {
    memcpy(accel,values,sizeof(accel));
    if(stage==Stage::Still) {
      const float g=sqrtf(accel[0]*accel[0]+accel[1]*accel[1]+accel[2]*accel[2]);
      stillSum+=g;stillSumSq+=g*g;++stillAccelN;
    }
  } else if(k==1) {
    memcpy(gyro,values,sizeof(gyro));
    if(stage==Stage::Still) {
      const float w=sqrtf(gyro[0]*gyro[0]+gyro[1]*gyro[1]+gyro[2]*gyro[2]);
      stillGyroPeak=fmaxf(stillGyroPeak,w);++stillGyroN;
    }
    if(stage==Stage::Motion) {motionPeak=fmaxf(motionPeak,fabsf(gyro[motionAxis]));++motionGyroN;}
  } else {
    for(int i=0;i<4;++i)quat[i]=values[i]/norm;
    const float w=quat[0],x=quat[1],y=quat[2],z=quat[3];
    rpy[0]=atan2f(2*(w*x+y*z),1-2*(x*x+y*y))*RAD_TO_DEG;
    rpy[1]=asinf(fmaxf(-1,fminf(1,2*(w*y-z*x))))*RAD_TO_DEG;
    rpy[2]=atan2f(2*(w*z+x*y),1-2*(y*y+z*z))*RAD_TO_DEG;
    if(stage==Stage::Motion) {
      ++motionQuatN;
      if(!motionHasBase) {memcpy(motionBaseQuat,quat,sizeof(quat));motionHasBase=true;}
      float dot=0;for(int i=0;i<4;++i)dot+=quat[i]*motionBaseQuat[i];
      motionAngle=fmaxf(motionAngle,2*acosf(fminf(1,fabsf(dot)))*RAD_TO_DEG);
    }
  }
}
void onSensorReport(void *,sh2_SensorEvent_t *event) {
  inSensorCallback=true;
  sh2_SensorValue_t value{};
  if(sh2_decodeSensorEvent(&value,event)!=SH2_OK) {
    bno08xDiagnosticTransport(7);
    static uint8_t details=0;
    if(details<8) {++details;logEvent("DECODE_ERROR","report_id=%u,len=%u",event->reportId,event->len);}
    inSensorCallback=false;return;
  }
  bno08xDiagnosticEvent(&value);
  inSensorCallback=false;
}
uint32_t elapsedStream() {return initialized?(active?millis():ended)-streamStart:0;}
uint32_t anomalyCount() {
  uint32_t n=transport.readErrors+transport.writeErrors+transport.intTimeouts+
    transport.hardwareResets+transport.decodeErrors+transport.oversized+unexpectedResets+enableErrors;
  for(auto &r:report)n+=r.bad+r.gaps+r.missing;
  return n+(fault&&n==0?1:0); // Includes initialization anomalies latched before streaming.
}
Evidence streamResult() {
  uint32_t counts[3]={report[0].good,report[1].good,report[2].good};
  return streamEvidence(initialized,elapsedStream(),MIN_STREAM_MS,anomalyCount(),counts,
                        MIN_OBSERVED_HZ,MAX_OBSERVED_HZ);
}
Evidence result() {return overallEvidence(streamResult(),stillDone,stillOK,motionDone,motionOK);}
const char *evidenceName(Evidence e) {
  return e==Evidence::Observed?"OBSERVED":e==Evidence::Review?"REVIEW":"INCOMPLETE";
}
bool reportsOn() {
  bool a=imu.enableReport(SH2_ACCELEROMETER,REPORT_US);
  bool g=imu.enableReport(SH2_GYROSCOPE_CALIBRATED,REPORT_US);
  bool q=imu.enableReport(SH2_ROTATION_VECTOR,REPORT_US);
  if(!a||!g||!q){++enableErrors;fault=true;}
  logEvent("REPORT_ENABLE","acc=%d,gyro=%d,quat=%d,request_us=%lu",a,g,q,(unsigned long)REPORT_US);
  return a&&g&&q;
}
void checkTimeouts() {
  const uint32_t now=millis();
  for(int i=0;i<3;++i) {
    Report &r=report[i];
    const uint32_t gap=now-(r.seen?r.lastMs:streamStart);
    if(gap>r.maxGap)r.maxGap=gap;
    if(gap>GAP_MS&&!r.timedOut) {
      r.timedOut=true;++r.gaps;fault=true;
      logEvent("REPORT_TIMEOUT","type=%d,gap_ms=%lu",i,(unsigned long)gap);
    }
  }
}
void summary() {
  logEvent("STATE","active=%d,initialized=%d,reports_armed=%d,int_level=%d,session_mode=%s",
    active,initialized,armed,digitalRead(BNO_INT),!runId[0]?"none":automaticSession?"automatic_boot":"operator");
  logEvent("SUMMARY","overall=%s,communication=%s,stream=%s,stream_ms=%lu,anomalies=%lu,still=%s,X=%s,Y=%s,Z=%s",
    evidenceName(result()),productResponded?"PRODUCT_RESPONSE":"NOT_OBSERVED",evidenceName(streamResult()),
    (unsigned long)elapsedStream(),(unsigned long)anomalyCount(),
    stillDone?(stillOK?"COARSE_WINDOW":"REVIEW"):"NOT_RUN",
    motionDone[0]?(motionOK[0]?"OBSERVED":"REVIEW"):"NOT_RUN",
    motionDone[1]?(motionOK[1]?"OBSERVED":"REVIEW"):"NOT_RUN",
    motionDone[2]?(motionOK[2]?"OBSERVED":"REVIEW"):"NOT_RUN");
  for(int i=0;i<3;++i) {
    const Report &r=report[i];
    logEvent("REPORT_SUMMARY","type=%d,valid=%lu,invalid=%lu,timeout_episodes=%lu,sequence_missing=%lu,duplicates=%lu,sh2_time_nonmonotonic=%lu,max_gap_ms=%lu,mean_hz=%.2f,last_accuracy=%u",
      i,(unsigned long)r.good,(unsigned long)r.bad,(unsigned long)r.gaps,(unsigned long)r.missing,
      (unsigned long)r.duplicate,(unsigned long)r.timestampBack,(unsigned long)r.maxGap,elapsedStream()?r.good*1000.0f/elapsedStream():0,r.accuracy);
  }
  logEvent("TRANSPORT","read=%lu,read_error=%lu,write=%lu,write_error=%lu,int_wait_timeout=%lu,host_hw_reset=%lu,device_reset_event=%lu,decode_or_frame_error=%lu,oversize=%lu,report_enable_error=%lu",
    (unsigned long)transport.reads,(unsigned long)transport.readErrors,(unsigned long)transport.writes,
    (unsigned long)transport.writeErrors,(unsigned long)transport.intTimeouts,(unsigned long)transport.hardwareResets,
    (unsigned long)unexpectedResets,(unsigned long)transport.decodeErrors,(unsigned long)transport.oversized,(unsigned long)enableErrors);
  logEvent("INIT_TRANSPORT","read=%lu,read_error=%lu,write=%lu,write_error=%lu,int_wait_timeout=%lu,planned_host_hw_reset=%lu,decode_or_frame_error=%lu,oversize=%lu",
    (unsigned long)initTransport.reads,(unsigned long)initTransport.readErrors,(unsigned long)initTransport.writes,
    (unsigned long)initTransport.writeErrors,(unsigned long)initTransport.intTimeouts,(unsigned long)initTransport.hardwareResets,
    (unsigned long)initTransport.decodeErrors,(unsigned long)initTransport.oversized);
}
void finishSession(const char *reason) {
  if(!active)return;
  if(initialized)checkTimeouts();
  ended=millis();active=false;armed=false;stage=Stage::Finished;
  logEvent("FINISH","reason=%s,test_duration_ms=%lu",reason,(unsigned long)(ended-started));
  summary();
}
bool validId(const char *id) {
  const size_t n=strlen(id);if(!n||n>24)return false;
  for(size_t i=0;i<n;++i)if(!isalnum((unsigned char)id[i])&&id[i]!='_'&&id[i]!='-')return false;
  return true;
}
void startSession(const char *id,bool automatic=false) {
  if(!validId(id)){logEvent("COMMAND_REJECT","reason=id_1_to_24_ASCII_letters_digits_dash_underscore");return;}
  if(active) {
    if(automaticSession&&!automatic)finishSession("REPLACED_BY_OPERATOR_LABEL");
    else {logEvent("COMMAND_REJECT","reason=finish_current_session_first");return;}
  }
  sh2_close();
  for(auto &r:report)r=Report{};
  transport=Transport{};initTransport=Transport{};unexpectedResets=enableErrors=0;
  initialized=productResponded=armed=fault=stillDone=stillOK=false;motionAxis=-1;
  memset(motionDone,0,sizeof(motionDone));memset(motionOK,0,sizeof(motionOK));
  memset(accel,0,sizeof(accel));memset(gyro,0,sizeof(gyro));memset(quat,0,sizeof(quat));memset(rpy,0,sizeof(rpy));
  stillSum=stillSumSq=stillGyroPeak=stillMean=stillStd=0;stillAccelN=stillGyroN=0;
  strlcpy(module,id,sizeof(module));
  automaticSession=automatic;
  snprintf(runId,sizeof(runId),"%08lX-%04lu",(unsigned long)bootId,(unsigned long)++sessionNo);
  active=true;started=millis();stage=Stage::Initializing;
  drawScreen();
  logEvent("START","fw=%s,address=0x%02X,SDA=%d,SCL=%d,INT=%d,RST=%d,I2C_Hz=%lu,id_source=%s,unique_serial=not_read",
    FW_ID,BNO_ADDRESS,BNO_SDA,BNO_SCL,BNO_INT,BNO_RST,(unsigned long)I2C_HZ,
    automaticSession?"automatic_boot_label":"operator_label");
  // Dedicated bus: probe the selected address only; an ACK never constitutes a result.
  Wire.beginTransmission(BNO_ADDRESS);uint8_t ack=Wire.endTransmission();
  logEvent("I2C_PROBE","address=0x%02X,wire_result=%u",BNO_ADDRESS,ack);
  if(!imu.begin(BNO_ADDRESS,Wire,BNO_INT,BNO_RST)) {
    logEvent("INIT_FAILED","reason=no_valid_SH2_product_response");finishSession("INIT_FAILED");return;
  }
  if(!imu.prodIds.numEntries) {logEvent("INIT_FAILED","reason=empty_product_response");finishSession("INIT_FAILED");return;}
  productResponded=true;
  sh2_setSensorCallback(onSensorReport,nullptr);
  for(unsigned i=0;i<imu.prodIds.numEntries;++i) {
    const auto &p=imu.prodIds.entry[i];
    logEvent("PRODUCT","entry=%u,sw_part=%lu,version=%u.%u.%u,build=%lu,reset_cause=%u,unique_serial=not_available",
      i,(unsigned long)p.swPartNumber,p.swVersionMajor,p.swVersionMinor,p.swVersionPatch,(unsigned long)p.swBuildNumber,p.resetCause);
  }
  if(!reportsOn()){finishSession("REPORT_ENABLE_FAILED");return;}
  (void)imu.wasReset();
  logEvent("INIT_TRANSPORT","read_error=%lu,write_error=%lu,int_wait_timeout=%lu,host_hw_reset=%lu",
    (unsigned long)initTransport.readErrors,(unsigned long)initTransport.writeErrors,
    (unsigned long)initTransport.intTimeouts,(unsigned long)initTransport.hardwareResets);
  // Initialization contains planned resets; errors/timeouts still require review.
  if(initTransport.readErrors||initTransport.writeErrors||initTransport.intTimeouts||initTransport.decodeErrors||initTransport.oversized)fault=true;
  initialized=true;armed=true;streamStart=stageStart=millis();stage=Stage::Warmup;
  logEvent("STAGE_BEGIN","duration_ms=%lu,instruction=place_module_still",(unsigned long)WARMUP_MS);
}
void startAutomaticSession() {
  char id[25];
  snprintf(id,sizeof(id),"AUTO_%08lX",(unsigned long)bootId);
  startSession(id,true);
}
void startMotion(int axis) {
  if(!active||stage!=Stage::Ready){logEvent("COMMAND_REJECT","reason=wait_for_READY");return;}
  // Repeating a stage never overwrites its prior outcome; use a new session for retest.
  if(motionDone[axis]){logEvent("COMMAND_REJECT","reason=axis_already_recorded_start_new_session_for_retest");return;}
  motionAxis=axis;motionPeak=motionAngle=0;motionGyroN=motionQuatN=0;motionHasBase=false;
  stage=Stage::Motion;stageStart=millis();logEvent("STAGE_BEGIN","axis=%c,duration_ms=%lu,instruction=rotate_about_module_axis_slowly",'X'+axis,(unsigned long)MOVE_MS);
}
void updateStages() {
  const uint32_t dt=millis()-stageStart;
  if(stage==Stage::Warmup&&dt>=WARMUP_MS) {
    stage=Stage::Still;stageStart=millis();logEvent("STAGE_BEGIN","duration_ms=%lu,instruction=do_not_move",(unsigned long)STILL_MS);
  } else if(stage==Stage::Still&&dt>=STILL_MS) {
    stillDone=true;stillMean=stillAccelN?stillSum/stillAccelN:0;
    stillStd=stillAccelN?sqrtf(fmaxf(0,stillSumSq/stillAccelN-stillMean*stillMean)):0;
    stillOK=stillAccelN>=STILL_MS*MIN_OBSERVED_HZ/1000&&stillGyroN>=STILL_MS*MIN_OBSERVED_HZ/1000&&
      stillMean>=8.3f&&stillMean<=11.3f&&stillStd<=0.25f&&stillGyroPeak<=0.15f;
    logEvent("STILL_RESULT","result=%s,acc_n=%lu,gyro_n=%lu,g_mean_ms2=%.4f,g_std_ms2=%.4f,gyro_norm_peak_rads=%.4f",
      stillOK?"COARSE_WINDOW":"REVIEW",(unsigned long)stillAccelN,(unsigned long)stillGyroN,stillMean,stillStd,stillGyroPeak);
    stage=Stage::Ready;logEvent("READY","instruction=send_X_then_Y_then_Z_each_8_seconds");
  } else if(stage==Stage::Motion&&dt>=MOVE_MS) {
    motionDone[motionAxis]=true;
    motionOK[motionAxis]=motionGyroN>=MOVE_MS*MIN_OBSERVED_HZ/1000&&motionQuatN>=MOVE_MS*MIN_OBSERVED_HZ/1000&&motionPeak>=0.6f&&motionAngle>=20;
    logEvent("MOTION_RESULT","axis=%c,result=%s,gyro_n=%lu,quat_n=%lu,axis_gyro_peak_rads=%.4f,orientation_change_deg=%.2f",
      'X'+motionAxis,motionOK[motionAxis]?"OBSERVED":"REVIEW",(unsigned long)motionGyroN,(unsigned long)motionQuatN,motionPeak,motionAngle);
    stage=Stage::Ready;
  }
}
void info() {
  Serial.printf("FW,%s,title=BNO086 module test,board=ESP32-2432S028R,display=%s,address=0x%02X,boot=%08lX\n",FW_ID,
#ifdef ST7789_DRIVER
    "ST7789",
#else
    "ILI9341",
#endif
    BNO_ADDRESS,(unsigned long)bootId);
  Serial.println("HELP,START M001 | X | Y | Z | STATUS | FINISH | INFO; disconnect power before changing modules");
  logEvent("STATE","active=%d,initialized=%d,reports_armed=%d,int_level=%d,session_mode=%s,boot_action=new_automatic_session",
    active,initialized,armed,digitalRead(BNO_INT),!runId[0]?"none":automaticSession?"automatic_boot":"operator");
}
void command(const char *s) {
  if(!strncmp(s,"START ",6))startSession(s+6);
  else if(!strcmp(s,"X"))startMotion(0);else if(!strcmp(s,"Y"))startMotion(1);else if(!strcmp(s,"Z"))startMotion(2);
  else if(!strcmp(s,"FINISH"))finishSession("OPERATOR_FINISH");
  else if(!strcmp(s,"STATUS")){if(initialized&&active)checkTimeouts();summary();}
  else if(!strcmp(s,"INFO"))info();
  else logEvent("COMMAND_REJECT","reason=unknown_command");
}
void readCommands() {
  while(Serial.available()) {
    const char c=Serial.read();
    if(c=='\r')continue;
    if(c=='\n') {
      if(lineOverflow)logEvent("COMMAND_REJECT","reason=line_too_long");
      else if(lineLength){lineBuffer[lineLength]='\0';command(lineBuffer);}
      lineLength=0;lineOverflow=false;
    } else if(lineLength<sizeof(lineBuffer)-1)lineBuffer[lineLength++]=c;
    else lineOverflow=true;
  }
}
void telemetry() {
  if(stage==Stage::Finished){lastTick=millis();return;}
  const uint32_t now=millis();const uint32_t dt=now-lastTick;
  for(auto &r:report){r.hz=dt?(r.good-r.rateBase)*1000.0f/dt:0;r.rateBase=r.good;}
  lastTick=now;
  if(!active||!initialized)return;
  logEvent("SAMPLE","a_ms2=%.3f/%.3f/%.3f,g_dps=%.2f/%.2f/%.2f,rpy_deg=%.1f/%.1f/%.1f,hz=%.1f/%.1f/%.1f,valid=%lu/%lu/%lu,age_ms=%lu/%lu/%lu,sh2_estimated_us=%llu/%llu/%llu,accuracy=%u/%u/%u,anomalies=%lu",
    accel[0],accel[1],accel[2],gyro[0]*RAD_TO_DEG,gyro[1]*RAD_TO_DEG,gyro[2]*RAD_TO_DEG,rpy[0],rpy[1],rpy[2],report[0].hz,report[1].hz,report[2].hz,
    (unsigned long)report[0].good,(unsigned long)report[1].good,(unsigned long)report[2].good,
    (unsigned long)(now-(report[0].seen?report[0].lastMs:streamStart)),(unsigned long)(now-(report[1].seen?report[1].lastMs:streamStart)),(unsigned long)(now-(report[2].seen?report[2].lastMs:streamStart)),
    (unsigned long long)report[0].sensorUs,(unsigned long long)report[1].sensorUs,(unsigned long long)report[2].sensorUs,
    report[0].accuracy,report[1].accuracy,report[2].accuracy,(unsigned long)anomalyCount());
}
void text(int y,const char *s,int font=2,uint16_t color=TFT_WHITE) {
  // Opaque glyph backgrounds and padding remove the previous line's trailing text.
  tft.setTextColor(color,TFT_BLACK);tft.setTextPadding(304);tft.drawString(s,6,y,font);
  // Drain ready data between rows so display drawing cannot delay an entire frame.
  if(active&&initialized&&digitalRead(BNO_INT)==LOW) sh2_service();
}
void drawScreen() {
  static bool painted=false, previousLayoutInitialized=false;
  char s[112];
  // Clear only when the two layouts change; regular updates never blank the full screen.
  if(!painted||previousLayoutInitialized!=initialized) {
    tft.fillScreen(TFT_BLACK);
    chinese.setForegroundColor(TFT_WHITE);chinese.drawUTF8(6,17,"BNO086 模块测试");
    painted=true;previousLayoutInitialized=initialized;
  }
  snprintf(s,sizeof(s),"ID:%s  run:%04lu",module,(unsigned long)sessionNo);text(21,s);
  uint32_t remaining=0;uint32_t dt=millis()-stageStart;
  const uint32_t limit=stage==Stage::Warmup?WARMUP_MS:stage==Stage::Still?STILL_MS:stage==Stage::Motion?MOVE_MS:0;
  if(limit&&dt<limit)remaining=(limit-dt+999)/1000;
  snprintf(s,sizeof(s),"%s %c %lus   0x%02X %s",stageName(),stage==Stage::Motion?'X'+motionAxis:' ',(unsigned long)remaining,BNO_ADDRESS,
    initialized?"SH2 OK":stage==Stage::Idle?"WAIT START":stage==Stage::Initializing?"STARTING":"NO SH2");text(39,s,2);
  if(!initialized) {
    text(68,stage==Stage::Finished?"INIT INCOMPLETE":stage==Stage::Initializing?"Starting sensor...":"Waiting for START",4);
    text(108,"SDA27 SCL22 INT35 RST19");text(130,"GPIO21 = BACKLIGHT");
    text(158,"TF card OUT / power 3.3V");text(204,"INFO / STATUS / FINISH");return;
  }
  const uint32_t now=active?millis():ended;
  const bool freshA=report[0].seen&&now-report[0].lastMs<=GAP_MS;
  const bool freshG=report[1].seen&&now-report[1].lastMs<=GAP_MS;
  const bool freshQ=report[2].seen&&now-report[2].lastMs<=GAP_MS;
  snprintf(s,sizeof(s),"A m/s2: %+.2f %+.2f %+.2f %s",accel[0],accel[1],accel[2],freshA?"":"OLD");text(59,s);
  snprintf(s,sizeof(s),"G d/s: %+.1f %+.1f %+.1f %s",gyro[0]*RAD_TO_DEG,gyro[1]*RAD_TO_DEG,gyro[2]*RAD_TO_DEG,freshG?"":"OLD");text(77,s);
  snprintf(s,sizeof(s),"RPY: %+.0f %+.0f %+.0f %s",rpy[0],rpy[1],rpy[2],freshQ?"":"OLD");text(95,s);
  snprintf(s,sizeof(s),"Hz A/G/Q: %.1f / %.1f / %.1f",report[0].hz,report[1].hz,report[2].hz);text(113,s);
  snprintf(s,sizeof(s),"Valid: %lu / %lu / %lu",(unsigned long)report[0].good,(unsigned long)report[1].good,(unsigned long)report[2].good);text(131,s);
  snprintf(s,sizeof(s),"I2Cerr:%lu Bad:%lu Gap:%lu Rst:%lu",
    (unsigned long)(transport.readErrors+transport.writeErrors+initTransport.readErrors+initTransport.writeErrors),
    (unsigned long)(report[0].bad+report[1].bad+report[2].bad+transport.decodeErrors+initTransport.decodeErrors),
    (unsigned long)(report[0].gaps+report[1].gaps+report[2].gaps+transport.intTimeouts+initTransport.intTimeouts),
    (unsigned long)(unexpectedResets+transport.hardwareResets));text(149,s);
  snprintf(s,sizeof(s),"Still:%s XYZ:%c%c%c   %lus/60s",stillDone?(stillOK?"OK":"CHECK"):"WAIT",
    motionDone[0]?(motionOK[0]?'O':'!'):'-',motionDone[1]?(motionOK[1]?'O':'!'):'-',motionDone[2]?(motionOK[2]?'O':'!'):'-',(unsigned long)(elapsedStream()/1000));text(167,s);
  const char *status=(fault||anomalyCount())?"REVIEW":stage==Stage::Finished?evidenceName(result()):"TESTING";
  text(188,status,4,TFT_WHITE);
  text(219,automaticSession?"AUTO label; START M001 to assign":
    stage==Stage::Ready?"Serial X/Y/Z; FINISH after 60s":"Serial STATUS / FINISH");
}
void setup() {
  Serial.begin(115200);bootId=esp_random();
  // Keep unused SPI peripherals deselected. GPIO19 is NOT initialized as SD MISO.
  pinMode(5,OUTPUT);digitalWrite(5,HIGH);pinMode(33,OUTPUT);digitalWrite(33,HIGH);
  pinMode(BNO_INT,INPUT); // GPIO35 has no internal pull-up; external bias is an optional module-specific check.
  Wire.setBufferSize(128);Wire.begin(BNO_SDA,BNO_SCL,I2C_HZ);Wire.setTimeOut(50);
  tft.init();tft.setRotation(1);tft.invertDisplay(false);
  chinese.begin(tft);chinese.setFont(u8g2_font_wqy16_t_gb2312);chinese.setFontMode(1);
  info();lastTick=millis();drawScreen();
  startAutomaticSession();
}
void loop() {
  readCommands();
  if(active&&initialized) {
    // BNO086 does not support I2C polling without INT. Drain ready packets only.
    for(int n=0;n<24&&digitalRead(BNO_INT)==LOW;++n) {sh2_service();yield();}
    if(imu.wasReset()) {
      ++unexpectedResets;fault=true;logEvent("DEVICE_RESET","instruction=review_power_bus_and_module");
      for(auto &r:report){r.seen=false;r.timedOut=false;}
      if(!reportsOn())finishSession("REPORT_REENABLE_FAILED");
    }
    if(active){checkTimeouts();updateStages();}
  }
  if(millis()-lastTick>=1000)telemetry();
  if(millis()-lastDraw>=500){lastDraw=millis();drawScreen();}
  delay(1);
}
test_config.h · 原文件
#pragma once
// Configured for the user-confirmed ESP32-2432S028R. Check physical wiring before START.
// GPIO19 requires TF DAT0 breakout.
// No SD card, touch, speaker, RGB, WiFi or Bluetooth functions are initialized.
constexpr int BNO_SDA = 27;
constexpr int BNO_SCL = 22;
constexpr int BNO_INT = 35; // Input only, no internal pull-up; 10k to 3.3V is an optional wiring check.
constexpr int BNO_RST = 19; // TF DAT0/DO, NOT P3 GPIO21 (screen backlight).
constexpr uint8_t BNO_ADDRESS = 0x4B;
constexpr uint32_t I2C_HZ = 400000; // Within BNO086 supported range; recorded in every run.
constexpr uint32_t REPORT_US = 20000;
constexpr uint32_t WARMUP_MS = 5000;
constexpr uint32_t STILL_MS = 15000;
constexpr uint32_t MOVE_MS = 8000;
constexpr uint32_t MIN_STREAM_MS = 60000;
constexpr uint32_t GAP_MS = 500;
constexpr float MIN_OBSERVED_HZ = 35.0f; // Practical host evidence floor, not datasheet precision.
constexpr float MAX_OBSERVED_HZ = 110.0f;
constexpr char FW_ID[] = "BNO086_MODULE_TEST_V1.7";
evidence_rules.h · 原文件
#pragma once
#include <cmath>
#include <cstdint>
enum class Evidence { Incomplete, Review, Observed };
inline Evidence streamEvidence(bool initialized, uint32_t elapsed, uint32_t minimum,
                               uint32_t anomalies, const uint32_t counts[3],
                               float lowHz, float highHz) {
  if (!initialized) return Evidence::Incomplete;
  if (anomalies) return Evidence::Review;
  if (elapsed < minimum) return Evidence::Incomplete;
  for (int i = 0; i < 3; ++i) {
    const float hz = elapsed ? counts[i] * 1000.0f / elapsed : 0;
    if (hz < lowHz || hz > highHz) return Evidence::Review;
  }
  return Evidence::Observed;
}
inline Evidence overallEvidence(Evidence stream, bool stillDone, bool stillOK,
                                const bool motionDone[3], const bool motionOK[3]) {
  if (stream == Evidence::Review || (stillDone && !stillOK)) return Evidence::Review;
  for (int i = 0; i < 3; ++i)
    if (motionDone[i] && !motionOK[i]) return Evidence::Review;
  if (stream != Evidence::Observed || !stillDone) return Evidence::Incomplete;
  for (int i = 0; i < 3; ++i) if (!motionDone[i]) return Evidence::Incomplete;
  return Evidence::Observed;
}
inline bool finiteVector(const float *v, int n, float limit) {
  for (int i = 0; i < n; ++i)
    if (!std::isfinite(v[i]) || std::fabs(v[i]) > limit) return false;
  return true;
}
platformio.ini · 原文件
[platformio]
default_envs = cyd_st7789

[env]
platform = espressif32@6.13.0
board = esp32dev
framework = arduino
platform_packages =
    platformio/framework-arduinoespressif32 @ ~3.20017.0
board_build.partitions = huge_app.csv
monitor_speed = 115200
; Upload port deliberately unset. Verify the physical target before uploading.
lib_ldf_mode = deep+
build_flags =
    -D USER_SETUP_LOADED=1
    -D TFT_WIDTH=240
    -D TFT_HEIGHT=320
    -D TFT_MOSI=13
    -D TFT_SCLK=14
    -D TFT_CS=15
    -D TFT_DC=2
    -D TFT_RST=-1
    -D TFT_MISO=-1
    -D TFT_BL=21
    -D TFT_BACKLIGHT_ON=HIGH
    -D LOAD_GLCD=1
    -D LOAD_FONT2=1
    -D LOAD_FONT4=1
    -D SPI_FREQUENCY=40000000

[env:cyd_st7789]
build_flags = ${env.build_flags} -D ST7789_DRIVER=1 -D TFT_INVERSION_OFF=1

[env:cyd_ili9341]
build_flags = ${env.build_flags} -D ILI9341_DRIVER=1

Repeatable steps

一轮测试怎么做,怎样保留独立记录

  1. 断电核线。 确认六线、ST7789、地址 0x4B、SD 卡未插。给实物贴自己的标签,再上电。V1.7 自动会话每次重启都换 boot / run,不复用旧计数。
  2. 连接日志工具并核固件。 安装 pyserial,先列端口,核对目标后显式指定端口。工具先用 INFO 核 V1.7,再输入 start 建操作员会话;它负责 START 时不需重复手动发送。
  3. 预热 5 秒,静置 15 秒。 放稳并保持不动,保存现场、计时和原始日志。加速度含重力,向量模长不该因“静止”而全为零。
  4. READY 后依次 X、Y、Z,各 8 秒。 按实物丝印轴缓慢往返转动,建议约 30–60°/s、姿态变化明显超过 20°。每轴结束等待 READY;不要只平移或靠屏幕方向猜轴向。
  5. 流观测达到至少 60 秒再 FINISH。 看完整 SUMMARY、REPORT_SUMMARY 和 TRANSPORT。初始化时间不算流时长;必须保留有效报告与异常,不能只截结果一行。
  6. 复测或换模块另开轮。 先保存结束记录,断电更换;写清改线、环境或动作变化。缺 FINISH 如实标“未完整收尾”,不能补写完成。
日志工具示例 · 端口需自己核对
python -m pip install -r requirements.txt
python 模块日志采集.py --list-ports
python 模块日志采集.py --port COM9 --module M001 --output ./logs

COM9 是本项目历史端口示例,使用时换成自己确认的目标端口。M001 是人工贴签,不是硬件 SN。永久文件由主机工具保存;未连接期间的启动输出无法从屏幕或 RAM 补回。

固件命令动作
INFO / STATUS核固件、会话、阶段、有效报告、异常。reports_armed 表示主机统计启用,不表示结束时已关芯片订阅
START M001结束 AUTO 并建立贴签独立 run;活动操作员会话重复 START 拒绝
X / Y / ZREADY 时启动相应轴阶段;同轮已完成轴不能重做
FINISH保存本轮实际结束与摘要;异常不因结束而清除
查看本版本的粗测窗口与结果含义

这些是程序筛查窗口,不是厂家的精度允差。静置 15 s:加速度模长均值 8.3–11.3 m/s²、标准差 ≤0.25 m/s²、角速度模长峰值 ≤0.15 rad/s,A/G 各至少 525 条;各轴 8 s:对应角速度峰值 ≥0.6 rad/s、相对起点姿态变化 ≥20°、G/Q 各至少 280 条。全轮各报告平均有效更新率窗口 35–110 Hz,超过 500 ms 无有效更新记超时。

OBSERVED 表示规定条件下已观察到所需阶段和数据;REVIEW 表示已发现异常或已执行阶段没满足窗口,需要复核;INCOMPLETE 表示初始化、阶段或时长不足。stream 和 overall 分别判断,流 OBSERVED 仍可因三轴未做而整体 INCOMPLETE。已有异常时 REVIEW 优先;所有记录按实际保存。

Local files · cloud link pending

资料下载

V1.7 公开源码工程包PlatformIO 配置、原固件源码、完整修补库与许可证、日志工具和说明;9.32 MB。未包含 Flash 备份或原始私人日志。
下载源码 ZIP
接线、构建、测试与版本说明可离线查看的公开说明;包含真实结果边界与手册版本提醒。
下载说明
源码文件 SHA-256 清单可核对源码与库文件;不会把源文件哈希当成应用二进制哈希。
下载清单
网盘下载入口:待配置目的地及真实分享链接尚未确定。取得有效链接后补入本区。
暂无分享链接

源码包 SHA-256:91af51899e6ab08aaa51ca7e64fc85e34019e76dd5316078938023f099ae68fd。真实网盘分享链接待配置。

Real footage · one module

实拍:13.5 秒连续读数响应

来自实际原声录像的连续区间 00:04.000–00:17.500,网站副本降为 720p / 30 fps。画面为一只已接模块,标签 AUTO_4B9E7573 / run0001,可见 TESTING、StillCHECK、XYZ---;背景中的其他模块不代表它们也完成了测试。

没有建立录像与全部串口记录的完整时间同步。短片不套用另一轮的时长、速率或最终结论;图像中的零计数只描述画面所属会话当时累计的程序观测。

Reference board ≠ measured module

硬件资料:参考电路与实物核验分开

事项官方 / 项目证据适用范围
PS0 / PS1SparkFun v10 原理图:0 / 0 对应 I²C实际模块需核协议选择
ADR 与地址SparkFun v10 ADR 开路默认 0x4B,闭合 0x4A;本项目设 0x4B其他模块以实物与有效协议响应为准
SDA / SCL 上拉SparkFun v10 各 2.2kΩ,经 I²C 跳线连接参考板参数,未测这只模块阻值
RST / INTSparkFun v10:RST 有 10kΩ 上拉,INT 网未画单独上拉不能据此保证用户模块电路或 INT 输出结构
GPIO35Espressif:仅输入、无内部上拉实际稳定电平应结合模块输出电路测量
精度 / 量程 / 温漂请查 CEVA 芯片手册及对应报告定义本机没有计量实测,不将典型芯片指标写成模块性能保证

地址对照采用 SparkFun 官方 v10 原理图 与 CEVA 手册。指南 ADR 段落的 0x48 与原理图不一致,本页按可核对的 0x4A / 0x4B 说明。

Primary sources · checked 2026-10-05

官方来源与本页信息来源

页面内容来源 / 边界
六线配置、程序行为、已启用报告当前 V1.7 原工程源码、最新六线软件图与来源映射;GPIO19采用用户确认的SKU02753 DAT0/DO引出,本次未新增硬件实测
表中时间、计数、阶段与结果原工程当前验证状态与独立实测记录,未合并不同 run
13.5 秒视频真实原声录像的连续片段;未与全部串口记录同步
芯片报告、融合、INT 与校准解释CEVA / SH-2 / SparkFun / Espressif 官方资料,参考电路与实物参数分开
LCD图解、背面接头与日志选摘V1.7 drawScreen与判断代码;同版双USB背面照片;V1.txt原行36–50、71–74、135–142、216、220–229,正文不改写,去除主机前缀。LCD图是字段位置复刻,照片不是用户本轮接线图
动画与交互示意教育解释用示意,不含实测曲线或预测性能
待补资料实际接线复测、专用购买链接、网盘真实分享地址、完整 XYZ 与计量 / 长期测试