M5Stack Gray

# はじめに
よくある３本線の心拍センサだと、振幅がイマイチと感じていたので、MAX30102センサ搭載に切り替えました。
Arduino Unoでは問題なく動作しましたが、ESP32-C3で知識不足で動かせなかったので、７年以上前のM5Stack Grayを引っ張り出し、動かしたメモです。



# 環境
MacOS 13.4.1
Arduino 2.2.1
M5Stack Gray(2018.3)(MPU9250)

MAX30102搭載心拍センサ

https://amzn.asia/d/2dX0h68

# M5Stack Gray
https://docs.m5stack.com/en/core/gray
説明や、ドライバがある。

電源オン：左の赤いボタンを押す
電源オフ：左の赤いボタンを２回素早く押す
※USB電源の場合、オフにはできない

完全放電後は、USB指しても、すぐには使えないっぽい。
書き込めないとき、赤いボタン長押しが必要かも。

M5Stack Basic V2.7が最新(ESP32)¥7000
CORE2シリーズ(ESP32-D0WD-V3)¥9000
CORE S3シリーズ(ESP32-S3)¥7000~




# 準備
### ①M5StackのUSBドライバをインストール
（自分の場合はガチャガチャやってたので、もしかしたら不要かも。）
（cu.usbserial-01897F3Dを使っても、大丈夫かも。）
（Windowsならそもそも不要かも。）

CP210x（Silicon Labs 製）

https://www.silabs.com/developers/usb-to-uart-bridge-vcp-drivers

ドライバをいれると```/dev/cu.SLAB_USBtoUART```というポートが追加されます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/510257/f3014b98-7a55-476b-9022-bfd53ef752fa.png)
ドライバを入れる際に、
macOSが警告を出す場合「セキュリティとプライバシー」に「許可」ボタンを押す必要がある。



②Arduinoの環境設定の追加のボードマネージャのURLを追加
```
https://dl.espressif.com/dl/package_esp32_index.json
```
多分この１行の追加で問題ないが、だめそうなら、下記も。
ガチャガチャやったので、どれがどれやら。

```
http://drazzy.com/package_drazzy.com_index.json
https://m5stack.oss-cn-shenzhen.aliyuncs.com/resource/arduino/package_m5stack_index.json
```

下記は、M5Unified用。今回は使わない。
```
https://raw.githubusercontent.com/espressif/arduino-esp32/gh-pages/package_esp32_index.json
```

③Arduinoのボードマネージャからesp32(2.0.14)をインストール
これが一番、ハマった。
最新データではダメ。
chatGPTに教えてもらった。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/510257/7cf4851d-3c0b-4e6f-aeac-24ef0723c412.png)

ボード > M5Stack Arduino → M5Stack-Core-ESP32を指定。
※最新データでは、これが一覧にない。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/510257/a054fcf2-54b0-4d03-8693-9230e92665fc.png)

④ArduinoのライブラリマネージャからM5Stackをインストール
これがまた曲者。新旧の環境で、M5Stack.hが２つ入ってしまい、問題が生じた。
最近のボードであれば、最新のボードマネージャを入れ、M5Unifiedライブラリを入れれば良い。
一旦ライブラリを削除して、まっさらな状態で、下記ライブラリを入れた。
M5Stack by M5Stack
MAX30105 by SparkFun（← MAX30102にも対応）

