M5StickC PlusとSwitchBot ボットでエントランスを自動解錠

この記事は[レコチョク Advent Calendar 2022](https://qiita.com/advent-calendar/2022/recochoku)の1日目の記事となります。

https://qiita.com/advent-calendar/2022/recochoku

株式会社レコチョクでAndroidアプリ開発をしている木村です。
普段は音楽に関するサービス開発を行なっていますが、今回は簡単な音解析でちょっとしたライフハックをしてみたのでまとめてみました。

# 背景
- 自分が住んでいるマンションではエントランスで鍵を使ってオートロックを解錠する必要がある
- 毎回物理鍵を鞄から取り出すのがめんどくさいので楽して解錠したい

# 解決方法
今回は3つのIoTデバイスを使ってこの問題を解決してみようと思います

### 1. M5StickC Plus
<img width="40%" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/122574/87c7eca3-d23e-dba0-da05-e33c5880d29d.jpeg">

さまざまなセンサーが内蔵されたIoTツールキットです。
今回はWiFiとマイクを中心に利用します。


### 2. SwitchBot ボット
![switchbotbotsuto-switchbot-howaito-1ge-985390 小.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/122574/924d4848-4496-a813-9ab1-674c232f10c4.jpeg)

SwitchBot社で開発しているスマートスイッチロボットです。
APIが公開されているので簡単に外部から操作できます。

今回は宅内のインターフォンの解錠ボタンを押すために利用します。
こんな感じで設置してみました。

<img width="50%" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/122574/4c4f458c-b58d-0d49-e7ac-c36e2a0b4d1e.jpeg">

### 3. SwitchBot ハブミニ

![100000001005830801_10204_003 小.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/122574/ad2d0131-c93a-245e-8905-48dd96ad3c67.jpeg)

SwitchBot ボットを外部ネットワークから操作する際に必要な中継機として利用します。
ボットとはBluetooth接続します。

# 目指す姿
今回は以下の流れで自動解錠する仕組みを実装していきます。

1. 部屋番号を入力して自宅のインターフォンを呼び出す
1. M5StickC Plusでインターフォン呼び出しを検出する
1. SwitchBot APIを使ってボットを動かす
1. エントランスが解錠される

![open_entrance.drawio (2).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/122574/f5a82aa6-ebbb-b897-a8a6-2a2b41a4e605.png)

# 前提
今回はあまり慣れていない作業が中心だったので実装方法に拙い部分があるかと思いますが温かい目でみていただけると嬉しいです。

- M5StickC Plus初心者
    - 今回のためにデバイスを購入
    - Arduino IDEを触るのは初めて
- C++初心者
    - ふんわり触ったことがある程度

# 作業環境
* Macbook Pro 14-inch 2021
    * チップセット : M1 Pro
    * メモリ ： 32GB
* Arduino IDE 2.0.1

# 環境構築
まずはArduino IDEの環境構築です。
基本的には以下の記事を参考にして進めていきました。

https://zenn.dev/kenken82/articles/d8537fde03e90e

途中いくつかはまったポイントがあったのでその部分だけ解説します。

### Arduino IDEでM5StickC Plusを認識しない
IDEの環境が整った後に端末を接続してもIDE上で認識されませんでした。
以下のサイトからUSBドライバーをダウンロードして解決しました。

https://www.silabs.com/developers/usb-to-uart-bridge-vcp-drivers?tab=downloads

### ビルド時にPythonのコマンド実行エラー
ビルド時にエラーが発生しました。
```
exec: "python": executable file not found in $PATH
```

以下の記事を参考にしてIDEが参照している `python` コマンドの設定を `python3` に変更することで解決しました

https://zenn.dev/takiser/articles/ff009862ddf74d#python3%E3%82%92%E4%BD%BF%E3%81%86%E3%82%88%E3%81%86%E3%81%AB%E5%A4%89%E6%9B%B4%E3%81%99%E3%82%8B

### HelloWorldがちゃんと表示されない
デバイスを認識してビルドができるようになったのでHelloWorldのサンプルプロジェクトをビルドしたところ、ちゃんとインストールできているっぽいのに画面にちゃんと文字が表示されませんでした。
IDEにインストールしたライブラリが`M5StickC Plus`ではなく、 `M5StickC`のものを参照していたことが原因でした。
`M5StickC`に比べ`M5StickC Plus`の方がディスプレイサイズが大きくなっていたため、描画時のサイズ指定部分で影響が出ていたようです。

Arduino IDEのボードマネージャーURLの設定を以下のように変え、`include Library`から`M5StickC Plus`を選択したことで解決しました。

- 変更前
    - https://dl.espressif.com/dl/package_esp32_index.json 
- 変更後
    - https://m5stack.oss-cn-shenzhen.aliyuncs.com/resource/arduino/package_m5stack_index.json

# 画面に文字を表示する
ビルドできるようになったのでまずはじめの一歩ということでディスプレイに文字の出力を試してみました。

``` cpp
#include <M5StickCPlus.h>

void setup() {
    M5.begin();             // M5StickC Plusを初期化
    M5.Lcd.setRotation(3);  // 画面を270°回転する
    M5.Lcd.setTextSize(2);
    M5.Lcd.setTextColor(BLACK, WHITE);
    M5.Lcd.setCursor(0, 0);
    M5.Lcd.print("Hello World!");
}
```

とっても簡単に出力できました。
文字サイズや描画位置は調整できます。

# ボタン押下を検出する
デバッグをするのに本体のボタンを使えると楽なので試してみました。

``` cpp
void loop() {
    M5.update();     // 最新化
    if (M5.BtnA.wasReleased()) {
        // ボタンAのボタンアップを検出
    } else if (M5.BtnA.wasReleasefor(2000)) { 
        // 2秒長押しでM5StickC Plusをリセット
        esp_restart();
    }
}
```
本体のリセットも簡単に実装できます。

# Wi-Fiに接続する

Wi-Fiアクセスポイント名とパスワードを指定するだけでM5StickC Plusから簡単にWi-Fiへ接続することができます

``` cpp
#include <WiFi.h>
#include "WifiSetting.h"

WiFiClass wifi; 

void initWiFi() {
    M5.Lcd.fillScreen(WHITE);
    M5.Lcd.setCursor(0, 0);
    M5.Lcd.setTextSize(2);
    M5.Lcd.print("Connecting Wifi");
    wifi.begin(WifiSetting::WIFI_AP_NAME, WifiSetting::WIFI_AP_PASSWORD); 
    while (wifi.status() != WL_CONNECTED) {
      delay(500);
      M5.Lcd.print(".");
    }
    M5.Lcd.println("\nWiFi Connected!");
    
    delay(1000);
    M5.Lcd.fillScreen(WHITE);
}
```

実際に動かしてみました。一瞬でWi-Fiにつながります。

<iframe width="997" height="617" src="https://www.youtube.com/embed/RlQx1yAE4iU" title="M5StickC Plus - Wi-Fi接続" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>

# SwitchBot APIをコールする
次にネットワーク経由でSwitchBotボットを操作していきます。
### ボットのDeviceIDを調べる
ボットを操作するにはDeviceIDが必要となります。

SwitchBot APIのリクエストをこなうためにAPIトークンを取得します。
こちらの記事を参考にしました。

https://dev.classmethod.jp/articles/switchbot-control-by-api/

curlで[Devices API](https://github.com/OpenWonderLabs/SwitchBotAPI/blob/main/README-v1.0.md#get-all-devices)を叩きます。

``` shell
curl https://api.switch-bot.com/v1.0/devices \
-H 'Authorization: ${API Token}' \
-H 'Content-Type: application/json; charset=utf8'
```

以下のようなレスポンスが返って来れば成功です。

![スクリーンショット 2022-11-16 3.27.15.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/122574/f2f177db-d91c-a819-e981-9f0251466709.png)

`deviceType`が`Bot`になっているデバイスの`deviceId`を覚えておきます

#### ボットにコマンドを送る
　先ほど取得した`deviceId`を指定してcurlで[Command API](https://github.com/OpenWonderLabs/SwitchBotAPI/blob/main/README-v1.0.md#bot-example)を叩きます。
ボットの押下コマンドは`press`を指定する必要があります。

``` shell
curl https://api.switch-bot.com/v1.0/devices/${DeviceId}/commands \
-X POST \
-H 'Authorization: ${API Token}' \
-H 'Content-Type: application/json; charset=utf8' \
-d '{"command": "press"}'
```

以下のようなレスポンスが返って来れば実際にボットが動作するはずです。

![スクリーンショット 2022-11-16 3.44.40.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/122574/2cb51816-3dd2-f5ad-3632-d2a7fb3199f8.png)

### M5StickC PlusからAPIリクエストを行う

実際にM5StickC Plusするために以下のような実装を行いました。

``` cpp
#include <HTTPClient.h>
#include "SwitchBotApiConfig.h"

HTTPClient http;

void requestSwitchBotApi() {

    M5.Lcd.fillScreen(WHITE);
    M5.Lcd.setCursor(20, 45);
    M5.Lcd.setTextSize(10);
    M5.Lcd.print("OPEN!");

    String url = "https://api.switch-bot.com/v1.0/devices/";
    url += SwitchBotApiConfig::BOT_DEVICE_ID;
    url += "/commands";

    Serial.printf("Request url:%s\n\n", url.c_str());

    http.begin(url);
    http.addHeader("Authorization", SwitchBotApiConfig::TOKEN);
    http.addHeader("Content-Type", "application/json; charset=utf8");

    int httpCode = http.POST("{\"command\": \"press\"}"); // Bot押下コマンド
    if (httpCode > 0) {
        Serial.printf("Result code: %d\n", httpCode);
        if (httpCode == HTTP_CODE_OK) {
            String payload = http.getString();
            Serial.println(payload);
            Serial.println("Success");
        }
    } else {
        Serial.print("Failure, error: ");
        Serial.println(http.errorToString(httpCode).c_str());
    }

    http.end();  
}
```

前述したボタン押下のイベントからAPIリクエストのメソッドを呼ぶことで簡単に確認できました。

``` cpp
void loop() {
    M5.update();
    if (M5.BtnA.wasReleased()) {
        requestSwitchBotApi();
    } else if (M5.BtnA.wasReleasefor(2000)) { 
        // 2秒長押しでリセット
        esp_restart();
    }
}
```

# マイクで環境音を取得する
次はマイクを使って周りの音を拾っていきます。
前処理としてi2sの初期化を行います。
``` cpp
#include <driver/i2s.h>

#define PIN_CLK     0
#define PIN_DATA    34
#define READ_LEN    (2 * 256)
#define GAIN_FACTOR 3

void i2sInit() {
    i2s_config_t i2s_config = {
        .mode = (i2s_mode_t)(I2S_MODE_MASTER | I2S_MODE_RX | I2S_MODE_PDM),
        .sample_rate = 44100,
        .bits_per_sample =
            I2S_BITS_PER_SAMPLE_16BIT,  // is fixed at 12bit, stereo, MSB
        .channel_format = I2S_CHANNEL_FMT_ALL_RIGHT,
#if ESP_IDF_VERSION > ESP_IDF_VERSION_VAL(4, 1, 0)
        .communication_format = I2S_COMM_FORMAT_STAND_I2S,
#else
        .communication_format = I2S_COMM_FORMAT_I2S,
#endif
        .intr_alloc_flags = ESP_INTR_FLAG_LEVEL1,
        .dma_buf_count    = 2,
        .dma_buf_len      = 128,
    };

    i2s_pin_config_t pin_config;

#if (ESP_IDF_VERSION > ESP_IDF_VERSION_VAL(4, 3, 0))
    pin_config.mck_io_num = I2S_PIN_NO_CHANGE;
#endif

    pin_config.bck_io_num   = I2S_PIN_NO_CHANGE;
    pin_config.ws_io_num    = PIN_CLK;
    pin_config.data_out_num = I2S_PIN_NO_CHANGE;
    pin_config.data_in_num  = PIN_DATA;

    i2s_driver_install(I2S_NUM_0, &i2s_config, 0, NULL);
    i2s_set_pin(I2S_NUM_0, &pin_config);
    i2s_set_clk(I2S_NUM_0, 44100, I2S_BITS_PER_SAMPLE_16BIT, I2S_CHANNEL_MONO);
}
```

続いてマイクの信号を読み取ります。

``` cpp
uint8_t BUFFER[READ_LEN] = {0};
int16_t *adcBuffer = NULL;

void setup() {
    ...        
    //マイク初期化 
    i2sInit();
    // 待ち受け開始
    xTaskCreate(mic_record_task, "mic_record_task", 4096, NULL, 1, NULL);
}

void mic_record_task(void *arg) {
    size_t bytesread;
    while (1) {
        i2s_read(I2S_NUM_0, (char *)BUFFER, READ_LEN, &bytesread,
                 (100 / portTICK_RATE_MS));
        adcBuffer = (int16_t *)BUFFER;
        showSignal();
        fft();
        callApiIfNeeded();      
        vTaskDelay(100 / portTICK_RATE_MS);
    }
}
```

せっかくディスプレイがあるので取得したマイクの信号データを画面常に波形で表示します

``` cpp
uint16_t oldy[240];

void showSignal() { 
  int32_t offset_sum = 0;
  for (int n = 0; n < 240; n++) {
    offset_sum += (int16_t)adcBuffer[n];
  }
  int offset_val = -( offset_sum / 240 );
  // Auto Gain
  int max_val = 200;
  for (int n = 0; n < 240; n++) {
    int16_t val = (int16_t)adcBuffer[n] + offset_val;
    if ( max_val < abs(val) ) {
      max_val = abs(val);
    }
  }
  
  int y;
  for (int n = 0; n < 240; n++){
    y = adcBuffer[n] + offset_val;
    y = map(y, -max_val, max_val, 10, 125);
    M5.Lcd.drawPixel(n, oldy[n],WHITE);
    M5.Lcd.drawPixel(n,y,BLACK);
    oldy[n] = y;
  }
}
```

音を拾うとこんな感じで表示されるようになります

![PXL_20221115_191041562.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/122574/5628c43e-23e8-ff00-c94b-e46ff0da8c1a.jpeg)

# チャイム音を検出する
実際にチャイムがなった事を検出するために今回は以下のアプローチを行いました

- FFT（高速フーリエ変換）を利用してマイクの音データを分析する
- チャイム音を録音してみて特徴のある周波数帯を調べる
- チャイム音の周波数帯で一定以上の音量を一定期間検出できたらチャイムとして判定する
- チャイムがなってから解錠するまでの速度を重視して検出の精度は低めに設定する

先ほど取得した音の信号を周波数帯毎に分解するためにFFTを行います。

``` cpp
#include <arduinoFFT.h>

#define SAMPLES 500
#define SAMPLING_FREQUENCY 40000

unsigned int sampling_period_us;

const uint16_t FFTsamples = 256;
double vReal[FFTsamples];
double vImag[FFTsamples];

void fft(){
    for (int i = 0; i < FFTsamples; i++) {
      unsigned long t = micros();
      vReal[i] = adcBuffer[i];
      vImag[i] = 0;
      while ((micros() - t) < sampling_period_us) ;
    }
  
    // 高速フーリエ変換     
    FFT.Windowing(FFT_WIN_TYP_HAMMING, FFT_FORWARD);
    FFT.Compute(FFT_FORWARD);
    FFT.ComplexToMagnitude();
}
```
    
チャイム音のデータにFFTをかけ、一定以上の音量が出る周波数帯をログ出力してみました
``` cpp
void sample() {
    int nsamples = FFTsamples/2;
    for (int band = 0; band < nsamples; band++) {    
        float d = vReal[band];
        if (1000 <= d) {
            Serial.print(band);
            Serial.print(" : ");
            Serial.print((band * 1.0 * SAMPLING_FREQUENCY) / FFTsamples);
            Serial.print("Hz : ");
            Serial.println(d);
        }
    }  
}

# 実際に出力されたログ
22:54:00.609 -> 4 : 625.00Hz : 4454.06
22:54:00.609 -> 5 : 781.25Hz : 1303.84
22:54:00.707 -> 4 : 625.00Hz : 4177.13
22:54:00.740 -> 5 : 781.25Hz : 1334.90
22:54:00.941 -> 4 : 625.00Hz : 2495.20
22:54:21.451 -> 5 : 781.25Hz : 1285.85
22:55:21.909 -> 4 : 625.00Hz : 1451.12
22:55:21.909 -> 5 : 781.25Hz : 1460.30
22:55:22.007 -> 4 : 625.00Hz : 1655.73
22:55:22.007 -> 5 : 781.25Hz : 1181.26
22:55:27.322 -> 4 : 625.00Hz : 10484.33
22:55:27.322 -> 5 : 781.25Hz : 10466.41
22:55:27.422 -> 5 : 781.25Hz : 1386.01
22:55:27.551 -> 5 : 781.25Hz : 1104.87
22:55:28.440 -> 4 : 625.00Hz : 1733.31
22:55:28.440 -> 5 : 781.25Hz : 8850.25
22:55:28.541 -> 5 : 781.25Hz : 1187.75
22:55:28.998 -> 4 : 625.00Hz : 1050.78
22:55:29.195 -> 4 : 625.00Hz : 2341.89
22:55:29.195 -> 5 : 781.25Hz : 1871.68
22:55:29.425 -> 4 : 625.00Hz : 1213.50
22:55:30.115 -> 5 : 781.25Hz : 2161.92
22:55:30.313 -> 5 : 781.25Hz : 1819.80
22:55:30.445 -> 4 : 625.00Hz : 1388.09
22:55:30.445 -> 5 : 781.25Hz : 1264.82
22:55:30.546 -> 5 : 781.25Hz : 1057.19
22:55:30.675 -> 5 : 781.25Hz : 1836.50
```

上記ログ出力結果より下記周波数帯に特徴があることがわかりました

- 625.00Hz
- 781.25Hz

そのため、**これらの周波数帯で一定時間、一定以上の音量を検出したらチャイムとして検出する**とし、
チャイムが検出されたらSwitchBot APIをリクエストする処理を実装しました

``` cpp
#define DETECT_LOWER_LIMIT 1000
#define DETECT_UPPER_LIMIT 5000
#define DETECT_COUNT_THRESHOLD 5

bool detectBuffer[10];

// band[4] : 625.00Hz
// band[5] : 781.25Hz
int targetBand[2] = { 4, 5 };

void callApiIfNeeded() {
    bool overTreshold = false;
    int nsamples = FFTsamples/2;
    for (int band = 0; band < nsamples; band++) {    
        for (int i = 0; i < sizeof(targetBand)/sizeof(*targetBand); i++) {
            if (band != targetBand[i]) continue;
            float d = vReal[band];

            Serial.print(band);
            Serial.print(" : ");
            Serial.print((band * 1.0 * SAMPLING_FREQUENCY) / FFTsamples);
            Serial.print("Hz : ");
            Serial.println(d);

            if (DETECT_LOWER_LIMIT <= d && DETECT_UPPER_LIMIT > d) {
                overTreshold = true;
                break;
            }
        }

    }  
        
    int detectCount = 0;
    for(int i = 0; i < sizeof(detectBuffer)/sizeof(*detectBuffer); i++) {
        if (i < sizeof(detectBuffer)/sizeof(*detectBuffer) - 1) {
            detectBuffer[i] = detectBuffer[i + 1];
        } else {
            detectBuffer[i] = overTreshold;  
        }          
        if (detectBuffer[i]) {
          detectCount++;
        }
    }
    
    Serial.print("detectCount : ");
    Serial.println(detectCount);

    if (detectCount >= DETECT_COUNT_THRESHOLD) {
        // 閾値を超えたらチャイムとして判定
        Serial.println("detect!");
        
        // 解錠
        requestSwitchBotApi();

        vTaskDelay(2000 / portTICK_RATE_MS);

        memset(detectBuffer, false, sizeof(detectBuffer));
        M5.Lcd.fillScreen(WHITE);
    }    
}
```

# 完成
以上の実装で目的としていたエントランスの自動解錠を実現できました！

<iframe width="397" height="705" src="https://www.youtube.com/embed/YlbwYKmUt7c" title="M5StickC PlusとSwitchBotボットでエントランスを自動解錠" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>


ここで無事終わり。としたかったのですが大きな問題があります。

勘の良い方なら既にお気づきかと思いますが、今回実装した仕組みをそのまま運用すると**特定の部屋番号を入力するだけで誰でもマンションのエントランスを突破**できるようになります😂

今回は**マンションのセキュリティホールを作りました！** とは言いたくないですし、管理会社に訴えられるのは嫌なのでこのまま運用するのは見送ります😇

脱セキュリティーホールするためにはこの仕組みを外部から一時的に有効化する手段を実装する必要がありそうです。
ただ、当初やりたいことは実現できたので一旦今回はここで終わりにしたいと思います。

今回実装したソースコードはGitHubにもあげているのでよかったらみてみてください

https://github.com/misoca12/AutoEntranceOpener/blob/master/AutoEntranceOpener.ino

# 参考
### Aruduino IDE環境構築関連

https://zenn.dev/kenken82/articles/d8537fde03e90e

https://www.silabs.com/developers/usb-to-uart-bridge-vcp-drivers?tab=downloads

https://zenn.dev/takiser/articles/ff009862ddf74d#python3%E3%82%92%E4%BD%BF%E3%81%86%E3%82%88%E3%81%86%E3%81%AB%E5%A4%89%E6%9B%B4%E3%81%99%E3%82%8B

### SwitchBot API関連

https://github.com/OpenWonderLabs/SwitchBotAPI/blob/main/README-v1.0.md

https://dev.classmethod.jp/articles/switchbot-control-by-api/

### 音解析関連

https://ambidata.io/samples/m5stack/sound/

https://homemadegarbage.com/m5stickc02

https://pages.switch-science.com/letsiot/vibration/

---

最後まで読んでいただきありがとうございました。
明日の[レコチョク Advent Calendar 2022](https://qiita.com/advent-calendar/2022/recochoku)は2日目 `【Unity2d】半日でオンライン格闘ゲーム（超簡易版）を作ってみた` となります。お楽しみに！


---

この記事は[レコチョクのエンジニアブログの記事](https://techblog.recochoku.jp/8568)を転載したものとなります。

https://techblog.recochoku.jp/8568