この記事はレコチョク 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の環境構築です。
基本的には以下の記事を参考にして進めていきました。
途中いくつかはまったポイントがあったのでその部分だけ解説します。
Arduino IDEでM5StickC Plusを認識しない
IDEの環境が整った後に端末を接続してもIDE上で認識されませんでした。
以下のサイトからUSBドライバーをダウンロードして解決しました。
ビルド時に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につながります。
SwitchBot APIをコールする
次にネットワーク経由でSwitchBotボットを操作していきます。
ボットのDeviceIDを調べる
ボットを操作するにはDeviceIDが必要となります。
SwitchBot APIのリクエストをこなうために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);
}
}
完成
以上の実装で目的としていたエントランスの自動解錠を実現できました!
ここで無事終わり。としたかったのですが大きな問題があります。
勘の良い方なら既にお気づきかと思いますが、今回実装した仕組みをそのまま運用すると特定の部屋番号を入力するだけで誰でもマンションのエントランスを突破できるようになります😂
今回はマンションのセキュリティホールを作りました! とは言いたくないですし、管理会社に訴えられるのは嫌なのでこのまま運用するのは見送ります😇
脱セキュリティーホールするためにはこの仕組みを外部から一時的に有効化する手段を実装する必要がありそうです。
ただ、当初やりたいことは実現できたので一旦今回はここで終わりにしたいと思います。
今回実装したソースコードはGitHubにもあげているのでよかったらみてみてください
参考
Aruduino IDE環境構築関連
SwitchBot API関連
音解析関連
最後まで読んでいただきありがとうございました。
明日のレコチョク Advent Calendar 2022は2日目 【Unity2d】半日でオンライン格闘ゲーム(超簡易版)を作ってみた
となります。お楽しみに!
この記事はレコチョクのエンジニアブログの記事を転載したものとなります。