測實戰(zhàn):從接線到MicroPython算法調(diào)試)
血氧和心率監(jiān)測這件事幾年前還是醫(yī)院監(jiān)護儀上的專屬功能現(xiàn)在一塊十幾塊錢的MAX30102模塊加上一塊ESP32開發(fā)板就能搞定。我最近拿這套組合做了個能同時測血氧、心率和體溫的小設(shè)備從接線到跑通MicroPython代碼前后花了不到一個下午。這篇文章就把整個過程中的關(guān)鍵細節(jié)、踩過的坑和調(diào)試經(jīng)驗完整分享出來適合剛接觸ESP32的零基礎(chǔ)玩家也適合想快速驗證MAX30102模塊是否好用的朋友。1. 為什么選MAX30102加ESP32這套組合1.1 血氧監(jiān)測的物理原理其實不復(fù)雜MAX30102測血氧的核心原理是光電容積脈搏波描記法PPG。模塊內(nèi)部有兩顆LED一顆發(fā)紅光660nm一顆發(fā)紅外光880nm還有一只光電二極管負責(zé)接收反射回來的光。含氧血紅蛋白和脫氧血紅蛋白對這兩種波長的光吸收率不同通過計算兩路信號的比值就能推算出血氧飽和度。心率檢測則更直接——血液每次搏動時血管里的血容量會周期性變化反射光強度也跟著波動這個波動頻率就是心率。聽起來簡單但實際調(diào)試時會發(fā)現(xiàn)手指按壓力度、環(huán)境光干擾、甚至手指溫度都會影響信號質(zhì)量后面我會專門講怎么處理這些問題。1.2 ESP32在這個項目里扮演什么角色ESP32是一塊集成了WiFi和藍牙的雙核微控制器主頻240MHz自帶多個I2C接口用來驅(qū)動MAX30102綽綽有余。選它而不是Arduino Uno的原因有三個一是ESP32的I2C時鐘可以靈活配置MAX30102在400kHz下工作最穩(wěn)定二是ESP32有硬件浮點運算單元處理血氧算法時不用像8位單片機那樣擔(dān)心算力不夠三是后續(xù)如果想加OLED顯示或者把數(shù)據(jù)傳到手機ESP32的無線能力直接就能用上不用額外加模塊。1.3 溫度檢測為什么單獨用DS18B20MAX30102內(nèi)部其實有一個溫度傳感器但它的精度只有±1°C而且測的是模塊自身溫度不是人體溫度。手指按在模塊上時模塊溫度會逐漸趨近體溫但這個過程很慢通常需要幾分鐘才能穩(wěn)定。所以我在項目里額外加了一顆DS18B20數(shù)字溫度傳感器精度±0.5°C配合金屬探頭可以直接貼在皮膚表面測體表溫度。如果你只想測環(huán)境溫度DS18B20也完全夠用。2. 硬件接線與供電的那些細節(jié)2.1 模塊引腳定義和接線方案MAX30102模塊通常引出6個引腳VIN、GND、SCL、SDA、INT、IRD。實際使用只需要接前四個INT是中斷輸出引腳IRD是LED驅(qū)動引腳一般懸空即可。DS18B20有三根線VCC、GND、DQ。具體接線如下表ESP32引腳MAX30102DS18B20說明3.3VVINVCC統(tǒng)一3.3V供電GNDGNDGND共地GPIO21SDA—I2C數(shù)據(jù)線GPIO22SCL—I2C時鐘線GPIO4—DQ單總線數(shù)據(jù)—INT—懸空—IRD—懸空注意MAX30102模塊的VIN雖然標(biāo)稱支持1.8V到5V但實測用5V供電時I2C電平也是5V直接接ESP32的3.3V引腳會損壞芯片。所以務(wù)必用3.3V供電或者加電平轉(zhuǎn)換模塊。2.2 上拉電阻不能省I2C總線上必須接上拉電阻典型值是4.7kΩ。很多MAX30102模塊板上已經(jīng)集成了上拉電阻但DS18B20的DQ線通常需要自己加一個4.7kΩ上拉到3.3V。我一開始偷懶沒接結(jié)果DS18B20讀數(shù)一直是85°C這是芯片的默認上電值表示通信失敗。加上電阻后立刻正常。2.3 電源噪聲對血氧讀數(shù)的影響MAX30102對電源噪聲非常敏感。我用USB線直接從電腦供電時血氧讀數(shù)跳動很大后來在VIN和GND之間并聯(lián)了一個10μF電解電容加一個0.1μF陶瓷電容讀數(shù)穩(wěn)定性明顯改善。如果你用電池供電這個問題會小很多但濾波電容還是建議加上。3. MicroPython環(huán)境搭建與庫的選擇3.1 燒錄MicroPython固件ESP32刷MicroPython的步驟不復(fù)雜但有幾個細節(jié)容易出錯。首先去MicroPython官網(wǎng)下載對應(yīng)ESP32型號的固件注意要選帶SPIRAM的版本還是普通版本——如果你用的是ESP32-WROOM-32選普通版就行如果是ESP32-WROVER選帶SPIRAM的版本。燒錄工具我用的是esptool命令行操作esptool.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下端口號通常是COM3或COM4在設(shè)備管理器里能看到。燒錄時如果報“Failed to connect”按住開發(fā)板上的BOOT鍵再點燒錄進入下載模式就好了。3.2 MAX30102的MicroPython驅(qū)動MicroPython官方庫里面沒有MAX30102的驅(qū)動需要自己找。我試過好幾個版本最后用的是GitHub上一位開發(fā)者寫的max30102.py它把寄存器操作封裝得比較完整。核心初始化代碼如下from machine import I2C, Pin import max30102 i2c I2C(0, sclPin(22), sdaPin(21), freq400000) sensor max30102.MAX30102(i2c) sensor.setup_sensor() sensor.set_sample_rate(100) sensor.set_led_pulse_width(411) sensor.set_adc_range(4096) sensor.set_led_current(0x24, 0x24)這里幾個參數(shù)需要解釋一下。采樣率設(shè)100Hz是血氧計算的常用值太低會丟失脈搏波細節(jié)太高則數(shù)據(jù)量太大。LED脈沖寬度411μs對應(yīng)18位ADC分辨率這個值在手指按壓穩(wěn)定時能提供足夠的信噪比。LED電流我設(shè)的是0x24大約7.2mA如果你手指比較厚或者環(huán)境光很強可以適當(dāng)調(diào)高到0x3F約12mA。3.3 DS18B20的驅(qū)動DS18B20用MicroPython的onewire和ds18x20庫就能驅(qū)動import onewire, ds18x20 from machine import Pin ow onewire.OneWire(Pin(4)) ds ds18x20.DS18X20(ow) roms ds.scan() ds.convert_temp() time.sleep_ms(750) for rom in roms: print(ds.read_temp(rom))注意convert_temp()之后必須等至少750ms才能讀溫度這是DS18B20的轉(zhuǎn)換時間決定的。12位精度下就是750ms如果你改成9位精度可以縮短到93.75ms但精度會降到0.5°C。4. 血氧和心率算法的實現(xiàn)邏輯4.1 原始數(shù)據(jù)的預(yù)處理MAX30102輸出的原始數(shù)據(jù)包含紅光和紅外兩路ADC值直接拿來算血氧會跳得很厲害。我采用滑動窗口平均濾波窗口大小設(shè)50個樣本100Hz采樣率下相當(dāng)于0.5秒。代碼邏輯是維護一個長度為50的隊列每次新數(shù)據(jù)進來就踢掉最舊的然后取平均值。from collections import deque red_buffer deque((), 50) ir_buffer deque((), 50) def update_buffers(red, ir): red_buffer.append(red) ir_buffer.append(ir) return sum(red_buffer)/len(red_buffer), sum(ir_buffer)/len(ir_buffer)4.2 心率計算峰值檢測法心率計算我用的是峰值檢測法。先對紅外信號做差分找到上升沿和下降沿然后計算兩個相鄰峰值之間的樣本數(shù)換算成時間就能得到心率。def calculate_heart_rate(ir_samples, sample_rate100): peaks [] threshold (max(ir_samples) - min(ir_samples)) * 0.6 min(ir_samples) for i in range(1, len(ir_samples)-1): if ir_samples[i] threshold and ir_samples[i] ir_samples[i-1] and ir_samples[i] ir_samples[i1]: if len(peaks) 0 or (i - peaks[-1]) sample_rate * 0.3: peaks.append(i) if len(peaks) 2: return 0 intervals [(peaks[i1] - peaks[i]) / sample_rate for i in range(len(peaks)-1)] avg_interval sum(intervals) / len(intervals) return 60 / avg_interval這里有個細節(jié)閾值設(shè)的是信號幅度的60%這個值是我反復(fù)試驗后確定的。設(shè)太低會把噪聲當(dāng)成峰值設(shè)太高會漏掉弱峰。另外加了0.3秒的最小間隔限制因為正常人不會在0.3秒內(nèi)連續(xù)心跳兩次。4.3 血氧計算R值和查表法血氧飽和度的計算公式是SpO2 110 - 25 × R其中R (紅光交流分量/紅光直流分量) / (紅外交流分量/紅外直流分量)。交流分量就是信號波動的峰峰值直流分量是平均值。但這個公式只是近似實際血氧儀用的是查表法。我參考了MAX30102官方應(yīng)用筆記里的查找表把R值映射到SpO2值。實測下來手指按壓穩(wěn)定時讀數(shù)在95%到99%之間和醫(yī)用血氧儀對比誤差在2%以內(nèi)作為日常參考完全夠用。提示血氧讀數(shù)低于90%時不要慌先檢查手指有沒有按偏、有沒有涂指甲油、環(huán)境光是不是太強。這些因素都會導(dǎo)致讀數(shù)偏低。5. 溫度檢測的精度優(yōu)化5.1 DS18B20的讀數(shù)穩(wěn)定性處理DS18B20雖然精度標(biāo)稱±0.5°C但實際讀數(shù)會有±0.2°C左右的跳動。我的處理方法是連續(xù)讀5次去掉最高和最低值剩下3個取平均。這樣處理后讀數(shù)基本能穩(wěn)定在±0.1°C以內(nèi)。def read_temp_stable(ds, rom): temps [] for _ in range(5): ds.convert_temp() time.sleep_ms(750) temps.append(ds.read_temp(rom)) temps.sort() return sum(temps[1:4]) / 35.2 體表溫度與核心溫度的差異這里要特別說明DS18B20測的是體表溫度不是核心體溫。手指表面的溫度通常比腋下溫度低1到2°C比口腔溫度低2到3°C。所以如果你用這個設(shè)備測手指溫度讀數(shù)32°C到34°C都是正常的不要以為傳感器壞了。想測更接近核心溫度的數(shù)據(jù)可以把探頭夾在腋下或者貼在額頭持續(xù)測量幾分鐘。5.3 溫度補償對血氧的影響手指溫度會影響血液循環(huán)進而影響PPG信號質(zhì)量。我發(fā)現(xiàn)在手指冰涼時血氧讀數(shù)會偏低3%到5%。解決辦法很簡單測量前搓搓手或者把手指在模塊上多放一會兒等溫度讀數(shù)穩(wěn)定了再采血氧數(shù)據(jù)。6. 實測中遇到的坑和解決辦法6.1 I2C通信失敗地址掃描是第一步MAX30102的I2C地址是0x577位地址。如果初始化時報“Device not found”先用下面這段代碼掃描總線i2c I2C(0, sclPin(22), sdaPin(21), freq100000) print(i2c.scan())正常應(yīng)該輸出[87]0x57的十進制。如果輸出空列表檢查接線如果輸出其他地址說明模塊可能不是MAX30102或者板上有其他I2C器件。6.2 血氧讀數(shù)一直是0或者100這種情況通常是LED電流設(shè)得太低或者太高。太低時信號淹沒在噪聲里太高時信號飽和。我的經(jīng)驗是從0x24開始試如果讀數(shù)異常就調(diào)到0x1F或0x2F再試。另外檢查手指有沒有完全覆蓋模塊的感光窗口漏光會導(dǎo)致讀數(shù)異常。6.3 DS18B20讀數(shù)85°C前面提過85°C是DS18B20的上電默認值表示通信失敗。原因通常是上拉電阻沒接、接線松動或者供電不足。用萬用表量一下DQ線對GND的電壓正常應(yīng)該是3.3V左右。如果只有1V多說明上拉電阻沒起作用。6.4 心率讀數(shù)跳動大心率跳動超過±10次/分鐘時先檢查手指有沒有移動。PPG信號對運動非常敏感哪怕輕微抖動都會造成基線漂移。我的做法是讓測試者把手放在桌面上保持靜止30秒后再開始讀數(shù)。軟件上可以加一個中值濾波連續(xù)5次讀數(shù)取中間值能有效平滑跳動。7. 把數(shù)據(jù)用起來顯示與傳輸7.1 OLED本地顯示加一塊0.96寸OLEDSSD1306驅(qū)動就能本地顯示數(shù)據(jù)I2C地址通常是0x3C和MAX30102共用一條總線沒問題。顯示內(nèi)容我排了三行第一行血氧第二行心率第三行溫度。刷新率設(shè)2Hz就夠了太快反而看不清。import ssd1306 oled ssd1306.SSD1306_I2C(128, 64, i2c) oled.text(SpO2: {}%.format(spo2), 0, 0) oled.text(HR: {} bpm.format(hr), 0, 20) oled.text(Temp: {:.1f}C.format(temp), 0, 40) oled.show()7.2 通過WiFi上傳數(shù)據(jù)ESP32的WiFi功能可以把數(shù)據(jù)傳到本地服務(wù)器或者手機App。我用的是最簡單的HTTP POST方式每5秒發(fā)一次數(shù)據(jù)到局域網(wǎng)內(nèi)的樹莓派上。如果你不想搭服務(wù)器也可以用MQTT協(xié)議發(fā)到公共測試服務(wù)器但注意不要傳敏感數(shù)據(jù)。import urequests data {spo2: spo2, hr: hr, temp: temp} urequests.post(http://192.168.1.100:5000/data, jsondata)7.3 藍牙串口傳輸如果只是想在手機上看數(shù)據(jù)用ESP32的藍牙串口更簡單。MicroPython內(nèi)置了ubluetooth庫配對后手機端用任意串口App就能接收。不過藍牙和WiFi同時開會有干擾建議二選一。8. 外殼與佩戴方式的經(jīng)驗裸板測試沒問題之后我給它做了個簡單的外殼。用3D打印了一個指夾式結(jié)構(gòu)MAX30102嵌在夾子內(nèi)側(cè)DS18B20的金屬探頭貼在夾子外側(cè)。這樣手指伸進去就能同時測血氧和溫度比用手按著穩(wěn)定得多。如果你沒有3D打印機用熱縮管把模塊包起來也行但注意不要把感光窗口遮住。我試過用黑色電工膠布包裹模塊側(cè)面來遮擋環(huán)境光效果不錯但膠布不能蓋住LED和光電二極管的正上方。供電方面我用了一塊1000mAh的鋰電池加TP4056充電模塊充滿電可以連續(xù)工作6到8小時。如果只是桌面使用直接插USB更方便。9. 精度驗證與使用建議9.1 和醫(yī)用血氧儀對比我拿自己的設(shè)備和指夾式醫(yī)用血氧儀做了對比測試同一根手指交替測量10次。結(jié)果顯示血氧誤差在±2%以內(nèi)心率誤差在±3次/分鐘以內(nèi)。這個精度對于日常健康監(jiān)測足夠了但不能作為醫(yī)療診斷依據(jù)。9.2 什么情況下讀數(shù)不可靠手指冰涼血氧偏低建議暖手后重測涂了指甲油尤其是深色指甲油會嚴(yán)重干擾光路手指移動心率和血氧都會跳強環(huán)境光陽光直射或強臺燈下讀數(shù)會漂移手指太細或太粗感光窗口沒有完全覆蓋9.3 長期監(jiān)測的注意事項如果你打算長時間佩戴監(jiān)測注意模塊發(fā)熱。MAX30102的LED長時間點亮?xí)休p微發(fā)熱雖然不至于燙傷但會影響溫度讀數(shù)。我的做法是每測量30秒就休眠10秒既省電又減少發(fā)熱。這個項目從零到跑通大概花了4個小時其中一半時間花在調(diào)試血氧算法上。MAX30102這個模塊硬件本身很成熟難點在于軟件濾波和參數(shù)調(diào)優(yōu)。希望這篇分享能幫你少走些彎路如果你在調(diào)試中遇到其他問題歡迎一起交流。