NRIネットコム Blog

NRIネットコム社員が様々な視点で、日々の気づきやナレッジを発信するメディアです

【SwiftUI】ESP32使用してスマートキーを自作する【CoreBluetooth】

概要

前回の記事ではiOSと接続するため、ESP32側の設定について記事を書かせていただきました。 今回はiOS側でもアプリを実装してESP32を操作していきたいと思います。

tech.nri-net.com

また実例として以下の様に鍵の開閉ができるサンプルコードも掲載しておきますので是非最後まで読んでいただければと思います。

環境

この記事は以下のバージョン環境のもと作成されたものです。
【Arduino】2.0.0
【Xcode】14.0.1
【iOS】16.0.2
【macOS】Monterey バージョン 12.5

BLEを使用するために押さえておきたい知識

まずBLEを使用する上で以下押さえておきたい知識があります。

  • ペリフェラル
  • セントラル
  • アドバタイズ

の3つについてです。 以下それぞれの解説です。

ペリフェラル

BLEに関してマスタ・スレーブ方式が採用されています。 マスタ・スレーブ方式とは、複数の機器を制御する「マスター」と呼ばれる機器と、マスターの制御下で動作する「スレーブ」と呼ばれる機器の基、制御を行う方式です。 ペリフェラルはスレーブ側に当たります。 例としてイヤホンやスマートキーなど、機能を提供する側を指します。 今回はESP32側がペリフェラル側となりますが、スマホをペリフェラル側にすることも可能です。

セントラル

こちらはペリフェラルとは反対にマスター側となります。 制御を行う側となります。 例としてスマホでの操作でイヤホンの音量を調整したり、スマートキーで施錠をおこなったりした場合、スマホ側がセントラル側となります。 今回はiPhone側がセントラル側となりますが、ESP32にボタンなどを接続してセントラル側にすることも可能です。

アドバタイズ

BLE接続するまでの流れとして、ペリフェラル側が無線の信号を発信し、セントラル側がその無線の信号を受信します。 この無線の信号をアドバタイズと呼びます。 ペリフェラル側がアドバタイズを発信するなかで、セントラル側は複数のアドバタイズをスキャンして受信することで、周囲のどのようなBLE機器があるのか認識することができます。 認識した後に、セントラル側は見つけたペリフェラルの中から1対1の通信接続を要求することができます。 ペリフェラル側は接続の要求を受信するとアドバタイズをやめて1対1の接続通信に切り替えます。 この接続された通信状態のことを、GATT(Generic attribute profile)通信と呼びます。 GATT通信はService(サービス)とCharacteristic(キャラクタリスティック)という概念でデータのやり取りをします。 前回の記事の中でスケッチ例からサンプルコードを生成しましたが、その中で

#define SERVICE_UUID        "4fafc201-1fb5-459e-8fcc-c5c9c331914b"
#define CHARACTERISTIC_UUID "beb5483e-36e1-4688-b7f5-ea07361b26a8"

とありましたがこれはGATT通信を行う上で必要なUUIDとなります。 Serviceについてはペリフェラルの中に1つ以上あり、Characteristicを包括するフォルダのようなものです。 CharacteristicはServiceの中に1つ以上あり、「Value」・「 Property」を定義することができます。 それぞれの役割は以下の通りです。

Value データそのものを指します。
前回のサンプルコードではpCharacteristic->setValue("Hello World says Neil")の部分でValueとして文字列を定義しています。
Property 対応している属性を定義します。
属性には「read」・「write」・「notify」 の3種類あり、一つもしくは複数の属性を持たせることも可能です。
前回のサンプルコードではBLECharacteristic::PROPERTY_READ | BLECharacteristic::PROPERTY_WRITEの部分でPropertyを定義しています。

以上簡単ですがBLEを使用するために押さえておきたい知識です。
またここまでの流れについて文字だけでは理解に難しいかもしれませんので、以下簡単な概念図も記載しておきます。

上記までの流れを基にした概念図

接続までの流れを整理

