2018年06月12日

SwitchBot を ESP32 で遠隔操作してみた

はてなブックマークに登録

SwitchBot と公開 API

物理的なスイッチやボタンを人に代わって操作してくれる BLE デバイス SwitchBot (by Wonderlabs,Inc.が人気です。有線/無線通信や赤外線等によるコントロールに対応していない機器をいわば強引に IoT 方面へ引き込もうという発想が面白いですね。


gigazine.net

技適マークと番号はパッケージに印刷されている

これは同じくクラウドファンディングを経て 2016年に商品化された MicroBot Push (by Naran Inc.: 工事設計認証番号 R208-160099 の後追い製品ではありますが、手元にある両者を比べてみると SwitchBot には後発であることのアドバンテージが随所に活かされているように見受けられます。とりわけ嬉しいのは本体のアームを制御するための API が公開されていることです。

この公約のとおり Wonderlabs,Inc. は Apache License 2.0 のもとに次のリソースを公開しています。

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」を選びました。無料枠も手元の要件には十分です。

HTTP to MQTT bridge

ノーマルな HTTP リクエストの体裁でメッセージを Publish できれば何かと便利です。Beebotte とは違い現時点で CloudMQTT に用意されている REST API は管理用のもののみですが、petkov さんによる「HTTP to MQTT bridge」の存在とこれを Heroku から利用できることを知りました。ちなみに、Heroku には CloudMQTT 用のアドオンも用意されていますが、ざっと説明を読んだところでは単に CloudMQTT 上のインスタンスを呼び出す内容のようで HTTP to MQTT bridge の利用と比べ際立ったメリットが見えないことと、また、Heroku でアドオンを利用するにはクレジットカード登録が必要である点が若干微妙でもあり今回は利用を見合わせました。

IFTTT

HTTP クライアントから ESP32 へメッセージを送る分には以上の準備で事足ります。一方、SwitchBot のメインのキーワードである「スマートホーム」にはこのところ「スマートスピーカー」がぴたりと寄り添っている感がありますね。なので、お約束どおり(?)手元の Google Home Mini 用に IFTTT アプレットを用意しました。トリガーには Google Assistant サービスを、アクションには Webhooks サービス(旧 Maker チャネル)を割り当て、後者へ上のブリッジへのリクエストを設定します。

コードサイズの超過と対処

引き続き 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 サブモジュールとして一式を追加しました。

処理の流れ

プログラムは以下の内容としました。

  1. WiFi 接続を確立
  2. MQTT セッションの開始
  3. トピック "msg" の メッセージ "1" を受信したら以下の BLE セントラル処理を発動
    • アドバタイズパケットのスキャンを実行
    • 手持ちの SwitchBot からのアドバタイズを検知したらスキャンを停止して接続を確立
    • SwitchBot のサービス一覧の取得を開始
    • サービス cba20d00-224d-11e6-9fb8-0002a5d5c51b を発見したらその配下のハンドルの範囲を記憶
    • サービス一覧の取得が完了したら上記サービス配下のキャラクタリスティック cba20002-224d-11e6-9fb8-0002a5d5c51b のハンドルを取得
    • 上記キャラクタリスティックへ Press コマンド {0x57, 0x01, 0x00} を書き込んで切断
  4. 上記 3. へ

動作の様子

動画:34秒 音量注意 (※ ウェイクワード「ねえ, グーグル」の発声もあります)

ソースコード


(tanabe)
klab_gijutsu2 at 04:44│Comments(0)IoT | Bluetooth

この記事にコメントする

名前:
URL:
  情報を記憶: 評価: 顔   
 
 
 
Blog内検索
Archives
このブログについて
DSASとは、KLab が構築し運用しているコンテンツサービス用のLinuxベースのインフラです。現在5ヶ所のデータセンタにて構築し、運用していますが、我々はDSASをより使いやすく、より安全に、そしてより省力で運用できることを目指して、日々改良に勤しんでいます。
このブログでは、そんな DSAS で使っている技術の紹介や、実験してみた結果の報告、トラブルに巻き込まれた時の経験談など、広く深く、色々な話題を織りまぜて紹介していきたいと思います。
最新コメント