技術開発部の原田です。新型コロナウィルス対策として在宅からの勤務を続けています。
適度に身体を動かさないと体力落ちますよね。ということでM5Stackを引っ張り出して今回は「応援してくれるエクササイズマシン」作りに挑戦です。
TL;DR ※まとめです
- M5Stack(Gray)+オムロン絶対圧センサー2SMPB-02Eで階段登りをセンシングした
- M5Stackのスピーカーで3,3,7拍子を流し、ユーザのやる気を維持した
- 通報/職務質問されても責任は持てません笑
動かしてみた
まず結論から。割と楽しいw
自宅の階段を登っている動画です。1階から7階まで上がっていく過程で、最初の高度から1.8メートル上がる度に3,3,7拍子の一節が流れます笑
※音が流れます。
はじめに
私は新型コロナウィルス対策として在宅勤務をしていますが、如何せん何もしないと運動不足になります。
特に足周りの筋肉の衰えについて対策を打ち出したいところです。
毎日散歩するのを考えましたが、感染リスクを考えてできるだけ不特定多数の人とすれ違いたくない。
であれば自宅マンションの階段登りをしたら良いのではという結論になりました。
階段を登ったことを検知するには何がいいかな?と考えましたが、これは「気圧の変化を検知する」ことにしました。
散歩にするのであればGPSは横の動きを把握できるので有効です。しかし今回は縦の動きなのでGPSで測っても意味がありません。GPSは没。
階段を一段登る動きを加速度センサーで検知する方法も考えました。ただ、これも没にしました。
加速度センサーの取り付け位置次第では動きの検出が難しいためです。階段の一段がいつも同じ高さであるわけでは無いので閾値なども設定しにくいですし。
というわけでいろいろ考慮した結果、現地点の高度がわかる高度計(気圧計と温度計から算出する)で検知することにしました。
ここで、アプリケーションとしてマイコンからの出力も考えました。
どうせするなら記録が出来て、かつやる気にさせてくれる要素があるといいじゃないですか。
状態管理、やる気を引き出すようななにか出力 --- 音声ですかね。音声にしましょう。
入力:高度計、出力:音声
そこで取り出すのがモバイル端末・・・ではなく、M5Stackというマイコンです。
今回は高度計ですが、マイコンに簡単に接続できるものを使います。
高精度に気圧と温度を測定出来る、Omronの絶対圧センサー2SMPB-02Eです。
マイコンとはGroveで接続するだけでOKです。ハードウェアのことを考えずに組めるのでめっちゃ楽です。
イメージはこちら。
用意した機材
- M5Stack Gray(左)
ESP32が搭載された画面付きのArduinoマイコンです。
拡張ボードを板状に積み重ねることで機能を追加可能にしています。今回は赤色のバッテリーパックを付属させました。
通信機能はWiFi/Bluetoothが使えます。3つのボタンによるユーザ操作受付、micoSDカードスロット搭載、スピーカー搭載、Grove入出力、GPIOピンetc..。
さらにGrayモデルは9軸センサーが付いており、今回は使いませんが加速度/ジャイロ/地磁気も取得可能です。
GroveやGPIO経由でセンサーへのアクセスが出来るので、Grove接続で絶対圧センサーを用います。
なお今回の開発環境はPlatformIOを用いました。 - 絶対圧センサー2SMPB-02E(右)
気温と気圧を測定できるセンサーです。気圧の計測範囲は300-1100[hPa]、±0.5[hPa]と高精度に測定できることが特長です。大気圧を測定する目的のセンサーです。
Grove形式で線を出せるようになっているため、M5StickCへの接続が簡単。ブレッドボードすら不要です。
ソフトウェアエンジニアに優しいですね。
システム構成
いつもならばシステム構成図を描くところですが、通信機能不使用、マイコン単体使用なので書きません。
単純に絶対圧センサー+マイコンの構成です。
以下の数式で高度を算出しています。出典h:現在地の標高[m]
P0 : 海面気圧[Pa]、1013.25[hPa]としてここでは固定
P:測定地点の気圧[Pa]
T:測定地点の気温[℃]
記事のカテゴリはIoT(Internet of Things)じゃあないの?Internetの要素は?と気にしてはいけません。気にしたら・・・負けです笑 (考察部分で追記
システム構築手順
システム構築の手順も省略です。
ArduinoIDE, またはVSCodeのPlatformIOを使ってそこで以下のinoファイルを実装しましょう。
私はVSCodeのPlatformIOからボード設定(M5Stack Grey)及び各ライブラリを導入しました。
※Omronのライブラリのみ手動で導入
通信機能を使っておらず、つなぎ合わせる相手も無し。すっきり!
ライブラリはソースコードにも書いていますが、以下のものを使用しています。
マイコン(M5Stack)本体 : github
絶対圧センサーライブラリ : github
計算用ライブラリ(QuickStats):github
M5Gray-Atmosphere-Exercise.ino
#include <M5Stack.h> #include <QuickStats.h> #include <TimeLib.h> #include <bcconfig.h> #include <Omron2SMPB02E.h> #include <BigNumber.h> #include <number.h> // Omron Library https://github.com/akita11/Omron2SMPB02E Omron2SMPB02E prs(1); // QuickStats Library https://github.com/dndubins/QuickStats QuickStats stats; // Constants #define STATUS_NOT_YET_STARTED 1 #define STATUS_RESETING 2 #define STATUS_STARTED 3 #define STATUS_FINISHED 4 const uint16_t samplingRate = 3000; const float bandHeight = 1.8; // meters // state values String elapsedTimeString = ""; float finishedHeight = 0.0; unsigned long resetTime = 0; int soundLevel = 0; float startedHeight = 0.0; int status = STATUS_NOT_YET_STARTED; void resetValues(){ // Reset state values elapsedTimeString = ""; finishedHeight = 0.0; resetTime = millis(); soundLevel = 0; // showReseting method resets startedHeight. See showReseting method. status = STATUS_STARTED; } float calcHeight(float p, float t) { // See also https://keisan.casio.jp/exec/system/1257609530 return (pow((1013.25/(p/100)), 1.0/5.257) - 1 )*(t + 273.15)/0.0065; } float getHeight() { float temparature = prs.read_temp(); float pressure = prs.read_pressure(); // Serial.printf("Temp[degC]:%f\r\n",temparature); // Serial.printf("Press[hPa]:%f\r\n",pressure/100); return calcHeight(pressure, temparature); } float getAveragedHeight(){ float heightList[10]; for(int idx = 0; idx < 10; idx++ ){ heightList[idx] = getHeight(); } return stats.average(heightList,10); } void setElapsedTime(int h, int m, int s){ elapsedTimeString = ""; elapsedTimeString.concat(String(h)); elapsedTimeString.concat(":"); elapsedTimeString.concat(String(m)); elapsedTimeString.concat(":"); elapsedTimeString.concat(String(s)); } void makeBeepSound(uint8_t* soundData, int frequency, int num){ for(int idx = 0; idx < num; idx++){ double theta = 2.0 * PI * frequency * idx / samplingRate; soundData[idx] = 128 + 128 * 0.8 * sin(theta); } } void playBeepSound(int freqency, int duration){ int num = duration * samplingRate / 1000; uint8_t soundData[num]; makeBeepSound(soundData, freqency, num); M5.Speaker.playMusic(soundData, samplingRate); } // showXXX must contain contents to show and delay() void showNotYetStarted(){ Serial.println("STATSUS is NOT_YET_STARTED"); showFooter1(); M5.Lcd.setTextColor(RED); M5.Lcd.drawCentreString("--- Climbing Staires ---", 160, 120, 1); M5.Lcd.drawCentreString("Left Button -> START", 160, 180, 1); delay(100); } void showReseting(){ Serial.println("STATUS is RESETING"); M5.Lcd.setTextColor(YELLOW); M5.Lcd.drawCentreString("READY?", 160, 120, 1); // Reset startedHeight here. float height = getAveragedHeight(); startedHeight = height; delay(1000); M5.Lcd.clear(); M5.Lcd.drawCentreString("3", 160, 120, 1); delay(1000); M5.Lcd.clear(); M5.Lcd.drawCentreString("2", 160, 120, 1); delay(1000); M5.Lcd.clear(); M5.Lcd.drawCentreString("1", 160, 120, 1); delay(1000); M5.Lcd.clear(); M5.Lcd.drawCentreString("Go!!", 160, 120, 1); delay(1000); // Change RESETING to STARTED automatically. resetValues(); } // Play Beep Sound if needed. void evalHeight(float latestHeight){ float nextHeight = startedHeight + (soundLevel + 1) * bandHeight; if(latestHeight > nextHeight){ if(soundLevel % 3 == 2){ playBeepSound(600, 100); delay(280); playBeepSound(600, 100); delay(280); playBeepSound(600, 100); delay(280); playBeepSound(600, 100); delay(280); playBeepSound(600, 100); delay(280); playBeepSound(600, 100); delay(280); playBeepSound(600, 100); delay(280); } else { playBeepSound(600, 100); delay(280); playBeepSound(600, 100); delay(280); playBeepSound(600, 100); delay(280); } soundLevel += 1; } } void setFinishedHeight(float h){ finishedHeight = h; } void showStarted(){ Serial.println("STATUS is STARTED"); showFooter2(); M5.Lcd.setTextColor(RED); M5.Lcd.drawCentreString("--- KEEP GOING! ---", 160, 10, 1); M5.Lcd.setTextColor(LIGHTGREY); M5.Lcd.setCursor(0, 40); setTime((millis() - resetTime)/1000); setElapsedTime(hour(),minute(), second()); M5.Lcd.print("Elapsed Time :"); M5.Lcd.println(elapsedTimeString); M5.Lcd.printf("Started_H[ m]:%f\r\n",startedHeight); float height = getAveragedHeight(); setFinishedHeight(height); M5.Lcd.setTextColor(YELLOW); M5.Lcd.printf("NOW_Height[m]:%f\r\n",finishedHeight); // Eval diff height. float diffHeight = height - startedHeight; if(diffHeight > 0){ M5.Lcd.setTextColor(GREEN); M5.Lcd.printf("DIFF_H [ m]:%f\r\n",diffHeight); evalHeight(height); } else { M5.Lcd.setTextColor(BLUE); M5.Lcd.printf("DIFF_H [ m]:%f\r\n",diffHeight); } delay(500); } void showFinished(){ Serial.println("STATUS is FINISHED"); showFooter3(); M5.Lcd.setTextColor(RED); M5.Lcd.drawCentreString("--- GOOD JOB! ---", 160, 10, 1); M5.Lcd.setTextColor(YELLOW); M5.Lcd.setCursor(0, 40); M5.Lcd.printf("Elapsed Time :"); M5.Lcd.println(elapsedTimeString); M5.Lcd.printf("Finished_H[m]:%f\r\n",finishedHeight); M5.Lcd.printf("DIFF_H [m]:%f\r\n",finishedHeight - startedHeight); M5.Lcd.setTextColor(RED); M5.Lcd.drawCentreString("--- STAY HEALTHY! ---", 160, 100, 1); delay(500); } void showUnknownStatus(){ Serial.println("[E]UNKNOWN STATUS"); delay(10000); } void showFooter1(){ M5.Lcd.setTextColor(WHITE); M5.Lcd.drawCentreString("START", 80, 220, 1); } void showFooter2(){ M5.Lcd.setTextColor(WHITE); M5.Lcd.drawCentreString("RESTART", 60, 220, 1); M5.Lcd.drawCentreString("FINISH", 260, 220, 1); } void showFooter3(){ M5.Lcd.setTextColor(WHITE); M5.Lcd.drawCentreString("RESTART", 60, 220, 1); } // buttonXPressed must contain changing status/values and Serial message. void buttonAPressed(){ Serial.println("A is pressed......"); switch (status) { case STATUS_NOT_YET_STARTED: case STATUS_STARTED: case STATUS_FINISHED: playBeepSound(1000,500); status = STATUS_RESETING; break; default: // Nothing to do. break; } } void buttonBPressed(){ Serial.println("B is pressed......"); // Nothing to do. } void buttonCPressed(){ Serial.println("C is pressed......"); switch (status) { case STATUS_STARTED: playBeepSound(1000,500); status = STATUS_FINISHED; break; default: // Nothing to do. break; } } // Setup M5, Sensor, default values. void setup() { M5.begin(true, true, true, true); M5.Power.begin(); prs.begin(); Serial.begin(115200); prs.set_mode(MODE_NORMAL); M5.Speaker.setVolume(1); status = STATUS_NOT_YET_STARTED; M5.Lcd.clear(BLACK); M5.Lcd.setCursor(0, 0); M5.Lcd.setTextFont(1); M5.Lcd.setTextSize(2); M5.update(); } // Main Code. void loop() { // update button state M5.update(); M5.Lcd.clear(); M5.Lcd.setCursor(0, 0); // Press Button -> Change State. if(M5.BtnA.isPressed()){ buttonAPressed(); } if(M5.BtnB.isPressed()){ buttonBPressed(); } if(M5.BtnC.isPressed()){ buttonCPressed(); } // Show Contents. switch (status) { case STATUS_NOT_YET_STARTED: showNotYetStarted(); break; case STATUS_RESETING: showReseting(); break; case STATUS_STARTED: showStarted(); break; case STATUS_FINISHED: showFinished(); break; default: showUnknownStatus(); break; } }
ふう。お疲れ様でした。
これでひとまず完了です。後は冒頭の動画のように高度をずっと測定して、途中3,3,7拍子で応援してくれますw
仕組み上の課題
取り組んでみてわかった、システムにおける課題です。PoCであればこれには言及しなければ(使命感
機材面:モバイル端末は使えないか?
結論から書けば、気温データ取得のために通信機能を使うことになるので、電池消費の面からモバイル端末の使用を避けたことになります。
モバイル端末としてiPhoneを例にとって考えましょう。
iPhoneの標準アプリで「コンパス」があります。
これを開くと通常位置情報の他、端末の高度が表示されます。画像左
しかし、データ通信なしにすると、端末の高度が表示されなくなります。画像右なぜ高度が表示されなくなるのでしょうか。
この理由は非常にシンプルで、iPhoneでは気温データを外部からデータ通信を用いて取得しているためです。
iPhoneには気圧センサーはあるものの、気温センサーは搭載されていません。
先に述べたように高度計算に必要なデータは気圧と気温です。
気圧は端末に搭載されたセンサーから得られるものの、気温データは周辺地域の値をサーバから取得する仕様になっています。
そのため、データ通信をOFFにすると、高度計算に必要な気温データが欠損して計算不能になる、結果アプリは高度を表記しないようになっているのです。
モバイル端末で通信機能を使うと、それだけ電池を消費します。
測定したい回数分だけ気温を測定すると通信回数が増えます。
そうなると電池消費が激しくなると考えたために今回は没にしました。
どこでも携帯回線網が使えて当たり前の世の中にはなってきてはいますが、
最小限必要なハードウェアはなにかを考えれば、無駄のない製品ができるかもしれませんね。
※推測かつ余談ですが、モバイル端末に温度計が付いていないのは、基板の発熱を検知してしまい、周囲の気温を測定するのが難しいためと思われます。温度計だけが端末外に出たモバイル端末、あったら見てみたいですねw
機材面:気圧のセンサーの精度や代替は?
OmronのGrove対応絶対圧センサー2SMPB-02Eの圧力精度は±0.5[hPa]と優秀です。
低価格(スイッチサイエンスで1,650円ただし現在は在庫切れ状態)ですがこれは良い精度です。
小型かつ低価格の製品の中では、他にBosch社のBME280が挙げられます。こちらの圧力精度は±1[hPa]です。
ただ、この精度が問題です。1[hPa]の違いは気圧の計算に大きく影響します。
気圧1009.65[hPa],気温21[℃] → 高度30.65[m]
気圧1010.65[hPa],気温21[℃] → 高度22.12[m] ※約8メートルの誤差!
※例として[1hPa]ズレた場合の影響を示しましたが、Boschのセンサーの測定が必ず1[hPa]ズレるわけではありません。念の為。
±0.5[hPa]の精度で不十分ならば、ソフトウェア的な解決(ソースコード参照、10回測定しての平均値にしている)をしましょう。
それでも駄目なら、ハードウェアとしての解決 --- より高精度の別の製品を扱えば良いでしょう。
しかし、精度の良いものはそれだけ高くつくわけです。数万円のオーダーにはなかなか手を出しにくいですね。
高度計の場合は気温計のセンサー精度も関わってきますので、自分で高度計を作る場合はその点も複合的に考える必要がありますね。
外乱に弱い(気圧と気温の変化)
このマイコンでの高度計算は、気圧と気温で行っている都合上、気圧と気温の僅かな変化に影響されることを理解しておかなければなりません。
日中に気温が上昇するのは想像に易いですが、同様に気圧も一日の中で変化します。
地表からの太陽光反射熱による局所的なゆらぎがあったり、天気の大きなうねり(低気圧が近づいているなど)があるためです。
これによる気圧と気温の変化は、高度計算に効いてきます。
事実、マイコンを日中ずっと同じ高度に置いていたとしても、気圧の変化によって算出される高度が少しずつ変化していきます。
朝方登山を開始したときの高度と、夕方同じ地点に戻ってきたときの高度が違うのはそのためです。
単体では絶対的に信頼できる高度計にはならないので注意が必要です。
なお、階段登り程度のユースケースであれば、時間的には天気の大きなうねりによる変化はほぼ無いため、高度の差分を出す程度であれば単体のマイコンでも十分でしょう。
※これも余談ですが、数式的には高度が分かっている地点で同様の測定を行うことでP0を計算、そのP0値を用いることで気圧変化の補正が効いた高度計にはなりそうです。
その他考察
M5シリーズのマイコンはPoCにすごく便利
割と何回もブログでは書いているのですが、IoTプロジェクトを推進したい場合は、まずM5Stackの使用が便利です。
前回の記事でもUIFlowを紹介していることからもわかるかと思いますが、アイデアを即座に実現できるという意味でソフトウェアエンジニアでも簡単にマイコンを扱えるようになっています。
今回はGrove接続のセンサーを用いていますが、通常のGPIOもM5Stackには用意されています。
Groveでなくても、センサーをGPIOポートに接続して実現すると良いでしょう。
また、ボタンも液晶も付いているので、センサーを使った簡易なゲーム、というのも実現可能です。
粗々だけど即座に実現して、使い勝手をテストしたい場合にはうってつけの製品になります。
今回は使いませんでしたが、MicroSDカードの差込口があり、その中の画像や音声ファイルも使用可能です。
新企画として製品をいきなり作り始めるのではなく、inはなにか、outはなにかを考えて仮説を数多く試せるハードウェアで実現する。
製品にするときにはそれまでのPoCで得られた知見を踏まえて新しく基板から作るのが良いと考えます。
センサーデータを送信するには?
リッチなデータ可視化サービス上でセンサーデータを閲覧したいというのはよくある要望です。
そうなるとデータを適宜マイコンから外に出す、つまり通信機能を使うことになります。
M5StackにはESP32が搭載されているため、WiFiとBluetoothが標準で使えます。
WiFi接続可能な環境やBluetoothでの送信先があるならば、それを用いると良いです。
また、M5StackはオプションでSIMカードを指せるようにすることも可能です。
外出先かつ単体で通信機能を用いたいという場合は、SIMカードを指して携帯回線網を使用すると良いでしょう。
WiFi/Bluetooth/携帯回線網を選べるため、より実際の環境でマイコンを使えるのかというテストもしやすくなりますね。
ここまで読んでいただきありがとうございました。
オチ
自宅のマンションでこれを使って階段登りをしていたところ、娘の同級生(小学生)に見つかり絡まれました。
音がするのが楽しいのか、子供たちが階段登りに付いてきたので、集中してトレーニングにならないでやんの笑
消音モードを作るか・・・w
おしまい。
※この記事に載せた機材は全て私物です。