ここまでの説明を踏まえて接続までの流れを整理します。 流れとしては以下の順で接続していきたいと思います。

  1. アドバタイズ:ペリフェラル側がアドバタイズを発信
  2. スキャン:セントラルがアドバタイズを受信
  3. コネクト:セントラルがペリフェラルに接続を要求し接続
  4. ディスコネクト:セントラルがアプリを終了する際に接続

この接続の流れの中で接続中にwriteの実装を行ってみたいと思います。

実装

いよいよ実装していきたいと思います。 今回はの実装ではESP32側をペリフェラル、iPhone側をセントラル側として実装していきたいと思います。 また実例としてスマートキーが作れる必要材料とサンプルコードも最後に紹介しますので、材料やサンプルコードだけ欲しい方は最後までスキップしてください。

ペリフェラルの実装

ESP32側の実装となります。 今回はNimBLEというライブラリを使用します。

NimBLEDeviceをESP32で使用する

リファレンスよりNimBLEに移行していく旨が書かれているので今回使用してみました。 以下引用ですがこの様に記載されています。

NimBLE に基づく arduino-esp32 用の Bluetooth Low Energy (BLE) ライブラリ。 これは、esp32 用の元の bluedroid BLE ライブラリに代わる、より更新された低リソースの代替手段です。同じ機能でフラッシュ容量を 50% 削減し、RAM を約 100KB 削減します。既存のアプリケーション コードとほぼ 100% の互換性があり、移行ガイドが含まれています。

www.arduino.cc

NimBLEのDL方法はArduino IDEにて、ツール → ライブラリの操作でライブラリマネージャーを立ち上げNimBLEで検索します。 検索結果で以下表示されますのでインストールします。これで使用できます。NimBLEDevice.hライブラリを使用することができます。

ペリフェラルのサンプルコード

まず初めにNimBLEDeviceをインポートしておきます。

#include <NimBLEDevice.h>

またこちらにてUUIDを生成できます。 今回は ・ServiceのUUID ・書き込みを行うためのCharacteristicのUUID ・通知を行うためのCharacteristicのUUID が必要となりますので3つほど生成します。生成したUUIDは以下の様に使用します。

#define LOCAL_NAME "ESP_TEST_DEVICE"
#define COMPLETE_LOCAL_NAME "ESP_TEST_DEVICE_LOCAL_NAME"
#define SERVICE_UUID "3c3996e0-4d2c-11ed-bdc3-0242ac120002"
#define CHARACTERISTIC_UUID "3c399a64-4d2c-11ed-bdc3-0242ac120002"        
#define CHARACTERISTIC_UUID_NOTIFY "3c399c44-4d2c-11ed-bdc3-0242ac120002" 

接続、切断などのメソッドは後にsetupメソッドにてコールバックの形でセットするのでひとまとめにしておきます。

class ServerCallbacks : public NimBLEServerCallbacks
{
  //接続時
  void onConnect(NimBLEServer *pServer)
  {
    Serial.println("Client connected");
    deviceConnected = true;
  };
  //切断時
  void onDisconnect(NimBLEServer *pServer)
  {
    Serial.println("Client disconnected - start advertising");
    deviceConnected = false;
    NimBLEDevice::startAdvertising();
  };
  void onMTUChange(uint16_t MTU, ble_gap_conn_desc *desc)
  {
    Serial.printf("MTU updated: %u for connection ID: %u\n", MTU, desc->conn_handle);
  };
  // Passのリクエスト
  uint32_t onPassKeyRequest()
  {
    Serial.println("Server Passkey Request");
    return 123456;
  };
  //確認
  bool onConfirmPIN(uint32_t pass_key)
  {
    Serial.print("The passkey YES/NO number: ");
    Serial.println(pass_key);
    return true;
  };
  //認証完了時の処理
  void onAuthenticationComplete(ble_gap_conn_desc *desc)
  {
    if (!desc->sec_state.encrypted)
    {
      NimBLEDevice::getServer()->disconnect(desc->conn_handle);
      Serial.println("Encrypt connection failed - disconnecting client");
      return;
    }
    Serial.println("Starting BLE work!");
  };
};

