SwitchBot を ESP32 で遠隔操作してみた
SwitchBot と公開 API
物理的なスイッチやボタンを人に代わって操作してくれる BLE デバイス SwitchBot (by Wonderlabs,Inc.)が人気です。有線/無線通信や赤外線等によるコントロールに対応していない機器をいわば強引に IoT 方面へ引き込もうという発想が面白いですね。
|
技適マークと番号はパッケージに印刷されている |
これは同じくクラウドファンディングを経て 2016年に商品化された MicroBot Push (by Naran Inc.: 工事設計認証番号 R208-160099) の後追い製品ではありますが、手元にある両者を比べてみると SwitchBot には後発であることのアドバンテージが随所に活かされているように見受けられます。とりわけ嬉しいのは本体のアームを制御するための API が公開されていることです。
- Switch Bot - The World’s Smallest Remote Robot by Wonder Tech Lab - www.kickstarter.com
Open API
APIs will be released to let DIYers integrate Switch Bot to your own home automation system. Swich Bot and Link are friendly to Ardunio, Raspberry Pi and OpenWRT.
この公約のとおり Wonderlabs,Inc. は Apache License 2.0 のもとに次のリソースを公開しています。
- python-host - github.com/OpenWonderLabs
SwitchBot のアーム操作に必要な手順が次のシンプルな内容であることがわかります。
- 対象とする BLE GATT サービスはユーザ定義の「cba20d00-224d-11e6-9fb8-0002a5d5c51b」
- その配下のキャラクタリスティック「cba20002-224d-11e6-9fb8-0002a5d5c51b」がターゲット
- 上記キャラクタリスティックへ次の値を書き込むとそれぞれ以下の挙動となる
- { 0x57, 0x01, 0x00 } : アームを 「倒す+引く」 ("Press")
- { 0x57, 0x01, 0x01 } : アームを 「倒す」 ("Turn On")
- { 0x57, 0x01, 0x02 } : アームを 「引く」 ("Turn Off")
以下は以前紹介した手順で採取した GATT 管理下の UUID の一覧です。
onServicesDiscovered: serviceList.size=4 onServicesDiscovered: svc uuid=00001800-0000-1000-8000-00805f9b34fb // Generic Access onServicesDiscovered: chrlist.size=3 onServicesDiscovered: chr uuid=00002a00-0000-1000-8000-00805f9b34fb // Device Name onServicesDiscovered: desclist.size=0 onServicesDiscovered: chr uuid=00002a01-0000-1000-8000-00805f9b34fb // Appearance onServicesDiscovered: desclist.size=0 onServicesDiscovered: chr uuid=00002a04-0000-1000-8000-00805f9b34fb // Peripheral Preferred Connection Parameters onServicesDiscovered: desclist.size=0 onServicesDiscovered: svc uuid=00001801-0000-1000-8000-00805f9b34fb // Generic Attribute onServicesDiscovered: chrlist.size=0 onServicesDiscovered: svc uuid=0000fee7-0000-1000-8000-00805f9b34fb // Custom UUID of Tencent Holdings Limited onServicesDiscovered: chrlist.size=3 onServicesDiscovered: chr uuid=0000fec8-0000-1000-8000-00805f9b34fb // Custom UUID of Apple, Inc. onServicesDiscovered: desclist.size=1 onServicesDiscovered: desc uuid=00002902-0000-1000-8000-00805f9b34fb // Client Characteristic Configuration Descriptor onServicesDiscovered: chr uuid=0000fec7-0000-1000-8000-00805f9b34fb // Custom UUID of Apple, Inc. onServicesDiscovered: desclist.size=0 onServicesDiscovered: chr uuid=0000fec9-0000-1000-8000-00805f9b34fb // Custom UUID of Apple, Inc. onServicesDiscovered: desclist.size=0 onServicesDiscovered: svc uuid=cba20d00-224d-11e6-9fb8-0002a5d5c51b // User defined service onServicesDiscovered: chrlist.size=2 onServicesDiscovered: chr uuid=cba20003-224d-11e6-9fb8-0002a5d5c51b // User defined characteristic onServicesDiscovered: desclist.size=1 onServicesDiscovered: desc uuid=00002902-0000-1000-8000-00805f9b34fb // // Client Characteristic Configuration Descriptor onServicesDiscovered: chr uuid=cba20002-224d-11e6-9fb8-0002a5d5c51b // User defined characteristic onServicesDiscovered: desclist.size=0
ちなみに、手元の SwithiBot 個体の MAC アドレスは「C0:65:9A:7D:61:E1」でした。
自作の Android アプリで操作してみる
上記の手順にそってまず Android アプリをざっくり書いてみました。以下は動作の様子を収めた動画とソースコードです。
動画:40秒 音量注意
SwitchBot01 - MainActivity.java
/** * * SwitchBot01 * * SwitchBot を操作する * * メーカーが公式に公開しているプログラムをベースに作成 * * https://github.com/OpenWonderLabs/python-host/ * */ package jp.klab.SwitchBot01; import android.Manifest; import android.app.Activity; import android.app.AlertDialog; import android.app.ProgressDialog; import android.bluetooth.BluetoothAdapter; import android.bluetooth.BluetoothDevice; import android.bluetooth.BluetoothGatt; import android.bluetooth.BluetoothGattCallback; import android.bluetooth.BluetoothGattCharacteristic; import android.bluetooth.BluetoothGattDescriptor; import android.bluetooth.BluetoothGattService; import android.bluetooth.BluetoothManager; import android.bluetooth.BluetoothProfile; import android.bluetooth.le.BluetoothLeScanner; import android.bluetooth.le.ScanCallback; import android.bluetooth.le.ScanRecord; import android.bluetooth.le.ScanResult; import android.bluetooth.le.ScanSettings; import android.content.Context; import android.content.DialogInterface; import android.content.Intent; import android.content.pm.PackageManager; import android.graphics.Color; import android.net.Uri; import android.os.Bundle; import android.os.Handler; import android.os.Message; import android.support.v4.app.ActivityCompat; import android.support.v4.content.ContextCompat; import android.support.v7.app.AppCompatActivity; import android.support.v7.widget.Toolbar; import android.util.Log; import android.view.View; import android.widget.Button; import android.widget.TextView; import com.google.android.gms.appindexing.Action; import java.util.List; import java.util.UUID; public class MainActivity extends AppCompatActivity implements Runnable, View.OnClickListener, Handler.Callback { private static final String TAG = "SB01"; private static final String TARGET_ADDR = "C0:65:9A:7D:61:E1"; // 手元の SwitchBot private static final int SCAN_MODE = ScanSettings.SCAN_MODE_LOW_LATENCY; private static final int MSG_DOSCAN = 100; private static final int MSG_FOUNDDEVICE = 110; private static final int MSG_STOPSCAN = 120; private static final int MSG_GATTCONNECT = 200; private static final int MSG_GATTCONNECTED = 210; private static final int MSG_GATTDISCONNECT = 300; private static final int MSG_GATTDISCONNECTED = 310; private static final int MSG_GATTGOTSERVICE = 400; private static final int MSG_SW_ON = 500; private static final int MSG_SW_OFF = 510; private static final int MSG_SW_PRESS = 520; private static final int MSG_ERROR = 10; private static final int REQ_ENABLE_BT = 0; private BluetoothAdapter mBtAdapter = null; private BluetoothLeScanner mBtScanner = null; private BluetoothGatt mBtGatt = null; private BluetoothDevice mBtDevice; private Handler mHandler; private Context mCtx; private ProgressDialog mProgressDlg = null; private TextView mTvAddr; private TextView mTvRssi; private Button mButtonDisconn; private Button mButtonConn; private Button mButtonTurnOn; private Button mButtonTurnOff; private Button mButtonPress; private BluetoothGattCharacteristic mChUser2 = null; private BluetoothGattDescriptor mDescUser1 = null; // SwitchBot の提供するサービス・キャラクタリスティック群の UUID より private UUID mUuidSvcUser1 = UUID.fromString("cba20d00-224d-11e6-9fb8-0002a5d5c51b"); private UUID mUuidChUser2 = UUID.fromString("cba20002-224d-11e6-9fb8-0002a5d5c51b"); // SwitchBot の 3 コマンド private byte[] mCmdSwPress = new byte[] {(byte)0x57, (byte)0x01, (byte)0x00}; private byte[] mCmdSwOn = new byte[] {(byte)0x57, (byte)0x01, (byte)0x01}; private byte[] mCmdSwOff = new byte[] {(byte)0x57, (byte)0x01, (byte)0x02}; private ScanCallback mScanCallback = new bleScanCallback(); private BluetoothGattCallback mGattCallback = new bleGattCallback(); // GATT イベントコールバック private class bleGattCallback extends BluetoothGattCallback { @Override public void onDescriptorWrite(BluetoothGatt gatt, BluetoothGattDescriptor desc, int status) { // writeDescriptor() 結果 super.onDescriptorWrite(gatt, desc, status); } @Override public void onCharacteristicRead(BluetoothGatt gatt, BluetoothGattCharacteristic ch, int status) { // readCharacteristic() 結果 super.onCharacteristicRead(gatt, ch, status); Log.d(TAG, "onCharacteristicRead: sts=" + status); } @Override public void onCharacteristicWrite(BluetoothGatt gatt, BluetoothGattCharacteristic ch, int status) { // writeCharacteristic 結果 super.onCharacteristicWrite(gatt, ch, status); Log.d(TAG, "onCharacteristicWrite: sts=" + status); } @Override public void onConnectionStateChange(BluetoothGatt gatt, int status, int newState) { super.onConnectionStateChange(gatt, status, newState); if (newState == BluetoothProfile.STATE_CONNECTED) { // 接続完了 mHandler.sendEmptyMessage(MSG_GATTCONNECTED); } else if (newState == BluetoothProfile.STATE_DISCONNECTED) { // 切断完了 mHandler.sendEmptyMessage(MSG_GATTDISCONNECTED); } } @Override public void onCharacteristicChanged (BluetoothGatt gatt, BluetoothGattCharacteristic ch) { Log.d(TAG, "onCharacteristicChanged"); } @Override public void onServicesDiscovered(BluetoothGatt gatt, int status) { // GATT サービス一覧取得完了 super.onServicesDiscovered(gatt, status); // SwitchBot のユーザ定義サービス のユーザ定義キャラクタリスティック 2 のオブジェクトを取得 BluetoothGattService svc = gatt.getService(mUuidSvcUser1); mChUser2 = svc.getCharacteristic(mUuidChUser2); /** すべての Services - Characteristics - Descriptors をログへ** List<BluetoothGattService> serviceList = gatt.getServices(); Log.d(TAG, "onServicesDiscovered: serviceList.size=" + serviceList.size()); for (BluetoothGattService s : serviceList) { Log.d(TAG, "onServicesDiscovered: svc uuid=" + s.getUuid().toString()); List<BluetoothGattCharacteristic> chlist = s.getCharacteristics(); Log.d(TAG, "onServicesDiscovered: chrlist.size=" + chlist.size()); for (BluetoothGattCharacteristic c : chlist) { UUID uuid = c.getUuid(); Log.d(TAG, "onServicesDiscovered: chr uuid=" + uuid.toString()); List<BluetoothGattDescriptor> dlist = c.getDescriptors(); Log.d(TAG, "onServicesDiscovered: desclist.size=" + dlist.size()); for (BluetoothGattDescriptor d : dlist) { Log.d(TAG, "onServicesDiscovered: desc uuid=" + d.getUuid()); } } } **/ mHandler.sendEmptyMessage(MSG_GATTGOTSERVICE); } }; // SCAN イベントコールバック private class bleScanCallback extends ScanCallback { @Override public void onBatchScanResults(List<ScanResult> results) { super.onBatchScanResults(results); Log.d(TAG, "onBatchScanResults"); } @Override public void onScanResult(int callbackType, ScanResult result) { super.onScanResult(callbackType, result); int rssi = result.getRssi(); mBtDevice = result.getDevice(); ScanRecord rec = result.getScanRecord(); String addr = mBtDevice.getAddress(); if (!addr.equals(TARGET_ADDR)) { return; } mTvAddr.setText("ADDRESS\n" + TARGET_ADDR); mTvRssi.setText("RSSI\n" + rssi); mHandler.sendEmptyMessage(MSG_FOUNDDEVICE); } @Override public void onScanFailed(int errorCode) { super.onScanFailed(errorCode); Log.e(TAG, "onScanFailed: err=" + errorCode); } } @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); Log.d(TAG, "onCreate"); mCtx = this; setContentView(R.layout.activity_main); Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar); setSupportActionBar(toolbar); mTvAddr = (TextView) findViewById(R.id.tvAddr); mTvRssi = (TextView) findViewById(R.id.tvRssi); mButtonDisconn = (Button)findViewById(R.id.buttonDisconn); mButtonConn = (Button)findViewById(R.id.buttonConn); mButtonTurnOn = (Button)findViewById(R.id.buttonTurnOn); mButtonTurnOff = (Button)findViewById(R.id.buttonTurnOff); mButtonPress = (Button)findViewById(R.id.buttonPress); mButtonDisconn.setOnClickListener(this); mButtonConn.setOnClickListener(this); mButtonTurnOn.setOnClickListener(this); mButtonTurnOff.setOnClickListener(this); mButtonPress.setOnClickListener(this); setButtonsVisibility(false); setButtonsEnabled(false); setTvColor(Color.LTGRAY); mHandler = new Handler(this); /***** add for Android 6.0 or later ****/ // https://developer.android.com/training/permissions/requesting.html // https://developer.android.com/topic/libraries/support-library/index.html#backward if (ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_COARSE_LOCATION) != PackageManager.PERMISSION_GRANTED) { ActivityCompat.requestPermissions(this, new String[]{Manifest.permission.ACCESS_COARSE_LOCATION}, 1); } // 端末の Bluetooth アダプタへの参照を取得 mBtAdapter = ((BluetoothManager) getSystemService(Context.BLUETOOTH_SERVICE)).getAdapter(); if (mBtAdapter == null) { // Bluetooth サポートなし showDialogMessage(this, "Device does not support Bluetooth.", true); // finish } else if (!mBtAdapter.isEnabled()) { // Bluetooth 無効状態 Log.d(TAG, "Bluetooth is not enabled."); // 有効化する Intent it = new Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE); startActivityForResult(it, REQ_ENABLE_BT); } else { mBtScanner = mBtAdapter.getBluetoothLeScanner(); if (!mBtAdapter.isMultipleAdvertisementSupported()) { showDialogMessage(this, "isMultipleAdvertisementSupported NG.", true); // finish } else { // スキャン開始 mHandler.sendEmptyMessage(MSG_DOSCAN); } } } @Override protected void onActivityResult(int requestCode, int resultCode, Intent data) { super.onActivityResult(requestCode, resultCode, data); Log.d(TAG, "onActivityResult"); switch (requestCode) { case REQ_ENABLE_BT: // Bluetooth 有効化 OK if (resultCode == Activity.RESULT_OK) { Log.d(TAG, "REQ_ENABLE_BT OK"); // スキャン開始 mHandler.sendEmptyMessage(MSG_DOSCAN); } else { Log.d(TAG, "REQ_ENABLE_BT Failed"); mHandler.sendEmptyMessage(MSG_ERROR); // finish } break; } } @Override public void onStart() { super.onStart(); Log.d(TAG, "onStart"); Action viewAction = Action.newAction( Action.TYPE_VIEW, // TODO: choose an action type. "Main Page", // TODO: Define a title for the content shown. // TODO: If you have web page content that matches this app activity's content, // make sure this auto-generated web page URL is correct. // Otherwise, set the URL to null. Uri.parse("http://host/path"), // TODO: Make sure this auto-generated app deep link URI is correct. Uri.parse("android-app://jp.klab.SwitchBot01/http/host/path") ); } @Override public void onStop() { super.onStop(); Log.d(TAG, "onStop"); } @Override public void onDestroy() { super.onDestroy(); Log.d(TAG, "onDestroy"); // GATT 接続終了 if (mBtGatt != null) { mBtGatt.disconnect(); mBtGatt.close(); mBtGatt = null; } // スキャン停止 if (mBtScanner != null) { mBtScanner.stopScan(mScanCallback); mBtScanner = null; } } @Override public void onClick(View v) { if (v == (View)mButtonConn) { mHandler.sendEmptyMessage(MSG_GATTCONNECT); } else if (v == (View)mButtonTurnOn) { mHandler.sendEmptyMessage(MSG_SW_ON); } else if (v == (View)mButtonTurnOff) { mHandler.sendEmptyMessage(MSG_SW_OFF); } else if (v == (View)mButtonPress) { mHandler.sendEmptyMessage(MSG_SW_PRESS); } else if (v == (View)mButtonDisconn) { mHandler.sendEmptyMessage(MSG_GATTDISCONNECT); } return; } @Override public boolean handleMessage(Message msg) { switch (msg.what) { case MSG_DOSCAN: // スキャン開始 Log.d(TAG, "msg: MSG_DOSCAN"); ScanSettings scanSettings = new ScanSettings.Builder(). setScanMode(SCAN_MODE).build(); mBtScanner.startScan(null, scanSettings, mScanCallback); break; case MSG_STOPSCAN: // スキャン停止 Log.d(TAG, "msg: MSG_STOPSCAN"); mBtScanner.stopScan(mScanCallback); break; case MSG_FOUNDDEVICE: // ペリフェラルのアドバタイズパケットを検出 Log.d(TAG, "msg: MSG_FOUNDDEVICE"); mHandler.sendEmptyMessage(MSG_STOPSCAN); setTvColor(Color.BLACK); setButtonsVisibility(true); break; case MSG_GATTCONNECT: // デバイスへの接続を開始 Log.d(TAG, "msg: MSG_GATTCONNECT"); showProgressMessage(getString(R.string.app_name), "デバイスへ接続中・・・"); mBtGatt = mBtDevice.connectGatt(mCtx, false, mGattCallback); mBtGatt.connect(); break; case MSG_GATTCONNECTED: // デバイスへの接続が完了 Log.d(TAG, "msg: MSG_GATTCONNECTED"); setTvColor(Color.LTGRAY); // デバイスの GATT サービス一覧の取得へ mBtGatt.discoverServices(); break; case MSG_GATTGOTSERVICE: // デバイスの GATT サービス一覧取得完了 Log.d(TAG, "msg: MSG_GATTGOTSERVICE"); if (mProgressDlg != null) { mProgressDlg.cancel(); mProgressDlg = null; } setButtonsEnabled(true); break; case MSG_GATTDISCONNECT: // デバイスとの切断 Log.d(TAG, "msg: MSG_GATTDISCONNECT"); mBtGatt.disconnect(); break; case MSG_GATTDISCONNECTED: // デバイスとの切断完了 Log.d(TAG, "msg: MSG_GATTDISCONNECTED"); setButtonsEnabled(false); if (mBtGatt != null) { mBtGatt.close(); mBtGatt = null; } showDialogMessage(mCtx, "デバイスとの接続が切断されました", false); mHandler.sendEmptyMessage(MSG_DOSCAN); break; case MSG_SW_ON: // Turn On Log.d(TAG, "msg: MSG_SW_ON"); mChUser2.setValue(mCmdSwOn); mBtGatt.writeCharacteristic(mChUser2); break; case MSG_SW_OFF: // Turn Off Log.d(TAG, "msg: MSG_SW_OFF"); mChUser2.setValue(mCmdSwOff); mBtGatt.writeCharacteristic(mChUser2); break; case MSG_SW_PRESS: // PRESS Log.d(TAG, "msg: MSG_SW_PRESS"); mChUser2.setValue(mCmdSwPress); mBtGatt.writeCharacteristic(mChUser2); break; case MSG_ERROR: showDialogMessage(this, "処理を継続できないため終了します", true); break; } return true; } @Override public void run() { } // ダイアログメッセージ private void showDialogMessage(Context ctx, String msg, final boolean bFinish) { new AlertDialog.Builder(ctx).setTitle(R.string.app_name) .setMessage(msg) .setPositiveButton("OK", new DialogInterface.OnClickListener() { public void onClick(DialogInterface dialog, int whichButton) { if (bFinish) { finish(); } } }).show(); } // プログレスメッセージ private void showProgressMessage(String title, String msg) { if (mProgressDlg != null) { return; } mProgressDlg = new ProgressDialog(this); mProgressDlg.setTitle(title); mProgressDlg.setMessage(msg); mProgressDlg.setProgressStyle(ProgressDialog.STYLE_SPINNER); mProgressDlg.show(); } private void setButtonsEnabled(boolean isConnected) { mButtonConn.setEnabled(!isConnected); mButtonTurnOn.setEnabled(isConnected); mButtonTurnOff.setEnabled(isConnected); mButtonPress.setEnabled(isConnected); mButtonDisconn.setEnabled(isConnected); } private void setButtonsVisibility(boolean visible) { int v = (visible)? View.VISIBLE : View.INVISIBLE; mButtonConn.setVisibility(v); mButtonTurnOn.setVisibility(v); mButtonTurnOff.setVisibility(v); mButtonPress.setVisibility(v); mButtonDisconn.setVisibility(v); } private void setTvColor(int color) { mTvRssi.setTextColor(color); mTvAddr.setTextColor(color); } }
AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?> <manifest xmlns:android="http://schemas.android.com/apk/res/android" package="jp.klab.SwitchBot01"> <uses-permission android:name="android.permission.BLUETOOTH"></uses-permission> <uses-permission android:name="android.permission.BLUETOOTH_ADMIN"></uses-permission> <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"></uses-permission> <uses-feature android:name="android.hardware.bluetooth_le" android:required="true"/> <application android:allowBackup="true" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" android:supportsRtl="true" android:theme="@style/AppTheme"> <activity android:name=".MainActivity" android:label="@string/app_name" android:configChanges="orientation|screenSize" android:screenOrientation="portrait" android:theme="@style/AppTheme.NoActionBar"> <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LAUNCHER" /> </intent-filter> </activity> <!-- ATTENTION: This was auto-generated to add Google Play services to your project for App Indexing. See https://g.co/AppIndexing/AndroidStudio for more information. --> <meta-data android:name="com.google.android.gms.version" android:value="@integer/google_play_services_version" /> </application> </manifest>
ESP32 で操作してみる
Android アプリからの操作を確認したところで、次にマイコン方面からのコントロールを試すことにしました。 手元にある BLE モジュールの中で BLE セントラル機能に対応しているのは ESP32 ボードの ESP-WROOM-32 (秋月版) だけなので選択の余地なくこれを使います。
ESP32 用の BLE セントラルコードは以前 ESP-IDF ベースで何度か作成したことがありますが、生産性は Arduino IDE + Arduino core for the ESP32 環境のほうが高いためできれば後者を利用したいと考えました。
幸い、ESP 界隈で著名な Neil Kolban さんが ESP32 Adruino core 用の高機能な BLE ライブラリ「ESP32 BLE for Arduino」を 2017年9月より公開していることを知り、さっそく examples ディレクトリ下の BLE_client.ino のコードを下敷きにプログラムを実装してみました。
プログラム起動時に BLE ペリフェラルからのアドバタイズパケットをスキャンし、自分の SwitchBot を見つけたら接続を確立して「Press」コマンドを一度発行する簡潔な内容としました。
動作の様子とソースコードです。動画ではボード上のリセットボタンを三度押下しています。
動画:22秒 音量注意
BLE_SwitchBot01_BLE.ino
/** * * BLE_SwitchBot01_BLE.ino (for ESP32) * * BLE セントラル. SwitchBot からのアドバタイジングを検出したら * 一度だけ同 GATT サーバへ接続~所定のキャラクタリスティックへ * Press コマンドを書き込んでアームを動かす. * * Arduino core for ESP32 向けの Neil Kolban 氏による * 下記 BLE ライブラリを使用 * https://github.com/nkolban/ESP32_BLE_Arduino * */ #include "BLEDevice.h" #include <HardwareSerial.h> // 手元の SwitchBot のアドレス static String addrSwitchBot = "c0:65:9a:7d:61:e1"; // SwitchBot のユーザ定義サービス static BLEUUID serviceUUID("cba20d00-224d-11e6-9fb8-0002a5d5c51b"); // 上記サービス内の対象キャラクタリスティック static BLEUUID charUUID("cba20002-224d-11e6-9fb8-0002a5d5c51b"); // SwitchBot の Press コマンド static uint8_t cmdPress[3] = {0x57, 0x01, 0x00}; static BLEAddress *pGattServerAddress; static BLERemoteCharacteristic* pRemoteCharacteristic; static BLEClient* pClient = NULL; static boolean doSendCommand = false; // BLEDevice::init() でのシリアルポート混乱対策 static HardwareSerial hs2(2); void dbg(const char *format, ...) { char b[512]; va_list va; va_start(va, format); vsnprintf(b, sizeof(b), format, va); va_end(va); hs2.print(b); } // アドバタイズ検出時のコールバック class advdCallback: public BLEAdvertisedDeviceCallbacks { void onResult(BLEAdvertisedDevice advertisedDevice) { dbg("BLE device found: "); String addr = advertisedDevice.getAddress().toString().c_str(); dbg("addr=[%s]\r\n", addr.c_str()); // SwitchBot を発見 if (addr.equalsIgnoreCase(addrSwitchBot)) { dbg("found SwitchBot\r\n"); advertisedDevice.getScan()->stop(); pGattServerAddress = new BLEAddress(advertisedDevice.getAddress()); doSendCommand = true; } } }; void setup() { hs2.begin(115200, SERIAL_8N1, 16, 17); Serial.begin(115200); dbg("start"); // BLE 初期化 BLEDevice::init(""); // デバイスからのアドバタイズをスキャン BLEScan* pBLEScan = BLEDevice::getScan(); pBLEScan->setAdvertisedDeviceCallbacks(new advdCallback()); pBLEScan->setActiveScan(true); pBLEScan->start(30); } void loop() { if (doSendCommand == true) { if (connectAndSendCommand(*pGattServerAddress)) { } else { dbg("connectAndSendCommand failed"); } doSendCommand = false; dbg("done"); } delay(1000); } // SwitchBot の GATT サーバへ接続 ~ Press コマンド送信 static bool connectAndSendCommand(BLEAddress pAddress) { dbg("start connectAndSendCommand"); pClient = BLEDevice::createClient(); pClient->connect(pAddress); dbg("connected\r\n"); // 対象サービスを得る BLERemoteService* pRemoteService = pClient->getService(serviceUUID); if (pRemoteService == nullptr) { dbg("target service not found\r\n"); return false; } dbg("found target service\r\n"); // 対象キャラクタリスティックを得る pRemoteCharacteristic = pRemoteService->getCharacteristic(charUUID); if (pRemoteCharacteristic == nullptr) { dbg("target characteristic not found"); return false; } dbg("found target characteristic\r\n"); // キャラクタリスティックに Press コマンドを書き込む pRemoteCharacteristic->writeValue(cmdPress, sizeof(cmdPress), false); delay(3000); if (pClient) { pClient->disconnect(); pClient = NULL; } return true; }
SwitchBot をインターネットごしに操作する
ESP32 といえば・・
上記のように ESP32 で適切に SwitchBot を操作可能であることが確認できました。そこでふと、ここにインターネットアクセスの要素を絡めることを思いつきました。 ESP32 が備えている BLE 機能と WiFi 機能をパラレルに利用できることは以前の試みを通じて確認ずみです。そのため「インターネットごしに所定のメッセージを受信したら SwitchBot を作動させる」ことができるのではないかと考えました。もちろん SwitchBot 公式のインターネットハブ製品である「SwitchBot Hub」のように高機能なものには到底及ばないにしても、ESP32 を使えば最低限の遠隔操作は実現できそうです。
道具立て
インターネット経由での ESP32 へのメッセージングには MQTT を利用することにしました。
CloudMQTT
MQTT ブローカーは最近 Beebotte が流行っているようですが、いくつかのサービスを試し今回はもっとも手に馴染んだ「CloudMQTT」を選びました。無料枠も手元の要件には十分です。
- CloudMQTT - A globally distributed MQTT broker - www.cloudmqtt.com
設定
Web コンソール
Pricing
FREE- 5 users/acl rules/connections
- 10 Kbit/s
HTTP to MQTT bridge
ノーマルな HTTP リクエストの体裁でメッセージを Publish できれば何かと便利です。Beebotte とは違い現時点で CloudMQTT に用意されている REST API は管理用のもののみですが、petkov さんによる「HTTP to MQTT bridge」の存在とこれを Heroku から利用できることを知りました。ちなみに、Heroku には CloudMQTT 用のアドオンも用意されていますが、ざっと説明を読んだところでは単に CloudMQTT 上のインスタンスを呼び出す内容のようで HTTP to MQTT bridge の利用と比べ際立ったメリットが見えないことと、また、Heroku でアドオンを利用するにはクレジットカード登録が必要である点が若干微妙でもあり今回は利用を見合わせました。
- HTTP to MQTT bridge - www.home-assistant.io
- http_to_mqtt - github.com/petkov
- Deploy your own HTTP to MQTT bridge - heroku.com
設定
価格体系
"認証済みアカウントで 1 月あたり 1,000 時間分の無料 dyno 時間を使えます。未認証アカウントの場合は 550 時間分です。無料の dyno がアクティブになるとプールから dyno が取り出され、dyno 時間がプールに残っている限り、無料でアプリケーションを実行できます。"
「Dyno」を徹底的に理解する CodeZine
IFTTT
HTTP クライアントから ESP32 へメッセージを送る分には以上の準備で事足ります。一方、SwitchBot のメインのキーワードである「スマートホーム」にはこのところ「スマートスピーカー」がぴたりと寄り添っている感がありますね。なので、お約束どおり(?)手元の Google Home Mini 用に IFTTT アプレットを用意しました。トリガーには Google Assistant サービスを、アクションには Webhooks サービス(旧 Maker チャネル)を割り当て、後者へ上のブリッジへのリクエストを設定します。
- IFTTT helps your apps and devices work together - ifttt.com
トリガー
アクション
コードサイズの超過と対処
引き続き Nick O'Leary さんによる Arduino Client for MQTT ライブラリを利用して CloudMQTT に対向しメッセージの Publish / Subscribe を行うシンプルなスケッチを作成、ESP32 上で問題なく動作することを確認しました。 これと前掲の BLE セントラルプログラムのふたつを軸として結合し細かい処理を書き加えれば完成でしょう。
まずはラフに結合してみました。すると、コンパイルは通ったものの ESP-WROOM-32 のフラッシュメモリへの書き込みでエラーが発生しました。コードサイズの超過です。
最大1310720バイトのフラッシュメモリのうち、スケッチが1568826バイト(119%)を使っています。
最大294912バイトのRAMのうち、グローバル変数が70452バイト(23%)を使っていて、ローカル変数で224460バイト使うことができます。
スケッチが大きすぎます。http://www.arduino.cc/en/Guide/Troubleshooting#size には、小さくするコツが書いてあります。
ボードESP32 Dev Moduleに対するコンパイル時にエラーが発生しました。
あらためて元のふたつのスケッチのコードサイズを確認してみると、内容的にはシンプルな BLE プログラム側が単体で書き込み可能容量の 94%にも及んでいました。ESP32 BLE for Arduino ライブラリのボリュームの大きさが窺えます。
最大1310720バイトのフラッシュメモリのうち、スケッチが1237529バイト(94%)を使っています。
以下を試しました。
- Arduino 公式のガイダンスとネット上の情報を参考にコーディングレベルでの対処を実施
- 結果:数10KB程度の削減に留まった
- ESP32 BLE for Arduino のアーカイブページから、もっともスリムな初版の ESP32_BLE_Arduino-0.1.0.zip(2017-09-10) へのバージョンダウンを試す
- 結果:すでに現行の Arduino core for the ESP32 とは互換性がなくコンパイルエラーが多発。手元で随所に手を加え最終的にビルド可能となったが当該スケッチのコードサイズは書き込み可能容量対比 115% 程度までにしか収まらず
- ネット上の情報にそってフラッシュメモリ上の書き込み可能領域を増やしてみる
- 結果:パーティションテーブルの編集によりコードの書き込みには成功した。だがプログラムは正常に動作せずリブートを繰り返した
現時点では他にこれといった対策も見当たらず Arduino IDE 環境での開発はここで一旦断念しました。
ESP-IDF 環境への移行 ~ 完成
こういった経緯を経てネイティブの ESP-IDF 環境へ移行しプログラムを書き直しました。 Espressif はコアライブラリを頻繁に更新しており API の後方互換性が必ずしも維持されないケースのあることが気がかりではありますが、その点は現在もなお活火山状態にある ESP32 向けの開発の宿命と割り切るべきでしょう。
準備
まず ESP-IDF をこの時点での最新版へアップデートしました。
~/esp/esp-idf$ git describe
v3.0-dev-2561-g358c822
MQTT ライブラリは Tuan さんによる「ESP32 MQTT Library」を使用することに。README の記述にそって <ESPIDF>/components/espmqtt へ Git サブモジュールとして一式を追加しました。
- ESP32 MQTT Library - github.com/tuanpmt
処理の流れ
プログラムは以下の内容としました。
- WiFi 接続を確立
- MQTT セッションの開始
- トピック "msg" の メッセージ "1" を受信したら以下の BLE セントラル処理を発動
- アドバタイズパケットのスキャンを実行
- 手持ちの SwitchBot からのアドバタイズを検知したらスキャンを停止して接続を確立
- SwitchBot のサービス一覧の取得を開始
- サービス cba20d00-224d-11e6-9fb8-0002a5d5c51b を発見したらその配下のハンドルの範囲を記憶
- サービス一覧の取得が完了したら上記サービス配下のキャラクタリスティック cba20002-224d-11e6-9fb8-0002a5d5c51b のハンドルを取得
- 上記キャラクタリスティックへ Press コマンド {0x57, 0x01, 0x00} を書き込んで切断
- 上記 3. へ
動作の様子
動画:34秒 音量注意 (※ ウェイクワード「ねえ, グーグル」の発声もあります)
ソースコード
- SwitchBotMqtt01 - github.com/mkttanabe
(tanabe)