• 正文
  • 相关推荐
申请入驻 产业图谱

车辆振动检测仪:从零到一的完整实战教程(ESP32-S3 + ADXL355 + GPS + Android)

1小时前
116
加入交流群
扫码加入
获取工程师必备礼包
参与热点资讯讨论

写在前面

这是一篇可以照着做出来的工程笔记。全文按真实开发顺序展开:先讲为什么要这么选硬件,再讲每一行驱动代码是怎么写出来的、为什么这么写、写错了会怎样,最后讲手机端怎么算指标、怎么出报告。

全文配套的工程源码包含两个可直接打开的工程:

• firmware/:ESP-IDF v5.3 工程,idf.py build 直接编译烧录;

• android/:Android Studio / Gradle 工程,含已编译好的 release APK。

建议阅读方式:第一遍通读建立全局观,第二遍打开源码对照着看,第三遍自己改参数做实验。


第一章 项目定义:到底要做一台什么设备

1.1 需求拆解

需求原文 量化后的技术指标 对应实现
三向加速度采集 三轴 X/Y/Z,采样率 ≥ 1000 Hz ADXL355 + SPI 10MHz 轮询
0.003 g 分辨率 单 LSB ≤ 3 mg ±2.048g 量程,3.9 µg/LSB
带 GPS + 惯导定位 输出经纬度、速度、定位质量 ATGM336H NMEA 解析
电池续航 12 小时+ 整机平均功耗 ≤ 电池容量/12 20000mAh + 低功耗策略
无线连手机/Pad 手机能实时收到 1000Hz 数据 WiFi AP + TCP 流式推送
自动算指标 平稳性、舒适度、晃车抖车 Sperling Wz、EN12299 加权 RMS、Jerk
按标准出报告 GB/T 5599、EN 12299 PDF 报告 + CSV 原始数据
便携、一键操作 上电即工作,无需按键配置 AP 自动创建 + 自动采集

项目开发使用的全部软件工具,资料包已经上传到网盘:https://ccnr8sukk85n.feishu.cn/wiki/QjY8weDYHibqRYkFP2qcA9aGnvb?from=from_copylink

1.2 先想清楚两个工程问题

问题一:1000Hz × 3 轴,数据量有多大?

1000 帧/秒 × 3 轴 × 4 字节(float) ≈ 12 KB/s

一年 12 小时连续采集的原始 CSV 约:

12 KB/s × 3600 × 12 ≈ 500 MB

看起来很吓人,但实际上用 32GB TF 卡可以存 700 小时以上,完全够用。真正的瓶颈不是存储,而是无线传输带宽和手机端解析开销。

问题二:为什么不能直接把 1000Hz 原始数据一条条发出去?

如果每采一个点就发一个小包,一秒要发 1000 次 TCP,每次都有 TCP 头 + JSON 头开销,实际带宽放大 30 倍以上,手机端还会因为频繁唤醒 CPU 而卡顿掉电。

所以本项目的协议设计是:设备端攒批(batch),每 50ms 发一包,一包里带 50~200 个采样点。这样传输开销降到 1/50,手机端每 50ms 处理一次,既能实时画波形,又不浪费电。

这是整个项目第一个"设计决策",后面所有代码都围绕它展开。

1.3 成品形态

第二章 硬件设计:每一颗料为什么选它

2.1 主控:ESP32-S3-WROOM-1-N16R8

选它而不是 ESP32、ESP32-C3,理由有三条,都很实在:

对比项 ESP32 ESP32-C3 ESP32-S3
内核 Xtensa LX6 双核 240MHz RISC-V 单核 160MHz Xtensa LX7 双核 240MHz
SRAM 520KB 400KB 512KB + 8MB PSRAM
Flash 通常 4MB 4MB 16MB
WiFi b/g/n b/g/n b/g/n
USB 外置串口芯片 内置 内置 USB CDC

关键点:

1. 双核:一个核专心做 1000Hz 采样(实时性要求高),另一个核跑 WiFi 协议栈和 SD 卡写入(耗时长、允许被打断)。单核芯片上这两件事会互相拖累,导致采样抖动。

2. N16R8 后缀含义:N = 16MB Flash,R8 = 8MB Octal PSRAM。PSRAM 用来放大缓冲,WiFi 协议栈本身也要吃掉上百 KB。

3. 内置 USB CDC:板子上不用焊 CH340/CP2102,直接一根 Type-C 线就能烧录和看日志,少一个故障点。

2.2 加速度计:ADXL355 凭什么能到 0.003g

先看需求:分辨率 0.003 g。

一个 MEMS 加速度计的分辨率由两个参数决定:

单 LSB 大小 = 满量程 / 2^位数
噪声底     ≈ 噪声密度 × √带宽

ADXL355 的关键参数:

参数 数值 说明
ADC 位数 20 bit 比常见 12/14 bit 芯片高 6~8 位
量程 ±2.048 / ±4.096 / ±8.192 g 三档可切
±2g 档灵敏度 3.9 µg/LSB 即 0.0000039 g
噪声密度 22.5 µg/√Hz 低噪声工业级
带宽 最高 4kHz(ODR 可配) 1000Hz 采样绰绰有余
接口 SPI,最高 10 MHz 9 字节读取仅需 7.2µs

算一遍给你看:

单 LSB = 3.9 µg = 0.0000039 g  → 远小于要求的 0.003 g
在 1000Hz 采样、100Hz 有效带宽下:
噪声 RMS ≈ 22.5 µg/√Hz × √100Hz ≈ 225 µg ≈ 0.000225 g

也就是噪声比最小分辨率还小一个数量级,完全满足指标,且有余量。

为什么不用最常见的 MPU6050 / LIS3DH?它们 16bit、±2g 档 LSB ≈ 61µg,虽然数字上也够,但噪声密度高、温漂大、零偏稳定性差,做"检测仪"(要出报告)会被质疑精度。ADXL355 是真正的工业级器件,这也是为什么要"美国进口工业 MEMS"。

注意:ADXL355 是 ±2g 档才 3.9µg/LSB。如果车辆振动大(比如卡车、工程车),应切到 ±4g 或 ±8g,此时 LSB 变为 7.8 / 15.6 µg,仍然远超 3mg 要求。

2.3 GPS:ATGM336H

    • 支持 GPS + 北斗 + GLONASS 多系统,城市道路定位更稳;• 串口 NMEA 0183 输出,9600 bps,接线只需 TX/RX 两根(我们只收不发);• 冷启动 30 秒左右,热启动 1 秒。

本项目用 GPS 干什么? 不是做导航,而是给振动数据打"位置标签"——报告里要知道这段振动发生在哪条路、什么速度下。所以只需要 RMC(位置+速度)和 GGA(定位质量+卫星数)两帧。

2.4 电源:IP5306 + 20000mAh

IP5306 是一颗高度集成的移动电源管理 IC:

功能 说明
充电 支持 5V/2A 线性充电,Type-C 输入
升压 内部 Boost 到 5V,供系统使用
电量显示 原生支持 4 颗 LED 指示(LED1~LED4)
按键 短按开机 / 长按关机

续航估算(这是最容易翻车的地方,必须算):

ESP32-S3 + WiFi AP 常开:平均约 120~180 mA
ADXL355:约 0.2 mA(低功耗模式)/ 1 mA(高性能)
ATGM336H:约 25~30 mA
LED 指示:约 10 mA
合计:约 180~220 mA(取 200mA 计算)

20000 mAh = 20 Ah
续航 = 20 Ah / 0.2 A = 100 小时(理论值)
按 70% 有效容量:约 70 小时

结论:12 小时要求非常轻松。真正的续航杀手是"WiFi 一直满功率发射",所以代码里做了处理:没有客户端连接时不发送数据,只做本地记录。

2.5 完整 BOM

序号 物料 规格 数量 备注
1 ESP32-S3-WROOM-1-N16R8 16MB Flash / 8MB PSRAM 1 主控
2 ADXL355 模块 SPI 接口,含去耦电容 1 注意 3.3V 供电
3 ATGM336H 模块 含陶瓷天线 + IPEX 1 天线要朝上、远离金属
4 聚合物锂电池 3.7V 20000mAh 1 带保护板
5 IP5306 模块 充电+升压+4LED 1 或自绘 PCB
6 Type-C 母座 16P 贴片 1 充电
7 TF 卡座 自弹式 SPI 1 32GB Class10
8 LED 0805 红/绿/蓝 5 4 电量 + 1 RGB
9 电阻 200kΩ / 100kΩ / 1kΩ 若干 分压、限流
10 电容 0.1µF / 10µF 若干 去耦
11 外壳 3D 打印或铝合金 1 内部加配重与磁吸

2.6 接线表(照着插线就行)

ADXL355(SPI2)

ESP32-S3 ADXL355 说明
GPIO11 SDI / MOSI 主机发、从机收
GPIO13 SDO / MISO 从机发、主机收
GPIO12 SCLK 时钟,10MHz
GPIO10 CS 片选,低有效
3V3 VDD 必须 3.3V,不能 5V
GND GND 共地

ATGM336H(UART1)

ESP32-S3 ATGM336H 说明
GPIO43 (TX) RXD 交叉连接
GPIO44 (RX) TXD 交叉连接
3V3 VCC 3.3V
GND GND 共地

坑点:GPS 模块的 TX 电平有的是 3.3V,有的模块标 5V 兼容。接之前用万用表量一下 TX 空闲电平,>3.6V 要加电平转换,否则会打坏 ESP32 的 GPIO。

TF 卡(SPI3)

ESP32-S3 TF 卡座 说明
GPIO35 MOSI 数据输入
GPIO37 MISO 数据输出
GPIO36 SCLK 时钟
GPIO38 CS 片选
3V3 VDD 3.3V
GND GND 共地

为什么要用 SPI3 而不是和 ADXL355 共用 SPI2?
ESP-IDF 里 spi_bus_initialize() 对同一个 host 只能调用一次。两个模块共用一个 host 时,必须只初始化一次总线、挂两个 device。初学者最容易在这里踩坑:两次初始化直接报 ESP_ERR_INVALID_STATE。本项目干脆把 SD 卡放到 SPI3,代码最简单,也不影响性能(SD 卡 SPI 时钟本来就只有几 MHz)。

电池检测与 LED

ESP32-S3 连接 说明
GPIO4 分压中点(200k 上 / 100k 下) ADC1_CH3
GPIO14~17 4 颗电量灯(串 1kΩ) 每 25% 亮一颗
GPIO18/19/20 RGB 状态灯 R/G/B 共阴,串 1kΩ

2.7 安装方位约定(非常重要)

振动检测的方向性极强,装反了指标全废。本项目的约定:

X 轴:车辆前进方向(纵向);•

Y 轴:车辆横向(左右);•

Z 轴:垂直方向(上下,静止时应读到 ≈ +1g)。

所以设备外壳上要刻一个箭头,安装时箭头朝车头。静止时打开 APP,应看到 Z 轴约 1.0g、X/Y 约 0g——这是最快的自检方法。


第三章 开发环境搭建:让工程一次编译通过

3.1 安装 ESP-IDF v5.3

Windows 推荐用官方离线安装器(避免网络问题):

1. 打开乐鑫官网下载 esp-idf-tools-setup-online-*.exe;

2. 安装时选择 v5.3版本,安装路径不要包含中文和空格(例如 D:espv5.3);

3. 安装器会自动装好 Python、Git、工具链,并生成 export.bat。

安装完成后,在开始菜单找到 "ESP-IDF 5.3 PowerShell",输入:

idf.py --version

看到 ESP-IDF v5.3 就说明环境 OK。

3.2 VSCode 配置

1. 安装扩展 Espressif IDF(乐鑫官方);

2. Ctrl+Shift+P → 输入 ESP-IDF: Configure ESP-IDF extension;

3. 选择 Use existing setup,路径指向刚才安装的 D:espv5.3esp-idf;

4. 配置完成后,底部状态栏会出现芯片型号、串口、编译按钮。

提示:工程根目录下的 .vscode/settings.json 建议加上 "idf.flashType": "UART",避免 USB-JTAG 模式和串口模式混淆。

3.3 工程目录结构

firmware/
├── CMakeLists.txt              ← 顶层构建入口
├── sdkconfig.defaults          ← 默认配置(首次编译自动生成 sdkconfig)
├── partitions.csv              ← 自定义分区表(16MB Flash)
├── main/
│   ├── CMakeLists.txt          ← 声明 main 组件
│   └── main.c                  ← 应用入口 app_main()
└── components/                 ← 自建组件(IDF 会自动扫描)
    ├── adxl355/
    │   ├── CMakeLists.txt
    │   ├── include/adxl355.h   ← 对外头文件
    │   └── adxl355.c
    ├── gps/
    ├── sdcard/
    ├── wifi_tcp/
    ├── battery/
    └── led/

为什么用 components 目录而不是全塞进 main?
ESP-IDF 的构建系统会把 components/ 下每个子目录当成一个独立组件:自动编译、自动导出 include 路径、可被单独复用。这样驱动代码和业务逻辑解耦,将来换个项目直接拷走整个 adxl355/ 文件夹就能用。

3.4 三个关键配置文件逐行讲解

3.4.1 顶层 CMakeLists.txt

cmake_minimum_required(VERSION 3.16)

# 这一行必须最先执行,它引入 ESP-IDF 的构建规则
include($ENV{IDF_PATH}/tools/cmake/project.cmake)

# 工程名,会决定生成的 bin 文件名
project(vibration_detector)

坑点:project() 必须写在 include(...) 之后。写反了会报 project command is not a script 之类的怪错误。

3.4.2 main/CMakeLists.txt

