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
この記事のURLComments(0)IoT | Bluetooth
2018年04月27日

最近のPython-dev(2018-04)

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

バックナンバー:

Python 3.7 がベータになり、大きな変更はなく安定期に入りました。 その間、Python の言語自体やエコシステムに関して重要な話題が幾つかありました。


pypi.python.org から pypi.org

長年 Python のエコシステムを支えてくれていた PyPI がリニューアルしました。

Python 3 への移行を始めとしてモダン化され、 Markdown で書いた README をレンダリングできるようになるなどの改善も入っています。

IRC から Zulip chat へ

freenode に python-dev という IRC チャンネルがあるのですが、新しい貢献者がコミュニケーションを取るのに今更IRCを使うのはハードルが高いんじゃないか。Slack や Discord みたいなモダンな環境を試してみないかというメールを投げた所、 Zulip を試用することになりました。

今のところは python-dev とその周辺に用途が限定されていますが、結果が好評なら将来的にもう少し広い用途にも開放されるかもしれません。

個人的には、UIやモバイルアプリの完成度は Slack や Discord にかなわないものの、サブチャンネル的な topic という機能が便利で、複数の話題が並列したときに1つの話題に集中でき、なおかつ Slack のスレッドのように議論が見えにくくなることもないという点にメリットを感じています。

RHEL 7.5 が Python 2 を deprecate

RHEL 7.5 がリリースされ、リリースノートで Python 2 が deprecate されました。つぎのメジャーバージョン (RHEL 8?) で Python 2 が削除され Python 3 だけがサポートされるようです。

Ubuntu 18.04 LTS の方は Python 2 を main リポジトリから外せませんでしたが、 20.04 LTS までには外すでしょうから、(サポート終了後のマシンが一部残るものの) Python 2 を使った環境はだいたい 2025 年ごろにリタイアすることになると思われます。

PEP 394 アップデート

PEP 394 -- The "python" Command on Unix-Like Systems

この PEP は、 Python 2 と3 の非互換による苦痛を緩和するためのコマンド名とshebangについてのガイドラインを提供しています。大雑把に言うと次のようになっています。

  • Python 2 が利用可能なら python2 コマンドを、 Python 3 が利用可能なら python3 コマンドを用意する。
  • python コマンドは python2 コマンドと同じ Python を起動するようにする。
  • Python 2 にしか対応しないスクリプトは shebang で python2 を使う。 Python 3 にしか対応しないスクリプトは shebang で python3 を使う。両対応のスクリプトが shebang に python を使う。

これはあくまでもガイドラインであり、 Gentoo や Arch のように python コマンドがデフォルトで Python 3 になっているディストリビューションもあるし、 macOS は python2 コマンドを用意してくれませんが、それでも Debian, Ubuntu, Fedora, Red Hat の開発者がこのガイドラインにより足並みを揃えてくれるだけでも無いよりはマシです。

さて、このガイドラインは時々更新されることになっていて、 Fedora / RHEL が Python 2 を捨てるのに合わせてどうするかが話題になりました。現在の Guido の考えはだいたいこんな感じです。

  • Python 2.7.10 が出ると共に、「2桁バージョン嫌い」を克服したので、Python 3.9 の次は多分 3.10 になる。 "python3" というコマンド名を使い続けるのに問題はない。 Python 4 は GIL の卒業といった大きな変更が入るときになるだろう。
  • (Dropbox では) まだ Python 2 と 3 の両方を使っているので、 "python" が常に Python 2 であって欲しい。
  • "python" コマンドが存在しないのは問題ない。
  • Python 3 で venv を作ったときに python というコマンドをオーバーライドしてしまうのは間違いだった。

Python 2 を捨てるのを機に "python" コマンドを Python 3 にしたかった Fedora / Red Hat のメンテナにとっては水を差された形です。

一方ですでに "python" コマンドが Python 3 になっているディストリや venv は現存するので、強引に "python" コマンドが Python 3 を指すことを禁止することもしません。結論として、今回のアップデートの大きな変更点は次のような変更になります。

  • "python" コマンドは存在しなくて良い (存在する場合は、今まで通り "python2" コマンドと同一であるべき)
  • (この PEP が有効な期間中に) "python" コマンドが "python3" になることを期待させる段落を削除

