news 2026/10/27 9:19:43

基于STM32的睡眠质量检测仪:传感器选型、算法与低功耗设计全解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
基于STM32的睡眠质量检测仪:传感器选型、算法与低功耗设计全解析

简介:基于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 VIN3.3V给传感器供电,不要接5V
MAX30102 SCL / SDAPB6 / PB7I2C1时钟和数据
MAX30102 INTPA0数据准备好中断,低电平有效
MPU6050 VIN3.3V模块自带稳压可接5V,这里统一3.3V
MPU6050 SCL / SDAPB6 / PB7与MAX30102共用I2C1
MPU6050 AD0GND固定地址0x68
MPU6050 INTPA1数据就绪中断,软件可不用只读轮询
USART1 TX / RXPA9 / 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做任务切分后,每个模块独立占用一个任务,代码解耦,调试时也能单独灌数据测。

任务名周期优先级职责
SensorTask10ms4(高)读MAX30102 FIFO和MPU6050加速度
AlgoTask30ms2(中)算体动投票、心率、血氧,维护状态机
StoreTask1s1(低)把睡眠记录写入Flash环形队列
ReportTask10s0(最低)跌倒串口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回放一遍,先确认状态迁移逻辑合理再上板实测,这个习惯帮我少走了很多弯路。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/27 9:19:08

FOC电机控制中的串级PID原理与工程实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/27 9:17:03

信创人脸机全解析:鸿蒙前端与麒麟统信后台架构实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/27 9:15:54

嵌入式工程师转型机器人:核心技术定位与能力升级指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/27 9:15:50

基于YOLOv5的交通路面缺陷检测系统设计与优化

1. 项目背景与核心价值交通路面缺陷检测是城市基础设施维护的重要环节。传统人工巡检方式效率低下且成本高昂&#xff0c;尤其在高速公路、城市主干道等场景下存在明显局限性。我们团队开发的这套基于YOLO算法的深度学习检测系统&#xff0c;能够实现沥青路面裂缝、坑洼、修补痕…

作者头像 李华
网站建设 2026/10/27 9:15:28

嵌入式Linux安卓驱动开发实战:从字符设备到HAL全链路解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华