セントラル側からのwriteやnotifyでペリフェラル側から通知などの処理もクラスにしておきます。

class CharacteristicCallbacks : public BLECharacteristicCallbacks
{
  void onWrite(BLECharacteristic *pCharacteristic)
  {
    std::string value = pCharacteristic->getValue();

    if (value.length() > 0)
    {
      String ledState = value.c_str();
      Serial.println(ledState);
      if (ledState == "0")
      {
        stateValue = ledState;
        pCharacteristic->setValue(stateValue);
        pCharacteristic->notify();
      }
      else if (ledState == "1")
      {
        stateValue = ledState;
        pCharacteristic->setValue(stateValue);
        pCharacteristic->notify();
      }
    }
  }
};

その他のペアリングの実装やボンディング(ペアリングで交換した暗号鍵を保存しておくかどうか)についてや、細々した処理はありますが、そちらGitHubのサンプルコードを参考にしていただければと思います。

セントラル側の実装

続いてiOS側の実装を行なっていきたいと思います。 今回実装するに当たってCoreBluetoothを使用していきます。 以下一部の概要を引用とリンクです。

Core Bluetooth フレームワークは、アプリが Bluetooth を搭載した Low Energy (LE) および Basic Rate / Enhanced Data Rate (BR/EDR) ワイヤレス テクノロジと通信するために必要なクラスを提供します。

developer.apple.com

詳細はドキュメントを見ていただければと思いますが、かなり簡単にざっくり説明するとCoreBluetoothを使用することでiPhoneをセントラル機器やペリフェラル機器として扱う事ができるようになります。 以下サンプルコードを交えた実装手順です。

CoreBluetoothを使用したサンプルコード

まず初めにCoreBluetoothを使用するためにライブラリのインポートに加え、必要なプロパティの宣言とcentralManagerの初期化を行っていきます。 また実機でサンプルコードを試すにはBLEに関する許諾のリクエストを行い、許可を取る必要がありますので最初にinfo.plistにて以下の処理を行う事をお勧めします。

info.plistにPrivacy - Bluetooth Always Usage Descriptionを追加し適当に文言を入れます

import CoreBluetooth
private var centralManager: CBCentralManager!
private var cbPeripheral: CBPeripheral? = nil
private var writeCharacteristic: CBCharacteristic? = nil
// 接続する時に使用するName
let bleLoacalName = "ESP_TEST_DEVICE_LOCAL_NAME"
// 接続時に用いるServiceUUID
let bleServiceUUID = CBUUID(string: "3c3996e0-4d2c-11ed-bdc3-0242ac120002")
// 各Characteristic
let bleWriteCharacteristicUUID = CBUUID(string:"3C399A64-4D2C-11ED-BDC3-0242AC120002")
let bleNotifyCharacteristicUUID = CBUUID(string:"3C399C44-4D2C-11ED-BDC3-0242AC120002")

override init() {
    super.init()
    bleInit()
}
//centralManager初期化
private func bleInit() {
    centralManager = CBCentralManager(delegate: self, queue: nil)
}

今回は特定のペリフェラルを読み込んで、接続・切断・書き込みをセントラル側で行いたいので以下のようなメソッドを用意します。

//Peripheralをスキャン
func scan() {
    //BLEのpermissionがONになってなければ早期リターンさせる
    guard centralManager.state == .poweredOn else { return }
    //ServiceUUIDを指定してスキャンをする
    //指定せずスキャンしたい場合はwithServicesにnilを渡す
    let services: [CBUUID] = [bleServiceUUID]
    centralManager.scanForPeripherals(withServices: services, options: nil)
    centralManager.scanForPeripherals(withServices: nil, options: nil)
}

//データの送信
func sendData(data:Data) {
    //データの書き込み:属性がwrite with responseの場合
    if let peripheral = self.cbPeripheral, let writeCharacteristic = self.writeCharacteristic{
        peripheral.writeValue(data, for: writeCharacteristic, type: CBCharacteristicWriteType.withResponse)
    }
}