ということで、「早くどこでも python というコマンドが Python 3 にならないかなー」という希望は捨てて、 "python3" が正式な推奨されるコマンド名なんだということに慣れたほうが良さそうです。

PEP 572 -- Assignment Expressions

Python はずっと代入を「文 (statement)」としてきましたが、新しく := という演算子で代入式を追加しようという提案です。

例えば次のようなコードが書けるようになります。

# 572 があるとき
if m := re.match(pat, s):
    # do something with m

# ないとき
m = re.match(pat, s)
if m:
    # do something with m

メールの量が膨大すぎるのと娘の誕生日があったので完全には議論を追えてないんですが、最初のモチベーションはリスト内包表記で値の再利用をしたいということだったと思います。

# foo() を2回計算してしまっている
ys = [foo(x) for x in xs if foo(x) is not None]

# 一回でやりたい
ys = [z for x in xs if (z := foo(x)) is not None]

内包表記に限った構文拡張や、新たにブロックスコープを追加するなど、いろんなアイデアが出ましたが、シンプルさと便利さのバランスで今の提案に落ち着きました。

とはいえ、 Python はずっと「代入は文」という制約と共にあった言語なので、この提案は制約がもたらす可読性を失わせるものでもあります。

今までは複雑な式を読み飛ばしても代入を見逃す危険は無かったのが、PEP 572 が承認された場合は、自分が代入を見逃していないか疑いながらコードを読む必要が出てきます。

なので「追加されれば便利に使うだろうけれども今の段階では +1 はしない」という慎重派も多いですし、反対の人もいます。私も慎重派の一人で、メリットを質だけじゃなくて量(頻度)でも説明してほしいなと思っています。

参照実装もあるので興味のある人は試してみてください。(でもMLでの議論に参加するときは冷静にお願いします。)


@methane

songofacandy at 12:40
この記事のURLComments(0)Python 
2018年04月04日

電波法適合の NFC モジュールで安価な NFC タグを利用する

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

はじめに

当ブログの記事「NFC タグで秘密情報を安全に携行する試み」では MIFARE Ultralight 準拠の NXP 製「NTAG21x」シリーズに目を向けました。そこでは市場で現在主流のこの NFC タグ製品の機能面での奥の深さを Android デバイスを使って試してみましたが、値段も安くすっかり気に入った NTAG21x を自作の装置でも利用したいと考えました。携帯端末の可搬性、据え置き型装置の拡張性、どちらも日常生活での NFC の実用性を支えている土台的な要素ですね。

NFC リーダ/ライタモジュール製品を探す

そんなわけでまずは電子工作向きの NFC リーダ/ライタモジュールを探すことから始めました。ネットを見渡してみると様々なものが出回っており、以下の製品などが目にとまりました。いずれもなかなか良さそうです。

国外のメーカーによるこれらのモジュールは個人でも簡単に購入することができます。一方、国内メーカーの製品情報を探したところでは、いくつもの企業が NFC リーダ/ライタモジュールの製造・販売を行ってはいるもののそのほとんどが法人向けの組み込み用であることを知りました。 その中で、私自身が今回調べた範囲では、2018年3月時点で個人が一般のディストリビュータから入手可能な国内製品はソニー株式会社様による「RC-S620/S」のみでした。もし他にも入手しやすい国内製品があればぜひご教示下さい。

さて、ここで気になるのがこれらの製品と電波法との関係です。

電波法と NFC モジュールとの関係について

ご承知のように、日本国内で「技適マーク」のない無線機を使用すると電波法違反となる可能性があり、外国製の機器を使いたい場合などにはこのことが最初に確認すべきポイントとなります。では、やはり電磁波を発生させる NFC リーダ/ライタは電波法においてどのように扱われているのでしょう。技適証明が必要なのか、それとも周囲への影響が微弱であるため特に規定されていないのか。この点について調べてみました。

まとめ

結論として、電波法には NFC リーダ/ライタに関する明確な規定があり、それに適合しない製品の日本国内での使用は NG であることを知りました。要点を整理してみます。

  • いわゆる技適証明は特定無線設備を対象とするものだが、NFC リーダ/ライタは電波法において特定無線設備にはあたらず「高周波利用設備」に該当し、そこに含まれる「誘導式読み書き通信設備」に分類される
    • つまり NFC リーダ/ライタは技適マークとは関係がない
  • 誘導式読み書き通信設備は、その電界強度が所定の条件を満たしている場合、または、総務大臣による型式指定を受けている場合には個別の許可なしに設置することができる
    • 型式指定の申請者は日本人または日本国内の法人でありかつ製造業者または輸入業者でなければならない
  • 電界強度条件との整合性の確認にも型式指定を受けるにも適正な方法での性能試験が必須

