BLEを使ってiosとm5stack間で通信する

やりたいこと

iOS端末とM5StackでBLEを使って通信を行いたい。 ここではiOSとM5のそれぞれで最小構成のコードを書いて試すことをゴールにする。

TL:DR

基礎知識

コーディングに入る前にBLEについてざっくり理解をしておきたい。 以下のサイトを参照している。理解のためにまとめ直しているが、詳細は元サイトを見てもらいたい。 https://www.musen-connect.co.jp/blog/course/trial-production/

BLEとは

Bluetooth Low Energyの略。Bluetoothは近距離無線通信の規格の1つである。BLEはBluetoothの規格の中で省電力に特化するために出来た通信方式。(補足.それまでのBluetoothの通信方式はBLEと区別するために、Bluetooth Classicと呼ばれる)

登場人物

大きく、CentralとPeripheralが登場する。 無線通信は大抵が[親機]と[子機]に分かれる。BLEでは親機のことを[Central]、子機のことを[Peripheral]と呼ぶ。 セントラルは大抵スマホやPCなどの高機能デバイスがなり、センサーやビーコン等はペリフェラルになる。 1つのセントラルには複数のペリフェラルが同時に接続できるようになっている。

通信

通信は2段階で行われる。まずペリフェラルが接続待ち状態になり自分自身を認識させるために単方向通信を定期的に行い続ける。このことをアドバタイズという。

セントラルは発信された情報をスキャンすることで周辺に存在するペリフェラルを認識する。 セントラルがペリフェラルに対して接続要求を出すと第2段階に移行する。 要求を受けたペリフェラルはアドバタイズを終了し、セントラルとの接続を確立する。 接続が確立したあとはデータチャンネルを通じてデータのやり取りが行われる。

このデータのやり取りのことをGATTというプロトコルを使って行う。 GATTにはServiceとCharacterristicという概念がある。 ServiceとはCharacteristicを包含しているラベルのようなもの。 Characteristicは属性が決められている。Read, Write, Notifyなどがある。セントラルはRead属性がないCharacteristicの内容を読むことはできず、Write属性がないCharacteristicの内容を書くことができない。 ペリフェラルは複数ののService, Characteristicを持つことができる。それぞれUUIDが振られており、セントラルはUUIDを指定してCharacteristicのデータ内容にアクセスする。

このやり方ではセントラルとペリフェラルで双方向でデータの通信ができ、「コネクトモード」と呼ばれる。 もう1つ「ブロードキャストモード」と呼ばれるものがある。これはペリフェラルがセンサーデータなどをアドバタイジングに乗せて送り、セントラルがそれを読むというもの。iBeacon等もコレ。

M5Stackで BLEのやり取りができる状態にする

BLEについてなんとなくわかったところでコードを書いてみる。 完成品はこちらにおいてある

https://github.com/masarusanjp/ble-sample-ios-and-m5stack

構成

基本的なことを実装をしながら確認しつつ最終的にはiOSで渡した文字列をM5Stackのディスプレイに表示することをゴールにする。

文字列を受け取るとlcdに表示するperipheralの実装

Peripheral側から実装していく。 M5Stackにはesp32というチップが載っておりこのチップ用のプログラムを書く。 ESP32のArduinoにはBLEモジュールが提供されている。 exampleやドキュメントへのリファレンスはgithubで公開されている。

https://github.com/espressif/arduino-esp32/tree/master/libraries/BLE

※ 余談だが、エラーハンドリングについて書かれておらずちょっと困った。 swiftだと失敗する可能性も含めて型で表されているのでドキュメントがなくても困らないのだが。

まずはアドバタイズができるようにする。

#include <M5Stack.h>
#include <BLEServer.h>
#include <BLEDevice.h>

void setup() {
  setupM5Stack();
  setupBLE();
}

void loop() {
  delay(1000);
}

void setupM5Stack() {
  M5.begin();
  Serial.begin(115200);
}

void setupBLE() {
  Serial.println("Starting BLE");
  BLEDevice::init("my-peripheral");
  BLEServer *server = BLEDevice::createServer();
  BLEDevice::startAdvertising();
}

これで接続はできるはずだが、確認のために接続・非接続をシリアルポートに吐き出すようにしたい。 BLEServersetCallbacksBLEServerCallbacksを継承したクラスのポインタを渡すことで接続・非接続のイベントを受け取ることができる。

class ServerCallbacks: public BLEServerCallbacks {
public:
  virtual void onConnect(BLEServer* pServer) {
    Serial.println("connected");
  }
  virtual void onDisconnect(BLEServer pServer) {
    Serial.println("disconnected");
  }
};

void setupBLE() {
  ...
  BLEServer *server = BLEDevice::createServer();
  server->setCallbacks(new ServerCallbacks());
  ...
}

ここで正常に待ち受けが出来ているか確認する。 確認にはBLEScannerというアプリが便利。

BLE Scanner 4.0

BLE Scanner 4.0

  • Bluepixel Technologies LLP
  • 仕事効率化
  • 無料
apps.apple.com

BLEScannerを起動し、一覧からmy-peripheralconnectを選択すると以下の画面に遷移する。

f:id:masarusanjp:20201202213805p:plain

同時にシリアルモニタにもconnectedが出力された。この画面から離れると接続が切断されるようでシリアルモニタdisconnectedと出力された。

次にクライアントから文字列を受け取ってlcdに表示するようにする。 まずサービスとキャラクタリスティックを作成する。

BLEService *service = server->createService(serviceUUID);
BLECharacteristic *characteristic = service->createCharacteristic(
  characteristicUUID, // 予め生成しておいたUUID。ハードコーディングしてある
  BLECharacteristic::PROPERTY_READ |
  BLECharacteristic::PROPERTY_WRITE
);