//切断処理
func disconnectPeripheral() {
    if let peripheral = cbPeripheral {
        centralManager.cancelPeripheralConnection(peripheral)
    }
}

初期化しBLEのPermissionの状態がONになるとCBCentralManagerDelegateの下記Delegateメソッドが呼ばれ、ペリフェラルの検索や、接続、切断などを行う事ができるようになります。

//Bluetoothの状態が変化する度に呼ばれるメソッド
func centralManagerDidUpdateState(_ central: CBCentralManager) {
    switch central.state {
    case CBManagerState.poweredOn:
        logText.append("poweredOn\n")
        break
    case CBManagerState.poweredOff:
        logText.append("poweredOff\n")
        break
    case CBManagerState.resetting:
        logText.append("resetting\n")
        break
    case CBManagerState.unauthorized:
        logText.append("unauthorized\n")
        break
    case CBManagerState.unsupported:
        logText.append("unsupported\n")
        break
    case .unknown:
        logText.append("unknown\n")
        break
    default:
        logText.append("other unknown\n")
        break
    }
}
//Peripheralが見つかる度に呼ばれるメソッド
func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String : Any], rssi RSSI: NSNumber) {
    //peripheralの情報を取得する
    //Name -> peripheral.name
    //advertiseの中身 -> advertisementData
    //advertiseになっている各ペリフェラルのRSSI -> RSSI.stringValue
    //bleLoacalNameで定義した名前とペリフェラル側で定義したCOMPLETE_LOCAL_NAMEを照合
    if let peripheralName = advertisementData["kCBAdvDataLocalName"] as? String{
        if peripheralName == bleLoacalName{
            //見つけたペリフェラルを保持
            self.cbPeripheral = peripheral
            central.connect(peripheral, options: nil)
            //スキャン停止
            centralManager.stopScan()
        }
    }
}

//接続が成功した時に呼ばれるデリゲートメソッド
func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
    logText.append("Connect Peripheral\n")
    isConnectButton = false
    isDisConnectButton = true
    connectStateLabel = "接続状態:接続中"
    centralManager.stopScan()
    peripheral.delegate = self
    //接続したペリフェラルのServiceUUIDを探す。 全て探す場合はdiscoverServicesにnilを渡す
    let services: [CBUUID] = [bleServiceUUID]
    peripheral.discoverServices(services)
    logText.append("discoverServices\n")
}

//接続が失敗した時に呼ばれるデリゲートメソッド
func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) {
    logText.append("接続失敗\n")
}
//切断した時に呼ばれるデリゲートメソッド
func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) {
    logText.append("切断\n")
    isConnectButton = true
    isDisConnectButton = false
    isWriteButton = false
    connectStateLabel = ("接続状態:未接続")
    logText.removeAll()
}

接続に成功するとCBPeripheralDelegateの下記Delegateメソッドが呼ばれ、各Characteristicsを検索したり、Characteristicsが持つ属性に対して書き込みや読み込みなどを行う事ができます。

//接続が成功したときに探したServiceUUIDが見つかると呼ばれるデリゲートメソッド
func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
    logText.append("didDiscoverServices\n")
    //全てのサービスのキャラクタリスティックの検索
    for service in peripheral.services! {
        peripheral.discoverCharacteristics(nil, for: service)
    }
}

//Characteristicsが見つかった時に呼ばれるデリゲートメソッド
func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
    for characteristic in service.characteristics!{
        if characteristic.uuid == bleWriteCharacteristicUUID {
            writeCharacteristic = characteristic
            isWriteButton = true
            logText.append("Write Characteristicが見つかりました\n\(characteristic.uuid)\n")
        }
        if characteristic.uuid == bleNotifyCharacteristicUUID {
            peripheral.setNotifyValue(true, for: characteristic)
            logText.append("Notify Characteristicが見つかりました\n\(characteristic.uuid)\n")
        }
    }
}

//write実行時に呼ばれる
func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) {
    if let error = error {
        print("書き込みに失敗しました: \(error.localizedDescription)\n")
        return
    }else{
        logText.append("書き込みに成功しました: " + (isWriteState ? "1" : "0") + "\n")
        
    }
}

