十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

ESP32+MAX30102+OLED:MicroPython打造心率血氧监测仪

ESP32+MAX30102+OLED:MicroPython打造心率血氧监测仪 1. 从零搭建健康监测仪的整体设计思路1.1 为什么选ESP32 MAX30102 OLED这个组合做健康监测类的小项目选型这件事往往比写代码更花时间。市面上能测心率和血氧的方案不少有现成的指夹式模块直接串口输出数据的也有用STM32加模拟前端自己搭电路的。我最终锁定ESP32 MAX30102 OLED这套组合理由很实在ESP32自带WiFi和蓝牙双核240MHz主频内存也够用关键是MicroPython固件烧进去之后开发效率极高不用折腾复杂的编译环境。MAX30102是一颗集成度非常高的心率血氧传感器内部集成了红光LED、红外LED、光电检测器和18位ADC通过标准I2C接口就能读出原始数据。OLED用0.96寸I2C接口的SSD1306接线简单显示效果在室内足够清晰。这套组合解决的核心问题是用最低的硬件门槛和最短的开发周期做出一个能实时采集心率、血氧并本地显示的完整设备。适合有基本电子常识、会一点Python或者Arduino的初学者也适合想快速验证传感器方案的开发者。整个项目不需要任何外部服务器所有计算和显示都在本地完成插上电就能用。1.2 系统架构与数据流向整个系统的数据流向很清晰MAX30102负责采集光电容积脉搏波PPG原始数据通过I2C总线传给ESP32ESP32在MicroPython环境下运行心率血氧计算算法把原始的光强信号转换成心率和血氧饱和度数值最后通过另一路I2C总线把结果送到OLED屏幕上显示。两路I2C可以共用一条总线只要地址不冲突就行——MAX30102的地址是0x57SSD1306的地址通常是0x3C完全不冲突。这里有个设计决策值得展开说为什么不用ESP32的蓝牙把数据传到手机App上显示而是坚持用本地OLED原因有三点。第一本地显示没有延迟数据刷新是实时的对于观察心率变化趋势更直观。第二不依赖手机设备独立运行使用场景更灵活。第三调试阶段本地显示能直接看到原始数据排查问题比看手机App方便得多。当然后续想扩展蓝牙功能也很容易ESP32的蓝牙和WiFi可以同时工作这是它相比ESP8266最大的优势之一。1.3 硬件清单与成本估算先列一下我实际用到的物料都是市面上很容易买到的通用件物料名称规格说明参考单价ESP32开发板ESP32-WROOM-32带USB转串口25-35元MAX30102模块集成红光红外LED和光电检测器12-20元OLED显示模块0.96寸I2C接口SSD1306驱动10-15元杜邦线母对母若干根3-5元面包板400孔或830孔5-10元总成本控制在60-80元之间比买一个成品指夹式血氧仪还便宜而且可玩性和可扩展性完全不是一个级别。这里提醒一句MAX30102模块市面上有两种版本一种带电平转换和稳压电路可以直接接3.3V或5V另一种是裸芯片版本必须接3.3V。买的时候看清楚ESP32的IO口是3.3V电平接5V会烧。2. 核心硬件原理与接线实操2.1 MAX30102传感器的工作原理解析MAX30102测心率和血氧的原理是光电容积脉搏波描记法。简单说就是利用血液对特定波长光的吸收特性。心脏每次搏动血管里的血液容积会发生变化导致透过皮肤的光强也跟着变化。MAX30102内部有两个LED一个红光660nm一个红外光880nm。红光和红外光在含氧血红蛋白和脱氧血红蛋白中的吸收率不同通过计算两个波长的吸收比值就能推算出血氧饱和度。心率则更直接就是检测光强变化的周期性数一数每分钟有多少个波峰就行。这个传感器内部集成了18位ADC采样率可以配置最高能到3200Hz。实际做心率检测50Hz到100Hz的采样率就够用了。它还带有一个FIFO缓冲区可以存32个样本这样ESP32不用一直守着读数据隔一段时间批量读一次就行大大降低了CPU占用。FIFO这个设计很关键因为I2C通信本身是有开销的如果每采一个点就读一次总线利用率会很低还容易丢数据。2.2 I2C通信协议的关键细节I2C是这个项目里最核心的通信协议两根线搞定所有外设SDA是数据线SCL是时钟线。I2C用开漏输出加外部上拉电阻的结构这是它最容易被忽视但又最重要的设计。开漏输出的意思是器件只能把线拉低不能主动拉高。线要变高靠的是上拉电阻。为什么这么设计因为总线上挂多个设备时如果两个设备同时输出一个想拉高一个想拉低就会短路。开漏加上的上拉电阻的结构天然实现了“线与”逻辑——只要有一个设备拉低线就是低电平不会出现电源短路的问题。上拉电阻的取值有讲究。阻值太小功耗大而且低电平时灌电流可能超过器件的承受能力阻值太大上升沿变缓高速通信时波形会变形。标准模式100kHz下4.7kΩ是常见值快速模式400kHz下2.2kΩ到4.7kΩ都可以。我实测下来ESP32内部有可配置的上拉电阻但阻值偏大约45kΩ在400kHz下波形上升沿明显变缓。所以如果你买的MAX30102模块上没有自带4.7kΩ上拉电阻最好在SDA和SCL上各加一个4.7kΩ的电阻接到3.3V。很多模块已经集成了买之前确认一下。I2C的数据帧格式也值得说清楚。一次完整的通信包括起始条件、地址帧、读写位、应答位、数据帧和停止条件。起始条件是SCL高电平时SDA从高变低停止条件是SCL高电平时SDA从低变高。地址帧是7位地址加1位读写标志MAX30102的7位地址是0x57写操作就是0xAE读操作就是0xAF。每传输一个字节接收方要拉低SDA一个时钟周期作为应答。如果接收方没有拉低就是非应答通常表示出错。2.3 接线步骤与注意事项接线本身不复杂但有几个坑我踩过这里直接给结论ESP32和MAX30102的连接ESP32的GPIO21接MAX30102的SDAESP32的GPIO22接MAX30102的SCLESP32的3.3V接MAX30102的VINESP32的GND接MAX30102的GNDESP32和OLED的连接SDA和SCL可以并联到同一组GPIO21和GPIO22上VCC接3.3VGND接GND注意MAX30102的INT引脚可以不接我们用轮询方式读FIFO。如果要用中断方式可以接到ESP32的任意输入引脚但MicroPython下中断处理不如C语言灵活初学者建议先用轮询。接线时还有一个细节杜邦线不要太长超过20厘米后I2C波形质量会下降尤其是SCL时钟线。如果必须用长线考虑降低I2C速率到100kHz或者用屏蔽线。我一开始用了一根30厘米的杜邦线OLED能点亮但MAX30102读数据偶尔出错换成15厘米的线之后问题消失。3. MicroPython环境搭建与驱动实现3.1 ESP32固件烧录与开发环境配置ESP32烧录MicroPython固件是第一步。需要的东西不多一根USB数据线、esptool工具、MicroPython固件文件。esptool用pip安装就行pip install esptool固件从MicroPython官网下载ESP32的通用版本注意要选对型号。ESP32-WROOM-32用标准的ESP32固件就行。烧录前先擦除Flashesptool.py --chip esp32 --port /dev/ttyUSB0 erase_flash然后写入固件esptool.py --chip esp32 --port /dev/ttyUSB0 --baud 460800 write_flash -z 0x1000 esp32-20230426-v1.20.0.binWindows下端口号是COMxLinux和Mac下是/dev/ttyUSB0或/dev/ttyACM0。烧录时如果报错“Failed to connect”按住开发板上的BOOT键再试一次。烧录完成后用串口工具连接波特率115200能看到MicroPython的REPL提示符就成功了。开发工具我推荐Thonny它对MicroPython的支持非常友好可以直接在IDE里写代码、上传文件、打开REPL。配置也很简单在Thonny的设置里选择“MicroPython (ESP32)”端口选对就行。另一个选择是VS Code加Pymakr插件功能更强但配置稍复杂。初学者用Thonny足够了。3.2 MAX30102驱动代码的编写要点MicroPython下没有现成的MAX30102库需要自己写驱动。核心就是通过I2C读写寄存器。先定义寄存器地址REG_INTR_STATUS_1 0x00 REG_INTR_ENABLE_1 0x02 REG_FIFO_WR_PTR 0x04 REG_OVF_COUNTER 0x05 REG_FIFO_RD_PTR 0x06 REG_FIFO_DATA 0x07 REG_FIFO_CONFIG 0x08 REG_MODE_CONFIG 0x09 REG_SPO2_CONFIG 0x0A REG_LED1_PA 0x0C REG_LED2_PA 0x0D REG_PILOT_PA 0x10 REG_MULTI_LED_CTRL1 0x11 REG_MULTI_LED_CTRL2 0x12 REG_TEMP_INTR 0x1F REG_TEMP_FRAC 0x20 REG_TEMP_CONFIG 0x21 REG_PROX_INT_THRESH 0x30 REG_REV_ID 0xFE REG_PART_ID 0xFF初始化流程分几步。先复位往MODE_CONFIG寄存器写0x40等待复位完成。然后配置FIFO设置采样平均数和滚动行为。接着配置SPO2模式设置ADC量程、采样率和LED脉冲宽度。最后设置LED电流红光和红外LED的电流值需要根据实际佩戴情况调整一般设置在0x24到0x3F之间。def init_sensor(self): self.i2c.writeto_mem(self.addr, REG_MODE_CONFIG, b\x40) time.sleep_ms(100) self.i2c.writeto_mem(self.addr, REG_FIFO_CONFIG, b\x4F) self.i2c.writeto_mem(self.addr, REG_MODE_CONFIG, b\x03) self.i2c.writeto_mem(self.addr, REG_SPO2_CONFIG, b\x27) self.i2c.writeto_mem(self.addr, REG_LED1_PA, b\x24) self.i2c.writeto_mem(self.addr, REG_LED2_PA, b\x24)读FIFO数据的逻辑是先读FIFO_WR_PTR和FIFO_RD_PTR计算有多少个样本可读然后从FIFO_DATA寄存器连续读取。每个样本是6个字节红光3字节加红外3字节每个通道是18位数据高6位在第一个字节的低6位里。def read_fifo(self): wr_ptr self.i2c.readfrom_mem(self.addr, REG_FIFO_WR_PTR, 1)[0] rd_ptr self.i2c.readfrom_mem(self.addr, REG_FIFO_RD_PTR, 1)[0] num_samples (wr_ptr - rd_ptr) 0x1F data self.i2c.readfrom_mem(self.addr, REG_FIFO_DATA, num_samples * 6) for i in range(num_samples): red ((data[i*6] 0x03) 16) | (data[i*61] 8) | data[i*62] ir ((data[i*63] 0x03) 16) | (data[i*64] 8) | data[i*65] self.fifo.append((red, ir))3.3 OLED显示驱动的实现OLED用SSD1306驱动MicroPython社区有现成的ssd1306.py库直接拿来用就行。初始化I2C对象from machine import I2C, Pin import ssd1306 i2c I2C(0, sclPin(22), sdaPin(21), freq400000) oled ssd1306.SSD1306_I2C(128, 64, i2c)显示内容分三行第一行显示心率第二行显示血氧第三行显示状态信息。字体用默认的8x8像素字体虽然小但够用。如果想显示更大的数字可以用framebuf的缩放功能或者自己画大字体。def update_display(hr, spo2, status): oled.fill(0) oled.text(HR: {} bpm.format(hr), 0, 0) oled.text(SpO2: {}%.format(spo2), 0, 20) oled.text(status, 0, 40) oled.show()这里有个细节oled.show()会刷新整个屏幕如果刷新太频繁I2C总线会一直被占用影响MAX30102的数据读取。我的做法是每计算完一次心率血氧再刷新一次大概每秒一次这样总线占用率很低。4. 心率血氧算法实现与数据处理4.1 原始信号预处理MAX30102读出来的原始数据是光强值包含直流分量和交流分量。直流分量主要来自皮肤、肌肉、骨骼等非搏动组织的吸收交流分量才反映血液搏动。要提取心率先要滤掉直流分量。最简单的方法是用滑动平均或者一阶高通滤波。我用的方法更直接维护一个长度为100的滑动窗口计算窗口内的平均值作为直流分量然后用当前值减去平均值得到交流分量。这个方法计算量小在ESP32上跑起来毫无压力。def preprocess(self, ir_value): self.dc_window.append(ir_value) if len(self.dc_window) 100: self.dc_window.pop(0) dc sum(self.dc_window) / len(self.dc_window) ac ir_value - dc return ac交流分量还需要进一步滤波去掉高频噪声。用一个简单的低通滤波器截止频率设在5Hz左右因为心率信号的主要能量集中在0.5Hz到4Hz之间。4.2 心率计算的峰值检测法心率计算的核心是检测交流信号的峰值。每检测到一个峰值就记录时间戳然后计算相邻峰值之间的时间间隔换算成每分钟心跳次数。峰值检测的难点在于抗干扰信号里会有各种噪声造成的假峰。我的做法是设置一个动态阈值。先计算最近一段时间内交流信号的最大值和最小值阈值设为最大值的60%左右。只有当信号从下方穿过阈值向上时才认为是一个有效峰值。同时设置一个最小时间间隔比如300毫秒因为正常人的心率不会超过200bpm对应间隔就是300毫秒。def detect_peak(self, ac_value, timestamp): if ac_value self.threshold and not self.peak_flag: if timestamp - self.last_peak_time 300: self.peak_flag True if self.last_peak_time 0: interval timestamp - self.last_peak_time self.hr 60000 // interval self.last_peak_time timestamp elif ac_value self.threshold * 0.5: self.peak_flag False这个算法在静止状态下准确度不错误差在±3bpm以内。但如果手指移动或者环境光变化会有波动。所以我在显示之前加了一个中值滤波取最近5次心率值的中位数这样显示出来的数值稳定很多。4.3 血氧饱和度的计算原理血氧饱和度计算比心率复杂一些。核心公式是SpO2 A - B * (R/IR)其中R是红光的交流分量除以直流分量IR是红外光的交流分量除以直流分量。A和B是经验系数需要根据传感器和测量部位校准。MAX30102的典型值A110B25但实际使用中需要微调。def calculate_spo2(self, red_ac, red_dc, ir_ac, ir_dc): r (red_ac / red_dc) / (ir_ac / ir_dc) spo2 110 - 25 * r if spo2 100: spo2 100 if spo2 0: spo2 0 return spo2这个公式算出来的血氧值在90%到100%之间比较准低于90%时误差会变大。对于日常健康监测来说够用了。需要注意的是血氧计算需要红光和红外两路信号同步采集MAX30102的FIFO里两个通道的数据是配对的读的时候要一起读出来。5. 常见问题排查与实操经验分享5.1 I2C通信失败的各种原因I2C通信失败是这个项目里最常见的问题表现是OLED不亮或者MAX30102读不到数据。排查思路按以下顺序来第一步用I2C扫描程序确认设备地址。MicroPython下很简单i2c I2C(0, sclPin(22), sdaPin(21), freq400000) print(i2c.scan())正常应该输出[60, 87]60是0x3COLED87是0x57MAX30102。如果只看到一个或者一个都没有说明接线或者上拉电阻有问题。第二步检查上拉电阻。用万用表测SDA和SCL对3.3V的电阻正常应该在2kΩ到10kΩ之间。如果无穷大说明没有上拉电阻需要外接。如果接近0说明短路了。第三步降低I2C速率试试。有些模块在400kHz下工作不稳定降到100kHz就好了。在I2C初始化时把freq参数改成100000。第四步检查电源。MAX30102对电源噪声比较敏感如果ESP32的3.3V输出纹波大传感器可能工作不正常。可以在传感器的VIN和GND之间并一个10uF的电容。5.2 心率数值跳动大的处理方法心率数值跳动大通常有三个原因。一是手指按得太紧或者太松。按得太紧会阻碍血液循环信号幅度变小按得太松环境光会干扰。正确的力度是手指轻轻贴在传感器上能感受到LED的光但不要用力压。二是环境光干扰。MAX30102的LED窗口旁边有光电检测器如果环境光太强会直接进入检测器造成干扰。解决方法是在传感器上盖一块黑色海绵或者用热缩管包一圈只留出指尖接触的区域。三是算法参数不合适。阈值设得太低会检测到噪声峰设得太高会漏掉真实峰。我建议在调试阶段把原始交流信号通过串口打印出来用Thonny的绘图功能观察波形根据波形调整阈值。正常的心率波形应该是周期性的每个周期有一个明显的峰值。5.3 OLED显示异常排查OLED常见的问题有几个。一是屏幕全亮或者全暗通常是初始化参数不对。SSD1306的初始化序列比较长如果用的库版本不对可能初始化失败。换一个库或者手动发送初始化命令试试。二是显示内容错位或者花屏一般是I2C速率太高导致数据传输出错。把速率降到100kHz或者在oled.show()之前加一个短延时。三是屏幕亮度不均匀这是OLED本身的特性0.96寸的屏在边缘区域亮度会稍低属于正常现象。如果整个屏幕都很暗检查VCC电压是不是低于3.0V。实操心得调试OLED的时候先写一个最简单的显示程序只显示一行文字确认屏幕能正常工作。然后再逐步加入心率血氧的显示逻辑。这样出问题的时候容易定位是显示驱动的问题还是数据处理的问题。5.4 常见问题速查表现象可能原因解决方法I2C扫描不到设备接线错误或上拉电阻缺失检查SDA/SCL接线外接4.7kΩ上拉OLED不亮地址不对或初始化失败确认地址0x3C检查初始化序列心率数值为0手指未放置或LED电流太小调整手指位置增大LED_PA值血氧数值异常红光红外信号不匹配检查FIFO读取逻辑确认两通道同步数据偶尔丢失I2C速率过高或线太长降低速率到100kHz缩短杜邦线屏幕刷新慢show()调用太频繁降低刷新率到1Hz左右6. 功能扩展与进阶玩法6.1 加入蓝牙数据传输ESP32的蓝牙和WiFi可以同时使用这是它比ESP8266强的地方。用MicroPython的bluetooth库可以创建一个BLE服务把心率血氧数据广播出去。手机端用nRF Connect之类的通用BLE调试工具就能看到数据。代码框架大概是import bluetooth ble bluetooth.BLE() ble.active(True)然后定义服务和特征值把心率数据写到特征值里。这部分代码稍长但MicroPython官方文档里有完整的示例照着改就行。注意BLE和WiFi同时工作时功耗会增加如果用电池供电续航会明显缩短。6.2 数据记录与历史趋势想记录历史数据的话可以加一个MicroSD卡模块通过SPI接口连接。每隔一段时间把心率血氧数据写入CSV文件。或者利用ESP32的WiFi功能把数据POST到本地服务器上。我试过用Flask写一个简单的接收端ESP32用urequests库发送HTTP请求数据存到SQLite数据库里然后用网页展示趋势图。这套方案适合做长期监测。6.3 外壳制作与佩戴优化裸板用起来不方便可以3D打印一个外壳。设计要点传感器窗口要留出指尖大小的开口OLED屏幕开窗要精确ESP32的USB接口要留出充电口。如果不想3D打印用亚克力板加铜柱也能搭一个简易外壳。佩戴方式我试过两种一种是做成指夹式用弹簧夹住手指另一种是做成腕带式传感器贴在手腕内侧。指夹式的信号质量更好因为指尖的毛细血管更丰富。我在实际使用中发现MAX30102对佩戴位置非常敏感。同一根手指按在传感器中心偏左和偏右信号幅度能差一倍。所以外壳设计时最好让传感器有一个轻微的凸起帮助手指自然定位到最佳位置。另外环境温度低的时候手指血液循环变差信号也会变弱冬天在室外用的话最好先搓搓手。
返回列表