この記事はレコチョク Advent Calendar 2022の1日目の記事となります。
株式会社レコチョクでAndroidアプリ開発をしている木村です。
普段は音楽に関するサービス開発を行なっていますが、今回は簡単な音解析でちょっとしたライフハックをしてみたのでまとめてみました。
背景
- 自分が住んでいるマンションではエントランスで鍵を使ってオートロックを解錠する必要がある
- 毎回物理鍵を鞄から取り出すのがめんどくさいので楽して解錠したい
解決方法
今回は3つのIoTデバイスを使ってこの問題を解決してみようと思います
1. M5StickC Plus
さまざまなセンサーが内蔵されたIoTツールキットです。
今回はWiFiとマイクを中心に利用します。
2. SwitchBot ボット
SwitchBot社で開発しているスマートスイッチロボットです。
APIが公開されているので簡単に外部から操作できます。
今回は宅内のインターフォンの解錠ボタンを押すために利用します。
こんな感じで設置してみました。
3. SwitchBot ハブミニ
SwitchBot ボットを外部ネットワークから操作する際に必要な中継機として利用します。
ボットとはBluetooth接続します。
目指す姿
今回は以下の流れで自動解錠する仕組みを実装していきます。
- 部屋番号を入力して自宅のインターフォンを呼び出す
- M5StickC Plusでインターフォン呼び出しを検出する
- SwitchBot APIを使ってボットを動かす
- エントランスが解錠される
前提
今回はあまり慣れていない作業が中心だったので実装方法に拙い部分があるかと思いますが温かい目でみていただけると嬉しいです。
- 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 に変更することで解決しました
HelloWorldがちゃんと表示されない
デバイスを認識してビルドができるようになったのでHelloWorldのサンプルプロジェクトをビルドしたところ、ちゃんとインストールできているっぽいのに画面にちゃんと文字が表示されませんでした。
IDEにインストールしたライブラリが
M5StickC Plusではなく、
M5StickCのものを参照していたことが原因でした。
M5StickCに比べ
M5StickC Plusの方がディスプレイサイズが大きくなっていたため、描画時のサイズ指定部分で影響が出ていたようです。
Arduino IDEのボードマネージャーURLの設定を以下のように変え、 include Libraryから M5StickC Plusを選択したことで解決しました。
- 変更前
- 変更後
画面に文字を表示する
ビルドできるようになったのでまずはじめの一歩ということでディスプレイに文字の出力を試してみました。
#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!"); } |
とっても簡単に出力できました。
文字サイズや描画位置は調整できます。
ボタン押下を検出する
デバッグをするのに本体のボタンを使えると楽なので試してみました。
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へ接続することができます
#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につながります。
https://www.youtube.com/embed/RlQx1yAE4iU
SwitchBot APIをコールする
次にネットワーク経由でSwitchBotボットを操作していきます。
ボットのDeviceIDを調べる
ボットを操作するにはDeviceIDが必要となります。
SwitchBot APIのリクエストをこなうためにAPIトークンを取得します。
こちらの記事を参考にしました。
https://dev.classmethod.jp/articles/switchbot-control-by-api/
curlでDevices APIを叩きます。
curl https://api.switch-bot.com/v1.0/devices \ -H 'Authorization: ${API Token}' \ -H 'Content-Type: application/json; charset=utf8' |
以下のようなレスポンスが返って来れば成功です。
deviceTypeが Botになっているデバイスの deviceIdを覚えておきます
ボットにコマンドを送る
先ほど取得した
deviceIdを指定してcurlでCommand APIを叩きます。
ボットの押下コマンドは
pressを指定する必要があります。
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"}' |
以下のようなレスポンスが返って来れば実際にボットが動作するはずです。
M5StickC PlusからAPIリクエストを行う
実際にM5StickC Plusするために以下のような実装を行いました。
#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リクエストのメソッドを呼ぶことで簡単に確認できました。
void loop() { M5.update(); if (M5.BtnA.wasReleased()) { requestSwitchBotApi(); } else if (M5.BtnA.wasReleasefor(2000)) { // 2秒長押しでリセット esp_restart(); } } |
マイクで環境音を取得する
次はマイクを使って周りの音を拾っていきます。
前処理としてi2sの初期化を行います。
#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); } |
続いてマイクの信号を読み取ります。
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); } } |
せっかくディスプレイがあるので取得したマイクの信号データを画面常に波形で表示します
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; } } |
音を拾うとこんな感じで表示されるようになります
チャイム音を検出する
実際にチャイムがなった事を検出するために今回は以下のアプローチを行いました
- FFT(高速フーリエ変換)を利用してマイクの音データを分析する
- チャイム音を録音してみて特徴のある周波数帯を調べる
- チャイム音の周波数帯で一定以上の音量を一定期間検出できたらチャイムとして判定する
- チャイムがなってから解錠するまでの速度を重視して検出の精度は低めに設定する
先ほど取得した音の信号を周波数帯毎に分解するためにFFTを行います。
#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をかけ、一定以上の音量が出る周波数帯をログ出力してみました
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をリクエストする処理を実装しました
#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); } } |
完成
以上の実装で目的としていたエントランスの自動解錠を実現できました!
https://www.youtube.com/embed/YlbwYKmUt7c
ここで無事終わり。としたかったのですが大きな問題があります。
勘の良い方なら既にお気づきかと思いますが、今回実装した仕組みをそのまま運用すると特定の部屋番号を入力するだけで誰でもマンションのエントランスを突破できるようになります😂
今回はマンションのセキュリティホールを作りました! とは言いたくないですし、管理会社に訴えられるのは嫌なのでこのまま運用するのは見送ります😇
脱セキュリティーホールするためにはこの仕組みを外部から一時的に有効化する手段を実装する必要がありそうです。
ただ、当初やりたいことは実現できたので一旦今回はここで終わりにしたいと思います。
今回実装したソースコードは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
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は2日目
【Unity2d】半日でオンライン格闘ゲーム(超簡易版)を作ってみた となります。お楽しみに!
この記事を書いた人
最近書いた記事
- 2023.12.23Jetpack Composeでスイカゲームは再現できる?
- 2022.12.01M5StickC PlusとSwitchBot ボットでエントランスを自動解錠
- 2021.12.01Flutterメディア再生のすすめ