# プログラム
```cpp
#include <M5Stack.h>
#include "MAX30105.h"

MAX30105 particleSensor;

const int graphHeight = 100;
const int graphBaseY = 160;
const int graphWidth = 320;

int x = 0;
float lastValue = 0;

unsigned long lastBeatTime = 0;
int bpm = 0;
const int AVG_COUNT = 4;
int bpmHistory[AVG_COUNT] = {0};
int bpmIndex = 0;
int avgBpm = 0;

const int scaleWindow = 150;
float irBuffer[scaleWindow];
int bufferIndex = 0;

const int SPO2_WINDOW = 100;
long redBuffer[SPO2_WINDOW];
long irRawBuffer[SPO2_WINDOW];
int spo2Index = 0;

void setup() {
  M5.begin();
  Wire.begin(21, 22);
  M5.Lcd.fillScreen(BLACK);
  M5.Lcd.setTextSize(2);

  if (!particleSensor.begin(Wire, I2C_SPEED_STANDARD)) {
    M5.Lcd.println("MAX30102 not found!");
    while (1);
  }
  particleSensor.setup();
  M5.Lcd.println("Sensor OK");
  M5.Lcd.fillScreen(BLACK);
}

void loop() {
  long irRaw = particleSensor.getIR();
  long redRaw = particleSensor.getRed();
  static float irSmoothed = 0;
  const float alpha = 0.3;
  irSmoothed = alpha * irRaw + (1.0 - alpha) * irSmoothed;

  unsigned long now = millis();

  // グラフ用スケーリング
  irBuffer[bufferIndex] = irSmoothed;
  bufferIndex = (bufferIndex + 1) % scaleWindow;

  float minIR = irBuffer[0], maxIR = irBuffer[0];
  for (int i = 1; i < scaleWindow; i++) {
    if (irBuffer[i] < minIR) minIR = irBuffer[i];
    if (irBuffer[i] > maxIR) maxIR = irBuffer[i];
  }
  if (maxIR - minIR < 1000) maxIR = minIR + 1000;

  float dynamicThreshold = minIR + (maxIR - minIR) * 0.6;

  int y = graphBaseY - map(irSmoothed, minIR, maxIR, -graphHeight / 2, graphHeight / 2);
  y = constrain(y, 0, 240);
  int lastY = graphBaseY - map(lastValue, minIR, maxIR, -graphHeight / 2, graphHeight / 2);
  lastY = constrain(lastY, 0, 240);

  M5.Lcd.drawLine(x, 0, x, 240, BLACK);
  M5.Lcd.drawLine(x - 1, lastY, x, y, GREEN);

  lastValue = irSmoothed;

  // ピーク検出（マーク表示）
  static float prev1 = 0, prev2 = 0;
  static bool isRising = false;
  if (irSmoothed > prev1 && prev1 > prev2) {
    isRising = true;
  }
  if (isRising && prev1 > irSmoothed && prev1 > dynamicThreshold) {
    int peakY = graphBaseY - map(prev1, minIR, maxIR, -graphHeight / 2, graphHeight / 2);
    peakY = constrain(peakY, 0, 240);
    M5.Lcd.fillCircle(x - 1, peakY, 3, RED);
    isRising = false;
  }
  prev2 = prev1;
  prev1 = irSmoothed;

  // BPM計算
  static bool wasLow = true;
  if (irSmoothed > dynamicThreshold && wasLow) {
    unsigned long dt = now - lastBeatTime;
    if (dt > 300) {
      bpm = 60000 / dt;
      lastBeatTime = now;

      bpmHistory[bpmIndex] = bpm;
      bpmIndex = (bpmIndex + 1) % AVG_COUNT;

      int sum = 0;
      for (int i = 0; i < AVG_COUNT; i++) sum += bpmHistory[i];
      avgBpm = sum / AVG_COUNT;
    }
    wasLow = false;
  } else if (irSmoothed < dynamicThreshold) {
    wasLow = true;
  }

  // SpO2計算
  redBuffer[spo2Index] = redRaw;
  irRawBuffer[spo2Index] = irRaw;
  spo2Index = (spo2Index + 1) % SPO2_WINDOW;

  long redMin = redBuffer[0], redMax = redBuffer[0];
  long irMin = irRawBuffer[0], irMax = irRawBuffer[0];
  for (int i = 1; i < SPO2_WINDOW; i++) {
    if (redBuffer[i] < redMin) redMin = redBuffer[i];
    if (redBuffer[i] > redMax) redMax = redBuffer[i];
    if (irRawBuffer[i] < irMin) irMin = irRawBuffer[i];
    if (irRawBuffer[i] > irMax) irMax = irRawBuffer[i];
  }

  float acRed = redMax - redMin;
  float dcRed = redMax;
  float acIr = irMax - irMin;
  float dcIr = irMax;

  float ratio = (acRed / dcRed) / (acIr / dcIr);
  int spo2 = 110 - 25 * ratio;
  spo2 = constrain(spo2, 70, 100);

  // 表示更新
  M5.Lcd.fillRect(0, 0, 160, 40, BLACK);
  M5.Lcd.setCursor(0, 0);
  M5.Lcd.print("BPM: ");
  M5.Lcd.print(avgBpm);
  M5.Lcd.setCursor(0, 20);
  M5.Lcd.print("SpO2: ");
  M5.Lcd.print(spo2);
  M5.Lcd.print(" %");

  x++;
  if (x >= graphWidth) {
    x = 0;
    M5.Lcd.fillScreen(BLACK);
  }

  delay(20);  // 50Hz
}
```

https://qiita.com/hextomino/items/2a8b43d709c5854c6f72

https://qiita.com/hextomino/items/69f9804038041618b243