简介:基于STM32的睡眠质量检测仪完整工程资料,面向嵌入式学习者和健康监测项目开发者,解决睡眠阶段分析与质量评估的实际需求。设计以STM32F103为核心控制平台,融合MAX30102心率传感器、MPU6050体动检测及DHT11/DHT22环境温湿度采集,实时获取心率、体动、体温、环境温湿度等关键数据;通过数据融合算法区分浅睡、深睡与觉醒阶段,并统计翻身次数、睡眠时长等指标,最终生成包含睡眠阶段分布与环境影响的评估报告。资源共172个文件,以C源码(71个)和头文件(96个)为主,涵盖标准外设库驱动(定时器、ADC、I2C、USART等)、OLED屏显驱动、cJSON数据处理,另有汇编启动文件、markdown说明文档、HTML文档、VSCode工作区配置及exe可执行文件,压缩包仅699KB,目录结构清晰,便于按模块研读与二次开发。已有149人学习浏览,源码注释完整,可直接编译烧录验证,适合用于课程设计、毕业设计或健康监测产品原型开发。
1. 基于STM32的睡眠质量检测仪:先想清楚“睡眠监测到底在测什么”
我最早做这款基于STM32的睡眠质量检测仪,是因为家里老人打鼾严重,想搞一个能整晚记录睡眠状况又不打扰休息的东西。去医院做多导睡眠监测要排队、要贴十几个电极,数据确实是金标准,但实在不适合居家场景。后来我把方案拆到一块STM32F103C8T6上:MAX30102测血氧心率、MPU6050测体动,跑一个简单的睡眠分期状态机,整晚数据存Flash、早上通过串口导出来。整板物料成本压在100元以内,准确度当然够不上临床级,但“深睡够不够、夜里醒几次、翻身频率正不正常”这些居家判断已经够用。适合两类人:做嵌入式毕业设计的学生,以及正在做健康类小产品的硬件工程师。这篇笔记把完整链路拆开讲,从传感器选型、算法实现,到RTOS任务划分和掉电保护,再到我实测踩过的几个坑。
2. 硬件选型与传感器接入:为什么是MAX30102加MPU6050,而不是医疗级方案
2.1 传感器选型的三个理由
睡眠质量检测的核心指标说起来就三样:心率、血氧、体动。市面上能测这三样的传感方案不少,但适合STM32低成本落地的其实不多。我把常见方案摆一起对比过:
| 方案 | 成本 | 开发难度 | 可测指标 | 定位 |
|---|---|---|---|---|
| MAX30102光电式 | 约15元 | 低,I2C即插即用 | 心率、血氧、间接体动 | 消费级手环同款 |
| ADS1292生物电 | 约50元 | 高,微弱心电信号调理复杂 | 心电、呼吸率 | 医疗级可穿戴 |
| 压电薄膜床垫 | 约80元 | 中,模拟信号滤波困难 | 呼吸、心率、体动 | 床垫式监测 |
选MAX30102加MPU6050的理由有三条。第一,两个传感器都走I2C接口,不需要额外运放和模拟调理电路,STM32标准库直连就能拿到原始数据。第二,MAX30102内置红光和红外双LED,直接给出ADC采样结果,血氧和心率算法都是公开的工程近似公式,不用自己搞高阻抗前端。第三,MPU6050自带数字输出和FIFO,睡眠场景下我们只需要加速度计的三轴数据,完全不碰陀螺仪,功耗还能再降一截。ADS1292方案不是不好,而是它的心电信号幅度只有毫伏级,PCB布线、电源纹波、屏蔽,任何一环没做好数据就没法看,不适合快速复现。
2.2 引脚分配与硬件接线表
这套工程我用的是STM32F103C8T6,两个传感器共用I2C1总线,地址不冲突所以不需要片选脚。MAX30102的7位地址是0x57,MPU6050的AD0引脚接GND时地址是0x68,总线上一问一答不会打架。
| 传感器引脚 | 接到STM32 | 说明 |
|---|---|---|
| MAX30102 VIN | 3.3V | 给传感器供电,不要接5V |
| MAX30102 SCL / SDA | PB6 / PB7 | I2C1时钟和数据 |
| MAX30102 INT | PA0 | 数据准备好中断,低电平有效 |
| MPU6050 VIN | 3.3V | 模块自带稳压可接5V,这里统一3.3V |
| MPU6050 SCL / SDA | PB6 / PB7 | 与MAX30102共用I2C1 |
| MPU6050 AD0 | GND | 固定地址0x68 |
| MPU6050 INT | PA1 | 数据就绪中断,软件可不用只读轮询 |
| USART1 TX / RX | PA9 / PA10 | 串口输出睡眠报告,接USB转TTL |
这里有个容易忽略的点:I2C总线上的SCL和SDA必须接上拉电阻,典型值4.7kΩ到3.3V。MAX30102的评估板一般自带,但如果你是自己打板,漏了这两个电阻会让总线电平浮空,读回来的寄存器全是0xFF。后面避坑章节我会详细讲这个现象。
2.3 I2C初始化与传感器ID探测:先确认链路是通的
拿到板子第一步不是写算法,而是先把两个传感器的ID读出来。MAX30102的寄存器0xFE是Revision ID,MPU6050的寄存器0x75是WHO_AM_I,固定返回0x68。这个步骤看起来简单,却能验证I2C时序、地址、上拉电阻全部正常。我在这套工程里没有用STM32的硬件I2C外设,而是直接用软件模拟I2C,原因是标准库硬件I2C中断事件状态机的顺序比较绕,一旦配置错,读出来的ID要么是乱码要么超时,排查起来玄学味道太重。软件模拟虽然占CPU,但在100kHz的时钟下读两个传感器,CPU占用率完全可以忽略。
#define I2C_SPEED_DELAY 5 // 约100kHz,实测这个延时值在8MHz主频下稳定 static void I2C_Delay(void) { volatile int i; for (i = 0; i < I2C_SPEED_DELAY; i++); } static void I2C_Start(void) { SDA_HIGH(); SCL_HIGH(); I2C_Delay(); SDA_LOW(); I2C_Delay(); SCL_LOW(); I2C_Delay(); } static void I2C_Stop(void) { SCL_LOW(); SDA_LOW(); I2C_Delay(); SCL_HIGH(); I2C_Delay(); SDA_HIGH(); I2C_Delay(); } uint8_t I2C_ReadReg(uint8_t dev_addr, uint8_t reg_addr) { uint8_t val = 0; I2C_Start(); I2C_SendByte(dev_addr & 0xFE); // 写方向 I2C_SendByte(reg_addr); I2C_Start(); // 重复起始,切换读方向 I2C_SendByte(dev_addr | 0x01); val = I2C_RecvByte(); I2C_Stop(); return val; }代码逻辑很简单:先发器件地址加寄存器地址,然后重复起始信号,把地址最低位置1切到读方向,最后读一个字节收尾。这里要注意dev_addr传的是8位地址,比如MAX30102写作0xAE,MPU6050写作0xD0,而不是7位地址0x57和0x68。很多人在这里搞混,导致I2C从机不响应。
主循环里验证链路的核心代码是:
uint8_t max30102_id = I2C_ReadReg(0xAE, 0xFE); uint8_t mpu6050_id = I2C_ReadReg(0xD0, 0x75); printf("MAX30102 Rev ID: 0x%02X\r\n", max30102_id); printf("MPU6050 WHO_AM_I: 0x%02X\r\n", mpu6050_id); if (max30102_id != 0x15 || mpu6050_id != 0x68) { printf("sensor probe failed, check I2C wiring\r\n"); }MAX30102的Revision ID常见值是0x15,但不同批次可能有差异,所以判断条件不用写死,先打印出来肉眼确认就行。MPU6050的0x68是固定值,错了基本就是接线问题或地址配置问题。我一般习惯在这个阶段就把串口波特率设为115200,用USART1并把printf重定向到串口,后续所有调试输出都走这条路。
3. 数据采集与睡眠分期算法:从原始波形到状态机判定
3.1 体动信号:滑动窗口与幅值阈值
睡眠分期的硬件基础是体动检测。夜里人的状态可以粗略分成三类:深睡时身体基本不动,浅睡时偶尔翻身,清醒时体动频繁幅度大。MPU6050加速度计在±2g量程下,静止时三轴矢量和接近1g,翻身时会出现明显的瞬时偏离。我们的算法就抓这个偏离信号。
#include <math.h> #define BODY_MOTION_THRESH 0.35f // 体动加速度阈值,单位g,按传感器安装方式调整 #define WINDOW_SIZE 30 // 评估窗口,单位秒 #define SECOND_TICK_MS 1000 // 每秒一个投票周期 typedef struct { float ax, ay, az; } accel_t; static uint8_t motion_votes[WINDOW_SIZE]; // 每秒投票结果,1代表有体动 void MotionDetect_Update(accel_t *a, uint8_t second_index) { float magnitude = sqrtf(a->ax * a->ax + a->ay * a->ay + a->az * a->az); float delta = fabsf(magnitude - 1.0f); if (delta > BODY_MOTION_THRESH) { motion_votes[second_index] = 1; } else { motion_votes[second_index] = 0; } } uint8_t MotionDetect_CountInWindow(void) { uint8_t count = 0; for (uint8_t i = 0; i < WINDOW_SIZE; i++) { if (motion_votes[i]) count++; } return count; }逻辑说明:MPU6050读出的加速度原始值除以16384才是重力加速度g,这块我在读取函数里已经换算好,传给MotionDetect_Update的a是float型g值。静止时矢量和约等于1g,delta很小;翻身时加速度计会测到重力矢量方向的快速变化,delta瞬间超过阈值。motion_votes数组用30个元素存30秒的逐秒投票,每秒更新一次,形成滑动窗口。
参数说明:BODY_MOTION_THRESH设为0.35g是经验值,传感器如果固定在手腕上,阈值可以放低到0.25g,因为手部微动更频繁;如果固定在床垫或睡衣上,建议提高到0.5g,否则翻身时产生的冲击会把整晚数据打成一连串“清醒”。这个阈值我做成宏定义,回放调参时直接改宏再重新编译烧录,比做成运行时配置更直观。
3.2 心率与血氧:红光红外AC分量比值法的简化实现
MAX30102的核心原理是光电容积脉搏波描记法。红光和红外两种LED交替点亮,照射皮肤后由光电二极管接收反射光强。动脉血脉动时,血管体积周期性变化,导致反射光强出现与心跳同步的交流分量AC,叠加在一个缓慢变化的直流分量DC上。血氧饱和度用经典工程公式计算,心率则用红外通道AC分量的过零检测。
#define IR_SAMPLE_RATE 50 // 红外采样率,单位Hz #define IR_BUFFER_LEN 500 // 10秒缓存 static int16_t ir_buffer[IR_BUFFER_LEN]; static float red_ac, red_dc, ir_ac, ir_dc; float SpO2_Calc(float ac_red, float dc_red, float ac_ir, float dc_ir) { float ratio = (ac_red / dc_red) / (ac_ir / dc_ir); float spo2 = 110.0f - 25.0f * ratio; if (spo2 > 100.0f) spo2 = 100.0f; if (spo2 < 70.0f) spo2 = 70.0f; return spo2; } uint16_t HR_ByZeroCross(int16_t *buffer, uint32_t len, uint32_t fs) { uint32_t crossings = 0; for (uint32_t i = 1; i < len; i++) { if (buffer[i-1] <= 0 && buffer[i] > 0) { crossings++; } } return (uint16_t)(crossings * 60.0f * fs / len); }逻辑说明:AC分量提取用的是滑动平均法,把原始波形减去一段时间内的均值就得到AC,均值本身是DC。采样率50Hz,10秒缓存500个点。过零检测统计红外信号从负到正的次数,每个周期对应一次心跳,再换算成每分钟心率。
参数说明:IR_SAMPLE_RATE设50Hz,是因为睡眠场景下心率范围大约40到120次/分,对应的信号频率不到2Hz,50Hz采样率配合10秒窗口足够做过零检测。如果你的代码跑在F103上,MAX30102的FIFO默认可以攒数据,注意每次读FIFO时把采样率配到与算法一致。这个方案有一个明显弱点:体动剧烈时红外波形被干扰,过零检测会数出虚假心跳。我一般会把心率结果和体动窗口关联起来,体动幅度大的时间段把心率标记为不可信,不上抛到状态机里。另一个备选方案是用STM32的定时器输入捕获去测量脉搏波周期,对瞬时抖动更鲁棒,但实现复杂度会高一些,初版先用过零检测。
3.3 睡眠分期状态机:清醒、浅睡、深睡的切换条件
有了体动次数和心率,睡眠分期就变成简单的状态机。我用的判据是:30秒窗口内的体动次数motion_in_window,加上当前心率hr,两个条件联合判断。深睡时心率通常比静息心率低10到15次/分,体动次数趋近于0;浅睡时心率接近静息值,允许少量翻身;清醒时体动次数明显增多。
typedef enum { STAGE_AWAKE = 0, STAGE_LIGHT = 1, STAGE_DEEP = 2 } sleep_stage_t; static sleep_stage_t stage = STAGE_AWAKE; void SleepStage_Update(uint8_t motion_in_window, float hr) { switch (stage) { case STAGE_AWAKE: if (motion_in_window <= 2 && hr < 72.0f) { stage = STAGE_LIGHT; // 连续一段时间体动少、心率降,进入浅睡 } break; case STAGE_LIGHT: if (motion_in_window >= 5) { stage = STAGE_AWAKE; // 频繁翻身或起夜 } else if (motion_in_window <= 1 && hr < 62.0f) { stage = STAGE_DEEP; // 体动极少且心率明显下降 } break; case STAGE_DEEP: if (motion_in_window >= 3) { stage = STAGE_LIGHT; // 从深睡转浅睡通常伴随·一次翻身 } else if (motion_in_window >= 6) { stage = STAGE_AWAKE; // 连续大动作,判定为清醒 } break; } }逻辑说明:状态机每30秒调用一次。motion_in_window由MotionDetect_CountInWindow返回,hr取最近10秒心率的平均值。STAGE_AWAKE进STAGE_LIGHT的门槛是“体动小于等于2次且心率低于72”,这个72是我按成人静息心率上限取的,不同用户建议做成可配置参数。
参数说明:状态机里最需要调的是深睡判定阈值。体动<=1这个条件很严格,因为很多人深睡期也会无意识挠痒、翻身;但心率<62和体动<=1同时成立时才进深睡,误判率明显下降。如果你给青少年用,心率阈值要整体上调5到10次/分。这套状态机刻意不做睡眠呼吸暂停判断——那需要呼吸波形,不是三个简单特征能覆盖的,硬做会变成玄学。
4. RTOS任务划分与低功耗设计:一块电池顶住一整晚的关键
4.1 FreeRTOS四任务划分与优先级表
裸机轮询不是不行,但这套系统有I2C读取、算法计算、Flash写入、串口输出四件事,时间敏感性不同。用FreeRTOS做任务切分后,每个模块独立占用一个任务,代码解耦,调试时也能单独灌数据测。
| 任务名 | 周期 | 优先级 | 职责 |
|---|---|---|---|
| SensorTask | 10ms | 4(高) | 读MAX30102 FIFO和MPU6050加速度 |
| AlgoTask | 30ms | 2(中) | 算体动投票、心率、血氧,维护状态机 |
| StoreTask | 1s | 1(低) | 把睡眠记录写入Flash环形队列 |
| ReportTask | 10s | 0(最低) | 跌倒串口CSV睡眠报告 |
void vSensorTask(void *arg) { TickType_t xLastWake = xTaskGetTickCount(); for (;;) { vTaskDelayUntil(&xLastWake, pdMS_TO_TICKS(10)); MAX30102_ReadFIFO(&red_sample, &ir_sample); MPU6050_ReadAccel(&acc_data); // 原始数据通过全局结构体共享,AlgoTask只读不改 xQueueOverwrite(sensor_queue_handle, &sensor_bundle); } }逻辑说明:vTaskDelayUntil保证10ms周期不漂移,比vTaskDelay更稳,这是FreeRTOS里固定周期任务推荐的写法。SensorTask把三个数据打包成sensor_bundle发给sensor_queue。队列用xQueueOverwrite而不是xQueueSend,因为算法任务处理不过来时丢旧数据即可,睡眠检测不需要逐点回放。
参数说明:优先级只差2以上是刻意的。SensorTask高了会抢CPU导致算法跑不完,低了又会让FIFO溢出。10ms采集周期对应MAX30102的FIFO深度,配50Hz采样率时FIFO每200ms才满一次,10ms读一次有充足余量。Queue长度为1,overwrite语义意味着永远保留最新的一包数据。
4.2 功耗控制:关掉LED驱动,进入Stop模式
整晚连续采集的话,MAX30102的红外LED驱动电流是板上功耗大头,典型配置下模组整体要20mA左右。F103C8T6跑72MHz大约20mA,加在一起40多mA,1000mAh电池只能撑20来个小时,一晚没问题,但要连续用就悬了。我的做法是把系统切成“采5分钟、停10分钟”的间歇工作模式,空闲时进Stop模式。
void EnterLowPowerStop(void) { // 关掉MAX30102的LED和内部电路,写入0x40让芯片进shutdown MAX30102_WriteReg(0x09, 0x40); // MPU6050进入sleep模式,加速度计停止采样 MPU6050_WriteReg(0x6B, 0x40); // 关掉不用的外设时钟,让I2C总线释放 RCC_APB1PeriphClockCmd(RCC_APB1Periph_I2C1, DISABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1, DISABLE); // 用RTC闹钟唤醒,每5分钟唤醒一次执行采集 RTC_SetAlarm(5 * 60); // 进入Stop模式,内部1.8V稳压器切低功耗档 PWR_EnterSTOPMode(PWR_Regulator_LowPower, PWR_STOPEntry_WFI); // 唤醒后重新配置系统时钟,从HSI启动 SystemInit(); while (RCC_GetSYSCLKSource() != 0x04); }逻辑说明:进入Stop前先写传感器寄存器让它们睡过去,MAX30102 shutdown后的电流降到微安级。RTC用外部32.768kHz晶振跑,闹钟到点后通过WKUP事件唤醒MCU。PWR_EnterSTOPMode执行后CPU停在那里,唤醒后第一件事是SystemInit重新锁PLL,否则外设时钟全是乱的,这也是新手最容易踩坑的地方。
参数说明:采集5分钟停10分钟,等效占空比33%,整机平均电流大概15mA左右,1000mAh电池大约可以撑三天两夜。如果你接受睡前手动按键开机、早起关机,那连续采集模式更实用,算法数据也更完整。我的建议是初版先跑连续采集把算法调稳,再上间歇模式省电,不然省了电却拿不到连续数据,调试直接卡死。
4.3 Flash环形队列存储与掉电保护
整晚按每30秒一条记录算,8小时一共960条记录,每条包含时间戳、睡眠分期、心率、血氧、体动次数。F103C8T6内置Flash一共64KB,代码区占掉前48KB后,我从0x0800C000开始划出16KB作为数据区,按1KB一页分成16页做环形队列。
#define FLASH_DATA_BASE 0x0800C000 #define FLASH_PAGE_SIZE 0x400 #define FLASH_PAGE_NUM 16 typedef struct { uint32_t timestamp; // RTC时间戳 uint8_t stage; // 0清醒 1浅睡 2深睡 uint8_t motion_count; // 30秒窗口体动次数 uint8_t hr; // 平均心率 uint8_t spo2; // 平均血氧 } sleep_record_t; void Flash_WriteSleepRecord(sleep_record_t *rec, uint32_t offset) { taskENTER_CRITICAL(); // 写Flash期间禁止任务切换,避免编程被打断 FLASH_Unlock(); if (offset % FLASH_PAGE_SIZE == 0) { FLASH_ErasePage(FLASH_DATA_BASE + offset); } FLASH_ProgramWord(FLASH_DATA_BASE + offset + 0, rec->timestamp); FLASH_ProgramWord(FLASH_DATA_BASE + offset + 4, (rec->stage << 24) | (rec->motion_count << 16) | (rec->hr << 8) | rec->spo2); FLASH_Lock(); taskEXIT_CRITICAL(); }逻辑说明:Flash只能把1写成0,所以写入前必须先擦除整页。这里按页偏移取模判断是否跨页,跨页才擦,避免每写一条都擦一次。任务切换临界区是必须的——STM32擦写Flash时,如果任务切换导致代码去访问Flash,芯片会卡在等待状态,严重点直接HardFault。
参数说明:sleep_record_t压缩到8字节,整晚960条共7.68KB,最坏情况每晚占8页,16页能存两晚数据。如果配了外部SPI Flash可以存更久,但内置Flash的好处是掉电不丢、不用额外芯片,适合做初版。注意STM32F103内置Flash每个字节只能承受约一万次擦写,环形队列翻页策略就是为这个准备的,避免每次都擦同一页。
5. 实测踩坑记录:五条让项目返工的隐蔽问题
5.1 传感器I2C读回错误ID或0xFF
现象:MAX30102的Revision ID读出来是0xFF,MPU6050的WHO_AM_I不是0x68,偶尔又是0x00,看起来像传感器没上电。
原因:最常见是I2C总线上没焊上拉电阻,SCL和SDA开漏输出,线上没有高电平,数据根本传不出去。另一个原因是用GPIO模拟I2C时,引脚初始化成了推挽输出而不是开漏输出,两个设备同时驱动总线时直接冲突。
解决:板子上SCL和SDA各接一个4.7kΩ到3.3V;软件初始化引脚时用GPIO_Mode_Out_OD开漏模式。如果实在来不及改板,可以临时把引脚配成GPIO_Mode_IPU内部上拉,但I2C的上升沿会变缓,速率只能压在50kHz以下,不建议作为长期方案。
5.2 Delay延时函数卡死
现象:裸机程序里调用标准库的Delay_ms,程序跑到一半就卡住不动,但点灯任务还能闪,说明中断嵌套出问题了。
原因:标准库的Delay是靠SysTick实现的,而FreeRTOS也拿SysTick做时基。裸机Delay和RTOS tick共用同一个中断,优先级配置不对或者FreeRTOS启动后SysTick中断被改,Delay就再也等不到那个递减标志。
解决:凡是RTOS环境一律用vTaskDelay或vTaskDelayUntil,不要再碰裸机Delay_ms。如果你在某个驱动里必须短延时,比如I2C时序,用空循环延时即可,不依赖SysTick,彻底避开冲突。我后来把这两个地方都改成自旋延时后,再没出现过卡死。
5.3 FreeRTOS下Flash写入被打断,程序跑飞
现象:设备连续运行几个小时后突然死机,重新上电后Flash里的睡眠记录出现半条数据,时间戳正确但心率字段是0xFFFF。
原因:Flash编程过程中任务被调度器切换,新任务去访问正在擦写的Flash页面,STM32的Flash控制器在忙时会让CPU等待,如果等待期间又来了更高优先级中断,系统直接HardFault。0xFFFF说明ProgramWord只写了一半就被打断。
解决:写Flash的操作包在taskENTER_CRITICAL和taskEXIT_CRITICAL之间,临界区内禁止任务切换和中断响应。另一个经验是不要在低电量的时候做Flash擦写,电压偏低会让编程时序不可靠,我在电池电压低于3.3V时直接禁止StoreTask写Flash,只保留RAM缓存。
5.4 ADC切换通道后读到上一个通道的残留值
现象:MAX30102和MPU6050的数据读取正常,但用ADC读电池电压时,第一次采样永远是上一个通道的值,接下来才正确。
原因:STM32 ADC内部采样电容在通道切换后会残留上一通道的电荷,加上外部信号源内阻大,一个采样周期内电容充不满,转换结果就是旧值。
解决:切换通道后主动丢弃第一次转换结果,或者把采样时间配到最长——ADC_SampleTime_239Cycles_5。我在代码里做了一层检测:每次切换通道后先启动一次转换,结果用掉,第二次结果才入库。这不算bug,是STM32 ADC的固有特性,只要记得丢第一次就不会翻车。
5.5 夜里翻身被记成“清醒”,整晚状态全是毛刺
现象:早上看串口报告,整晚清醒次数超过15次,但用户反馈自己睡得很死,明显算法和主观感受对不上。
原因:BODY_MOTION_THRESH设成了0.3g,窗口只有5秒。翻身产生的瞬时加速度很容易超过0.3g,5秒窗口内只要有一次超阈值就判清醒,误报率全拉满。另一个因素是传感器绑在手腕上,手臂无意识微动的幅度比躯干翻身小不了多少。
解决:把体动阈值从0.3g提到0.6g,窗口从5秒扩到30秒,同时把“连续2次翻身且间隔小于10秒”才允许从浅睡切清醒。这样调完,整晚清醒次数从15次降到4次以内。从那以后我把阈值全部做成Flash可配置参数,每个用户第一次使用后回放一夜数据,再按体动分布自动校准,不再用固定值跑全场。
6. 验证与进阶:用整晚串口数据回放校准算法阈值
睡眠检测仪这类项目,算法参数调得对不对,不能靠白天掐表看,必须把一整晚的串口数据原样录下来,离线回放对比。我的做法是睡眠前把设备串口接到一个USB转TTL记录器上,PC端开串口调试助手把数据原样存成CSV。每行格式固定为:RTC时间戳、睡眠分期、体动次数、心率、血氧。
# 串口输出格式 t=1694083200,stage=2,motion=0,hr=58,spo2=96 t=1694083230,stage=2,motion=1,hr=57,spo2=97 t=1694083260,stage=1,motion=3,hr=63,spo2=95用下面这个Python脚本做基础统计:
import sys from datetime import datetime hourly = {} for line in sys.stdin: line = line.strip() if not line: continue parts = line.split(',') if len(parts) != 5: continue ts = int(parts[0].split('=')[1]) stage = int(parts[1].split('=')[1]) hour = datetime.fromtimestamp(ts).strftime('%H') hourly.setdefault(hour, [0, 0, 0]) hourly[hour][min(stage, 2)] += 1 # index: 0清醒, 1浅睡, 2深睡 for h in sorted(hourly): awake, light, deep = hourly[h] total = awake + light + deep if total == 0: continue print(f"{h}:00 清醒占比={awake/total:.1%} 浅睡={light/total:.1%} 深睡={deep/total:.1%}")脚本逻辑很简单:按小时聚合三档状态的帧数比例。正常情况下,深睡占比应该在上半夜更高,凌晨后浅睡变多。如果你的输出显示某小时深睡占比超过60%但体动次数也高,那大概率是状态机转移条件太宽松,深睡和体动的矛盾信号没有互相校验。我调到第3版时才摸清这个规律——深睡判定必须同时满足“心率低于阈值”和“体动次数低于阈值”,且这两个条件在同一个30秒窗口内同时成立才允许转移,否则保持原状态。
进阶方向有两个。一个是把串口换成ESP8266模块,早上自动把睡眠报告推送到云端,手机端直接看趋势曲线,这块改动不触碰状态机核心,只是把ReportTask的输出从串口改成TCP上报。另一个是加一块OLED屏,睡醒按键直接看昨晚的深浅睡占比,用到的GUI框架不必太重,单屏绘制加按键翻页就够。但不管加什么功能,我每次调完阈值都强制把整晚CSV回放一遍,先确认状态迁移逻辑合理再上板实测,这个习惯帮我少走了很多弯路。希望帮到你。
本文还有配套的精品资源,点击获取