BLECharacteristicもBLEServerと同様にsetCallbacksを持っている。このメソッドにBLECharacteristicCallbacksを継承したクラスのポインタを渡すことで、writeが発生した場合のイベントを受け取ることができる。

class CharacteristicCallbacks: public BLECharacteristicCallbacks {
public:
  virtual void onRead(BLECharacteristic* pCharacteristic) {
    Serial.println("read");
  }
  virtual void onWrite(BLECharacteristic* pCharacteristic) {
    Serial.println("write");
    std::string value = pCharacteristic->getValue();
    M5.Lcd.setCursor(10, 10);
    M5.Lcd.printf(value.c_str());
  }
};

...

void setupBLE() {
  ...
  characteristic->setCallbacks(new CharacteristicCallbacks());
  ...
}

onWriteのコールバック時にM5.Lcd.printfメソッドでlcdに渡されたテキストを表示している。 ここまででファームに書き込み、BLEScannerアプリから[CUSTOM SERVICE] > [Write, Read] > [Write Value] でテキストを書き込むと、書き込んだ内容がLcdに表示される。

最後にiOSからのスキャンをやりやすくする修正を先に加えておく。iOSにはスキャンを行う際にserviceのuuidで発見するperipheralを絞ることができる仕組みが用意されている。これを機能させるためアドバタイズにserviceのuuidを加え、アドバタイズを開始するようにする。

void setupBLE() {
  ...
  // BLEDevice::startAdvertising();
  BLEAdvertising *advertising = server->getAdvertising();
  advertising->addServiceUUID(service->getUUID());
  advertising->start();
}

iOSをセントラルとして実装する

iOS側の実装をしていく。 スキャンしたペリフェラルの一覧を表示し、選択をさせる。選択した先でテキストの入力をするとM5Stackのlcdにテキストが表示される。というアプリを作る。 LCDへの表示は前項で作ったM5Stack上のBLEServerのあるキャラクタリスティックに値を書き込めば行えるようになっている。

以下それぞれどういう処理を行うのか書いていくが、簡単のために相当はしょって書いてあるので、詳しくはgithubのコードを見てほしい。 ペリフェラルに接続するまでのクラスと、サービス、キャラクタリスティックを手に入れテキストを書き込むまでを責務としているクラスの2つに分けて作ってある。 ViewはSwiftUIで作ってあるが、トピック外なのでこちらもソースを参照してほしい。

https://github.com/masarusanjp/ble-sample-ios-and-m5stack/tree/main/ios-ble-sample/BLECentral/ble

まずはスキャンを行いペリフェラルのリストを手に入れる処理。

class BLESample: NSObject, CBCentralManagerDelegate {
    private var manager: CBCentralManager!
    override init() {
        super.init()
        manager = CBCentralManager(delegate: self, queue: nil)        
    }

    func scan() {
        // manager.state が .poweredOnでないと成功しないので、実際には.poweredOnになるのを待って実行する
        manager.scanForPeripherals(withServices: [CBUUID(string: serviceUUID), options: nil)
    }

    func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String : Any], rssi RSSI: NSNumber) {
        peripherals.append(peripheral)
    }
}

scanForPeripheralsの第一引数にはスキャン対象のサービスのUUIDを渡すことで見つける対象を絞ることができる(nilを渡すと絞り込みをせずにadvertiseしているものをすべて見つけてくる) リストが手に入るのでなく見つけたCBPeripheralが1つ1つ渡される。 stopScanを呼びスキャンを停止するまで見つける度にコールバックされる。

次に見つけたペリフェラルに接続をする。CBCentralManagerにconnectというメソッドがあり、これを利用する。

class BLESample: NSObject, CBCentralManagerDelegate {
    func connect(peripheral: CBPeripheral) {
        manager.conect(peripheral, options: nil)
    }

    func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
        // move to peripheral view
    }
}

ペリフェラルへの接続成功後はサービスを見つける。CBPeripheralのdiscoverServicesを呼び出すことで行うことができる。このメソッドは非同期でCBPeripheralのdelegateにコールバックされる。

func discoverServices(peripheral: CBPeripheral) {
    peripheral.delegate = self
    peripheral.disoverServices([CBUUID(string: serviceUUID)])
}

func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
    discoverCharacteristic(service: peripheral.services[0])
}

キャラクタリスティックもサービスと同様のフローで処理する。

func discoverCharacteristic(service: CBService) {
    peripheral.discoverCharacteristics(
        [CBUUID(string: BLESample.characteristicUUID)], 
        for: service
    )
}

func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
    writeText(text: text, characteristic: service.characteristics[0])
}

最後にキャラクタリスティックに対してデータを書き込む

func writeText(text: String, characteristic: CBCharacteristic) {
    guard let data = text.data(using: .utf8) else {
        return
    }
    peripheral.writeValue(data, for: characteristic, type: .withResponse)
}

まとめ

BLEとは何か、どういう概念が存在するのかの基礎をざっくりと学び、M5StackとiOSで動作を確認した。 何か理解が違っているところがあれば指摘してください。

以下感想。 セントラル側もペリフェラル側も想像していたよりも実装のインターフェースがシンプルであることがわかったのが面白かった。 iOSもesp32の方も大きくハマることなくサクッと作れたし思った通りに動いてくれる。 (簡単なサンプルを実装しただけなので、実際にはもっとハマるのだと思うけど) 難しかったのはiOSで非同期処理をシリアルに呼ぶ必要があったところくらい。 コードを書く前にBLE自体には仕様を読み込んでいたことが良かったと思う。