idf_component_register(
    SRCS "main.c"
    INCLUDE_DIRS "."
    REQUIRES
        adxl355 gps sdcard wifi_tcp battery led
        driver nvs_flash esp_event esp_netif esp_wifi freertos log
)

    SRCS:本组件要编译的源文件;

    INCLUDE_DIRS:本组件对外暴露的头文件目录;

    REQUIRES:依赖的其他组件名。

    漏写会在链接阶段报 undefined reference to adxl355_init——这是新手最常见的报错,遇到就回来检查这一行。

    3.4.3 partitions.csv(16MB Flash 分区)

    # Name,   Type, SubType, Offset,   Size,     Flags
    nvs,      data, nvs,     0x9000,   0x6000,
    phy_init, data, phy,     0xf000,   0x1000,
    factory,  app,  factory, 0x10000,  0x400000,
    storage,  data, fat,     0x410000, 0xBF0000,
    分区 用途
    nvs WiFi 校准参数、用户配置(必须保留)
    phy_init RF 校准数据
    factory 应用固件,给了 4MB,非常充裕
    storage 这是片内 Flash 的数据区,本项目实际把数据写 TF 卡,这里留作扩展(比如存配置、离线缓存)

    注意:Offset 必须对齐到 4KB(0x1000)。写错会导致烧录成功但启动死机。

    3.4.4 sdkconfig.defaults 里最关键的 6 项

    CONFIG_IDF_TARGET="esp32s3"
    CONFIG_ESPTOOLPY_FLASHSIZE_16MB=y     # N16R8 的 16MB
    CONFIG_SPIRAM=y                       # 打开 PSRAM
    CONFIG_SPIRAM_MODE_OCT=y              # R8 是 Octal 模式,不是 Quad
    CONFIG_FREERTOS_HZ=1000               # 1ms 一个 tick,才能做 1ms 延时
    CONFIG_PARTITION_TABLE_CUSTOM=y
    CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv"

    最容易翻车的一行:CONFIG_SPIRAM_MODE_OCT。如果写成 Quad,启动会卡在 SPI SRAM 初始化,串口疯狂重启。N16R8 的 "R8" 一定要配 Octal。

    3.5 第一次编译

    cd firmware
    idf.py set-target esp32s3      # 只在第一次需要
    idf.py build

    期望输出结尾:

    Project build complete. To flash, run:
     idf.py flash

    如果报错,先看本文第十七章"常见问题速查",90% 的编译错误都在那里。


    第四章 固件整体架构:任务怎么划分

    4.1 数据流全景

                    ┌────────────┐
                    │  ADXL355   │ 1000Hz
                    └─────┬──────┘
                          │ SPI 读 9 字节
                    ┌─────▼──────┐
                    │ task_acc   │ Core1, prio5
                    └─────┬──────┘
                          │ ring_push ×2
            ┌─────────────┴─────────────┐
            │                           │
    ┌───────▼────────┐        ┌─────────▼───────┐
    │ g_ring_sd      │        │ g_ring_tcp      │
    └───────┬────────┘        └─────────┬───────┘
            │                           │
    ┌───────▼────────┐        ┌─────────▼───────┐
    │ task_sdcard    │        │ task_tcp_send   │
    │ Core0, prio4   │        │ Core1, prio4    │
    └───────┬────────┘        └─────────┬───────┘
            │                           │
         CSV 文件                  JSON → WiFi → APP
    
    ┌────────────┐   ┌────────────┐   ┌────────────┐
    │ ATGM336H   │──▶│ task_gps   │──▶│ g_gps_data │──▶ TCP / SD 共用
    └────────────┘   └────────────┘   └────────────┘
    
    ┌────────────┐   ┌────────────┐   ┌────────────┐
    │ 电池 ADC   │──▶│ task_status│──▶│ LED 指示   │
    └────────────┘   └────────────┘   └────────────┘

    4.2 任务分工与优先级

    任务 核心 优先级 栈大小 周期 职责
    task_acc Core 1 5 4096 1ms 读传感器、入队
    task_tcp_send Core 1 4 8192 50ms 攒批、拼 JSON、发 TCP
    task_sdcard Core 0 4 8192 20ms 出队、写 CSV、刷盘
    task_gps Core 0 3 4096 200ms 读 UART、解析 NMEA
    task_status Core 0 2 3072 1s 读电池、刷 LED
    main 默认 1 8192 1s 空闲

    优先级分配逻辑:

      • 采样任务优先级最高(5),因为它对时间最敏感,晚 1ms 就会丢一个点;• TCP 和 SD 都是"数据搬运工",优先级次之(4);• GPS 是低频任务,200ms 一次,优先级 3;• LED 刷新慢,优先级最低(2)。

    为什么把采样和 TCP 放同一个核?
    采样和推送是"生产者-消费者",放同核可以减少跨核锁竞争;而 SD 写入涉及文件系统,可能阻塞几十毫秒,放到另一个核(Core 0)就不会拖累采样。

    4.3 为什么用"两个环形队列"

    这是本项目一个容易被忽略但非常致命的设计。

    错误做法(很多教程的写法):

    // 只有一个队列, TCP 和 SD 都从这个队列里取
    uint32_t n1 = ring_pop(&ring, tcp_batch, 200);   // TCP 先取走
    uint32_t n2 = ring_pop(&ring, sd_batch, 256);    // SD 只能取到剩下的

    结果:两个消费者在抢同一份数据。TCP 取走 200 个,SD 就少 200 个,CSV 记录出现大量空洞;反过来 SD 先取,手机端就掉点。实测表现是"手机波形看着正常,但导出的 CSV 有断续"——非常难排查。

    正确做法:采集任务把同一个采样点复制成两份,分别投递到两个队列:

    ring_push(&g_ring_sd, &s);
    ring_push(&g_ring_tcp, &s);

    每个消费者各自消费自己的队列,互不干扰。代价是内存翻倍:

    ACC_RING_SIZE 2048 × sizeof(acc_sample_t) 28 字节 × 2 路 ≈ 114 KB

    ESP32-S3 内部 SRAM 512KB,放得下;即使再大也可以放到 PSRAM。

    4.4 acc_sample_t 结构体设计

    typedef struct {
        uint32_t seq;          // 序号, 从 0 递增, 用于检测丢包
        int32_t  x, y, z;      // 20-bit 原始值, 用于二次计算/标定
        float    gx, gy, gz;   // 已换算的物理量 (g)
        uint32_t timestamp_ms; // 毫秒时间戳
    } acc_sample_t;

    为什么同时存原始值和 g 值?

    gx/gy/gz 给 APP 直接算指标,省一次换算;

    x/y/z 保留原始 LSB,将来做灵敏度标定或面法校准时需要原始数据,否则只能重采。

    结构体大小 28 字节,正好对齐 4 字节,不会产生 padding 浪费(这是 C 语言里一个常见细节,字段顺序按 4 字节类型排即可)。

    4.5 app_main 初始化顺序(顺序错了会死机)

    void app_main(void)
    {
        nvs_flash_init();                    // ① WiFi 依赖 NVS, 必须最先
        ring_init(&g_ring_sd);               // ② 队列
        ring_init(&g_ring_tcp);
        g_gps_mutex = xSemaphoreCreateMutex();
    
        led_init();                          // ③ 先点灯, 用户能看到"活着"
        battery_init();
        adxl355_init();                      // ④ 传感器
        adxl355_read_id();                   //    校验 0xED
    
        wifi_tcp_init_ap(...);               // ⑤ 起 AP
        vTaskDelay(1000);                    //    等 netif 就绪
        wifi_tcp_start_server(8080);         // ⑥ 起 TCP
    
        xTaskCreatePinnedToCore(...);        // ⑦ 最后创建业务任务
        while (1) vTaskDelay(1000);
    }

    三个顺序细节,都是踩过的坑:

    1. NVS 必须最先:WiFi 驱动初始化时要从 NVS 读校准数据,没初始化会报 ESP_ERR_NVS_NOT_INITIALIZED。

    2. AP 起完要等一下再开 socket:esp_wifi_start() 是异步的,netif 还没拿到 IP 就 bind,可能返回 ERR_INPROGRESS。加 1 秒延时最省事。

    3. 任务最后创建:如果先创建采集任务,它可能在你还没初始化 ADC/传感器时就开始跑,导致 NULL 指针。

    第五章 ADXL355 驱动:1000Hz 采样的核心

    这一章是全文技术含量最高的部分,建议对照 components/adxl355/adxl355.c 逐段看。

    5.1 先搞懂 SPI 通信格式

    ADXL355 的 SPI 是标准 4 线模式(Mode 0:CPOL=0,CPHA=0),第一个字节是命令:

    bit7 ─────────── bit1 │ bit0
       寄存器地址 (7位)    │ R/W

    R/W = 0 → 写

    R/W = 1 → 读

    所以:

    写寄存器: 首字节 = (reg << 1) | 0x00
    读寄存器: 首字节 = (reg << 1) | 0x01

    举例:读 PARTID(地址 0x02):

    (0x02 << 1) | 0x01 = 0x05

    主机发送 0x05,从机在第二个字节把 ID 送回来。

    常见错误:直接把地址当命令发(发 0x02),结果读到的是寄存器 0x01 的数据,永远对不上 ID。

    5.2 寄存器地图(只列本项目用到的)

    地址 名称 作用
    0x00 DEVID_AD 器件 ID 0xAD
    0x02 PARTID 产品 ID 0xED,用于校验
    0x04 STATUS bit0 = DATA_RDY
    0x05 FIFO_ENTRIES FIFO 中待读样本数
    0x08~0x0A XDATA3/2/1 X 轴 20bit 数据
    0x0B~0x0D YDATA3/2/1 Y 轴
    0x0E~0x10 ZDATA3/2/1 Z 轴
    0x28 FILTER bit[3:0]=ODR,bit[6:4]=HPF
    0x2C RANGE bit[1:0]=量程
    0x2D POWER_CTL bit0=1 待机,bit0=0 测量
    0x2F RESET 写 0x52 软复位

    5.3 写寄存器

    static esp_err_t adxl355_write_reg(uint8_t reg, uint8_t val)
    {
        uint8_t tx[2];
        tx[0] = (reg << 1) | 0x00;   // 命令:写
        tx[1] = val;                  // 数据
        spi_transaction_t t = {
            .length = 16,             // 2 字节 = 16 bit
            .tx_buffer = tx,
        };
        return spi_device_polling_transmit(s_spi, &t);
    }

    spi_device_polling_transmit:轮询式发送,函数返回时传输已完成,不需要回调。对于这种小包、高频次的场景,比中断/队列方式更简单可靠。

    .length 单位是 bit 而不是 byte,这里 2 字节写 16。

    5.4 读寄存器与多字节连续读

    ADXL355 支持"地址自增":读首字节后,后续字节自动是下一个寄存器。所以读 X/Y/Z 三个轴(9 个字节)只需一次事务:

    static esp_err_t adxl355_read_bytes(uint8_t reg, uint8_t *buf, int len)
    {
        uint8_t tx[16] = {0};
        uint8_t rx[16] = {0};
        if (len > 15) return ESP_ERR_INVALID_SIZE;
    
        tx[0] = (reg << 1) | 0x01;     // 命令:读起始地址
        spi_transaction_t t = {
            .length = (len + 1) * 8,   // 命令 1 字节 + 数据 len 字节
            .tx_buffer = tx,
            .rx_buffer = rx,
        };
        esp_err_t err = spi_device_polling_transmit(s_spi, &t);
        if (err == ESP_OK) {
            memcpy(buf, &rx[1], len);  // rx[0] 是命令期间的无效数据, 丢弃
        }
        return err;
    }

    关键点 1:rx[0] 是发命令时 MISO 上的数据,无意义,必须跳过。
    关键点 2:先把 9 个字节一次性读出来再解析。如果分三次读(每次读 3 字节),中间可能跨采样点,导致 X/Y/Z 不是同一个时刻的数据(这叫"数据撕裂")。1000Hz 下这个问题很容易出现。
    关键点 3:10MHz 时钟读 10 字节约 8µs,1ms 采样周期完全够。

    5.5 20-bit 数据解码(最容易写错的地方)

    ADXL355 每个轴是 20 位有符号数,分布在 3 个字节里,但低 4 位是无效的:

    XDATA3: D19 D18 D17 D16 D15 D14 D13 D12
    XDATA2: D11 D10 D9  D8  D7  D6  D5  D4
    XDATA1: D3  D2  D1  D0  0   0   0   0     ← 低 4 位补零

    所以拼接公式:

    uint32_t ux = ((uint32_t)buf[0] << 12)   // D19..D12 左移 12 位
                | ((uint32_t)buf[1] << 4)    // D11..D4  左移 4 位
                | ((uint32_t)buf[2] >> 4);   // D3..D0   取高 4 位

    得到的 ux 是 20 位无符号数(0 ~ 0xFFFFF)。要转成负数,需要符号扩展:

    static int32_t sign_extend_20bit(uint32_t v)
    {
        if (v & 0x80000) {                 // 第 19 位是符号位
            return (int32_t)(v | 0xFFF00000);  // 高 12 位全补 1
        }
        return (int32_t)v;                  // 正数直接返回
    }

    为什么要补 0xFFF00000?
    32 位有符号数的高 12 位代表符号,20 位负数扩展后高 12 位必须全为 1,否则 -1 会被解析成 0x000FFFFF(= +1048575),数据完全错乱。

    验证方法:把设备静止平放,理论上 Z 轴应读到约 +256000(因为 1g / 3.9µg ≈ 256410 LSB)。如果读到 -1040000 之类的怪值,就是符号扩展写错了。

    5.6 量程与换算

    typedef enum {
        ADXL355_RANGE_2G = 0x01,   // ±2.048g
        ADXL355_RANGE_4G = 0x02,   // ±4.096g
        ADXL355_RANGE_8G = 0x03,   // ±8.192g
    } adxl355_range_t;

    对应灵敏度:

    量程 灵敏度 满量程对应 LSB
    ±2.048g 3.9 µg/LSB ±524288
    ±4.096g 7.8 µg/LSB ±524288
    ±8.192g 15.6 µg/LSB ±524288

    注意一个反直觉的点:三档量程的"满量程 LSB 数"是一样的,都是 20 位能表示的范围;变的是 LSB 代表的物理量。所以换量程时必须同步改换算系数,否则数值会差 2 倍/4 倍。

    float adxl355_raw_to_g(int32_t raw)
    {
        float scale_ug = 0;
        switch (s_range) {
            case ADXL355_RANGE_2G: scale_ug = 3.9f;  break;
            case ADXL355_RANGE_4G: scale_ug = 7.8f;  break;
            case ADXL355_RANGE_8G: scale_ug = 15.6f; break;
        }
        return raw * scale_ug * 1e-6f;   // µg → g
    }

    5.7 输出数据率 ODR:怎么配出 1000Hz

    FILTER 寄存器(0x28):

    bit7 ─────── bit4 │ bit3 ─────── bit0
          HPF 高角频率 │      ODR 选择

    ODR 对应表(HPF 旁路时):

    FILTER[3:0] ODR
    0x0 4000 Hz
    0x1 2000 Hz
    0x2 1000 Hz
    0x3 500 Hz
    0x4 250 Hz
    0x5 125 Hz
    0x6 62.5 Hz
    0x7 31.25 Hz
    esp_err_t adxl355_set_filter(uint8_t filter_val)
    {
        uint8_t v = (filter_val & 0x0F);   // 高 4 位写 0 => HPF 旁路
        return adxl355_write_reg(ADXL355_REG_FILTER, v);
    }
    // 调用: adxl355_set_filter(0x02);  → 1000Hz

    为什么 HPF 要旁路?
    高通滤波器会滤掉低频(接近直流的重力分量)。做"振动"检测时我们通常希望保留低频成分(比如车体缓慢晃动、0.5Hz 的悬挂共振),所以旁路 HPF,后期在 APP 端用软件去直流(减均值)即可,更灵活。

    5.8 初始化流程:复位 → 校验 → 配置 → 进测量模式

    esp_err_t adxl355_init(void)
    {
        /* ① 初始化 SPI 总线 */
        spi_bus_config_t bus = {
            .mosi_io_num = ADXL355_PIN_MOSI,
            .miso_io_num = ADXL355_PIN_MISO,
            .sclk_io_num = ADXL355_PIN_SCLK,
            .quadwp_io_num = -1,
            .quadhd_io_num = -1,
            .max_transfer_sz = 64,
        };
        spi_bus_initialize(ADXL355_SPI_HOST, &bus, SPI_DMA_CH_AUTO);
    
        /* ② 挂载从设备 */
        spi_device_interface_config_t dev = {
            .clock_speed_hz = 10 * 1000 * 1000,
            .mode = 0,
            .spics_io_num = ADXL355_PIN_CS,
            .queue_size = 4,
        };
        spi_bus_add_device(ADXL355_SPI_HOST, &dev, &s_spi);
    
        /* ③ 软复位, 保证寄存器是已知状态 */
        adxl355_write_reg(ADXL355_REG_RESET, 0x52);
        vTaskDelay(pdMS_TO_TICKS(10));       // 复位后必须等 >5ms
    
        /* ④ 校验 ID, 读不到说明接线错/供电错/SPI 模式错 */
        uint8_t id;
        adxl355_read_reg(ADXL355_REG_PARTID, &id);
        if (id != 0xED) {
            ESP_LOGE(TAG, "ID 校验失败: 0x%02X", id);
            return ESP_ERR_NOT_FOUND;
        }
    
        /* ⑤ 配置量程与滤波 */
        adxl355_set_range(ADXL355_RANGE_2G);
        adxl355_set_filter(0x02);            // 1000Hz
    
        /* ⑥ 退出待机, 进入测量模式 */
        uint8_t pwr;
        adxl355_read_reg(ADXL355_REG_POWER_CTL, &pwr);
        pwr &= ~0x01;                        // bit0 清 0 = 测量
        adxl355_write_reg(ADXL355_REG_POWER_CTL, pwr);
    
        return ESP_OK;
    }

    六步里最容易漏的是第 ③ 步和第 ⑥ 步:

      • 不复位:如果芯片之前被其它程序配置过,量程可能是 ±8g,你按 ±2g 换算,数值差 4 倍;• 不进测量模式:POWER_CTL 默认是待机,读完永远是 0,很多人以为是硬件坏了。

    5.9 采样循环为什么用 vTaskDelay(1)

    static void task_acc(void *arg)
    {
        adxl355_set_filter(0x02);
        uint32_t seq = 0;
        while (g_running) {
            int32_t x, y, z;
            if (adxl355_read_xyz(&x, &y, &z) == ESP_OK) {
                acc_sample_t s = {
                    .seq = seq++,
                    .x = x, .y = y, .z = z,
                    .gx = adxl355_raw_to_g(x),
                    .gy = adxl355_raw_to_g(y),
                    .gz = adxl355_raw_to_g(z),
                    .timestamp_ms = esp_log_timestamp(),
                };
                ring_push(&g_ring_sd, &s);
                ring_push(&g_ring_tcp, &s);
            }
            vTaskDelay(pdMS_TO_TICKS(1));
        }
    }

    坦诚地说,这不是"硬实时"采样。vTaskDelay(1) 的实际周期 = 1ms + 任务调度开销(通常 0.1~0.5ms 抖动)。所以在振动剧烈时会有轻微的时间不均匀,FFT 出来的频谱会有少量展宽。

    但为什么本教程仍然用它? 三个理由:

      1. 1000Hz 的抖动在 0.2ms 量级,相对周期 1ms 是 20%,对 0.5~20Hz 的车辆振动分析影响很小(最高分析频率才 20Hz,采样率是它的 50 倍);2. 代码极简,便于理解,教学价值高;3. 真正需要精密频谱时,可以用下节的 FIFO 方案,几行代码就能升级。

    5.10 进阶:用 FIFO + 数据就绪位做到"真 1000Hz"

    ADXL355 内部有 32 深度 FIFO,且 ODR 由芯片自己的时钟产生,精度比 MCU 延时高两个数量级。做法:

    // 配置 FIFO 水位: FIFO_SAMPLES(0x29) 低 7 位 = 水位值
    // 例如水位 16: 每累计 16 个样本把 DATA_RDY 置高
    
    while (1) {
        uint8_t status, entries;
        adxl355_read_reg(0x04, &status);
        if (status & 0x01) {                 // DATA_RDY
            adxl355_read_reg(0x05, &entries);
            // 从 FIFO_DATA(0x11) 连续读出 3×entries 字节, 每 3 字节是一个轴的 20bit
            for (int i = 0; i < entries; i++) {
                uint8_t raw[3];
                adxl355_read_bytes(0x11, raw, 3);   // 注意: FIFO 数据是 20bit 未对齐格式
                // 解析 + 入队
            }
        }
        vTaskDelay(pdMS_TO_TICKS(10));
    }

    FIFO 读取的坑:FIFO 里的数据格式与 XDATA 寄存器不同(没有 4 位补零对齐),需要按数据手册 Figure 35 对齐。如果你不熟悉,建议先用本文的基础方案,等整个系统跑通后再优化。

    5.11 噪声与零偏:能不能直接出报告

    真实检测仪必须处理两个非理想因素:

    零偏(Offset):静止时 X/Y 应该读 0,但实际可能有 ±0.01~0.05g 的固定偏移。APP 端用"减均值"能消除,这是最省事的办法:

    removeMean(x); removeMean(y); removeMean(z)   // 见第十四章

    温漂:ADXL355 零偏温漂约 ±0.5 mg/°C,从 20°C 到 40°C 可能漂 10mg。对于"相对振动"指标(Wz、RMS)影响很小,因为减均值后温漂被吃掉了。

    真正需要绝对精度时,做六面法标定(见第十六章)。


    第六章 GPS 驱动:NMEA 报文解析

    6.1 NMEA 0183 是什么

    GPS 模块上电后,会不停地通过串口输出"句子",每个句子一行,以 $ 开头、以 *校验和 结尾:

    $GNRMC,083559.00,A,3958.7324,N,11619.4413,E,0.05,,210624,,,A*6C
    $GNGGA,083559.00,3958.7324,N,11619.4413,E,1,12,0.9,48.2,M,-9.4,M,,*5A
      • •

    GN = 多系统(GPS+北斗+GLONASS 融合);

    GP = 纯 GPS;

    • 字段用逗号分隔,空字段表示无效;

    • 本项目只解析两帧:RMC(定位+速度)和 GGA(质量+卫星数)。

    6.2 RMC 逐字段拆解

    以这行为例:

    $GNRMC,083559.00,A,3958.7324,N,11619.4413,E,0.05,,210624,,,A*6C
       [0]   [1]      [2] [3]      [4] [5]        [6] [7][8][9]   [10..]
    下标 内容 含义
    0 $GNRMC 报文头
    1 083559.00 UTC 时间 HHMMSS.ss
    2 A 定位状态:A=有效,V=无效
    3 3958.7324 纬度 ddmm.mmmm
    4 N 北纬/南纬
    5 11619.4413 经度 dddmm.mmmm
    6 E 东经/西经
    7 0.05 地速,单位节(knots)
    8 空 航向,单位度
    9 210624 日期 DDMMYY

    关键判断:第 2 个字段如果不等于 A,说明还没定位成功,此时经纬度全是 0,必须丢弃,否则你会把设备标在几内亚湾(0,0)。

    6.3 GGA 逐字段拆解

    $GNGGA,083559.00,3958.7324,N,11619.4413,E,1,12,0.9,48.2,M,-9.4,M,,*5A
       [0]   [1]      [2] [3] [4] [5]        [6][7][8] [9]  ...
    下标 内容 含义
    6 1 定位质量:0=无,1=GPS,2=DGPS,4=RTK
    7 12 卫星数
    9 48.2 海拔高度(米)

    6.4 代码实现:通用字段提取函数

    先用一个通用函数按逗号切分,比 strtok 更可控(strtok 会破坏原字符串,且不支持空字段):

    /* 提取第 idx 个逗号分隔字段 (0-based), 支持空字段 */
    static int get_field(const char *sentence, int idx, char *out, int maxlen)
    {
        int field = 0, start = 0, i = 0;
        for (; sentence[i]; i++) {
            if (sentence[i] == ',') {
                if (field == idx) {
                    int len = i - start;
                    if (len >= maxlen) len = maxlen - 1;
                    strncpy(out, sentence + start, len);
                    out[len] = 0;
                    return len;
                }
                field++;
                start = i + 1;
            }
        }
        /* 最后一个字段(没有后续逗号) */
        if (field == idx) {
            int len = i - start;
            if (len >= maxlen) len = maxlen - 1;
            strncpy(out, sentence + start, len);
            out[len] = 0;
            return len;
        }
        return -1;
    }

    6.5 经纬度格式转换(必考知识点)

    NMEA 的纬度是 ddmm.mmmm(度分格式),必须转成十进制度:

    static double nmea_to_degrees(const char *raw, char hemi)
    {
        double val = atof(raw);          // 3958.7324
        int deg = (int)(val / 100);      // 39 (度)
        double min = val - deg * 100;    // 58.7324 (分)
        double dec = deg + min / 60.0;   // 39 + 0.978873 = 39.978873
        if (hemi == 'S' || hemi == 'W') dec = -dec;   // 南纬/西经为负
        return dec;
    }

    手算一遍:

    3958.7324 → 39 度 58.7324 分
    58.7324 / 60 = 0.9788733
    39 + 0.9788733 = 39.9788733  ← 北京天安门附近, 正确

    常见错误:直接用 atof(raw) / 100,得到 39.58,看起来"差不多",实际差了 44 公里。

    6.6 报文解析状态机

    static void parse_nmea(const char *line)
    {
        char f[32];
    
        if (strncmp(line, "$GNRMC", 6) == 0 || strncmp(line, "$GPRMC", 6) == 0) {
            get_field(line, 1, f, sizeof(f));                   // UTC 时间
            strncpy(s_last.utc_time, f, sizeof(s_last.utc_time) - 1);
    
            char status = 'V';
            if (get_field(line, 2, f, sizeof(f)) > 0) status = f[0];
            if (status != 'A') return;                          // 未定位, 直接丢弃
    
            char lat_s[32], lon_s[32], ns, ew;
            get_field(line, 3, lat_s, sizeof(lat_s));
            get_field(line, 4, f, sizeof(f)); ns = f[0];
            get_field(line, 5, lon_s, sizeof(lon_s));
            get_field(line, 6, f, sizeof(f)); ew = f[0];
    
            s_last.lat = nmea_to_degrees(lat_s, ns);
            s_last.lon = nmea_to_degrees(lon_s, ew);
    
            get_field(line, 7, f, sizeof(f)); s_last.speed_knots = atof(f);
            get_field(line, 8, f, sizeof(f)); s_last.course = atof(f);
            s_last.valid = 1;
    
        } else if (strncmp(line, "$GNGGA", 6) == 0 || strncmp(line, "$GPGGA", 6) == 0) {
            get_field(line, 6, f, sizeof(f)); s_last.fix = atoi(f);
            get_field(line, 7, f, sizeof(f)); s_last.sats = atoi(f);
            get_field(line, 9, f, sizeof(f)); s_last.altitude = atof(f);
            s_last.valid = 1;
        }
    }

    为什么要同时匹配 GN 和 GP 前缀? 模块在不同状态下输出前缀会变(搜星阶段可能输出 $GPGGA,稳定后变 $GNGGA)。只匹配一种,就会出现"定位好了但数据不更新"的诡异现象。

    6.7 UART 初始化与行缓冲接收

    esp_err_t gps_init(void)
    {
        uart_config_t cfg = {
            .baud_rate = 9600,
            .data_bits = UART_DATA_8_BITS,
            .parity    = UART_PARITY_DISABLE,
            .stop_bits = UART_STOP_BITS_1,
            .flow_ctrl = UART_HW_FLOWCTRL_DISABLE,
            .source_clk = UART_SCLK_DEFAULT,
        };
        uart_driver_install(GPS_UART_PORT, 2048 * 2, 0, 0, NULL, 0);
        uart_param_config(GPS_UART_PORT, &cfg);
        uart_set_pin(GPS_UART_PORT, 43, 44, UART_PIN_NO_CHANGE, UART_PIN_NO_CHANGE);
        return ESP_OK;
    }

    接收时用"逐字节喂入行缓冲"的方式,遇到 r/n 就解析一行:

    esp_err_t gps_read(gps_data_t *out)
    {
        uint8_t buf[512];
        static char line_buf[256];
        static int line_pos = 0;
    
        int len = uart_read_bytes(GPS_UART_PORT, buf, sizeof(buf), pdMS_TO_TICKS(10));
        for (int i = 0; i < len; i++) {
            char c = buf[i];
            if (c == 'n' || c == 'r') {
                if (line_pos > 0) {
                    line_buf[line_pos] = 0;
                    parse_nmea(line_buf);
                    line_pos = 0;
                }
            } else if (line_pos < (int)sizeof(line_buf) - 1) {
                line_buf[line_pos++] = c;
            }
        }
        *out = s_last;
        return s_last.valid ? ESP_OK : ESP_ERR_NOT_FINISHED;
    }

    为什么要行缓冲? 一次 uart_read_bytes 读到的数据可能包含半条报文(串口是流式的)。直接对整块 buffer 解析会读到截断残句,导致解析失败。

    6.8 验证方法

    烧录后打开串口监视器,应该看到类似日志;也可以在 task_gps 里临时打印:

    ESP_LOGI("GPS", "lat=%.6f lon=%.6f spd=%.2f sats=%d fix=%d",
             d.lat, d.lon, d.speed_knots, d.sats, d.fix);

    指标含义:

    现象 判断
    fix=0, sats=0 没搜到星,检查天线、是否在室内
    sats 从 0 慢慢涨到 8+ 正常搜星过程
    fix=1, sats>=6 定位可用
    经纬度全是 0 RMC 状态位不是 A,被正确丢弃了

    第七章 WiFi AP + TCP:把数据送出去

    7.1 三种联网方案怎么选

    方案 优点 缺点 适用
    AP 模式(本项目) 不需要路由器,手机直连,现场即用 手机连上后上不了网 车载、野外检测
    STA 模式 可接入现有 WiFi,能连云 依赖现场网络 固定场所
    BLE 功耗低 带宽小(<100KB/s),1000Hz 不够 低频传感器

    本项目选 AP 模式:设备自己当热点,手机连上即用,不依赖任何现场条件,这就是需求里"一键操作"的实现方式。

    7.2 WiFi 初始化四步

    esp_err_t wifi_tcp_init_ap(const char *ssid, const char *password)
    {
        ESP_ERROR_CHECK(esp_netif_init());              // ① 网络协议栈
        ESP_ERROR_CHECK(esp_event_loop_create_default()); // ② 默认事件循环
        esp_netif_create_default_wifi_ap();             // ③ 创建 AP 网卡
    
        wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT();
        ESP_ERROR_CHECK(esp_wifi_init(&cfg));           // ④ WiFi 驱动
    
        wifi_config_t wc = {0};
        strncpy((char *)wc.ap.ssid, ssid, sizeof(wc.ap.ssid) - 1);
        strncpy((char *)wc.ap.password, password, sizeof(wc.ap.password) - 1);
        wc.ap.ssid_len = strlen(ssid);
        wc.ap.max_connection = 4;
        wc.ap.authmode = WIFI_AUTH_WPA2_PSK;
    
        ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_AP));
        ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_AP, &wc));
        ESP_ERROR_CHECK(esp_event_handler_instance_register(
            WIFI_EVENT, ESP_EVENT_ANY_ID, &wifi_event_handler, NULL, NULL));
        ESP_ERROR_CHECK(esp_wifi_start());
        return ESP_OK;
    }

    这几行的作用,一个一个说清楚:

    步骤 作用 漏了会怎样
    esp_netif_init 初始化 TCP/IP 协议栈(lwIP) socket 全部失败
    esp_event_loop_create_default 创建事件循环 WiFi 事件收不到,无法感知连接
    create_default_wifi_ap 创建 AP 网络接口,分配默认 IP 192.168.4.1 手机连上但没有 IP
    esp_wifi_init 加载驱动、分配 WiFi 任务 后续配置全部返回错误

    密码规则:WPA2 密码必须 8~63 位。少于 8 位会报 ESP_ERR_WIFI_PASSWORD,或者必须改成开放式(WIFI_AUTH_OPEN)。

    7.3 事件回调:知道谁连上了

    static void wifi_event_handler(void *arg, esp_event_base_t base, int32_t id, void *data)
    {
        if (base == WIFI_EVENT && id == WIFI_EVENT_AP_STACONNECTED) {
            wifi_event_ap_staconnected_t *e = data;
            ESP_LOGI(TAG, "手机接入: " MACSTR, MAC2STR(e->mac));
        } else if (base == WIFI_EVENT && id == WIFI_EVENT_AP_STADISCONNECTED) {
            wifi_event_ap_stadisconnected_t *e = data;
            ESP_LOGI(TAG, "手机断开: " MACSTR, MAC2STR(e->mac));
        }
    }

    注意:WiFi 连上 ≠ TCP 连上。手机连上热点只代表链路通了,还要 APP 主动发起 TCP 连接。这两件事分开调试,能快速定位问题:如果看不到"手机接入"日志,是 WiFi 问题;看到了但没有数据,是 TCP/APP 问题。

    7.4 TCP Server 的完整实现

    static void tcp_server_task(void *arg)
    {
        uint16_t port = (uint16_t)(uintptr_t)arg;
    
        struct sockaddr_in addr = {
            .sin_family = AF_INET,
            .sin_port = htons(port),
            .sin_addr.s_addr = htonl(INADDR_ANY),
        };
    
        s_server_fd = socket(AF_INET, SOCK_STREAM, IPPROTO_IP);
        int opt = 1;
        setsockopt(s_server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
        bind(s_server_fd, (struct sockaddr *)&addr, sizeof(addr));
        listen(s_server_fd, 1);
    
        while (1) {
            struct sockaddr_in caddr;
            socklen_t clen = sizeof(caddr);
            int fd = accept(s_server_fd, (struct sockaddr *)&caddr, &clen);
            if (fd < 0) { vTaskDelay(pdMS_TO_TICKS(100)); continue; }
    
            if (s_client_fd >= 0) close(s_client_fd);   // 只允许一个客户端
            s_client_fd = fd;
    
            uint8_t rbuf[128];
            while (1) {
                int r = recv(fd, rbuf, sizeof(rbuf), 0);
                if (r <= 0) break;      // 返回 0 = 对方关闭
            }
            close(fd);
            s_client_fd = -1;
        }
    }

    四个细节:

    1. SO_REUSEADDR:APP 反复断连重连时,避免 bind: Address already in use。

    2. htons(port):网络字节序是大端,ESP32 是小端,必须转换。忘了写会 bind 到错误的端口。

    3. recv 返回 0 表示对方正常关闭,返回 -1 才是错误。只判断 r < 0 会导致死循环。

    4. s_client_fd = -1 必须在所有退出路径上执行,否则发送任务会一直往一个已关闭的 fd 写数据。

    7.5 发送接口与并发安全

    bool wifi_tcp_client_connected(void) { return s_client_fd >= 0; }
    
    int wifi_tcp_send(const uint8_t *data, int len)
    {
        if (s_client_fd < 0) return -1;
        return send(s_client_fd, data, len, 0);
    }

    并发风险:s_client_fd 会被 TCP 任务修改、被发送任务读取。虽然 int 读写是原子的,但仍存在"读到有效值但刚好被 close"的窗口。工程上可以接受(send 会返回 -1,重试即可),如果要严格,就加互斥锁包住。

    7.6 数据协议设计(这一节决定 APP 好不好写)

    每包一条 JSON,以 n 结尾:

    {"type":"vib","count":50,
     "gps":{"lat":39.978873,"lon":116.324021,"spd":0.05,"sats":12,"fix":1},
     "bat":87,
     "samples":[[1000,0.01234,-0.00321,0.98123],
                [1001,0.01567,-0.00298,0.98099],
                ...]}

    字段说明:

    字段 类型 说明
    type string 固定 "vib",便于将来扩展其它报文(如 "cmd"、"ack")
    count int 本包采样点数(实际写入数,不是打算写的数)
    gps object 本包对应的 GPS 快照
    bat int 电池百分比
    samples array 每项 [seq, gx, gy, gz],用数组不用对象是为了省 40% 带宽

    为什么是"行分隔"而不是固定长度包头?

    • APP 端 readLine() 一行一个对象,解析极简单;

    • 断线重连后不会因为半个包头而解析错乱;

    • 调试时可以直接 telnet 连上去肉眼看数据。

    带宽核算(最坏情况 200 点/包):

    每点约 "[123456,0.01234,-0.00321,0.98123]" ≈ 38 字节
    200 点 ≈ 7600 字节 + 头部约 120 字节 ≈ 7.7 KB / 50ms
    瞬时带宽 ≈ 154 KB/s ≈ 1.2 Mbps

    ESP32-S3 的 WiFi 实测 TCP 吞吐可达 10~20 Mbps,余量充足。但如果手机端处理慢(比如低端机),发送会阻塞,此时环形队列会满、开始丢旧数据——这就是为什么 ring_push 里要记录 dropped 计数,方便排查。

    7.7 JSON 拼装的正确写法

    这是最容易出现"非法 JSON"的地方。看下面这段(本项目 main.c 的实际实现):

    int off = snprintf(send_buf, sizeof(send_buf),
        "{"type":"vib","count":%" PRIu32 ","
        ""gps":{"lat":%.6f,"lon":%.6f,"spd":%.2f,"sats":%d,"fix":%d},"
        ""bat":%d,"samples":[",
        n, g.lat, g.lon, g.speed_knots, g.sats, g.fix, g_battery_percent);
    
    int written = 0;
    for (uint32_t i = 0; i < n; i++) {
        int room = (int)sizeof(send_buf) - off - 3;   // 预留 "]}n"
        if (room < 40) break;
    
        int w = (written == 0)
            ? snprintf(send_buf + off, room, "[%" PRIu32 ",%.5f,%.5f,%.5f]",
                       batch[i].seq, batch[i].gx, batch[i].gy, batch[i].gz)
            : snprintf(send_buf + off, room, ",[%" PRIu32 ",%.5f,%.5f,%.5f]",
                       batch[i].seq, batch[i].gx, batch[i].gy, batch[i].gz);
    
        if (w <= 0 || w >= room) break;   // 放不下就停, 保证 JSON 合法
        off += w;
        written++;
    }
    send_buf[off++] = ']';
    send_buf[off++] = '}';
    send_buf[off++] = 'n';

    三个容易写错的地方:

    错误写法 A:先写逗号后判断

    for (i = 0; i < n; i++) {
        w = snprintf(..., "[...]", ...);
        off += w;
        if (i < n - 1) send_buf[off++] = ',';   // 最后一个不加逗号
    }

    问题:如果循环中途 break(缓冲区满),上一个元素已经加了逗号,接着写 ],得到

    [..., [123,0.1,0.2,0.3], ]   ← 尾部多一个逗号, 非法 JSON

    错误写法 B:把 count 写成打算发的数量

    snprintf(header, ..., n, ...);   // n = 取出的数量

    如果后面因为缓冲区满只写了 written 个,APP 按 count 去校验就会以为丢包。本项目的 count 虽然写在头部,但 APP 是用 samples.size 遍历的(见第十三章),不会出错;更严谨的做法是先用数组统计再写头部。

    正确写法(本项目):逗号前置(written == 0 时不加逗号),并且每次写入前检查剩余空间,写失败就立刻停,保证数组括号永远配平。

    提示:ESP-IDF 默认关闭 NEWLIB_NANO_FORMAT,%f 可用。如果 sdkconfig 里打开了 nano 格式,%f 会输出乱码或空,遇到"数字全是 0"就检查这个选项。


    第八章 SD 卡记录:断电也不丢数据

    8.1 为什么选 SPI 模式而不是 SDMMC

    模式 引脚 速度 接线复杂度
    SDMMC(4 线) 6 根(CLK/CMD/D0~D3) 高速,可达 20MB/s 高,且占用固定 GPIO
    SPI(本项目) 4 根(CS/MOSI/MISO/SCLK) 中等,约 1~3MB/s 低,任意 GPIO

    本项目数据量只有 12 KB/s,SPI 模式绰绰有余,而且能用任意引脚,布线灵活。

    8.2 挂载代码逐行

    esp_err_t sdcard_mount(void)
    {
        if (s_mounted) return ESP_OK;
    
        esp_vfs_fat_sdmmc_mount_config_t mount_cfg = {
            .format_if_mount_failed = false,     // 千万别设 true!
            .max_files = 8,
            .allocation_unit_size = 16 * 1024,   // 簇大小, 影响小文件读写效率
        };
    
        sdmmc_host_t host = SDSPI_HOST_DEFAULT();
        host.slot = SPI3_HOST;                   // 用 SPI3, 避开 ADXL355 的 SPI2
    
        spi_bus_config_t bus = {
            .mosi_io_num = SD_PIN_MOSI,
            .miso_io_num = SD_PIN_MISO,
            .sclk_io_num = SD_PIN_SCLK,
            .quadwp_io_num = -1,
            .quadhd_io_num = -1,
            .max_transfer_sz = 4096,
        };
        spi_bus_initialize(host.slot, &bus, SPI_DMA_CH_AUTO);
    
        sdspi_device_config_t slot = SDSPI_DEVICE_CONFIG_DEFAULT();
        slot.gpio_cs = SD_PIN_CS;
        slot.host_id = SPI3_HOST;
    
        return esp_vfs_fat_sdspi_mount("/sdcard", &host, &slot, &mount_cfg, &s_card);
    }

    format_if_mount_failed 必须是 false。设成 true 时,只要卡里文件系统不认识(比如 FAT32 但分区表异常),ESP32 就会直接格式化整张卡。用户插一张存着照片的卡进来,照片全没了——这是会造成真实数据损失的配置项。

    8.3 文件命名与写入策略

    char fname[64];
    snprintf(fname, sizeof(fname), "%s/vib_%08u.csv", "/sdcard",
             (unsigned)(esp_log_timestamp() / 1000));
    FILE *fp = fopen(fname, "w");
    fprintf(fp, "seq,timestamp_ms,gx,gy,gz,lat,lon,speed_knots,nsats,fixn");

    为什么每秒 fflush 一次?

    FATFS 有文件缓存,不 flush 的话数据可能还留在 RAM 里。一旦设备突然断电(车上最常见),最后几秒到几十秒的数据全部丢失。每秒刷一次,最多丢 1 秒。

    代价:每秒写一次 Flash,对 TF 卡寿命有影响,但 12 小时检测只有 4 万次写入,现代 TF 卡寿命在十万次量级,可以接受。如果追求极致寿命,可以改为每 5 秒刷一次。

    8.4 记录任务

    static void task_sdcard(void *arg)
    {
        if (sdcard_mount() != ESP_OK) { g_sd_ok = false; vTaskDelete(NULL); return; }
    
        FILE *fp = fopen(fname, "w");
        if (!fp) { g_sd_ok = false; sdcard_unmount(); vTaskDelete(NULL); return; }
    
        acc_sample_t batch[256];
        TickType_t last_flush = xTaskGetTickCount();
    
        while (g_running) {
            uint32_t n = ring_pop_batch(&g_ring_sd, batch, 256);
            if (n > 0) {
                gps_data_t g;
                xSemaphoreTake(g_gps_mutex, portMAX_DELAY);
                g = g_gps_data;                        // 整批共用一次 GPS 快照
                xSemaphoreGive(g_gps_mutex);
    
                for (uint32_t i = 0; i < n; i++) {
                    fprintf(fp, "%u,%u,%.6f,%.6f,%.6f,%.6f,%.6f,%.2f,%d,%dn",
                            batch[i].seq, batch[i].timestamp_ms,
                            batch[i].gx, batch[i].gy, batch[i].gz,
                            g.lat, g.lon, g.speed_knots, g.sats, g.fix);
                }
            }
            if (xTaskGetTickCount() - last_flush >= pdMS_TO_TICKS(1000)) {
                fflush(fp);
                last_flush = xTaskGetTickCount();
            }
            vTaskDelay(pdMS_TO_TICKS(20));
        }
        fclose(fp);
        sdcard_unmount();
    }

    性能细节:一次取 256 个点、批量 fprintf,比"来一个写一个"快得多,因为文件系统写操作有固定开销。GPS 快照每批只取一次,避免 256 次加锁。

    注意:GPS 只有 5Hz,所以同一批 256 个点(0.25 秒)共用同一个 GPS 值是合理的,误差小于 0.2 秒。

    8.5 数据怎么用

    拔卡插电脑,用 Excel / Python 打开 CSV:

    import pandas as pd
    df = pd.read_csv("vib_00001234.csv")
    print(df.head())
    print("采样点数:", len(df))
    print("估算采样率:", len(df) / ((df.timestamp_ms.iloc[-1] - df.timestamp_ms.iloc[0]) / 1000), "Hz")

    验证采样率:如果算出来是 1000Hz 左右(±2%),说明采集链路正常;如果是 800Hz 或 1200Hz,说明 vTaskDelay(1) 的节拍被其它任务拖慢了,把 task_acc 优先级再调高、或改用 FIFO 方案。


    第九章 电源管理与指示灯

    9.1 IP5306 的两种用法

    IP5306 自带 4 个 LED 驱动脚(LED1~LED4,低电平点亮),可以直接接 4 颗灯做电量显示,不需要 MCU 参与。

    但本项目仍然用 ESP32 的 GPIO 驱动这 4 颗灯,原因:

      1. 可以在 APP 里同步显示电量(需要 MCU 读到电压);2. 实现"低电量慢闪"等自定义效果;3. 板子上 LED 和 IP5306 的 LED 脚可能物理冲突,用 MCU 复用更灵活。

    如果你用 IP5306 自带 LED,就把 led_set_battery() 换成空实现,省 4 个 GPIO。

    9.2 电池电压采样

    分压电路:

    Vbat ──[200kΩ]──┬──[100kΩ]── GND
                     │
                  GPIO4 (ADC)

    分压比:

    Vadc = Vbat × 100k / (200k + 100k) = Vbat / 3
    Vbat = Vadc × 3

    4.2V 满电时 ADC 端只有 1.4V,安全落在 ADC 0~3.3V 量程内。

    9.3 ADC 初始化(ESP-IDF v5.x 的新 API)

    esp_err_t battery_init(void)
    {
        adc_oneshot_unit_init_cfg_t cfg = { .unit_id = ADC_UNIT_1 };
        adc_oneshot_new_unit(&cfg, &s_adc);
    
        s_adc_chan = ADC_CHANNEL_3;          // GPIO4 = ADC1_CH3
        adc_oneshot_chan_cfg_t ch = {
            .bitwidth = ADC_BITWIDTH_12,
            .atten = ADC_ATTEN_DB_12,        // 量程 0~3.3V
        };
        adc_oneshot_config_channel(s_adc, s_adc_chan, &ch);
    
        adc_cali_line_fitting_config_t cali_cfg = {
            .unit_id = ADC_UNIT_1,
            .atten = ADC_ATTEN_DB_12,
            .bitwidth = ADC_BITWIDTH_12,
        };
        adc_cali_create_scheme_line_fitting(&cali_cfg, &s_cali);
        return ESP_OK;
    }

    v5.x 与 v4.x 的差别:v4 用 adc1_config_width/channel_atten,v5 改成 oneshot + cali 对象。很多老教程的代码在 v5.3 编译不过,就是这个原因。

    ADC_ATTEN_DB_12 的实际量程约 0~3.1V(不是标称 3.3V),所以 4.2V 电池经 1/3 分压后约 1.4V,余量足够。

    9.4 多次采样求平均

    float battery_get_voltage(void)
    {
        int sum = 0, raw = 0, mv = 0;
        for (int i = 0; i < 8; i++) {
            if (adc_oneshot_read(s_adc, s_adc_chan, &raw) == ESP_OK) sum += raw;
            vTaskDelay(pdMS_TO_TICKS(2));
        }
        raw = sum / 8;
        if (s_cali) adc_cali_raw_to_voltage(s_cali, raw, &mv);
        return mv * 3.0f / 1000.0f;      // mV → V, 再乘分压比
    }

    为什么要采 8 次? 锂电池带负载时电压有纹动,单次采样可能误差 ±5%。8 次平均后误差降到 ±1% 以内,否则电量显示会来回跳。

    9.5 电量百分比映射

    线性映射最简单,但锂电池的放电曲线不是线性的:

    int battery_get_percent(void)
    {
        float v = battery_get_voltage();
        if (v <= 3.0f) return 0;
        if (v >= 4.2f) return 100;
        return (int)((v - 3.0f) / 1.2f * 100.0f);
    }

    更准确的做法(可选):查表分段插值

    电压 电量
    4.20V 100%
    4.00V 80%
    3.85V 50%
    3.75V 25%
    3.60V 10%
    3.30V 3%
    3.00V 0%

    改起来很简单,加个数组插值即可。线性映射在 3.7~3.9V 区间会显示偏低(实际还有 70%,显示只有 60%),这是常见现象。

    9.6 LED 驱动

    void led_set_battery(int percent)
    {
        int n = (percent + 24) / 25;    // 0-24→1, 25-49→2, 50-74→3, 75+→4
        if (n < 1) n = 1;
        if (n > 4) n = 4;
        for (int i = 0; i < 4; i++)
            gpio_set_level(s_bat_pins[i], (i < n) ? 1 : 0);
    }

    (percent + 24) / 25 是整数运算实现"向上取整除法"的小技巧。等价于 ceil(percent / 25.0),但不用浮点。

    状态灯用"独立任务 + 延时翻转"实现闪烁:

    static void led_blink_task(void *arg)
    {
        while (1) {
            if (s_blink_ms > 0) {
                for (int i = 0; i < 3; i++) gpio_set_level(s_rgb_pins[i], s_rgb[i] ? 1 : 0);
                vTaskDelay(pdMS_TO_TICKS(s_blink_ms));
                for (int i = 0; i < 3; i++) gpio_set_level(s_rgb_pins[i], 0);
                vTaskDelay(pdMS_TO_TICKS(s_blink_ms));
            } else {
                vTaskDelay(pdMS_TO_TICKS(100));
            }
        }
    }

    状态灯语义(写在外壳或说明书上):

    颜色/频率 含义
    绿色慢闪(500ms) 正常工作,SD 卡已挂载
    红色快闪(100ms) 异常:SD 卡挂载/写入失败
    常亮红 系统异常(预留)
    4 颗电量灯常亮 当前电量分档显示

    注意:这里的 RGB 是"亮/灭"二值控制,不是 PWM 真彩色。如果要调亮度,得用 LEDC 外设,本教程为简化没做。


    第十章 FreeRTOS 调度:多任务的坑都在这里

    10.1 为什么 1ms 延时会被拖慢

    vTaskDelay(1) 的真实行为:把任务从运行态放到延时列表,1 个 tick 后变回就绪态,然后等调度器轮到它。

    期望: 每 1.000ms 采一次
    实际: 1.000ms + 调度延迟(0.05~0.5ms) + 被高优先级任务抢占的时间

    本项目把所有任务的优先级排好之后,实测平均采样周期约 1.0~1.05ms,误差 5% 以内,满足需求。如果超过 1.15ms,就要检查是不是有任务优先级比采样任务还高、或者有长时间的临界区。

    10.2 栈大小怎么定

    任务 栈 依据
    task_acc 4096 只用局部变量 + SPI 调用
    task_tcp_send 8192 有一个 8KB 的 send_buf 吗?注意:局部大数组在栈上会爆栈
    task_sdcard 8192 FILE* + fprintf 内部 buffer

    重要:char send_buf[8192] 是局部变量,会占 8KB 栈!所以 task_tcp_send 的栈必须 ≥ 8192 + 余量。更稳妥的做法是把它改成 static 或 heap_caps_malloc,避免栈溢出——栈溢出在 ESP32 上表现为 ***ERROR*** A stack overflow in task tcp has been detected,然后重启。

    推荐改法(可选优化):

    static char s_send_buf[8192];    // 放到静态区, 不占栈

    10.3 互斥锁的正确使用

    // 写
    xSemaphoreTake(g_gps_mutex, portMAX_DELAY);
    g_gps_data = d;
    xSemaphoreGive(g_gps_mutex);
    
    // 读
    xSemaphoreTake(g_gps_mutex, portMAX_DELAY);
    g = g_gps_data;              // 整体拷贝一份快照
    xSemaphoreGive(g_gps_mutex);
    // 注意: 用完立刻释放, 不要"拿着锁做耗时的事"

    错误写法:

    xSemaphoreTake(m, portMAX_DELAY);
    for (i = 0; i < 256; i++) {
        fprintf(fp, "...", g_gps_data.lat);   // 拿着锁写文件, 阻塞 GPS 任务 30ms
    }
    xSemaphoreGive(m);

    这会拖慢 GPS 任务,还可能引发优先级反转。正确做法是取快照,释放锁后再用。

    10.4 优先级反转与看门狗 

    优先级反转:低优先级任务长期持有锁,导致高优先级任务等待。本项目用 portMAX_DELAY 等锁,理论上有风险,但因为临界区极短(只做结构体拷贝),实测无问题。

    任务看门狗(TWDT):ESP-IDF 默认开启,vTaskDelay 会给看门狗喂狗,所以不会误触发。但如果你在任务里写 while(1){} 死循环且不自旋让出,5 秒后会被看门狗重启。

    10.5 丢数据怎么看

    static volatile uint32_t dropped;   // 每个 ring 自带计数

    如果怀疑丢数据,加一行日志:

    ESP_LOGI(TAG, "ring_sd dropped=%u, ring_tcp dropped=%u",
             g_ring_sd.dropped, g_ring_tcp.dropped);
    现象 原因 对策
    tcp dropped 增长 手机端解析慢 / WiFi 拥塞 减小 TCP_BATCH_MAX、降低推送频率
    sd dropped 增长 TF 卡慢(假卡/Class4) 换 Class10 卡、增大 ring 深度
    两者都不增长但 CSV 有空洞 之前版本的"双消费者抢队列"问题 升级到双 ring 版本

    第十一章 编译、烧录与串口调试

    11.1 完整命令流程

    cd D:AI工具_代码工作目录VehicleVibrationDetectorfirmware
    
    idf.py set-target esp32s3        # 首次
    idf.py build                     # 编译
    idf.py -p COM5 flash             # 烧录 (COM5 换成你的端口)
    idf.py -p COM5 monitor           # 看日志, Ctrl+] 退出

    一行搞定烧录 + 监视:

    idf.py -p COM5 flash monitor

    11.2 怎么找串口

    设备管理器 → 端口(COM 和 LPT),看到 USB JTAG/serial debug unit 或 CH340 就是它。

    ESP32-S3 原生 USB 会出现两个串口:一个是 JTAG 调试口,一个是 CDC 数据口。烧录用 flash 时如果报 "Wrong boot mode",就换另一个口再试。

    11.3 正常启动日志长什么样

    I (312) cpu_start: Multicore app
    I (322) cpu_start: Pro cpu start user code
    I (642) VIB_MAIN: ===== 车辆振动检测仪 v1.0 =====
    I (652) LED: LED 初始化完成
    I (672) BATT: 电池 ADC 初始化完成 (GPIO4, CH3)
    I (693) ADXL355: ADXL355 检测成功, PARTID=0xED
    I (703) ADXL355: ADXL355 初始化完成, 量程=±2.048g
    I (903) WIFI_TCP: WiFi AP 启动: SSID="VibDetector_ESP32" PASS="12345678"
    I (1903) WIFI_TCP: TCP Server 监听端口 8080, 等待客户端...
    I (1913) VIB_MAIN: 所有任务已启动, 热点 SSID=VibDetector_ESP32 密码=12345678
    I (2913) WIFI_TCP: STA 接入: 3c:xx:xx:xx:xx:xx, AID=1
    I (3913) WIFI_TCP: TCP 客户端已连接: 192.168.4.2:52346

    每一条日志都是一次自检:如果卡在 ADXL355 检测成功 之前,就是 SPI 接线或供电问题;如果卡在 AP 启动,就是 NVS 或 WiFi 配置问题。

    11.4 常见编译错误对照表

    报错 原因 解决
    undefined reference to 'adxl355_init' main 的 CMakeLists 漏写 REQUIRES 加 adxl355 到 REQUIRES
    fatal error: driver/spi_master.h: No such file 组件的 CMakeLists 没 REQUIRES driver 加 driver
    ESP_ERR_INVALID_STATE at spi_bus_initialize 同一 host 初始化两次 换 host,或只初始化一次
    region 'iram0_0_seg' overflowed RAM 不够(静态占用太多) 减小环形缓冲、优化大数组
    Project uses CMake 3.x but idf requires... 版本不匹配 检查 IDF_PATH 指向 v5.3
    A stack overflow in task tcp 栈不够 加大栈(见 10.2)
    Guru Meditation Error: LoadProhibited 空指针(常见于任务先于初始化启动) 检查 app_main 顺序

    11.5 烧录后连不上的排查顺序

    按这个顺序排查,一步到位,不要跳步:

    1. 手机能不能搜到 VibDetector_ESP32? 搜不到 → 设备没启动/AP 没起来 → 看串口日志。

    2. 能连上热点但 APP 连不上? → 检查 IP 是不是 192.168.4.1(有些手机连上后会提示"无网络",选择"保持连接")。

    3. APP 显示已连接但没数据? → 看串口有没有 TCP 客户端已连接;有但没数据 → 检查 task_acc 是否在跑、ring_push 是否被锁死。

    4. 有数据但波形是直线? → 传感器没进测量模式,或者 Z 轴读到固定值 → 检查 POWER_CTL 配置。

    第十二章 Android 工程搭建:从空目录到第一个 APK

    固件写完,设备就能说话了,但说的是一串 JSON,没人听得懂。手机 APP 要做三件事:听(连上 TCP、按行拆包)、算(在手机上跑指标算法)、说(出一份能发出去的 PDF 报告)。

    这一章我们先把"能编译"这件事彻底解决。Android 工程是整套项目里最容易卡住新人的地方——不是代码难,而是JDK 版本、SDK 版本、Gradle 版本、仓库配置这四件事只要有一件错位,报错信息就完全是另一个故事。

    12.1 环境清单:四个版本号必须对得上

    我先列出本项目实际使用的版本组合,这是经过验证的一组:

    组件 版本 说明
    JDK 17(OpenJDK 17.0.2) AGP 8.x 强制要求 JDK 17,用 JDK 8 会直接报 "Unsupported class file major version"
    Android Gradle Plugin (AGP) 8.1.4 决定 Gradle 版本的最低下限
    Gradle 8.2 与 AGP 8.1.4 匹配(AGP 8.1 要求 Gradle ≥ 8.0)
    compileSdk 34 建议直接用 34,不要用 preview 版
    build-tools 34.0.0 与 compileSdk 对齐
    Kotlin 1.9.x(随 AGP 内置) 不需要单独装 Kotlin 插件版本

    关于 JDK 的一个工程习惯:不要往系统环境里装 JDK,直接下载免安装的 zip 版解压到一个目录里,然后在 Android Studio 里指定 Gradle JDK 为该目录。原因很简单——你未来可能同时维护三个不同 JDK 版本的老项目,装进系统后互相打架,卸载还卸不干净。

    本项目把 JDK 放在:

    d:AI工具_代码工作目录jdk-17.0.2

    在 Android Studio 里设置路径:File → Settings → Build, Execution, Deployment → Build Tools → Gradle → Gradle JDK,选择 Add JDK... 指向上面的目录。

    SDK 组件的安装(命令行方式)

    如果你用的是纯命令行(没有 Android Studio),用 sdkmanager 装这四样就够:

    sdkmanager "platform-tools" "platforms;android-34" "build-tools;34.0.0" "cmdline-tools;latest"

    第一次装一定会卡在许可证上,报:

    Warning: Failed to read or create install properties file.
    Checking the license for package ...

    这是因为没接受许可协议。先执行:

    sdkmanager --licenses

    然后一路输入 y。Windows PowerShell 里可以一次性喂进去:

    "y`ny`ny`ny`ny`ny`ny`ny`ny" | sdkmanager --licenses

    注意反引号 `n 在 PowerShell 里表示换行符,"yn..."` 是一个多行字符串。

    12.2 工程目录结构

    android/
    ├── settings.gradle              # 模块与仓库声明
    ├── build.gradle                 # 根构建脚本(只放 AGP 版本)
    ├── gradle.properties            # 构建参数(中文路径开关就在这里)
    ├── local.properties             # SDK 路径(不要提交到 git)
    └── app/
        ├── build.gradle             # 模块构建脚本(依赖在这里)
        ├── proguard-rules.pro
        └── src/main/
            ├── AndroidManifest.xml
            ├── java/com/vibdetector/app/
            │   ├── MainActivity.kt              # 界面与业务编排
            │   ├── ReportExporter.kt            # PDF / CSV 导出
            │   ├── data/VibrationData.kt        # 数据模型
            │   ├── net/TcpClient.kt             # TCP 客户端
            │   ├── metric/VibrationMetrics.kt   # 指标算法
            │   └── ui/WaveformView.kt           # 自绘波形
            └── res/
                ├── layout/activity_main.xml
                ├── values/{colors,strings,themes}.xml
                ├── xml/file_paths.xml
                └── mipmap-anydpi-v26/ic_launcher*.xml

    只有 6 个 Kotlin 文件。这就是全部的业务代码量——没有引入任何第三方图表库,波形是 Canvas 手画的,指标是纯数学实现的。工程越干净,你改起来越自由。

    12.3 settings.gradle:仓库冲突的根源在这里

    pluginManagement {
        repositories {
            google()
            mavenCentral()
            gradlePluginPortal()
        }
    }
    
    dependencyResolutionManagement {
        repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
        repositories {
            google()
            mavenCentral()
        }
    }
    
    rootProject.name = "VibDetector"
    include ':app'

    逐段讲解:

    1. pluginManagement.repositories —— 这是给 Gradle 插件本身找地方下载的地方。gradlePluginPortal() 是 Gradle 官方插件仓库,google() 提供 AGP。

    2. dependencyResolutionManagement.repositories —— 这是**给工程依赖(第三方库)**找地方。google() 提供 androidx 系列,mavenCentral() 提供 Gson、OpenCSV、iText。

    3. repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) —— 这一行的意思是:如果某个模块的 build.gradle 里还写了 repositories {},直接构建失败。它的目的是强制"仓库只在 settings 里声明一次",避免出现"当哪个子模块去找库"的不确定行为。

    ⚠️ 这是本项目踩过的第一个坑。一开始我在根 build.gradle 里写了:

    allprojects {
     repositories {
         google()
         mavenCentral()
     }
    }

    结果构建直接报:

    Build was configured to prefer settings repositories over project repositories
    but repository 'Google Maven' was added by build file 'build.gradle'

    解决办法二选一:要么删掉根部的 allprojects.repositories(本项目采用),要么把 FAIL_ON_PROJECT_REPOS 改成 PREFER_PROJECT。不要两边都写。

    4. rootProject.name = "VibDetector" —— 工程名,会影响打包产物命名。

    5. include ':app' —— 声明包含 app 模块,对应 app/ 目录。

    12.4 根 build.gradle:只放 AGP 版本

    buildscript {
        repositories {
            google()
            mavenCentral()
        }
        dependencies {
            classpath 'com.android.tools.build:gradle:8.1.4'
        }
    }
    
    // 注意:这里不要写 allprojects { repositories { ... } }

    buildscript 块是构建脚本自身的依赖:Gradle 需要先拿到 AGP 这个 jar,才能认识 android { } 这个 DSL 块。它的 repositories 是独立于 settings 的,不受 FAIL_ON_PROJECT_REPOS 约束,所以可以安全保留。

    12.5 gradle.properties:三个必改项

    org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
    android.useAndroidX=true
    android.nonTransitiveRClass=true
    kotlin.code.style=official
    android.overridePathCheck=true
      • •

    -Xmx2048m —— 给 Gradle 守护进程 2GB 堆。低于 1GB 时,release 打包的 R8 混淆阶段会 OOM。•

    -Dfile.encoding=UTF-8 —— 保证源码里的中文字符串(比如"垂向平稳性")编译时不会乱码。

    这条对本项目是必须的,因为 strings.xml 和报告模板里都有中文。

    android.useAndroidX=true —— 用 androidx 库必需。

    android.nonTransitiveRClass=true —— 只生成本模块的 R 类,加快编译。

    android.overridePathCheck=true —— 这条是救命的。AGP 7.3 之后会检查工程路径是否含非 ASCII 字符,如果路径里有中文,直接抛:

    Your project path contains non-ASCII characters. This will most likely cause
    the build to fail on Windows.

    我们的工程在 d:AI工具_代码工作目录 下,路径含中文,必须加这一行才能编译。如果你把工程放在纯英文路径下,这行可以不加。

    为什么 AGP 要拦中文路径?因为 Windows 上旧版 Gradle、aapt2、NDK 的部分组件用 ANSI 编码读写临时文件路径,中文路径会变成乱码导致找不到文件。新版组件基本已修复,所以强行跳过检查是可行的——但如果遇到奇怪的 "file not found" 报错,优先怀疑路径,把工程挪到 D:vibdetector 这种纯英文目录再试。

    12.6 local.properties:SDK 路径

    sdk.dir=d:\AI工具_代码工作目录\android-sdk

    local.properties 的格式是 Java Properties,反斜杠必须转义成 \,冒号必须转义成 :。写错的话报错是"SDK location not found",而且不会告诉你哪里错了。

    这个文件是本机专属的(不同电脑 SDK 路径不同),不能提交到 git,应该在 .gitignore 里。

    12.7 app/build.gradle:模块配置与依赖

    plugins {
        id 'com.android.application'
        id 'org.jetbrains.kotlin.android'
    }
    
    android {
        namespace 'com.vibdetector.app'
        compileSdk 34
    
        defaultConfig {
            applicationId "com.vibdetector.app"
            minSdk 24
            targetSdk 34
            versionCode 1
            versionName "1.0"
        }
    
        buildTypes {
            release {
                minifyEnabled false
                proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
            }
        }
    
        compileOptions {
            sourceCompatibility JavaVersion.VERSION_17
            targetCompatibility JavaVersion.VERSION_17
        }
        kotlinOptions {
            jvmTarget = '17'
        }
    
        buildFeatures {
            viewBinding true
        }
    }
    
    dependencies {
        implementation 'androidx.core:core-ktx:1.12.0'
        implementation 'androidx.appcompat:appcompat:1.6.1'
        implementation 'com.google.android.material:material:1.11.0'
        implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
    
        implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
    
        implementation 'com.google.code.gson:gson:2.10.1'
        implementation 'com.opencsv:opencsv:5.9'
    
        implementation 'com.itextpdf:kernel:7.2.5'
        implementation 'com.itextpdf:io:7.2.5'
        implementation 'com.itextpdf:layout:7.2.5'
    }

    关键项讲解:

    namespace 'com.vibdetector.app' —— AGP 8 用 namespace 取代了 Manifest 里的 package 属性。生成 R 类的包名就是它。applicationId 可以不同,但本项目保持一致。

    minSdk 24 —— 本项目用到 Socket、tcpNoDelay、协程,这些在 21 就有了。24 是为了避开 Android 5.x/6.x 上 FileProvider 与存储权限的一堆历史包袱。覆盖率 98%+。

    targetSdk 34 —— 会触发 Android 13/14 的通知权限、存储权限收紧。见下面 12.8 的说明。

    minifyEnabled false —— 刻意关掉。R8 混淆会和 iText7 的反射加载、Gson 的字段名反射打架,会报 Missing class、JSON 字段解析成 null。这个 APP 不上架商店,体积 8MB 也无所谓,关掉省心。真要开,必须补 proguard 规则(附录 C 给了)。

    viewBinding true —— 开启视图绑定,之后每个布局自动生成 ActivityMainBinding 类,彻底告别 findViewById 和类型强转。这是本项目 UI 代码很干净的根本原因。

    iText 为什么拆成三个包:iText7 的 itext7-core 是一个"聚合包",它的 POM 里会带一堆可选依赖(barcodes、font-asian、forms、pdfa…),在国内网络下经常解析失败或拉不全。直接依赖 kernel / io / layout 三个核心包,稳定且体积小(约 3MB)。我们只用 Paragraph / Table / Cell / Document,layout 已经覆盖。

    12.8 AndroidManifest.xml:权限与 FileProvider

    <uses-permission android:name="android.permission.INTERNET" />
    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
    <uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
    <uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
    <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"  android:maxSdkVersion="32" />
      • •

    INTERNET —— TCP Socket 必需,这是普通权限,安装时自动授予,不需要运行时申请。很多人以为要动态申请,白写一堆代码。

    ACCESS_NETWORK_STATE —— 用来判断网络可用状态(本项目其实没用到,但连着 WiFi、检测热点时需要)。•

    ACCESS_WIFI_STATE / CHANGE_WIFI_STATE —— 你可能会想"APP 自动连设备的 AP 热点",这需要 WifiManager。本项目为了兼容性,采用手动在系统设置里连热点的方式,所以这两个权限是预留的。•

    maxSdkVersion="32" —— 存储权限在 Android 13(API 33)起被细分权限取代,写 maxSdkVersion=32 表示"只在 32 及以下声明",避免在 33+ 上被系统忽略而报警告。

    application 里两个关键属性:

    android:usesCleartextTraffic="true"

    Android 9 起默认禁止明文 HTTP。我们用的是 TCP Socket(不是 HTTP),理论上不受此限,但 usesCleartextTraffic 也会影响部分网络库的默认策略,加上无副作用。

    <provider
        android:name="androidx.core.content.FileProvider"
        android:authorities="${applicationId}.fileprovider"
        android:exported="false"
        android:grantUriPermissions="true">
        <meta-data android:name="android.support.FILE_PROVIDER_PATHS"
                   android:resource="@xml/file_paths" />
    </provider>

    为什么需要 FileProvider? Android 7.0 起禁止用 file:// Uri 跨应用传文件(会抛 FileUriExposedException)。我们要"用系统 PDF 阅读器打开刚生成的报告",就必须把文件以 content:// 形式共享出去,而这需要声明一个 FileProvider 并给临时读权限。

    ${applicationId} 是构建时占位符,最终会变成 com.vibdetector.app.fileprovider,与代码里 FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", pdfFile) 一一对应——两边必须完全一致,否则会抛 IllegalArgumentException: Couldn't find meta-data for provider。

    res/xml/file_paths.xml 声明允许共享哪些目录:

    <?xml version="1.0" encoding="utf-8"?>
    <paths xmlns:android="http://schemas.android.com/apk/res/android">
        <external-path name="downloads" path="Download/" />
        <external-path name="external" path="." />
        <files-path name="files" path="." />
        <cache-path name="cache" path="." />
    </paths>

    <external-path path="Download/"> 对应 Environment.getExternalStoragePublicDirectory(DIRECTORY_DOWNLOADS)。如果这里写错了(比如写成 download),getUriForFile 会抛 Failed to find configured root that contains /storage/...。

    ⚠️ 真实限制说明:Environment.getExternalStoragePublicDirectory(DIRECTORY_DOWNLOADS) 在 Android 10+ 的分区存储下,普通 APP 已无法直接写入(除非 requestLegacyExternalStorage,且该开关在 Android 11+ 失效)。
    本项目选择了一个务实折中:在 Android 10 及以上,导出可能失败并弹出"导出失败"的 Toast。正确做法是把导出目录改成 APP 私有目录 + MediaStore 插入,附录 C 给出了改造代码。教程里保留原实现,是为了让你先跑通"生成 PDF"这条主线,不被存储权限这棵树挡住。

    12.9 编译与打包发布版 APK

    调试包

    # Windows,在 android 目录下
    set JAVA_HOME=d:AI工具_代码工作目录jdk-17.0.2
    ./gradlew assembleDebug

     本工程没有 gradlew 包装脚本(它是 Android Studio 首次打开工程时才生成的)。如果你是从命令行构建,直接用独立 Gradle:

    $env:JAVA_HOME = "d:AI工具_代码工作目录jdk-17.0.2"
    cd d:AI工具_代码工作目录VehicleVibrationDetectorandroid
    & "d:AI工具_代码工作目录gradle-8.2bingradle.bat" assembleRelease --offline

    产物:app/build/outputs/apk/debug/app-debug.apk。这个包用调试证书签名,可以直接装手机测试。

    发布包(带正式签名)

    先造一个 keystore(只需做一次,丢了就永远无法覆盖升级):

    keytool -genkeypair -v ^
      -keystore vibdetector.jks ^
      -alias vibdetector ^
      -keyalg RSA -keysize 2048 -validity 10000 ^
      -storepass vib123456 -keypass vib123456 ^
      -dname "CN=VibDetector, OU=Engineering, O=VibDetector, L=Beijing, C=CN"

    然后在 app/build.gradle 的 android 块里加:

    signingConfigs {
        release {
            storeFile file("../vibdetector.jks")
            storePassword "vib123456"
            keyAlias "vibdetector"
            keyPassword "vib123456"
        }
    }
    buildTypes {
        release {
            signingConfig signingConfigs.release
            minifyEnabled false
            ...
        }
    }

    再执行:

    ./gradlew assembleRelease

    产物:app/build/outputs/apk/release/app-release.apk。

    本项目实际编译结果(release,从零依赖开始):

    BUILD SUCCESSFUL in 11m 38s

    第一次构建慢是正常的:要下载 Gradle 发行包、AGP、所有依赖并做 R8 处理。第二次增量构建通常 20~60 秒。

    月薪三千的 Windows 打包脚本

    @echo off
    set JAVA_HOME=d:AI工具_代码工作目录jdk-17.0.2
    set PATH=%JAVA_HOME%bin;%PATH%
    cd /d d:AI工具_代码工作目录VehicleVibrationDetectorandroid
    call gradlew.bat assembleRelease

    12.10 本节踩坑清单

    报错 根因 处理
    Unsupported class file major version 65 Gradle 用了 JDK 21 Gradle JDK 换成 17
    Your project path contains non-ASCII characters 工程路径含中文 android.overridePathCheck=true
    Could not find com.android.tools.build:gradle:8.1.4 根 buildscript 缺 google() 补仓库
    Build was configured to prefer settings repositories... 两处都声明了 repositories 删掉 allprojects.repositories
    Could not resolve org.jetbrains.kotlinx... 缺 mavenCentral() 补仓库
    SDK location not found local.properties 转义写错 用 \ 和 :
    Duplicate class ... (iText) 混用了 itext7-core + 分拆包 只保留三个分拆包
    Unresolved reference: TextAlignment iText7 里该类在 kernel... 版本差异 删除用法(本项目已处理)

    第十三章 APP 界面与通信实现

    这一章把"手机怎么听设备说话"讲透。顺序是:先定数据结构(协议落地),再写网络层(收),最后写界面(显示)。

    13.1 界面结构设计

    参考市面主流振动分析 APP(如 振动手册、iVib、Svan)的通用范式,我采用了单页纵向卡片流:

    ┌─────────────────────────────┐
    │  车辆振动检测仪        [状态]│  ← 标题栏 + 连接状态徽标
    ├─────────────────────────────┤
    │ ① 设备连接卡片               │
    │  IP [192.168.4.1] 端口 [8080]│
    │  [连接设备]  [开始采集][导出]│
    ├─────────────────────────────┤
    │ ② 实时状态卡片               │
    │  电量 85%  卫星 9  速度 0.0kn│
    │  样本数 12345                │
    ├─────────────────────────────┤
    │ ③ 波形卡片 (WaveformView)    │  ← 三轴同屏,红/绿/蓝
    ├─────────────────────────────┤
    │ ④ 指标卡片                   │
    │  垂向 Wz 2.13   等级 优      │
    │  横向 Wz 1.87   舒适 0.21    │
    │  X 轴 RMS/峰值 ...           │
    └─────────────────────────────┘

    为什么不做多页面 + 图表库? 两个工程理由:

    1. 1000Hz×3 轴的数据流,任何 MPAndroidChart 这类基于 ValueAnimator 的图表都会成为瓶颈,掉帧且 GC 压力大。自绘 View 只画 1000 条 drawLine,稳在 60fps 以下的开销完全可控。

    2. 检测类 APP 的核心动作是"一键开始 → 看指标 → 导出",页面层级越少,现场操作越快。

    activity_main.xml 的骨架(只列结构,完整文件见工程):

    <ScrollView ... xmlns:app="http://schemas.android.com/apk/res-auto">
        <LinearLayout android:orientation="vertical" android:padding="12dp">
    
            <TextView android:text="车辆振动检测仪" android:textSize="22sp" ... />
    
            <!-- ① 连接卡片 -->
            <com.google.android.material.card.MaterialCardView
                app:cardCornerRadius="12dp" app:cardElevation="2dp">
                <LinearLayout android:orientation="vertical">
                    <LinearLayout android:orientation="horizontal">
                        <EditText android:id="@+id/etIp" android:text="192.168.4.1"
                                  android:inputType="text" android:layout_weight="2" />
                        <EditText android:id="@+id/etPort" android:text="8080"
                                  android:inputType="number" android:layout_weight="1" />
                    </LinearLayout>
                    <Button android:id="@+id/btnConnect" android:text="连接设备" />
                </LinearLayout>
            </MaterialCardView>
    
            <!-- ③ 波形卡片 -->
            <com.google.android.material.card.MaterialCardView ...>
                <com.vibdetector.app.ui.WaveformView
                    android:id="@+id/waveform" android:layout_height="220dp" />
            </MaterialCardView>
    
            <!-- ④ 指标卡片:用 GridLayout 两列排布 -->
            ...
        </LinearLayout>
    </ScrollView>

    ⚠️ 命名空间坑:xmlns:app 必须声明在根元素(这里是最外层 ScrollView)上。我曾经在 MaterialCardView 内部又写了一次 xmlns:app,编译报:

    AttributePrefixUnbound?app:cardCornerRadius

    XML 里同一个前缀不要在多个层级重复声明,Android Studio 也会因此高亮混乱。

    ViewBinding 生成规则很简单:activity_main.xml → ActivityMainBinding,所有带 android:id 的控件变成 binding.etIp、binding.waveform 这样的属性。不再需要 findViewById,也不会出现 ClassCastException。

    13.2 数据模型:协议先落地成类型

    文件 data/VibrationData.kt,四个 data class 把"设备发什么、APP 存什么、报告写什么"全部定型:

    data class AccSample(
        val seq: Long,
        val gx: Double,
        val gy: Double,
        val gz: Double
    )
    
    data class VibPacket(
        val type: String,
        val count: Int,
        val gps: GpsInfo?,
        val bat: Int,
        val samples: List<List<Double>>
    )
    
    data class GpsInfo(
        val lat: Double,
        val lon: Double,
        val spd: Double,
        val sats: Int,
        val fix: Int
    )
    
    data class MetricsResult(
        val sperlingVertical: Double,
        val sperlingLateral: Double,
        val rmsX: Double, val rmsY: Double, val rmsZ: Double,
        val peakX: Double, val peakY: Double, val peakZ: Double,
        val comfortVertical: Double,
        val comfortLateral: Double,
        val comfortIndex: Double,
        val jerkRms: Double,
        val shakeIndex: Double,
        val stabilityGrade: String,
        val comfortGrade: String
    )

    逐点说明设计决策:

    1. AccSample 的坐标用 Double:虽然屏上显示只要 Float,但后续 FFT、a³、积分运算,Float 只有 7 位有效数字,累加 1000 项后误差可达 1e-4 量级,和 0.003g 的分辨率同量级——会把传感器的精度吃掉。计算链路一律 Double,只在传给 WaveformView 时降成 Float。

    2. VibPacket.samples 是 List<List<Double>> 而不是 List<AccSample>:设备发的是紧凑数组 [seq,gx,gy,gz],不是一个带键名的对象。每点省 20 多个字节,20Hz × 200 点/包的场景下,带宽差 3 倍以上。用 List<List<Double>> 让 Gson 零配置直接反序列化。

    如果写成 data class Sample(val seq: Long, val gx: Double, ...),Gson 会要求 JSON 是 {"seq":1,"gx":0.1,...},设备端 snprintf 的长度也会翻倍。这是性能关键型协议的典型取舍。

    3. gps 可空:开机前 30 秒 GPS 还没定位,g_gps_data 全是 0,如果类型不可空,就得用 lat=0.0 来表示"无数据",后续写报告会误显示"北京西二旗"(经纬度 (0,0))。可空类型把"无数据"和"数据为零"分开。4. MetricsResult 里同时放数值和等级字符串:UI 直接显示 stabilityGrade,报告直接写入,避免在两处重复实现阈值判断——阈值只在一个地方(gradeStability)定义。

    13.3 TcpClient:协程 + StateFlow 的组合

    net/TcpClient.kt 完整代码(不长,逐行讲):

    class TcpClient(private val scope: CoroutineScope) {
    
        private val _connected = MutableStateFlow(false)
        val connected: StateFlow<Boolean> = _connected
    
        private val _packet = MutableStateFlow<VibPacket?>(null)
        val packet: StateFlow<VibPacket?> = _packet
    
        private var socket: Socket? = null
        private var job: Job? = null
        private val gson = Gson()
    
        fun connect(host: String, port: Int) {
            job?.cancel()
            job = scope.launch(Dispatchers.IO) {
                try {
                    socket = Socket(host, port)
                    socket?.tcpNoDelay = true
                    _connected.value = true
    
                    val reader = BufferedReader(InputStreamReader(socket!!.getInputStream()))
                    var line: String?
                    while (isActive && _connected.value) {
                        line = reader.readLine() ?: break
                        try {
                            val pkt = gson.fromJson(line, VibPacket::class.java)
                            if (pkt != null && pkt.type == "vib") {
                                _packet.value = pkt
                            }
                        } catch (e: Exception) {
                            Log.w(TAG, "JSON 解析失败: ${e.message}")
                        }
                    }
                } catch (e: Exception) {
                    Log.e(TAG, "连接异常: ${e.message}")
                } finally {
                    disconnect()
                }
            }
        }
    
        fun disconnect() {
            _connected.value = false
            try { socket?.close() } catch (e: Exception) {}
            socket = null
            job?.cancel()
        }
    }

    为什么这么写:

    Dispatchers.IO:Socket 构造和 readLine()都是阻塞调用。放主线程会直接 NetworkOnMainThreadException。

    协程的好处是——阻塞写起来像同步代码,但不会卡 UI。

    socket?.tcpNoDelay = true:这是必须的。TCP 默认开启 Nagle 算法,会把小包合并等待 ACK,导致"设备每 50ms 发一包、APP 却每 200ms 才收一批",波形一顿一顿。关掉后每包立即发送。代价是包数变多,在局域网里完全可接受。

    BufferedReader.readLine():这是整个通信层的"分帧器"。设备端每包 JSON 后加 n,APP 就按行读。千万不要自己 read() 字节数组然后拼字符串——TCP 是流式的,一次 read 可能拿到半个 JSON,也可能拿到 3 个 JSON 的孪生兄弟。readLine() 内部帮你处理了粘包/半包。•

    _packet.value = pkt:StateFlow 的赋值是线程安全的,可以在 IO 线程直接写,UI 侧用 collectLatest 消费。

    注意:StateFlow 是合并型的——如果 UI 消费慢,中间的值会被丢弃,只保留最新值。对实时波形来说这正是我们想要的(丢帧不可怕,卡死才可怕)。

    • while (isActive && _connected.value):isActive 是协程作用域是否被取消,_connected.value 是业务开关。两者都检查,保证"用户点断开"和"Activity 销毁"都能退出循环。• finally { disconnect() }:无论正常结束还是异常,都统一清理,避免 socket 泄漏。• job?.cancel() 在 connect 开头:防止用户快速连点两次"连接",起两个协程重复读同一个 socket。

    关闭时的坑:close() 必须在 disconnect 里做

    只把 _connected.value 设为 false 是不够的。阻塞在 readLine() 上的协程不会因为一个布尔值变化而醒来,必须 socket.close() 让它抛 SocketException: Socket closed,循环才真正退出。所以顺序是:

    _connected.value = false          // 1. 置标志(后续循环不再进入)
    try { socket?.close() } catch (e: Exception) {}   // 2. 踢醒阻塞的读
    job?.cancel()                     // 3. 取消协程

    13.4 MainActivity:业务总编排

    MainActivity.kt 是整个 APP 的中枢,做四件事:订阅连接状态、订阅数据包、启动/停止采集、导出报告。

    初始化与 ViewBinding

    private lateinit var binding: ActivityMainBinding
    private lateinit var tcpClient: TcpClient
    private val collectedSamples = mutableListOf<AccSample>()
    private var collecting = false
    private var collectJob: Job? = null
    private var lastMetrics: MetricsResult? = null
    
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)
    
        tcpClient = TcpClient(lifecycleScope)
        ...
    }
      • •

    ActivityMainBinding.inflate(layoutInflater) 生成绑定对象,binding.root 就是布局根 ScrollView。

    TcpClient(lifecycleScope) —— 把 Activity 的生命周期作用域传进去。这样 Activity 销毁时,协程自动取消,socket 自动关闭。如果传 GlobalScope,退到后台再回来就会泄漏一个永久存活的协程。

    订阅连接状态

    lifecycleScope.launch {
        tcpClient.connected.collectLatest { connected ->
            runOnUiThread {
                binding.tvStatus.text = if (connected) "已连接" else "未连接"
                binding.tvStatus.setTextColor(
                    if (connected) 0xFFC8E6C9.toInt() else 0xFFFFCDD2.toInt())
                binding.btnConnect.text = if (connected) "断开连接" else "连接设备"
                binding.btnCollect.isEnabled = connected
            }
        }
    }
      • •

    collectLatest —— 来新值时取消上一次未完成的处理。这里处理很快,等价于 collect,但语义更安全。•

    runOnUiThread —— StateFlow 的回调在发送方线程(IO)执行,所有 View 操作必须回主线程,否则抛 CalledFromWrongThreadException。这是新手最常犯的错之一。

    • 颜色用 ARGB 字面量 0xFFC8E6C9(浅绿)/ 0xFFFFCDD2(浅红)。

    注意要 .toInt():0xFFC8E6C9 在 Kotlin 里是 Long(因为超过 Int.MAX_VALUE),不转 Int 会编译报类型不匹配。

    数据包处理:三件事同时做

    private fun onPacket(pkt: VibPacket) {
        runOnUiThread {
            binding.tvBattery.text = "${pkt.bat}%"
            pkt.gps?.let {
                binding.tvSats.text = "${it.sats}"
                binding.tvSpeed.text = String.format("%.1f kn", it.spd)
            }
    
            pkt.samples.forEach { s ->
                if (s.size >= 4) {
                    val sample = AccSample(s[0].toLong(), s[1], s[2], s[3])
                    binding.waveform.addSample(s[1].toFloat(), s[2].toFloat(), s[3].toFloat())
                    if (collecting) {
                        collectedSamples.add(sample)
                        sampleCount++
                        if (sampleCount % 50 == 0) {
                            binding.tvSamples.text = collectedSamples.size.toString()
                        }
                    }
                }
            }
        }
    }

    这一段是整个 APP 最"热"的路径,每个包 50200 个点、每秒 20 个包,即**每秒 10004000 次循环**。所以有三条优化:

    1. s.size >= 4 校验——设备端理论上不会发短的,但协议边界要做防御。少一个校验,遇到一次不完整包就是 IndexOutOfBoundsException 崩溃。

    2. sampleCount % 50 == 0 才刷新文本——每点都改 TextView 会触发 1000 次/秒的 requestLayout + invalidate,直接把 UI 线程压死。每 50 点刷新一次(20 次/秒)肉眼完全看不出差别。

    3. waveform.addSample 内部会 invalidate()——但它只标记重绘,系统会合并同一帧内的多次 invalidate,所以 1000 次调用实际只重绘 60 次,开销可控。

    ⚠️ 内存风险提示:collectedSamples 是一个无限增长的 MutableList<AccSample>。1000Hz 采样 10 分钟 = 60 万点,每点 4 个 Double + 一个 Long,约 24 字节 + 对象头,60 万点约 20MB。检测 1 小时会到 120MB 并 OOM。

    生产环境应该做环形截断(比如只保留最近 30 分钟)或者边采边落盘。教程里保留"全量内存"是为了让算法章节的示例简单;附录 C 给出分片落盘的改造方案。

    采集控制与每秒结算

    private fun startCollecting() {
        collecting = true
        collectedSamples.clear()
        binding.waveform.clear()
        binding.btnCollect.text = "停止采集"
        binding.btnExport.isEnabled = false
        binding.tvSamples.text = "0"
    
        collectJob = lifecycleScope.launch {
            while (collecting) {
                delay(1000)
                if (collectedSamples.size > 100) {
                    val result = VibrationMetrics.calculate(collectedSamples)
                    lastMetrics = result
                    updateMetricsUI(result)
                }
            }
        }
    }

    这段的设计意图很关键:算法不是每来一个包就算一次,而是每秒算一次全量。

    原因:

    • Sperling 和 EN12299 都是统计量,需要足够长的数据窗才有意义。逐包(50 点 = 0.05 秒)算出来的 FFT 分辨率只有 20Hz,完全无意义。

    • FFT 复杂度 O(n log n),每秒对 6 万个点做 7 次 FFT(3 轴 Sperling + 2 轴 EN + …)在主线程上是灾难,必须放到后台。这里 lifecycleScope.launch 默认跑在主线程——这是个隐患,正确做法是 withContext(Dispatchers.Default) 包住 calculate。见 13.6 的性能修正。

    private fun stopCollecting() {
        collecting = false
        collectJob?.cancel()
        binding.btnCollect.text = "开始采集"
        binding.btnExport.isEnabled = collectedSamples.isNotEmpty()
    
        if (collectedSamples.isNotEmpty()) {
            val result = VibrationMetrics.calculate(collectedSamples)
            lastMetrics = result
            updateMetricsUI(result)
            Toast.makeText(this, "采集完成, 共 ${collectedSamples.size} 个样本", Toast.LENGTH_SHORT).show()
        }
    }

    停止时强制再算一次最终结果——因为周期任务可能刚好在 delay(1000) 里被取消,最后一秒的数据没算进去。这个"收尾结算"是所有周期采样的通用模式。

    指标刷新到界面

    private fun updateMetricsUI(m: MetricsResult) {
        binding.tvWzV.text = String.format("%.2f", m.sperlingVertical)
        binding.tvWzL.text = String.format("%.2f", m.sperlingLateral)
        binding.tvStabilityGrade.text = m.stabilityGrade
        binding.tvComfort.text = String.format("%.3f m/s²", m.comfortIndex)
        binding.tvShake.text = String.format("%.2f", m.shakeIndex)
        binding.tvComfortGrade.text = m.comfortGrade
        binding.tvStatX.text = String.format("%.4f / %.4f", m.rmsX, m.peakX)
        binding.tvStatY.text = String.format("%.4f / %.4f", m.rmsY, m.peakY)
        binding.tvStatZ.text = String.format("%.4f / %.4f", m.rmsZ, m.peakZ)
        binding.tvJerk.text = String.format("%.4f g/s", m.jerkRms)
    }

    有效数字的讲究:平稳性指标 Wz 保留 2 位(0.01 的分辨已足够区分 2.50/2.75/3.00 的档位);舒适度 NMV 保留 3 位(阈值 0.315 就在第三位);RMS/峰值保留 4 位(0.003g 分辨率对应的量级)。保留位数由判据精度决定,不是随手写的。

    13.5 波形绘制:自己画,才算可控

    ui/WaveformView.kt 的核心是一个自定义 View,三件事:环形缓冲、坐标映射、自适应量程。

    缓冲与采样入口

    private val maxPoints = 1000
    private val bufX = FloatArray(maxPoints)
    private val bufY = FloatArray(maxPoints)
    private val bufZ = FloatArray(maxPoints)
    private var head = 0
    private var count = 0
    private var scale = 1.0f    // 当前显示量程(±scale g)
    
    fun addSample(gx: Float, gy: Float, gz: Float) {
        bufX[head] = gx
        bufY[head] = gy
        bufZ[head] = gz
        head = (head + 1) % maxPoints
        if (count < maxPoints) count++
    
        // 必须取绝对值
        val maxV = max(max(abs(gx), abs(gy)), abs(gz)).let { max(it, 0.05f) }
        if (maxV > scale * 0.9f) scale = maxV * 1.2f
        else if (maxV < scale * 0.3f && scale > 0.1f) scale *= 0.95f
    
        invalidate()
    }
      • •

    FloatArray + head 环形:1000 点固定占用 12KB,永不分配新对象,零 GC。这是 1000Hz 刷新的前提——如果用 MutableList 并在头部 removeAt(0),每秒就是 1000 次内存搬移,卡到爆。

    count < maxPoints 才增长:前 1000 点还没填满时,绘制只画 count 个点,不会画出后面那些初值 0 的假数据。

    自适应量程:maxV > scale * 0.9 放大到 maxV * 1.2(留 20% 头部空间);maxV < scale * 0.3 缓慢缩小 0.95 倍(回落用乘法而非直接赋值,避免量程抖动)。

    let { max(it, 0.05f) }

       把下限钳到 0.05g,防止静止时量程缩到 1e-6,一点噪声就满屏。

    ⚠️ 这里是最容易写错的一行,值得单独讲。

    很多人的第一版写的是:

    val maxV = max(max(gx, gy), gz).let { max(it, 0.05f) }   // 错的

    max() 取的是带符号的代数最大值。设备静止平放时,Z 轴读数是 -1g(重力),max(-1.0, 0.00x, 0.00x) 得到 0.00x,被下限钳成 0.05 → 量程判断认为"信号很小",于是 scale 一路缩到最小 → -1g 的重力画到了屏幕外,波形只在中线以下露出一条平线。

    凡是"取最大值"的地方,先问自己一句:这个量是"幅值"还是"代数"? 波形量程、峰值统计、包络提取,全部要取绝对值:

    val maxV = max(max(abs(gx), abs(gy)), abs(gz)).let { max(it, 0.05f) }   // 对的

    工程里用的是后者。如果你自己写的时候发现"静止时波形贴边、看不清",99% 就是这个原因。

    绘制:坐标映射就一行

    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
        val w = width.toFloat()
        val h = height.toFloat()
        val cy = h / 2
    
        for (i in 1..3) {
            val yy = h * i / 4
            canvas.drawLine(0f, yy, w, yy, paintGrid)
        }
        canvas.drawLine(0f, cy, w, cy, paintAxis)
    
        if (count < 2) return
        val stepX = w / maxPoints
        val scalePx = (h / 2 - 10) / scale
    
        drawChannel(canvas, bufX, stepX, cy, scalePx, paintX)
        drawChannel(canvas, bufY, stepX, cy, scalePx, paintY)
        drawChannel(canvas, bufZ, stepX, cy, scalePx, paintZ)
    }
    
    private fun drawChannel(canvas: Canvas, buf: FloatArray, stepX: Float,
                            cy: Float, scalePx: Float, paint: Paint) {
        var prevX = 0f
        var prevY = cy - buf[0] * scalePx
        for (i in 1 until count) {
            val cx = i * stepX
            val cyp = cy - buf[i] * scalePx
            canvas.drawLine(prevX, prevY, cx, cyp, paint)
            prevX = cx; prevY = cyp
        }
    }
      • •

    cy = 屏幕中线 = 0g,cyp = cy - value * scalePx 就是"加速度 → 像素"的映射。

    注意是减号:屏幕 Y 轴向下为正,加速度向上为正,方向相反。

    scalePx = (h / 2 - 10) / scale:h/2 - 10 是留给上下各 10px 边距,/ scale 把"±scale 克"映射到半个屏高。±1g 占满半屏时 scalePx = 100(220dp 高的卡片约 400px)。

    drawLine 而不是 drawPath:1000 段线段在硬件加速下开销极低,而且逐段绘制不需要 Path 的 reset()/moveTo() 开销。三通道 × 1000 段 = 3000 次调用,在 60fps 下毫无压力。

    13.6 性能:三处必须知道的修正

    教程代码能跑,但要把 1000Hz 跑得稳,这三处值得改:

    1. 算法搬到后台线程

    collectJob = lifecycleScope.launch {
        while (collecting) {
            delay(1000)
            if (collectedSamples.size > 100) {
                val snapshot = collectedSamples.toList()          // ① 拷贝快照
                val result = withContext(Dispatchers.Default) {   // ② 后台计算
                    VibrationMetrics.calculate(snapshot)
                }
                lastMetrics = result
                updateMetricsUI(result)                            // ③ 回主线程更新
            }
        }
    }
    • ① 

    必须拷贝快照。直接传 collectedSamples 给后台线程,同时主线程还在 add(),ArrayList 并发修改会抛 ConcurrentModificationException。

    • ② 7 次 FFT 在 Default线程池做,不阻塞 UI。

    • ③ launch 恢复后本身就在主线程,可直接更新 View。

    2. 采集长度设上限

    private const val MAX_SAMPLES = 1000 * 60 * 10   // 10 分钟
    
    // onPacket 里
    if (collecting) {
        if (collectedSamples.size >= MAX_SAMPLES) {
            collectedSamples.removeAt(0)      // 或批量裁剪,见附录 C
        }
        collectedSamples.add(sample)
    }

    3. 波形降采样显示

    1000Hz 一屏 1000 点,正好 1 秒。如果要看 10 秒趋势,应该先算每 10 点的 min/max,画成"柱状包络"(这是示波器的通用做法),而不是压缩 1 万个点画 1000 段——压缩会丢尖峰,包络不会。


    第十四章 指标算法详解:GB/T 5599 与 EN 12299

    这是整个项目最"硬"的一章。很多人做到这一步就卡住了,因为标准原文里全是公式和加权曲线,看完不知道自己该写什么代码。我把每个公式先讲物理意义,再给数学式,再落到 Kotlin,最后给一个能手算验证的数值例子。

    14.1 三个标准各自在测什么

    先建立直觉,否则公式只是符号游戏。

    标准 测什么 输出 为什么这样设计
    GB/T 5599(Sperling) 人对振动平顺性的主观感受 平稳性指标 人对 1~5Hz 最敏感,对高频不敏感,所以用频率加权 + 三次方放大
    EN 12299 乘客在铁路车辆内的乘坐舒适度 加权加速度 、 按人体对垂向/横向的不同敏感度分别加权(Wk / Wd)
    Jerk(工程惯用) 车辆冲击性(晃、抖、顿) 加加速度 RMS 加速度的导数,对"变化快"的信号敏感,反映舒适性突变

    一条关键的工程原则:这三个指标都不是瞬时值,而是一段时间的统计量。所以必须有"窗口"概念——本项目用"自采集开始至今的全部数据",正式检测应按标准取 5 分钟或更长的匀速段。

    14.2 预处理:三步走,一个都不能省

    代码里 calculate() 开头:

    val n = samples.size
    val x = DoubleArray(n) { samples[it].gx * G_TO_MS2 }   // g -> m/s²
    val y = DoubleArray(n) { samples[it].gy * G_TO_MS2 }
    val z = DoubleArray(n) { samples[it].gz * G_TO_MS2 }
    
    removeMean(x); removeMean(y); removeMean(z)

    第一步:单位换算

    传感器给的是 g(重力倍数),标准里全是 m/s² 和 cm/s²。

    ⚠️ 这一步最容易被忽略。如果不换算,Sperling 算出来会小 981³ ≈ 9.4 亿倍(经过 10 次方根后仍差 8 倍),结果看着"很平稳",但完全不真实。任何指标实现的第一行代码都应该是单位归一。

    第二步:去直流(去均值)

    private fun removeMean(a: DoubleArray) {
        var sum = 0.0
        for (v in a) sum += v
        val m = sum / a.size
        for (i in a.indices) a[i] -= m
    }

    为什么必须去? 设备装在车上,Z 轴始终受重力,静止时读数约 -1g(取决于安装方向)。这个直流量在 FFT 里会变成一个巨大的 0Hz 分量,把旁边 1Hz 的信号"淹没"(频谱泄漏)。

    去均值后:

        • • 静态时三轴都接近 0;• 动态时只剩振动分量——

      这才是标准里定义的"振动加速度"

        。

    严格来说,标准还要求"高通滤波(0.4Hz 以上)",因为 0.4Hz 以下的缓慢姿态变化不应该算振动。去均值只去掉 0Hz,是 0.4Hz 高通的一阶近似。这是个精度折中,写在报告附录里说明是诚实的做法。

    第三步:不做加窗(有代价,我也说清楚)

    FFT 对非整周期截断的信号有频谱泄漏。工业做法是加 Hanning 窗:

    加了窗要把幅值补偿回 1.5 倍(Hanning 窗的相干增益 0.5)。本项目没加窗,原因:

      • • 1000Hz 采 1024 点,频率分辨率 0.98Hz,泄漏主要影响相邻 bin;
        • • Sperling 用的是

      宽带积分

        (把 0.5~100Hz 全部累加),泄漏的能量大部分仍落在积分区间内,误差通常在 5% 以内;
      • • 加窗会让"整周期"信号的幅值反而降 50%,实现更复杂。

    结论:用于现场快速评估够用;要出正式报告,加上 Hanning 窗。附录 C 给了加窗版代码。

    14.3 FFT:从 DFT 定义到基 2 实现

    为什么需要 FFT

    Sperling 的公式要求"按频率加权",而时域信号没有"频率"这个维度。唯一的路是把信号变到频域。

    离散傅里叶变换(DFT)的定义:

    它的第  个点对应频率:

    f_s = 1000Hz、N = 1024 时,。频率分辨率就是这个 0.98Hz——分数越细(N 越大),越能分辨低频细节,但耗时也越久。

    DFT 朴素实现是 :N=1024 时约 100 万次复数乘加,每秒算 7 次 = 700 万次,Kotlin 也要几秒。FFT(基 2 时间抽取)把它降到 : 次,快 100 倍。

    FFT 的两个前提

    1. N 必须是 2 的整数次幂。所以代码里有 nextPow2:

    private fun nextPow2(n: Int): Int {
        var p = 1
        while (p < n) p = p shl 1
        return p
    }

    输入 60000 点会被补零到 65536 点。补零不增加信息量,只是让栅格对齐——它让频谱更"平滑"(插值),但真正的频率分辨率仍由原长度决定。这一点很多教程讲错了。

    2. 补零部分的值为 0,等价于"在信号后面接一段静音",不会引入虚假能量。

    完整实现逐行

    private fun fft(re: DoubleArray, im: DoubleArray) {
        val n = re.size
    
        // ① 位反转置换
        var j = 0
        for (i in 1 until n) {
            var bit = n shr 1
            while (j and bit != 0) {
                j = j xor bit
                bit = bit shr 1
            }
            j = j xor bit
            if (i < j) {
                re[i] = re[j].also { re[j] = re[i] }
                im[i] = im[j].also { im[j] = im[i] }
            }
        }
    
        // ② 蝶形运算
        var len = 2
        while (len <= n) {
            val ang = -2 * PI / len
            val wlr = cos(ang); val wli = sin(ang)
            for (i in 0 until n step len) {
                var wr = 1.0; var wi = 0.0
                for (k in 0 until len / 2) {
                    val ur = re[i + k]; val ui = im[i + k]
                    val vr = re[i + k + len / 2] * wr - im[i + k + len / 2] * wi
                    val vi = re[i + k + len / 2] * wi + im[i + k + len / 2] * wr
                    re[i + k] = ur + vr; im[i + k] = ui + vi
                    re[i + k + len / 2] = ur - vr; im[i + k + len / 2] = ui - vi
    
                    val nwr = wr * wlr - wi * wli
                    wi = wr * wli + wi * wlr
                    wr = nwr
                }
            }
            len = len shl 1
        }
    }

    ① 位反转置换(bit-reversal)

    基 2 FFT 要求输入按"位反转顺序"排列。比如 n=8 时:

    i 二进制 反转 j
    0 000 000 0
    1 001 100 4
    2 010 010 2
    3 011 110 6
    4 100 001 1
    5 101 101 5
    6 110 011 3
    7 111 111 7

    代码里那个 while 循环是在递增 j 的"反转计数器":每次都从最高位开始"进位",遇到 1 就翻成 0(xor)并继续,遇到 0 翻成 1 并停止。这比"每次算一遍位反转"快得多。

    if (i < j) 保证每对只交换一次,否则会换回去。

     Kotlin 语法小坑:re[i] = re[j].also { re[j] = re[i] } 这个"三元交换"依赖 also 的求值顺序。它是对的:先把 re[j] 算出来传给 also,also 里把 re[j] 改写,最后再把 re[i] 写成旧 re[j]。但可读性差,生产代码建议用临时变量,避免维护者看不懂。

    ② 蝶形运算(butterfly)

    每次把"两个长度 len/2 的 DFT 合成长度 len 的 DFT":

    其中  是旋转因子。

    代码里:

    ur/ui 是上半支(偶数部分)当前值;•

    vr/vi 是下半支乘以旋转因子后的值(复数乘法 (a+bi)(c+di) = (ac-bd) + (ad+bc)i);

    • 写出 u+v 和 u-v;

    • 然后递推下一个旋转因子:,就是那三行 nwr / wi / wr。递推比每次调 cos/sin 快一个数量级。

    外层 len = 2, 4, 8, ... 到 n,共  级;内层每组 len/2 个蝶形,共  个。总计  次复数乘加。

    幅值归一:这一步决定你的数值对不对

    X[k] 是复数,其模是"幅度 × N / 2"(对实数信号、单边谱)。所以:

    val amp = sqrt(re[k] * re[k] + im[k] * im[k]) / n * 2.0   // 单边幅值

    为什么 /n * 2?

    / n 抵消 DFT 定义里的 N 因子;•

    * 2 把"双边谱"合成单边谱(负频率对称那一半的能量并过来)。

    验证方法(非常重要,务必自己做一次):

    造一个已知信号——幅值 1.0(即 1g)的 10Hz 正弦,采样率 1000Hz,长度 1024:

    val test = DoubleArray(1024) { sin(2 * PI * 10.0 * it / 1000.0) }
    // 走一遍 fft,找 k = 10 fs / N ≈ 10.24 附近的 bin
    // 期望 amp ≈ 1.0(±5%)

    如果算出来是 0.5 或 1024,说明归一化公式错了。这是所有 FFT 应用的第一道自检题,不要跳过。

    本项目自检结果:幅值 1.0 的 10Hz 正弦,峰值 bin 落在 k=10(f=9.77Hz,因栅格不落在整数 10Hz 上),幅值 0.98。误差 2%,来自频谱栅格不匹配,属正常。

    14.4 Sperling 平稳性指标:公式、来历与数值算例

    公式

    工程上写成离散求和(这就是代码里的实现):

    其中:

    • :第  个频率 bin 的单边幅值,单位 cm/s²;

    • :频率修正系数(见下表);• :频率(Hz);• :频率分辨率。

    为什么是"三次方 + 十分之一次方 + 除以 f"

    三个设计都有物理依据:

    1. :人对振动的主观感受不是线性的,而是超线性的——加速度翻倍,难受程度远超两倍。三次方是一种经验拟合(Sperling 1930 年代在火车上做的大量主观试验)。

    2. 除以 :低频大位移(比如匀速转弯的侧倾)虽然加速度不大,但位移大、人更难受。除  相当于对低频给更高权重。

    3.  次方根:把三次方放大的量纲"拉回"到指标量级,让结果落在 14 的可用区间。从量纲看, 的 10 次方根正好是 ,数值上落在 23 附近,便于分档。

    F(f) 频率修正系数(GB/T 5599)

    频率范围 含义
     Hz 极低频,权重小(人几乎感觉不到慢漂)
     Hz 人体最敏感区间,满权重
     Hz 中高频,权重按  衰减
     Hz 继续衰减(也常写成 0 或更快的衰减)

    对应代码:

    private fun sperlingWeight(f: Double): Double {
        return if (f <= 0.5) {
            0.8 * f.pow(2)
        } else if (f <= 5.9) {
            1.0
        } else if (f <= 20.0) {
            59.0 / f.pow(2)
        } else {
            59.0 / f.pow(2)
        }
    }

    注意 else if (f <= 20.0) 和 else 分支的内容相同——这是刻意合并的。很多教材在 20Hz 以上用更强的衰减(如  或直接 0)。本项目采用连续到 100Hz 的版本,确保 100Hz 内的能量都被计入,避免"漏算高频导致指标偏小"。

    代码实现

    private fun sperlingIndex(a: DoubleArray, fs: Double): Double {
        val n = nextPow2(a.size)
        val re = DoubleArray(n)
        val im = DoubleArray(n)
        for (i in a.indices) re[i] = a[i] * 100.0   // m/s² -> cm/s²
        fft(re, im)
    
        val df = fs / n
        var integral = 0.0
        val halfN = n / 2
        for (k in 1 until halfN) {
            val f = k * df
            if (f < 0.5 || f > 100.0) continue
            val amp = sqrt(re[k] * re[k] + im[k] * im[k]) / n * 2.0
            val W = sperlingWeight(f)
            integral += amp.pow(3) * W / f * df
        }
        if (integral <= 0) return 0.0
        return integral.pow(1.0 / 10.0)
    }

    逐行讲解:

    re[i] = a[i] * 100.0 —— m/s² → cm/s²。标准用的单位是 cm/s²。少了这一步,结果小  倍。

    for (k in 1 until halfN) —— 从 1 开始(跳过 k=0 的直流分量,虽然已去均值,但补零后仍有微小残差),到 n/2(奈奎斯特频率,只算单边谱)。•

    if (f < 0.5 || f > 100.0) continue —— 只看 0.5~100Hz。这是标准规定的分析频段。•

    amp.pow(3) * W / f * df —— 严格对应公式 。•

    边界保护:if (integral <= 0) return 0.0。静止时若所有 bin 都极小,pow 可能下溢成 0,0.pow(0.1)是 0(正常),但若因浮点问题变负,pow 会得到 NaN。所有开方/开根运算前都要判非负。

    数值算例(请务必手算一遍)

    假设车辆振动主要成分是 5Hz、幅值 0.05g 的垂向正弦,频率分辨率  Hz。

      1. 换算单位:2. (1~5.9Hz 满权重)3. 4. 单 bin 积分贡献:
      5. 开 10 次方根:

    结论:,正好落在"良"与"合格"的边界线上。

    这个算例说明两件事:

    1. 数值合理:0.05g(约 0.5 m/s²)的振感对应"良",符合实际乘坐体验(地铁正常运行的垂向振动约 0.02~0.05g)。2.

    算法正确:如果你实现完算出的数量级是 0.2 或 20,一定是单位或权重错了。

    实车上你会看到  在 1.8~3.2 之间波动;如果掉到 0.x,多半是传感器量程配错(见 5.6)或数据单位还是 g。

    评级

    private fun gradeStability(wz: Double): String {
        return when {
            wz < 2.5  -> "优"
            wz < 2.75 -> "良"
            wz < 3.0  -> "合格"
            else      -> "不合格"
        }
    }

    GB/T 5599 对客车平稳性等级:

      • :优• :良• :合格• :不合格

    代码用 < 而不是 <=,边界值 2.50 会落入"良"而不是"优"。这种边界差异在实际报告中无实际意义,但要在文档里写明口径,避免争议。

    14.5 EN 12299 乘坐舒适度

    标准做法

    EN 12299 的核心是:

    即先按频率加权 ,再按带宽求和,最后取综合值。垂向用  曲线,横向用  曲线。

    更进一步,EN 12299 定义了综合指标 (mean comfort):

    对应代码:

    val comfortVertical = en12299WeightedRms(z, SAMPLE_RATE, true)
    val comfortLateral  = en12299WeightedRms(y, SAMPLE_RATE, false)
    val comfortIndex    = sqrt(comfortVertical.pow(2) + comfortLateral.pow(2))

    ⚠️ 这里的横向用的是 Y 轴,而 Sperling 横向用的是 max(X, Y)。为什么不一致?因为安装方位约定(见 2.7)里已有明确说明:设备纵向为 X,横向为 Y,垂向为 Z。EN 12299 只关心"横摆",用 Y;而 Sperling 的横向在国标里对应"左右摇摆 + 蛇行",实车上有 X 分量的耦合,取 max 是更保守的工程处理。这类"标准之间的口径差异"必须在设计文档里交代清楚,否则两份报告的数字对不上会被质疑。

    加权实现(工程近似)

    真实标准曲线是由一长串 1/3 倍频程滤波器(Wk 有 20 多个频段系数)组成的。本项目用高斯钟形近似,理由:手机上跑 20 级 IIR 滤波器组开销大、代码长,而现场评估更看重"趋势对不对"。

    private fun en12299WeightedRms(a: DoubleArray, fs: Double, vertical: Boolean): Double {
        val n = nextPow2(a.size)
        val re = DoubleArray(n); val im = DoubleArray(n)
        for (i in a.indices) re[i] = a[i]
        fft(re, im)
    
        val df = fs / n
        var weightedPower = 0.0
        val halfN = n / 2
        for (k in 1 until halfN) {
            val f = k * df
            val amp = sqrt(re[k] * re[k] + im[k] * im[k]) / n * 2.0
            val w = if (vertical) wkWeight(f) else wdWeight(f)
            weightedPower += (amp * w).pow(2)
        }
        return sqrt(weightedPower)
    }

    关键点:

    在频域加权,比在时域滤波简单得多:滤波就是把每个 bin 乘以 。

    这是"频域加权"相对"时域滤波"的压倒性优势——一段代码搞定所有频段。•

    Parseval 校验:时域 RMS 应等于频域幅值平方和开根。代码里 amp 已是单边幅值,weightedPower 就是 ,开根即为加权 RMS。

    这就是为什么用幅值(不是幅值平方的谱密度)就直接能算 RMS——单边幅值已包含 bin 带宽,两者恰好互为逆运算。•

    vertical 布尔参数决定用 Wk 还是 Wd。

    两条加权曲线

    private fun wkWeight(f: Double): Double {
        if (f < 0.4) return 0.0
        if (f <= 10.0) return exp(-((f - 5.0).pow(2)) / (2 * 3.0.pow(2)))   // 峰值 5Hz, σ=3
        if (f <= 80.0) return exp(-((f - 5.0).pow(2)) / (2 * 25.0.pow(2))) * 0.5
        return 0.0
    }
    
    private fun wdWeight(f: Double): Double {
        if (f < 0.4) return 0.0
        if (f <= 20.0) return exp(-((f - 1.5).pow(2)) / (2 * 2.0.pow(2)))   // 峰值 1.5Hz, σ=2
        return 0.0
    }

    高斯曲线  的物理含义:

    Wk(垂向):人体对 4~8Hz 最敏感(内脏共振),中心取 5Hz,σ=3 → 从 1Hz 到 10Hz 都有可观权重。10Hz 以上权重减半(* 0.5),模拟"高频振动传不到脊柱"。•

    Wd(横向):人体对 1~2Hz 最敏感(晕动、头部摇摆),中心取 1.5Hz,σ=2 → 0.4~20Hz 有效。

    与标准曲线的差距:真实 Wk 在 5Hz 处为 1.0,在 1Hz 处约 0.5,在 10Hz 处约 0.5,在 20Hz 处约 0.1。我们的高斯近似在 1Hz 给 0.41、10Hz 给 0.25、20Hz 给 0.02——量级正确,细节偏差 2~5 倍。所以:

    报告必须写明"加权为工程近似,非标准滤波器组实现"。要严格对标,用附录 C 的 1/3 倍频程实现。

    NMV 评级

    private fun gradeComfort(nmv: Double): String {
        return when {
            nmv < 0.20  -> "非常舒适"
            nmv < 0.315 -> "舒适"
            nmv < 0.50  -> "一般"
            nmv < 0.80  -> "不舒适"
            else        -> "非常不舒适"
        }
    }

    对应 ISO 2631-1 / EN 12299 的常用分档( 单位 m/s²)。

    14.6 晃车抖车:Jerk 为什么比加速度更能反映"难受"

    物理意义

    加速度 = 位置变化的速度()——"推你一下的力量"•

    加加速度(jerk) = 加速度的变化率()——"力量变化的快慢"

    人对突变特别敏感。同样是 0.1g 的颠簸,"缓慢加到 0.1g 再缓慢回落"几乎无感;"2ms 内从 0 跳到 0.1g"就是"顿挫"。jerk 正是度量这个"跳变速度"的量。

    代码

    private fun computeJerk(x: DoubleArray, y: DoubleArray, z: DoubleArray, fs: Double): DoubleArray {
        val n = x.size
        val dt = 1.0 / fs
        val jerk = DoubleArray(n)
        for (i in 1 until n) {
            val jx = (x[i] - x[i - 1]) / dt
            val jy = (y[i] - y[i - 1]) / dt
            val jz = (z[i] - z[i - 1]) / dt
            jerk[i] = sqrt(jx * jx + jy * jy + jz * jz)   // 三向合成模
        }
        return jerk
    }
      • •

    一阶差分 ≈ 求导:。采样率 1000Hz 时 ms,差分对 500Hz 以内信号误差可接受(差分是对高频的"高通放大器",高频噪声会被放大——这就是为什么真实设备通常要先低通再算 jerk)。•

    三向合成:先算每轴调整后再取模 ,得到"总冲击强度"。如果分轴报告,抖车看 Z,晃车看 Y。•

    jerk[i] = sqrt(...) 存在 double 数组里,单位是 m/s³。

    然后:

    val jerk = computeJerk(x, y, z, SAMPLE_RATE)
    val jerkRms = rms(jerk) / G_TO_MS2    // 转成 g/s
    val shakeIndex = jerkRms * 10.0       // 晃车指数
      • •

    rms(jerk) / 9.81 → g/s。用 g 为单位是为了让"0.0x"的数值好看,报告可读。•

    * 10 →晃车指数。这是工程标定系数,把典型的"轻微晃"落在 1~3,让操作员能一眼判断(类似于 Richterscale 的人为分度)。

    它不是标准指标,必须在报告里注明"企业内控经验值"。

    判据参考(实车经验,非标准)

    场景 jerkRms (g/s) 晃车指数 主观感受
    平滑行驶 0.01~0.03 0.1~0.3 无感
    正常过轨缝 0.05~0.15 0.5~1.5 轻微起伏
    明显颠簸 0.2~0.4 2~4 不适,需检查悬挂
    异常冲击 >0.5 >5 严重,立即停车检查

    14.7 三轴统计与"最大值"处理

    val rmsX = rms(x) / G_TO_MS2
    val peakX = maxAbs(x) / G_TO_MS2
      • •

    RMS:衡量"平均振动能量",是评价振动强度的标准量。•

    峰值(maxAbs):衡量"最坏瞬间"。注意用的是

    绝对值最大值(max(m, abs(v))),否则负向冲击会被漏掉。这与 13.5 波形量程里那个 max() 的坑是同一个问题——凡是"最大值",先问自己:要不要取绝对值?

    • 报告里再给"峰峰值 = 峰值 × 2"(peakX * 2),这是惯用的近似(严格峰峰值应跟踪最大与最小之差)。

    14.8 精度的边界:这套算法能到哪一步

    诚实地列出本实现的精度与适用边界,写进报告附录,才是工程师的做法:

    指标 本实现精度 主要误差来源 适用场景
    RMS / 峰值 ±3%(相对传感器自身精度) 传感器噪声、ADC 量化 现场评估、趋势对比
    Sperling ±0.3(以 2.75 为基准约 10%) 未加窗、未高通、权重分段简化 快速评级,非验收
    EN NMV ±20%(近似加权) 高斯近似 vs 标准 Wk/Wd 舒适度趋势,非对标
    Jerk ±10%,且受安装共振影响大 差分放大高频噪声 冲击事件检出

    要出"可用于验收"的报告,还需补齐三件事:

      1. 加 Hanning 窗 + 幅值补偿 1.5 倍;2. 0.4Hz 二阶高通( 频段下限);3. Wk/Wd 用 1/3 倍频程滤波器组(附录 C 给了系数表)。

    14.9 算法自检清单(强烈建议逐条打勾)

    1. 单位:进 sperlingIndex 前是 cm/s²?gx 进来是 g,乘 981 而不是 9.81。

    2. 去直流:输入 FFT 的数组均值是否接近 0?

    3. 补零:nextPow2 长度对不对?re 只有前 n 个有值?

    4. 单点幅值验证:1g @10Hz 正弦 → 幅值 ≈ 1.0。

    5. 静止基线:静止放置时  应 < 0.1, < 0.01。若动辄 2.x,说明去直流失败或量程错。

    6. 量纲复核: 在 14 之间, 在 01 之间,jerk 在 0~1 g/s 之间。任何一个超出这个范围一个数量级,都是单位问题。

    7. 边界值:输入全 0(n=32 最小长度)不崩、不 NaN、不 Infinity。


    第十五章 报表导出:把数字变成能发出去的文件

    现场检测完,甲方要的不是你手机屏幕上的数字,而是一份能存档、能打印、能发微信的 PDF。这一章讲 ReportExporter.kt。

    15.1 报告该放什么:四张表的逻辑

    先想清楚"谁看这份报告",再决定放什么:

    读者 关心什么 对应表格
    车辆工程师 平稳性等级、是否合格 表一 GB/T 5599
    乘客体验负责人 舒适度等级 表二 EN 12299
    维修工 有没有异常冲击 表三 晃车抖车
    复核人员 原始数据、可否重算 表四 三轴统计 + CSV

    所以 PDF 的结构是固定的四张表,不追求花哨排版,追求"一眼能判定"。

    15.2 PDF 生成:iText7 的最小可用集

    private fun exportPdf(file: File, m: MetricsResult, sampleCount: Int) {
        val writer = PdfWriter(file.absolutePath)
        val pdf = PdfDocument(writer)
        val doc = Document(pdf)
    
        val dateStr = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).format(Date())
        doc.add(Paragraph("车辆振动检测报告").setBold().setFontSize(20f))
        doc.add(Paragraph("报告时间: $dateStr").setFontSize(10f))
        doc.add(Paragraph("采样点数: $sampleCount").setFontSize(10f))
        doc.add(Paragraph(" "))
        ...
        doc.close()
    }

    对象链要记清楚:PdfWriter(落盘)→ PdfDocument(PDF 结构)→ Document(高层排版)。

    Paragraph(" ").setFontSize(10f) —— iText7 没有 addEmptyLine,加空行的方式就是塞一个空格段落。这是从 iText5 迁移过来的人最常踩的坑。

    setBold() / setFontSize() 是链式调用,返回自身。注意:这些是 Paragraph 的方法,Cell 没有 setFontSize,字号要设在单元格里的 Paragraph 上。

    表格的写法:

    val t1 = Table(floatArrayOf(2f, 2f, 2f))
    t1.addCell(Cell().add(Paragraph("项目").setBold()))
    t1.addCell(Cell().add(Paragraph("数值").setBold()))
    t1.addCell(Cell().add(Paragraph("说明").setBold()))
    
    t1.addCell("垂向平稳性 Wz")
    t1.addCell(String.format("%.3f", m.sperlingVertical))
    t1.addCell("Sperling 指标")
    ...
    doc.add(t1)
      • •

    Table(floatArrayOf(2f, 2f, 2f)) —— 三个参数是列宽比例,不是像素。3 列等宽就写 2f,2f,2f。

    • t1.addCell("字符串") —— iText7 会自动把它包成 Cell + Paragraph。这是最省事的写法,但也就失去了单独设样式的机会。•

    Cell() 的构造可以传行列合并参数 Cell(rowspan, colspan),本项目不需要。

    四张表的内容见 15.1。

    15.3 FileProvider 打开 PDF 的完整链路

    val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", pdfFile)
    val intent = Intent(Intent.ACTION_VIEW).apply {
        setDataAndType(uri, "application/pdf")
        addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
    }
    context.startActivity(Intent.createChooser(intent, "打开报表"))

    三行的作用:

    1. getUriForFile —— 把 file:///storage/.../VibReport_xxx.pdf 转成 content://com.vibdetector.app.fileprovider/downloads/VibReport_xxx.pdf。

    2. FLAG_GRANT_READ_URI_PERMISSION —— 给目标 APP 一次性的读权限。不加这一行,打开时会报 Permission Denial。

    3. createChooser —— 弹出"用哪个应用打开"。如果手机上没装 PDF 阅读器,会提示"无应用可执行此操作"——这是正常现象,不是代码 bug。

    15.4 CSV:给复核人员留的"原始账本"

    private fun exportCsv(file: File, samples: List<AccSample>) {
        FileWriter(file).use { w ->
            w.write("seq,gx,gy,gzn")
            for (s in samples) {
                w.write("${s.seq},${s.gx},${s.gy},${s.gz}n")
            }
        }
    }
      • •

    use { } —— Kotlin 的自动关闭(等价于 Java 的 try-with-resources)。不写 use 就必须 finally { w.close() },否则缓冲区不刷盘,文件是空的。

    • 表头与设备端 SD 卡 CSV 的列名保持一致风格(seq,gx,gy,gz),这样你拿两份数据可以用同一段 Python 脚本读。

     一个工程习惯:CSV 的时间列最好带上。设备端 CSV 有 timestamp_ms,手机端这份为了体积省了。如果要两份数据对齐,应该把设备时间戳一路带到手机——这是附录 C 的改进项。

    15.5 导出环节的三个坑

    坑 1:文件名带中文

    val pdfFile = File(dir, "VibReport_$ts.pdf")

    文件名刻意全英文。原因是:Android 的 FileProvider 在旧版本上对中文文件名做 URI 编码时有兼容问题(部分 OEM 的系统阅读器会打开失败)。文件名英文,内容中文,是最稳的组合。

    坑 2:字体缺字

    iText7 默认用 Helvetica(标准 14 字体之一),它不含中文字形。本项目能正常显示中文,是因为 iText7 的 layout 在遇到中文时会回退到系统字体(Android 上通常可用)。但如果遇到"方块字"或"空白":

    // 需要显式嵌入中文字体(放一个 ttf 到 assets)
    val font = PdfFontFactory.createFont("assets/NotoSansSC-Regular.ttf", PdfEncodings.IDENTITY_H)
    doc.setFont(font)

    坑 3:doc.close() 忘了调用

    Document.close() 才会把内容 flush 到文件。如果中途 return 或抛异常没 close,得到的是一个 0 字节的 PDF。本项目用 try/catch 包住整个导出,doc.close() 在 exportPdf 末尾——要更稳的话,应该把 doc 声明在外面、在 finally 里 close。

    15.6 导出验证清单

    1. 点"导出"后,Download/VibDetector/ 目录下是否出现两个文件(.pdf + .csv)?

    2. PDF 打开后是否四张表都存在、中文正常、数值与界面一致?

    3. CSV 行数是否等于 采样点数?(差一行就是表头,正常)

    4. 用 Excel 打开 CSV,gx 列是否在 ±1.5 之间、gy/gz 是否有明显的直流偏移(说明没去直流,正常,因为 CSV 是原始数据)?


    第十六章 整机联调与标定

    代码能编译、能跑,不等于测出来的数据可信。这一章是"把设备变成仪器"的过程。

    16.1 上电顺序检查(第一次通电必做)

    不要一上来就插传感器测车。按这个顺序,每一步都确认后再往下:

    步骤 操作 期望现象 不通过怎么办
    1 只接 USB,不插任何外设 串口打印 ===== 车辆振动检测仪 v1.0 ===== 检查烧录、波特率 115200
    2 接 ADXL355 打印 ADXL355 检测成功, PARTID=0xED 见 5.8 / 17 章接线表
    3 插 TF 卡 打印 SD 卡挂载成功 + 卡容量信息 见 8.2
    4 接 GPS 天线(必须在户外或窗边) 30~60 秒后出现 fix=1 sats=8+ 见 6.8
    5 手机搜 VibDetector_ESP32 能连上,拿到 192.168.4.x 地址 见 7.2
    6 APP 连 192.168.4.1:8080 波形开始滚动 见 17 章
    7 拔 USB,改电池供电 电量灯亮、RGB 灯绿闪 见 9.1

    16.2 六面法标定:把"零偏"和"灵敏度"钉死

    MEMS 加速度计出厂就有零偏(典型几十 mg),装到 PCB 上还有焊接应力、贴装倾斜带来的额外误差。六面法是最经典、成本最低的标定方法:把设备分别以 6 个朝向静止放置,每个朝向都让重力完整落在某一个轴上。

    原理

    理想情况下,静止时三轴读数应为:

    朝向 X Y Z
    正面朝上(Z+ 向上) 0 0 +1
    正面朝下(Z− 向上) 0 0 −1
    X+ 向上 +1 0 0
    X− 向上 −1 0 0
    Y+ 向上 0 +1 0
    Y− 向上 0 −1 0

    实际读数会是  之类。设某一朝向的读数为 ,则该轴的:

    零偏灵敏度

    灵敏度正常应接近 1.000。若 ,说明你的换算系数偏小 8%(可能是量程配错,见 5.6)。若 ,说明传感器本身或安装角度有问题。

    操作步骤(照着做)

    1. 设备平放在水平大理石/玻璃台面上,静置 2 分钟(等温度稳定,MEMS 有温漂)。

    2. 打开串口,等 SD 卡挂载成功 后把原始 CSV 拷出来。

    3. 翻转 6 个朝向,每朝向静置 30 秒,记录起止时间。

    4. 用 Python 算每段的均值:

    import pandas as pd
    
    df = pd.read_csv("vib_00123456.csv")
    # 假设已按时间标注好 6 段(也可以用 seq 区间)
    segs = {
        "Z+": df.iloc[0:30000],
        "Z-": df.iloc[30000:60000],
        "X+": df.iloc[60000:90000],
        "X-": df.iloc[90000:120000],
        "Y+": df.iloc[120000:150000],
        "Y-": df.iloc[150000:180000],
    }
    
    for k, seg in segs.items():
        print(f"{k}: gx={seg.gx.mean():+.4f} gy={seg.gy.mean():+.4f} gz={seg.gz.mean():+.4f}")
    
    bx = (segs["X+"].gx.mean() + segs["X-"].gx.mean()) / 2
    sx = (segs["X+"].gx.mean() - segs["X-"].gx.mean()) / 2
    print(f"X 轴: 零偏={bx:+.4f} g, 灵敏度={sx:.4f}")
      1. 5. 把三个轴的零偏填进寄存器(ADXL355 有

    OFFSET_X_H/L

      1.  等 6 个寄存器,见 5.2 寄存器表)。

    这是硬件级标定,比软件减均值更彻底

      ——软件减均值只能去掉"当次安装"的零偏,换一台车又要重来;硬件标定则一劳永逸。

    ⚠️ OFFSET 寄存器是 16 位、单位是 3.9µg/LSB 的补码。写入前要换算:

    int16_t off = (int16_t)(bias_g * 1e6 / 3.9);   // g -> LSB
    adxl355_write_reg(ADXL355_REG_OFFSET_X_H, (uint8_t)(off >> 8));
    adxl355_write_reg(ADXL355_REG_OFFSET_X_L, (uint8_t)(off & 0xFF));

    本项目没有写这段代码,采用"软件去直流"的简化方案(14.2)。原因:硬件标定需要一台水平台和 6 次翻面,不适合作为教程的第一步。但正式产品一定要做硬件标定。

    16.3 静态噪声实测:确认 0.003g 分辨率是真的

    把设备放在大理石台上,静置 60 秒,用 SD 卡数据算标准差:

    import numpy as np
    noise = np.std(df.gz.values) * 1000   # mg
    print(f"Z 轴静态噪声: {noise:.2f} mg RMS")

    期望值:ADXL355 在 ±2g 量程、1000Hz ODR 下,噪声密度约 25 µg/√Hz,折算到 1000Hz 带宽:

    实测在 0.4~1.2 mg RMS 之间都算正常(受电源噪声、PCB 走线、地平面影响)。

      • • 明显 > 3mg:电源噪声大(检查 LDO、加大滤波电容)、SPI 走线过长、地平面不完整。• 明显 < 0.2mg:

    可疑

      ——要么数据被平滑了(比如误开了低通),要么读寄存器读错了(比如一直读同一个字节)。

    这个"噪声下限"是判断"数据是不是真数据"的最好指标。很多人接好线看到波形正常就以为成了,其实传感器可能根本没在测量,读到的是上一帧的缓存。

    16.4 GPS 验证

    检查项 方法 期望
    冷启动时间 断电 10 分钟后上电,户外开阔处 30~60 秒首次定位
    定位精度 静止 10 分钟,看经纬度抖动 漂移 < 5 米
    速度 车内 GPS 与手机 GPS 同时记录 差值 < 2 km/h
    卫星数 $GNGGA 第 7 字段 户外 8~12 颗
    室内 放在室内 fix=0,属正常

    常见错误:把 GPS 天线放在金属车顶下,或者天线朝下。陶瓷天线需要朝天、且上方无金属遮挡。

    16.5 系统联调 Checklist(打勾交付)

    •上电 10 秒内,4 颗电量灯按实际电量点亮

    •RGB 灯正常时绿色慢闪(0.5Hz),SD 卡故障时红色快闪(10Hz)

    •手机能搜到热点并连上,IP 段 192.168.4.x

    •APP 显示电量与设备端串口打印一致(±2%)

    •波形三轴同时滚动,静止时贴近中线

    •摇动设备,波形幅度明显增大,指标变化

    •停止采集后能导出 PDF,四张表齐全

    •断电重启后,SD 卡里上一次的 CSV 仍在(验证 1 秒 flush)

    •连续运行 2 小时不死机、不丢包(串口

    dropped

       计数为 0)•电池从 100% 到 20% 的实际时长 ≥ 10 小时

    16.6 实车测试流程与记录表

    测试条件(写进报告,否则数据没法复现):

    项目 记录内容
    车型 / 车号 例如 CRH380A-XXXX
    测点位置 例如 1 车 3 排座椅下方地板
    安装方式 双面胶 / 磁吸 / 螺栓(必须写,影响高频响应)
    运行工况 直线匀速 km/h / 过站 / 过岔 / 制动
    载重 空载 / 满载
    采样时长 每段 ≥ 5 分钟
    天气 / 温度 影响温漂

    测试步骤:

    1. 设备水平放置,用双面胶+扎带固定(绝对不能只用双面胶,振动会让它慢慢跑位)。

    2. 先静置 1 分钟(记录静态基线)。

    3. 手机连热点,点"开始采集",观察 30 秒确认数据流正常。

    4. 按工况分段:每段开始/结束都在纸上记时间和车速(后期对齐数据用)。

    5. 每段结束点"停止采集"+"导出",文件名带上工况(如 VibReport_20261101_过岔.pdf)。

    6. 全程结束后,取出 SD 卡备份原始 CSV。

    16.7 数据一致性核对:SD 卡 vs APP

    同一个时间段,SD 卡 CSV 与 APP 内存数据的样本总数应相差不到 2%。差得多说明有丢数据:

    现象 原因
    SD 卡行数明显少于 APP 样本数 SD 写卡太慢,环形队列溢出(看 dropped)
    APP 样本数少于 SD 卡 TCP 发送慢或 Wi-Fi 抖动,TCP 队列溢出
    两者都比"理论值 1000/s × 秒数"少 5% vTaskDelay(1) 抖动累积(见 10.1),这是真实的采样率偏差,做频率分析时必须用实际采样率而不是 1000

    最后一条很重要:vTaskDelay(pdMS_TO_TICKS(1)) 在 1000Hz 配置下实际周期约 1.05~1.15ms,真实采样率可能是 870950Hz。**如果你的 Sperling 结果总是偏低 510%,先怀疑采样率,而不是公式**。修正方法:用 seq 与 timestamp_ms 做线性回归,估出真实 ,传给算法(而不是硬编码 1000)。


    第十七章 常见问题速查

    按"症状"查,不按模块查——因为现场你只会看到症状。

    17.1 固件 / 编译

    症状 最可能原因 验证 修复
    fatal error: freertos/FreeRTOS.h: No such file 组件没在 REQUIRES 里声明 freertos 看组件 CMakeLists REQUIRES freertos
    undefined reference to vTaskDelay 同上,且源文件没 include 同上 include + REQUIRES
    esp_err_to_name 未定义 缺 esp_check.h / esp_err.h 编译日志 补头文件
    编译过但运行立刻重启 栈溢出 / 空指针 看 Backtrace、Task watchdog 加大栈(10.2)
    Guru Meditation Error: LoadProhibited 访问空指针或野指针 看 PC 地址对应哪个函数 检查 mutex 未创建就 take
    SPI bus already initialized 两个驱动用了同一个 host 看 adxl355.h / sdcard.h 的 host 宏 ADXL355=SPI2,SD=SPI3
    sdmmc_card_init failed 卡没插好 / 供电不足 / CS 接错 换卡、量 3.3V 见 8.2
    ADXL355 ID 校验失败, 读到 0x00 MISO 没接 / CS 接错 / 传感器没供电 万用表量 VDD、MISO 电平 查接线表 2.6
    ADXL355 ID 校验失败, 读到 0xFF MISO 悬空(上拉) 同上 补焊 MISO
    采样率只有 500~900Hz vTaskDelay(1) 抖动 + 日志打印 看 seq 增速 见 5.10 FIFO 方案

    17.2 Wi-Fi / TCP

    症状 最可能原因 验证 修复
    手机搜不到热点 WiFi 没启动 / 天线没接 串口是否打印 WiFi AP 启动 检查 7.2 四步顺序
    能连热点但 APP 连不上 端口错 / 防火墙 / 手机自动切回 4G ping 192.168.4.1 手机设置里关"智能切换网络"
    连上就断开 accept 后没保持,或客户端发数据触发 recv 返回 0 串口看 TCP 客户端断开 见 7.4 单客户端逻辑
    数据一卡一卡 Nagle 算法 抓包看包间隔 socket.tcpNoDelay = true
    波形不动但连接正常 JSON 解析失败 Logcat 看 JSON 解析失败 见 7.7 逗号位置
    运行几分钟后 Wi-Fi 掉 电源供电不足(峰值电流 350mA) 量 3.3V 是否有跌落 加大电容 / 换 LDO
    数据包越来越大 缓冲没清空 串口看 dropped 无客户端时要 pop 清空

    17.3 APP

    症状 最可能原因 验证 修复
    NetworkOnMainThreadException Socket 在主线程 看堆栈 放 Dispatchers.IO
    CalledFromWrongThreadException 后台线程改 UI 看堆栈 runOnUiThread
    界面卡死 每秒算 7 次 FFT 在主线程 看 CPU withContext(Dispatchers.Default)
    Couldn't find meta-data for provider authority 不一致 对比 Manifest 与代码 用 ${applicationId}.fileprovider
    Failed to find configured root file_paths.xml 路径不匹配 看报错里的路径 补 <external-path path="."/>
    PDF 是 0 字节 没 doc.close() 文件大小 见 15.5
    中文变方块 字体不含中文 看 PDF 嵌入中文字体
    编译报 AttributePrefixUnbound xmlns:app 重复声明 看 XML 只在根元素声明
    波形顶出屏幕 量程用了带符号 max 静止时观察 改 abs()(13.5)

    17.4 指标 / 数据

    症状 最可能原因 验证 修复
     只有 0.2~0.5 单位错(还是 g,没换 cm/s²) 手算 14.4 算例 乘 100
     高达 30+ 没去直流 看 CSV 的 gz 均值是否 ≈ -1 removeMean
    静止时  就 2.x 电源噪声 / 传感器没测量 看噪声标准差(16.3) 查供电、POWER_CTL
    舒适度全是"非常舒适" 加权曲线把能量滤没了 打印 comfortVertical 检查 wkWeight 下限
    三轴数据完全一样 读到同一个寄存器地址 打印原始 x/y/z 检查 read_bytes 偏移
    Z 轴恒为 0 MISO 断 / 寄存器地址错 同上 查接线
    数值全 NaN pow 负数 / 除零 加边界判断 14.4 的 integral <= 0

    17.5 硬件 / 电源

    症状 最可能原因 验证 修复
    插 USB 不亮灯 Type-C 焊接虚焊 / IP5306 未起振 量 VBUS、BAT 补焊
    电池供电不启动 IP5306 处于待机(无负载检测) 按一下 KEY 见 9.1
    充电时电量灯乱闪 充电状态引脚未接 量 IP5306 引脚 见 9.1
    续航只有 3 小时 电池虚标 / Wi-Fi 功耗高 量平均电流 见 2.4
    电量百分比跳变 ADC 分压电阻精度差 万用表量分压点 见 9.2
    用手摸就噪声大 地平面差 / 天线干扰 手指靠近看波形 改板、加屏蔽

    附录 A 工程文件清单

    VehicleVibrationDetector/
    ├── 车辆振动检测仪设计文档.md          ← 本文
    ├── 车辆振动检测仪_v1.0_release.apk    ← 已签名发布版(8.08 MB)
    ├── 车辆振动检测仪_v1.0_debug.apk      ← 调试版(9.83 MB)
    ├── firmware/                          ← ESP-IDF 工程
    │   ├── CMakeLists.txt                 ← 顶层工程文件
    │   ├── sdkconfig.defaults             ← 关键配置(16MB Flash / PSRAM Octal / 1kHz tick)
    │   ├── partitions.csv                 ← 自定义分区表
    │   ├── main/
    │   │   ├── CMakeLists.txt
    │   │   └── main.c                     ← 主程序:双环形队列 + 5 个任务
    │   └── components/
    │       ├── adxl355/                   ← SPI 驱动 + 20bit 解码 + 量程换算
    │       ├── gps/                       ← UART + NMEA 解析(RMC/GGA)
    │       ├── wifi_tcp/                  ← AP + TCP Server
    │       ├── sdcard/                    ← SPI 模式挂载 + 卸载
    │       ├── battery/                   ← ADC oneshot + 校准 + 电量映射
    │       └── led/                       ← 4 颗电量灯 + 1 颗 RGB 状态灯
    └── android/                           ← Android 工程
        ├── settings.gradle / build.gradle / gradle.properties / local.properties
        └── app/
            ├── build.gradle
            └── src/main/
                ├── AndroidManifest.xml
                ├── java/com/vibdetector/app/
                │   ├── MainActivity.kt
                │   ├── ReportExporter.kt
                │   ├── data/VibrationData.kt
                │   ├── net/TcpClient.kt
                │   ├── metric/VibrationMetrics.kt
                │   └── ui/WaveformView.kt
                └── res/{layout,values,xml,mipmap-anydpi-v26,drawable}

    关键宏定义速查(改硬件只改这些)

    文件 宏 默认值
    adxl355/include/adxl355.h ADXL355_PIN_MOSI/MISO/SCLK/CS 11 / 13 / 12 / 10
    adxl355/include/adxl355.h ADXL355_SPI_HOST SPI2_HOST
    sdcard/include/sdcard.h SD_PIN_MOSI/MISO/SCLK/CS 35 / 37 / 36 / 38
    sdcard/include/sdcard.h SD_MOUNT_POINT /sdcard
    gps/include/gps.h GPS_UART_TX_PIN / RX_PIN / BAUD 43 / 44 / 9600
    battery/include/battery.h BATTERY_ADC_PIN / BATTERY_DIVIDER 4 / 3.0
    wifi_tcp 调用处(main.c) SSID / 密码 / 端口 VibDetector_ESP32 / 12345678 / 8080

    附录 B 通信协议速查表

    B.1 连接参数

    项 值
    工作模式 Wi-Fi AP(设备是热点)
    SSID VibDetector_ESP32
    密码 12345678(WPA2-PSK)
    设备 IP 192.168.4.1(AP 默认网关)
    端口 8080(TCP)
    最大客户端 1(新连接会踢掉旧连接)
    传输方向 设备 → APP 单向推流(APP 暂不发命令)
    分帧 每条 JSON 以 n 结尾
    推送周期 50 ms(代码 TCP_SEND_INTERVAL_MS)
    单包上限 200 点(代码 TCP_BATCH_MAX)

    B.2 数据包 JSON

    {"type":"vib","count":50,
     "gps":{"lat":39.904200,"lon":116.407400,"spd":0.00,"sats":9,"fix":1},
     "bat":85,
     "samples":[[12345,0.00123,-0.00456,0.98765],[12346,0.00118,-0.00461,0.98770]]}

    字段说明:

    字段 类型 单位 说明
    type string — 固定 "vib",预留 "ack"/"cmd" 扩展
    count int 点 本包采样点数量(应与 samples 长度一致)
    gps.lat double 度 十进制度,南纬为负
    gps.lon double 度 十进制度,西经为负
    gps.spd double 节 1 节 = 1.852 km/h
    gps.sats int 颗 参与定位卫星数
    gps.fix int — GGA 定位质量:0=无效,1=GPS,2=DGPS
    bat int % 电量 0~100
    samples[i][0] long — 序号(自增,用来检测丢包)
    samples[i][1..3] double g X / Y / Z 加速度,5 位小数

    ⚠️ count 与实际长度的关系:设备端 snprintf 缓冲区满时会提前 break,导致实际写入的点数少于 count。APP 解析时以 samples.size 为准,不要用 count 做循环上界——这是 7.7 那个坑的残留表现,改 count 需要在拼装完成后再回填,代码里做了简化。

    B.3 抓包验证(不用 APP 也能验协议)

    Windows PowerShell 直接连设备:

    Test-NetConnection -ComputerName 192.168.4.1 -Port 8080

    用 telnet(Win10 需手动启用)或 Python:

    import socket, json
    
    s = socket.create_connection(("192.168.4.1", 8080))
    buf = b""
    while True:
        buf += s.recv(4096)
        while b"n" in buf:
            line, buf = buf.split(b"n", 1)
            pkt = json.loads(line)
            print(pkt["count"], pkt["bat"], pkt["samples"][0])

    这个脚本是排查"到底是设备没发、还是 APP 没解"的分水岭。先用它,再改 APP。

    B.4 SD 卡 CSV 格式

    seq,timestamp_ms,gx,gy,gz,lat,lon,speed_knots,nsats,fix
    0,12,0.001234,-0.002345,0.987654,39.904200,116.407400,0.00,9,1
    列 说明
    seq 自增序号,用来算实际采样率与丢点率
    timestamp_ms 开机后的毫秒数(esp_log_timestamp())
    gx/gy/gz 加速度,g,6 位小数
    lat/lon 十进制度,6 位小数(约 0.1 米精度足够)
    speed_knots 节
    nsats 卫星数
    fix 定位质量

    注意:GPS 一列是"本批次数据的快照"(20ms 刷一次),不是逐点值。GPS 更新率只有 1Hz,逐点写是浪费。


    附录 C 扩展方向与进阶实现

    C.1 加 Hanning 窗(把 Sperling 精度再提一档)

    private fun applyHann(a: DoubleArray): DoubleArray {
        val n = a.size
        val out = DoubleArray(n)
        for (i in 0 until n) {
            val w = 0.5 - 0.5 * cos(2 * PI * i / (n - 1))
            out[i] = a[i] * w
        }
        return out
    }
    
    // 在 sperlingIndex 里:
    val windowed = applyHann(a)
    for (i in windowed.indices) re[i] = windowed[i] * 100.0 * 2.0   // 幅值补偿 2 倍

    Hanning 窗的相干增益是 0.5,所以幅值要 ×2 补偿。注意 n 要取补零前的长度(a.size),不是 nextPow2(a.size),否则窗会"压在填充区"。

    C.2 0.4Hz 二阶高通(Butterworth,fs=1000Hz)

    // 直接 II 型 biquad 系数(fc=0.4Hz, fs=1000Hz, Q=0.707)
    private val b0 = 0.99999246; private val b1 = -1.99998492; private val b2 = 0.99999246
    private val a1 = -1.99998492; private val a2 = 0.99999246
    
    // 状态变量
    private var x1 = 0.0; private var x2 = 0.0; private var y1 = 0.0; private var y2 = 0.0
    
    private fun highpass(x: Double): Double {
        val y = b0 * x + b1 * x1 + b2 * x2 - a1 * y1 - a2 * y2
        x2 = x1; x1 = x; y2 = y1; y1 = y
        return y
    }

    这段系数在 0.4Hz 处几乎等于"直通",因为 0.4Hz 相对 1000Hz 采样率极低,极点非常接近 z=1。浮点精度不够时(Float)会失稳,所以必须 Double。

    C.3 Wk / Wd 的 1/3 倍频程实现(严格对标)

    EN 12299 的 Wk 加权实际是"按 1/3 倍频程中心频率的权重表":

    中心频率 (Hz) Wk 权重 Wd 权重
    0.5 0.418 1.000
    1.0 0.500 1.011
    2.0 0.930 1.000
    4.0 1.000 0.716
    5.0 1.000 0.610
    8.0 0.930 0.398
    16.0 0.468 0.208
    32.0 0.155 0.083
    63.0 0.052 —

    实现方式:把频谱按 1/3 倍频程分带,每带求 RMS,乘以对应权重,再按 14.5 的公式合成。代码量约 60 行,比高斯近似准得多。

    C.4 存储改造:Android 10+ 用 MediaStore

    val values = ContentValues().apply {
        put(MediaStore.Downloads.DISPLAY_NAME, "VibReport_$ts.pdf")
        put(MediaStore.Downloads.MIME_TYPE, "application/pdf")
        put(MediaStore.Downloads.RELATIVE_PATH, "Download/VibDetector")
    }
    val uri = contentResolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values)!!
    contentResolver.openOutputStream(uri)!!.use { out ->
        PdfWriter(out)   // 直接写流,不落中间文件
    }

    这样在 Android 10~16 全版本可用,且不需要任何存储权限。

    C.5 打开混淆时的 ProGuard 规则

    -keep class com.itextpdf.** { *; }
    -dontwarn com.itextpdf.**
    -keep class com.google.gson.** { *; }
    -keep class com.vibdetector.app.data.** { *; }   # Gson 反射字段
    -keepattributes Signature, *Annotation*

    C.6 真 1000Hz:FIFO + 数据就绪中断

    见 5.10 的思路,落地要点:

    1. FIFO_SAMPLES(0x29) 配水位(如 32 点);

    2. INT_MAP(0x2A) 把 DATA_RDY 映射到 INT1/INT2;

    3. GPIO 配下降沿中断,xQueueSendFromISR 通知采集任务;

    4. 读 FIFO_DATA(0x11) 连续读 FIFO_ENTRIES(0x05) 个点;

    5. 每个 FIFO 点里其实带的是三轴数据,接口是 3 字节一组、20bit 对齐,读 9 字节/点。

    这样采样率由传感器的内部时钟决定,抖动 < 1µs,是真正能用于频谱分析的数据源。

    C.7 多设备与云平台 

    多设备:把 SSID 改成 VibDetector_XX(XX 为 MAC 后两位),APP 侧增加设备扫描列表。•

    接入云:ESP32 保持 STA 模式连路由器 + MQTT(esp-mqtt 组件),JSON 直接透传;APP 改为订阅主题。注意要保留 AP 模式作为现场无网环境的兜底。•

    OTA:esp_https_ota + 双分区,partitions.csv 已经是双 OTA 布局,只要在 sdkconfig 里打开即可。

    C.8 数据落盘改造(解决 APP 内存上限)

    见 13.4 的内存风险。推荐做法:

    1. 采集时边写 CSV 到私有目录,内存只保留最近 5 分钟(供实时指标);2. 停止采集后,从 CSV 重新流式读取,分块(每块 65536 点)算指标,加权平均;3. 这样理论上可以连续记录数小时。


    结语

    回头看这个项目,真正难的不是某一行代码,而是每个环节的取舍:

    • 采样用 vTaskDelay 简单但抖,用 FIFO 精准但复杂——教程选前者,因为能让你先跑通。

    • 加权用高斯近似快但欠准,用滤波器组准但长——教程选前者,因为手机要实时。

    • 报告用公共 Downloads 目录方便但受分区存储限制——教程选它,因为你会先遇到"文件在哪"这个问题。

    一个能被复现的、诚实的工程实现,比一个看起来完美但说不清原理的实现有价值得多。

    这套东西你已经能完整走通了:从选一颗 0.003g 的 MEMS,到让一根网线里的字节变成一张带等级的 PDF。剩下的,是你把它装到真实的车上,去听那台车想告诉你什么。

    相关推荐

    嵌入式开发工程师

    微信公众号