詳細

型式指定表示の実例

手元にあるふたつの製品での型式指定表示と公式サイトからの引用を示します。前掲の RC-S620/S にもしっかり印刷されています。  ※クリックで大きく表示

RC-S620/S について

貴重な選択肢として

今回手元で調べた範囲では、日本の電波法に準じた型式指定表示のある国外メーカーの NFC モジュールは見当たりませんでした、また、「3mの距離において電界強度が500μV/m」などという性能情報を目にすることもありません。 そのため、いまの時点で手近な実験や電子工作を適法に行うためには入手が容易で電波法適合の RC-S620/S は貴重な選択肢ということになります。

個人的には、電波法の必然性と重要性を十分に理解・納得しながらも、一方で、とりわけ IoT 方面での国外メーカーによる国際的にメジャーな製品を公然と試すことのできないケースが発生することをもどかしく思うこともあります。手近な実験や試作のためにわざわざ電波暗室を借りたり渡航したりするわけにもいきませんしね。。 (^^;
さっそくスイッチサイエンス様のサイトから RC-S620/S を購入しました。 本来組み込み用途のこのモジュールの入出力端子は FFC コネクタで一般的な電子工作では何かと取り回しにくいのですが、同社オリジナルのブレークアウト基板と専用ケーブルのセットがあわせて販売されているためとても助かります。

利用者向けの公式リソース

利用者向けに以下のリソースが公式サイトで公開されています。各ダウンロードページに記載された規約・使用許諾契約の内容を十分に確認した上でこれらを利用します。

  • Sony Japan | FeliCa | 法人のお客様 | ダウンロード
    • RC-S620/S製品仕様書<簡易版>
    • RC-S620/Sコマンドリファレンスマニュアル<簡易版>
      規約
             :
      第3条(使用権)

      1. ソニーは、利用者に対して、利用者が対象文書を使用目的のために本ウェブサイトから複製することができる非独占的な権利を無償にて利用者に許諾します。
      2. 利用者は、対象文書の全部または一部を、さらに複製したり、これに対する修正、追加等の改変をすることはできません。また、対象文書およびその複製物を、第三者に頒布したり、ウェブサイトにアップロードするなどして第三者が取得可能な状態にしないものとします。
             :
  • Arduino向けRC-S620/S制御ライブラリの提供
    • 「Arduino向けRC-S620/S制御ライブラリ」ダウンロードページ
      RC-S620/S制御ライブラリ 使用許諾契約
             :
      第1条(総則)
       1.ソニーは、本規約に定める各条項に従い、本ソフトウェアおよび関連資料に関する非独占的かつ譲渡不能な次の権利(但し特許権は除く。以下「使用権」とします)を、お客様に許諾します。
        (1)お客様が、ソフトウェア使用者をして、本ソフトウェアおよび関連資料の全部もしくは一部を使用、複製、複写または改変してアプリケーションソフトウェアを開発する権利。 なお、本規約において、ソフトウェア使用者およびアプリケーションソフトウェアとは、それぞれ、次の意味を有します。
         .愁侫肇ΕД∋藩兌圈本ソフトウェアを入手頂いたお客様1名の方。
         ▲▲廛螢院璽轡腑鵐愁侫肇ΕД◆Д愁法疾夙鸚椰ICカードリーダ/ライター製品「RC-S620/S」を制御するためのソフトウェアプログラム。
        (2)お客様が、本項第1号に基づき開発されたアプリケーションソフトウェアを複製したうえ、ソースコード形式またはバイナリ形式にて、第三者に頒布する権利。
       2.お客様は、前項で定める場合を除き、本ソフトウェアおよび関連資料の全部もしくは一部を使用、複製、複写または改変してはならないものとします。
             :

公式リソースのカバーする範囲

このようにドキュメントに加えサンプルコードが公式に提供されていることは利用者にとってとても嬉しい配慮ですが、これらの参照・使用に際しては以下の事情をあらかじめ理解しておく必要があるでしょう。

  • RC-S620/S はあくまでも組み込み用製品であり本来の顧客は法人である
  • 各仕様書は冒頭のページの注釈のとおり「調査、試作および評価」を目的とする「簡易版」であり、「商用向け」の版は顧客が「正式に RC-S620/S を導入」する場合に提供される
  • 「製品仕様書<簡易版>」 1, 2 項に記載のあるように RC-S620/S は FeliCa に加え ISO/IEC 14443 (Type A, Type B) にも対応しているが、主眼はあくまでも前者である
  • 各仕様書およびサンプルコードは FeliCa まわりの一部の機能に特化した内容である

思いがけない援軍の存在

さて、冒頭で触れたように手元で実現したいのは MIFARE Ultralight NTAG21x へのアクセスです。RC-S620/S が ISO/IEC 14443 Type A(MIFARE)に対応していることは上記の通り仕様書に明記されているものの、公式のリソースではそのための具体的な方法や手順がわかりません。

まずそもそも MIFARE の近接を検知するにはどうすればいいのか。「Arduino向けRC-S620/S制御ライブラリ」の polling() 関数は FeliCa を検知するために「コマンドリファレンスマニュアル<簡易版>」に記載されている「InListPassiveTarget」というコマンドを呼び出しています。
ためしに「InListPassiveTarget ISO/IEC 14443 Type A」のキーワードで情報を検索したところ次の興味深い資料が目にとまりました。

PN532」は NXP による NFC モジュールです。この資料の中の InListPassiveTarget の記述箇所を探すと Page 115 (上図)にそのものずばりのコマンド説明がありました。

「RC-S620/Sコマンドリファレンスマニュアル<簡易版> Version 2.11」 Page 80 の InListPassiveTarget の説明と読み合わせてみると、ターゲットが FeliCa の場合のパラメータ説明は一致しており、さらに「簡易版」には記載されていない ISO/IEC 14443 Type A, B への対応方法を含め 7 ページに渡り詳しい説明が掲載されています。その他のコマンドについても同様で、CommunicateThruEx など RC-S620/S 側のみに存在するいくつかのコマンドはあるものの、全体としてはいわばスーパーセットのマニュアルのように見受けられました。実際にこのマニュアルに添って「Arduino向けRC-S620/S制御ライブラリ」の polling() 関数を以下の要領で書き換えてみると ISO/IEC 14443 Type A の近接を検知することができました。

FeliCa 対応のオリジナルコード

/* InListPassiveTarget */
memcpy(buf, "\xd4\x4a\x01\x01\x00\xff\xff\x00\x00", 9);
buf[5] = (uint8_t)((systemCode >> 8) & 0xff);
buf[6] = (uint8_t)((systemCode >> 0) & 0xff);

ret = rwCommand(buf, 9, response, &responseLen);

ISO/IEC 14443 Type A 用

ret = rwCommand((const uint8_t *)"\xd4\x4a\x01\x00", 4,
                 response, &responseLen);

このように、メーカーの異なる RC-S620/S と PN532 との間にコマンドレベルでの共通性がある理由は NFC フォーラム仕様のどこかにあるのかも知れませんが手元では今のところ該当する情報の特定に至っていません。 あるいは、両社がともに「デバイスやサービス間の新機能と互換性を実現するための規格策定」をミッションのひとつとする NFC フォーラムのコアメンバーであることに関係があるのかも知れません。いずれにせよ PN532 のマニュアルのおかげで先が見えてきました。

NTAG21x ネイティブコマンドの発行について

先般の記事では、NTAG21x のメモリアクセスに必要な次のよっつのネイティブコマンドを挙げました。

  • GET_VERSION
    NTAG 製品のバージョン,ストレージサイズ等を得る
  • READ
    所定のページアドレスから始まる 4 ページ分(16 バイト)のデータを得る
  • WRITE
    所定のページアドレスへ 1 ページ分(4 バイト)のデータを書き込む
  • PWD_AUTH
    パスワードで保護された領域へのアクセス要求に先行してパスワード認証を要求する

これらを RC-S620/S 経由でターゲットへ送出してやれば NTAG21x のメモリを自由に操作できるはずです。

RC-S620/S にはこのようにターゲットとする PICC 固有のコマンドを発行するための CommunicateThruEX コマンドが用意されています。ただし、「コマンドリファレンスマニュアル<簡易版> Version 2.11」 Page 71 の「本コマンドは 212/424 kbps の RF パケットの送受信を行います」の記述のとおりこのコマンドは FeliCa 専用で、通信速度 106 kbps の ISO/IEC 14443 向けには使えません。

一方、「PN532 User Manual Rev.02」にはそれと同様の機能で汎用性のある InDataExchange, InCommunicateThru のふたつのコマンドの情報が掲載されています。同マニュアルの InListPassiveTarget コマンドのページには MaxTg フィールドについて「The PN532 is capable of handling 2 targets maximum at once, so this field should not exceed 0x02.」と記述されており、InDataExchange においては target (Tg) を明示的に指定するのに対し、InCommunicateThruでは「it is assumed that a target has been first activated.」という違いがあります。

  • PN532 User Manual Rev.02 - 7.3.5 InListPassiveTarget
  • PN532 User Manual Rev.02 - 7.3.8 InDataExchange
  • PN532 User Manual Rev.02 - 7.3.9 InCommunicateThru

ふたたび RC-S620/S 側に目を向けると、「コマンドリファレンスマニュアル<簡易版> Version 2.11」 Page 50 の InListPassiveTarget コマンド説明で MaxTg フィールドについては「Target ID を取得する Target の最大数. 01h を指定してください」と記述されています。つまりこの部分の仕様は PN532 と異なる様子であり、この要素の影響を受ける InDataExchange, InCommunicateThru の挙動は、両コマンドの存在有無の確認を含め RC-S620/S 上で実際に試してみる必要がありそうです。

前掲のよっつの NTAG21x ネイティブコマンドの発行を RC-S620/S 経由で試みたところ、InListPassiveTarget によるターゲットの捕捉後に以下の組み合わせが有効であることを手元で確認しました。また、この結果から RC-S620/S においても InDataExchange, InCommunicateThru の両コマンドを利用可能であることが明らかになりました。

  • NTAG: GET_VERSION コマンド (0x60)
    • InCommunicateThru コマンド(0x42)経由での発行のみ有効
      • rwCommand((const uint8_t*)"\xd4\x42\x60", 3, response, &responseLen); の要領
  • NTAG: READ コマンド (0x30)
    • InDataExchange コマンド(0x40)、InCommunicateThru コマンド(0x42)いずれを経由しても有効
      • rwCommand((const uint8_t*)"\xd4\x40\x01\x30[開始ページ番号 1byte]", 5, response, &responseLen); の要領
      • rwCommand((const uint8_t*)"\xd4\x42\x30[開始ページ番号 1byte]", 4, response, &responseLen); の要領
  • NTAG: WRITE コマンド (0xA2)
    • InDataExchange コマンド(0x40)経由での発行のみ有効
      • rwCommand((const uint8_t*)"\xd4\x40\x01\xA2[対象ページ番号 1byte][書き込みデータ 4byte]", 9, response, &responseLen); の要領
  • NTAG: PWD_AUTH コマンド (0x1B)
    • InCommunicateThru コマンド(0x42)経由での発行のみ有効
      • rwCommand((const uint8_t*)"\xd4\x42\x1b[パスワード 4byte]", 7, response, &responseLen); の要領

ライブラリへの加筆とテストプログラムの作成

ここまでの経緯でおおむね材料が揃ったところで、「Arduino向けRC-S620/S制御ライブラリ」へ自分のほしかった機能を追加し、あわせて動作確認用にテストプログラムの作成を行います。

想定した内容

以下の内容を想定しました。NDEF メッセージの解釈や ISO/IEC 14443 Type B の ID 取得など若干の要素を追加しています。さしあたり手元で用途のなかった NTAG21x ネイティブの WRITE, PWD_AUTH コマンドは今回は利用していません。今後必要になったときに実装を追加しようと思います。

  • ライブラリへ追加する関数のイメージ
    • ISO/IEC 14443 Type A の近接検知と ID の取得
    • ISO/IEC 14443 Type B の近接検知と ID の取得
    • NTAG21x 専用: メモリの総ページ数を取得
    • MIFARE Ultralight 用: 所定のメモリページの内容を読み出す
  • テストプログラムのイメージ
    • ターゲットが ISO/IEC 14443 Type A (MIFARE) の場合
      • ID を取得しシリアルへ出力
        • MIFARE Ultralight の場合
          • メモリ内容のダンプをシリアルへ出力
            • NDEF メッセージが記録されている場合
              • NDEF レコード内容を順次取得しシリアルへ出力
    • ターゲットが ISO/IEC 14443 Type B の場合
      • 擬似 ID である PUPI (Pseudo-Unique PICC Identifier) を取得しシリアルへ出力
    • ターゲットが FeliCa (Standard / Lite / Lite-S) の場合
      • IDm を取得しシリアルへ出力

機器の接続

手元ではこれまで Arduino IDE を利用する機会はありましたが Arduino 実機を扱うのは今回が初めてでした。

RC-S620/S と Arduino UNO をシリアル接続して制御を行い、上記のように処理状況を UNO から USB 経由で PC 上のシリアルターミナルへ随時出力することを想定していたのですが、UNO のハードウェアシリアルが一系統のみであることを知り、別立ての USB シリアル変換モジュール(FT231X)と Arduino 標準の SoftwareSerial ライブラリを併用することにしました。

同じ理由で、Arduino IDE から UNO 本体の USB シリアル経由でプログラムを書き込む際には [RC-S620/S 側 TXD] <--> [RX ピン] の結線を外して排他する必要があります。このあたりの事情は Arduino ユーザには当然のことなのでしょうがビギナーにはちょっと新鮮でした。

  

ソースコード

動作の様子

動画:3分19秒

  


(tanabe)
klab_gijutsu2 at 08:38
この記事のURLComments(0)NFC | IoT
2018年03月20日

LVSの高負荷対策 その3 〜drop entryのサービスへの影響について〜

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

こんにちは。インフラ担当の岡村です。
LVSの高負荷対策 その2 〜障害の再現とその原因〜」の記事で、LVSに備わっている高負荷対策機能であるdrop entry機能について紹介しました。今回は、drop entry機能が有効になった時のサービスへの影響を調査したので紹介します。

前回のおさらい

詳しくは前回の記事を見ていただきたいのですが、簡単にまとめると次のようになります。

  • drop entryは、IPVSのエントリをランダムに削除する機能。
    • SYN_RECV状態とSYNACK状態のエントリについては、1秒おきにIPVSのコネクションハッシュテーブルからランダムに選んだ範囲(全体の32分の1)をスキャンし、その中のエントリを削除する。
    • ESTABLISHED状態のエントリについては、「最後のパケットが届いてから60秒以上経過」かつ「最初のパケットが届いてからの受信パケットの合計数が8以下」のエントリのみ削除の対象とする。
  • 空きメモリのサイズが設定した閾値を下回ったときに、自動でdrop entryを有効にすることが可能。
    • 閾値を大きくすれば、少ないエントリ数でもdrop entryが有効になるようになりLVSの耐性は増すが、その分正規のサービス利用者の通信にも影響が出やすくなる。

前回、『drop entry有効時には、IPVSを経由するどの接続もエントリ削除の影響を受ける可能性がありますが、ロードバランサが落ちてサービス停止してしまうよりはずっと良いのではないか』と締めくくりましたが、実際にどれだけ正規の通信に影響を与えるのか気になります。
drop entryが有効になった時のサービスへの影響の大きさも、閾値を決めるための判断材料として知っておきたかったため、検証環境で調査してみました。

調査

まず、ESTABLISHED状態のエントリについてですが、一般的なWebアプリの通信では、「最後のパケットが届いてから60秒以上経過」かつ「最初のパケットが届いてからの受信パケットの合計数が8以下」となるような(正常な)通信は無いと判断し、調査の対象にしていません。
サービスへの影響が考えられるのは、SYN_RECV状態のエントリが削除されるときのみ(※)なので、その割合を測定しました。
(※KLabではDSR構成にしており、SYNACK状態にはならないためです。以降、DSR構成での調査の紹介になります。)

LVS経由でWebサーバと通信する場合、SYN_RECV状態のエントリが削除されると、WebサーバからのSYN,ACKは返ってきますが、クライアントからのACKがWebサーバに届かなくなり、お互いに再送を繰り返し、最終的にtimeoutします。 なので、指定した回数のhttpリクエストを行ってtimeoutになった回数をカウントするスクリプトを作成して、エントリ削除の回数を測りました。
LVSでは、/proc/sys/net/ipv4/vs/drop_entry に3を設定し、drop entry機能を常に有効にしています。検証環境内で、次の図の構成で測定を行いました。


packet_flow2

この場合、何度スクリプトを実行してもエントリは削除されず、timeoutは発生しませんでした。
これは、測定用サーバとLVSがネットワーク的に非常に近く、図の◆銑Δ隆屬僚衢彁間が0.3ms程度だったため、drop entryの処理に引っかからなかったためと考えられます。

SYN_RECV状態の時間が短い方が、drop entry機能で削除されにくくなることを説明するため、イメージ図1,2を作成しました。

drop_entry_image3

図内の矢印はIPVSのコネクションハッシュテーブル上に存在するSYN_RECV状態のエントリを表し、矢印の長さがSYN_RECV状態である時間です。 また、1秒おきに実行されるdrop entry機能のスキャンと削除の処理を、青の長方形で表しました。 すると、この長方形と重なった矢印(エントリ)が、drop entryで削除されると考えることができます。

drop_entry_image4

図2は、矢印の長さだけを変更したもので、図1の半分にしています。SYN_RECV状態が短いほど、drop entryの処理に引っかかりにくく、削除されにくいことがイメージできると思います。
それでは次に、実際に測定してみます。

遅延を考慮した測定

実際の通信では、パケットは外部の回線を流れるのでその分遅延します。遅延が大きければエントリがSYN_RECV状態である時間が長くなるため、削除される割合が多くなると予想されます。
そこで、測定用サーバ上でtcコマンドを使用して、外部と通信する際に発生する遅延を検証環境内で再現した上で、再度測定してみました。

packet_flow1

手元のスマホからLVSへpingすると、往復に100msほどかかっていたので、 50ms,100ms,200msの3通りの遅延時間で、削除されるエントリ数を確認しました。 その結果が次のようになります。

tcで指定した遅延時間50ms100ms200ms
エントリ削除回数 / 全リクエスト数37回 / 32000回79回 / 32000回201回 / 32000回
エントリ削除された割合0.115625 %0.246875 %0.628125 %

どの遅延の場合もおよそ、80 Requests per Second (計測時間400秒)となるように測定しています。
予想通り、遅延時間が大きいほど削除されるエントリ数が多くなりました。

念のため前回の記事と同様にhpingを使用して、エントリ数が異常に増えた場合のtimeoutの割合も調べましたが、結果はほぼ変わりませんでした。例えば、hpingでSYNパケットを350kppsの頻度で送りつけると、LVSの総エントリ数が1300万程度になりました。その状態で100ms遅延させた測定用サーバから上と同じ測定を行った結果、timeout数は70回(およそ0.22%)でした。
ただし、ppsが大きく、途中経路やLVS,Webサーバでパケロスが発生してしまうほどの場合は、パケットの再送により遅延が大きくなり、削除されるエントリ数が上の結果より多くなると思いますのでご注意ください。

まとめ

調査の結果、通信の遅延時間がエントリ削除の割合に関係していることがわかりました。
遅延はクライアントの通信環境やネットワーク的な距離などにも依るので一概には言えません。 しかし、200ms以下の遅延なら削除される割合は1%に満たなかったので、SYNパケットが大量に到来し始めたら早い段階でdrop entryを有効にし、LVSの耐性を上げることを優先して良いのではないでしょうか。

okamura_h at 17:10
この記事のURLComments(0)lvs 
2018年02月08日

Re: Configuring sql.DB for Better Performance

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

Configuring sql.DB for Better Performance という記事を知りました。 コネクションプールの大きさを制御する3つの設定を丁寧に解説されたとても良い記事です。

しかし、この記事で推奨されている設定については同意することができません。私が推奨する設定とその理由を解説していきたいと思います。

Limit ConnMaxLifetime instead of MaxIdleConns

Allowing just 1 idle connection to be retained and reused makes a massive difference to this particular benchmark — it cuts the average runtime by about 8 times and reduces memory usage by about 20 times. Going on to increase the size of the idle connection pool makes the performance even better, although the improvements are less pronounced.

この、 "to this particular benchmark" というのが問題です。このベンチマークでは、8並列で常にDBにクエリを投げ続けています。1つのクエリが終了するとすぐに次のクエリを投げるので、 DB.SetMaxIdleConns(1) で大きな効果が現れました。

このベンチマークの動作は、例えばDBに大量のデータを挿入するバッチ処理などに当てはまりますが、Web アプリケーションなどには当てはまりません。

1秒間に1000回クエリを実行するアプリケーションを想定した簡単なシミュレータを書いてみました。クエリは一様分布でランダムなタイミングで実行され、各クエリと新規接続には10msかかるとします。 (このシミュレータのgist)

MaxOpenConns(20) の時、 MaxIdleConns(4) と MaxIdleConns(10) の動作を比べてみましょう。オレンジの線は総接続数、青い線は使用中の接続数、緑の線は接続が利用可能になるのを待っている時間の最大値をミリ秒で表しています。

maxidle-4-vs-10

1000回のクエリを実行するのに、 MaxIdleConns(4) だと 285 回接続していますが、 MaxIdleConns(10) だとそれを 69 回まで減らすことができています。一方で、負荷が止まった後もずっと維持し続ける接続も増えてしまっています。

今度は SetMaxIdleConns(100); SetConnMaxLifetime(time.MilliSecond * 300) のシミュレーション結果を見てください。

maxlifetime-300

20x4=80 回の接続をしています。 MaxIdleConns(10) のときの 69 回よりも多いですが、これは動作をわかりやすくするために lifetime を短く設定しているためです。シミュレーション時間を100秒に伸ばしたら、MaxIdleConns(10) の場合では接続回数はおよそ 690 回になり、 SetConnMaxLifetime(time.Second * 30) の場合の接続回数は 80 回になるでしょう。

このグラフで、再接続が特定のタイミングに集中し、そのタイミングでレイテンシが伸びてしまっているのが気になるかもしれません。これはシミュレーションが完全に一様分布になっていて、最初に全ての接続がほぼ同時に作られてしまっているからです。時間によって負荷が変動するアプリケーションでは、接続が作られるタイミングがもっと分散するので、このスパイクは発生しにくいはずです。次のグラフは、200msかけて段階的に負荷が増えた後に、上のグラフと同じ1000msの負荷がかかったときのものです。

maxlifetime-300-2

SetConnMaxLifetime を使う他の理由

DB.SetConnMaxLifetime() を提案し実装したのは私です。このAPIはアイドルな接続を減らす SetMaxIdleConns() よりも良い方法ですが、それだけではありません。

"Configuring sql.DB for Better Performance" で紹介されたとおり、 MySQL では wait_timeout という設定で接続がサーバーから切られる恐れがあります。また、OSやルーターが長時間利用されていないTCP接続を切断することもあります。どのケースでも、 go-sql-driver/mysql はクエリを送信した後、レスポンスを受信しようとして初めてTCPが切断されたことを知ります。切断を検知するのに何十秒もかかるかもしれませんし、送信したクエリが実行されたかどうかを知ることもできないので安全なリトライもできません。

こういった危険をなるべく避けるためには、長時間使われていなかった接続を再利用せずに切断し、新しい接続を使うべきです。 SetConnMaxLifetime() は接続の寿命を設定するAPIですが、寿命を10秒に設定しておけば、10秒使われていなかった接続を再利用することもありません。

接続の寿命を設定することで、他にも幾つかの問題に対処することができます。

  • DBサーバーがロードバランスされているとき、サーバーの増減をしやすくする
  • DBサーバーのフェイルオーバーをしやすくする
  • MySQL でオンラインで設定変更したとき、古い設定で動作するコネクションが残り続けないようにする

接続のアイドル時間を制限するAPIを別に追加しなかったのは、現実的な環境における性能への影響と、 sql.DB の実装の複雑さを天秤にかけた結果です。

推奨する sql.DB の設定

  • SetMaxOpenConns() は必ず設定する。負荷が高くなってDBの応答が遅くなったとき、新規接続してさらにクエリを投げないようにするため。できれば負荷試験をして最大のスループットを発揮する最低限のコネクション数を設定するのが良いが、負荷試験をできない場合も max_connection やコア数からある程度妥当な値を判断するべき。
  • SetMaxIdleConns() は SetMaxOpenConns() 以上に設定する。アイドルな接続の解放は SetConnMaxLifetime に任せる。
  • SetConnMaxLifetime() は最大接続数 × 1秒 程度に設定する。多くの環境で1秒に1回接続する程度の負荷は問題にならない。1時間以上に設定したい場合はインフラ/ネットワークエンジニアによく相談すること。

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