//Notify実行時に呼ばれる
func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
    if let error = error {
        logText.append("通知の受け取りに失敗しました: \(error.localizedDescription)")
    } else {
        let receivedData = String(bytes: characteristic.value!, encoding: String.Encoding.ascii)
        //今回はnotifyしか使用していないのでswitchは必要ではない
        switch characteristic.properties{
        case .read:
            logText.append("read: ")
        case .indicate:
            logText.append("indicate: ")
        case .notify:
            logText.append("notify: ")
        default:
            logText.append("unknown: ")
        }
        logText.append("\(receivedData ?? "breaked data") \n")
    }
}

後は適当にUIを作ってあげれば完成です。 今回はSwiftUIでViewを作成しました。

struct BLECommicationView: View {
    @StateObject var bleModel = BLEModel()
    var body: some View {
        VStack {
            HStack {
                Text("ESP32 BLE Sampler")
                    .font(.title.bold())
                    .padding(.top)
                Spacer()
            }
            ScrollView {
                VStack {
                    HStack {
                        Text(bleModule.logText)
                        Spacer()
                    }
                    Spacer()
                }
                .padding(.horizontal)
            }
            .frame(width: 360, height: 400, alignment: .leading)
            .background(
                RoundedRectangle(cornerRadius: 8)
                    .stroke(lineWidth: 3)
            )
            VStack {
                HStack {
                    VStack(alignment: .leading) {
                        Text(bleModule.connectStateLabel)
                    }
                    Spacer()
                }
                HStack {
                    Button {
                        bleModule.scan()
                    } label: {
                        Text("接続")
                            .padding(.vertical)
                            .frame(width: 100)
                            .background(
                                Capsule()
                                    .stroke(lineWidth: 3)
                                    .foregroundColor(.gray)
                            )
                    }
                    .disabled(!bleModule.isConnectButton)
                    Button {
                        bleModule.disconnectPeripheral()
                    } label: {
                        Text("切断")
                            .padding(.vertical)
                            .frame(width: 100)
                            .background(
                                Capsule()
                                    .stroke(lineWidth: 3)
                                    .foregroundColor(.gray)
                            )
                    }
                    .disabled(!bleModule.isDisConnectButton)
                    Button {
                        bleModule.isWriteState.toggle()
                        let strData : String = bleModule.isWriteState ? "1" : "0"
                        let data : Data = strData.data(using: .utf8)!
                        bleModule.sendData(data: data)
                    } label: {
                        Text("書込")
                            .padding(.vertical)
                            .frame(width: 100)
                            .background(
                                Capsule()
                                    .stroke(lineWidth: 3)
                                    .foregroundColor(.gray)
                            )
                    }
                    .disabled(!bleModule.isWriteButton)
                }
            }
            Spacer()
        }
        .padding(.horizontal)
    }
}

おまけ:スマートキーを自作する

以上の事を踏まえて、スマートキーを自作する方法を紹介したいと思います。 ただ、ざっくりとした作り方になっているので、実際に試す際は自己責任でお願いします。

必要なもの

以下開発環境(PC)やiPhone以外で必要な物です。大抵100円均一で揃えれるので合計で2500円ぐらいだった記憶です。

  • ESP32-DevKitC ESP-WROOM-32開発ボード
  • サーボモータSG90(トルクの関係で現在はSG92Rを使用しています。必要に応じて選定してください)
  • 取り付け用の金具
  • 結束バンド
  • ダブルクリップ
  • IoT用モバイルバッテリー

後は以下サンプルコードをXcodeとArduino IDEにて展開して、実機に書き込みするだけでお試しいただけます。

github.com

まとめ

スマートキーを自作することでCoreBluetoothについてかなり勉強になりました! まだまだペアリング時のPassや、エラーハンドリング、非同期処理に関して考えなければいけない部分や検証していきたい部分はあるので引き続き追っていきたいと思います!

執筆者岡優志

iOSエンジニア
iOSを専門とし、モバイルアプリの開発を行なっています。

Twitter