bluepyで始めるBluetooth Low Energy(BLE)プログラミング

  •  
 
トビウオ2018年11月21日 - 16:42 に投稿

概要

Bluetooth Low Energy通信規格(以下BLE)に準拠したデバイスに、bluepyと呼ばれるPython用ライブラリから接続する方法についてまとめました。
試行錯誤して得た使い方のコツについても解説しますので、BLEデバイスを操作してみたい人にとっての助けになればいいかなと思います。

  • BLEの通信についての説明は、このスライドをご覧ください
  • ライブラリの仕様上、Pythonコードはセントラル(≒親機)からペリフェラル(≒子機)に接続するコードしか書けません。また、動作OSはLinuxのみサポートされています

デバイスをスキャンする

接続する際にはまず、周囲にデバイスがないかを検索(スキャン)する必要があります。
対象のペリフェラルデバイスにおけるMACアドレス・アドレスタイプが分かっていれば、この過程をスキップできます……が、一般的には分からないことが多いでしょう。

なお、このスキャン処理は root権限で行う必要があります

from bluepy import btle

# デバイスをスキャンするためのクラスを初期化する。
# 引数(index=0)は、使用するBluetoothインターフェースの番号を表す
# ※ index=0 が /dev/hci0 に対応する
scanner = btle.Scanner(0)

# デバイスをスキャンする(結果はValuesView[ScanEntry]型で返される)
# 引数(timeout=10.0)は、スキャンする秒数を表す
devices = scanner.scan(3.0)

# スキャンした結果を表示
# ・ペリフェラルデバイスとの接続にはMACアドレスとアドレスタイプが必要
# ・RSSIは要するに電波強度で、それなりにないと接続に成功しない
# ・アドバタイシングデータは、そのペリフェラルデバイスがアドバタイズパケットで
#  周囲に発信している、デバイスの情報を表すデータ
for device in devices:
  print(f'MACアドレス:{device.addr}')
  print(f'  アドレスタイプ:{device.addrType}')
  print(f'  RSSI:{device.rssi}')

  # adTypeCodeはアドバタイシングデータのキーで、
  # descriptionはそれを人間が読めるように翻訳したもの。
  # そしてvalueTextはアドバタイシングデータの値
  print(f'  アドバタイシングデータ:')
  for (adTypeCode, description, valueText) in device.getScanData():
    print(f'    {description}:{valueText}')

ここでアドレスタイプは、「btle.ADDR_TYPE_PUBLIC」「btle.ADDR_TYPE_RANDOM」のどちらかを取ります。中身としてはただのstr型で、それぞれ「'public'」「'random'」と書かれています。

また、アドレスタイプがbtle.ADDR_TYPE_RANDOMな場合、対象デバイスのMACアドレスが(ペリフェラルデバイスの再起動などで)変更される可能性があることを表します。じゃあどうやって見分けるねんといった話ですが、 アドバタイシングデータで見分けるしかない かと思われます。

デバイスに接続する

対象のデバイスにおけるMACアドレスとアドレスタイプが分かれば、対象のデバイスと接続することができます。アドレスタイプがbtle.ADDR_TYPE_PUBLICだと分かっていれば、単にMACアドレスを指定するだけでも接続可能です。

面白いことに、スキャン処理と異なり、 接続処理にはroot権限が不要 です。……ただ、アドレスタイプがbtle.ADDR_TYPE_RANDOMな場合、MACアドレスが動的に変化することから、事前にスキャンしないと接続できないため、実質的にroot権限は必須と言えます。

なお、MACアドレスにおける英字は大文字でも小文字でも構いません。また、MACアドレス部分にScanEntity型のオブジェクトを渡すと、MACアドレス・アドレスタイプ・使用するBluetoothインターフェースの番号を全て指定した扱いになります(他の引数が不要になる)。

# コンストラクタで直接指定するパターン
peripheral = btle.Peripheral(MAC_ADDRESS)
peripheral2 = btle.Peripheral(MAC_ADDRESS2, btle.ADDR_TYPE_RANDOM)
peripheral = btle.Peripheral(scan_entity)

# コンストラクタで指定せず、connectメソッドで指定するパターン
peripheral = btle.Peripheral()
peripheral.connect(MAC_ADDRESS)
peripheral2.connect(MAC_ADDRESS2, btle.ADDR_TYPE_RANDOM)

# disconnectメソッドで明示的に接続を破棄する。
# デストラクタでも自動で呼ばれるが、通信終了時は明示的に呼び出すべき
peripheral.disconnect()

接続が完了した場合、キャラクタリスティックに対してデータの読み込み・書き込みなどの操作が可能になります。そのため、事前にどのようなサービス・キャラクタリスティックがペリフェラルデバイスから提供されているかを調べる必要があるでしょう。

そこで、提供されているサービス・キャラクタリスティックを調べてみます。

peripheral = btle.Peripheral(MAC_ADDRESS)
for service in peripheral.getServices():
    print(f'UUID:{service.uuid}')
    for characteristic in service.getCharacteristics():
        print(f'  UUID:{characteristic.uuid}')
        print(f'    ハンドル:{characteristic.getHandle()}')
        print(f'    プロパティ:{characteristic.propertiesToString()}')

上記コードでserviceService型で、characteristicCharacteristic型です。走査したいのではなく、指定したService型やCharacteristic型のオブジェクトにアクセスしたい場合は次のようなコードになります。

peripheral = btle.Peripheral(MAC_ADDRESS)

# UUIDを指定してService・Characteristicを取得。
# 前者はService型が返るが後者はValuesView[Characteristic]型が返る
service = peripheral.getServiceByUUID(SERVICE_UUID)
characteristic = peripheral.getCharacteristics(uuid=PERIPHERAL_UUID)

# ハンドルの範囲を指定してCharacteristicを取得する。
# 以下の例の場合、ハンドル3〜11が指定される……のではなくハンドル4〜11が指定されるので注意!
# 戻り値はValuesView[Characteristic]型になる
characteristic = peripheral.getCharacteristics(3, 11)

# 察しの通りServiceクラスにもgetCharacteristicsメソッドはあるが、こちらの引数は(指定する場合)UUID限定である
characteristic = service.getCharacteristics(PERIPHERAL_UUID)

なお、Peripheral#getServicesの戻り値はValuesView[Service]型ですが、Service#getCharacteristicsの戻り値はList[Characteristic]型です。また、Peripheral#getCharacteristicsもありますがこちらも戻り値はList[Characteristic]型です。なぜListで統一していないのでしょうね……?

Descriptorについて

bluepyのドキュメントには、Descriptorクラスが「not currently support」……要するにサポートされていないと書かれています。つまりまだ未実装なのかと思いきや、調査した結果、 もう実装されている と判明しました。

# どちらもList[Descriptor]を返すが、公式ドキュメントによると後者が推奨されている
# (他方では未サポート、他方では使用方法が書いてあるという「公式がガバガバ」な状況)
desc_list = peripheral.getDescriptors()
desc_list = service.getDescriptors()

# 実装されているメソッドやプロパティを叩いてみる
desc = desc_list[0]
print(desc.uuid)    # DescriptorのUUIDがbtle.UUID型で返ってくる
print(desc.handle)  # Descriptorのハンドル
print(str(desc))    # 「'Descriptor <Model Number String>'」などが返る
print(desc.read())  # Descriptorに対応したキャラクタリスティックの内容
desc.write(data)    # もちろん書き込みもできる。Characteristic型のそれと同様に扱える

これをどう使うかですが、

  • サービスについての情報をより深く知る
    • キャラクタリスティックだけ読み取ると、それがどういった情報をユーザーにもたらすのかが分かりづらい
    • Bluetooth SIGで定義するサービスなら分かるがそれ以外は分からない
    • デスクリプタには具体的な説明が「Descriptor <Model Number String>」のように書いてあるので分かりやすい
  • 通知機能をONにする際に使用する
    • 後述する通知機能(キャラクタリスティックのプロパティがNOTIFYやINDICATEなもの)を使用する際、キャラクタリスティックで見えるハンドルとは違うハンドルにデータを書き込む必要がある。その際のヒントとして、デスクリプタが「Descriptor <Client Characteristic Configuration>」となっているものを探せばいい
    • ちなみに、「通知機能を持つキャラクタリスティックのハンドルの数字」+1=「その通知をONにするためのデスクリプタのハンドルの数字」であることが多い

となります。なお、同じサービスの中で、UUIDが同一なキャラクタリスティック・デスクリプタは対応していることに気をつけましょう。

デバイスと通信する

Characteristic型まで取得できましたので、後は各種操作を行うことになります。propertiesToString()メソッドの文字列に含まれているキーワードによって、行える操作が変わってきます(文字列操作が嫌ならpropertiesプロパティを直接読み取って判断することも可能)。主な種類としては次のようなものがあります。

  • READ……データをセントラルから明示的に読み取る操作ができる。メソッドで言えばread()
  • WRITE……データをセントラルから書き込む操作ができる。メソッドで言えばwrite(data)
  • NOTIFY……データをペリフェラルから通知してもらうことができる。詳しくは後述
  • INDICATE……およそNOTIFYと同じだが、セントラルからの応答も要求する。詳しくは後述

READなどで対象となるキャラクタリスティックを読み取ることができる場合、supportsRead()メソッドがTrueを返します。また、read()の戻り値やwrite(data)で書き込む値はbyte型です。つまり、例えばstr型を読み込んだり書き込んだりしたい場合は、その都度encode()decode()する必要があります。

ちなみに、対象となるキャラクタリスティックのハンドルが事前に判明している場合、Peripheralクラスから直接読んだり書いたりすることができます。

# 以下の2つは等価
# (getCharacteristicsはハンドルの範囲を指定して取得するメソッド。
#  前述のように開始値+1〜終了値までを検索するので、「3,3」ではなく
#  「2,3」と指定することになる)
data = peripheral.getCharacteristics(2,3)[0].read()
data = peripheral.readCharacteristic(3)

なお、前述したDescriptorクラスの場合、propertiesToString()メソッドやsupportsRead()メソッドがありませんので、もしread()できるか分からない場合は、 例外処理で強引に確認する といった作戦があります。

desc = list(service.getDescriptors())[0]
try:
    data = desc.read()
except btle.BTLEException:
    data = None
print(data)

コールバックについて

たぶん サンプルコードを読むのが一番分かりやすい と思われます。ポイントとしては、DefaultDelegate型を継承したクラスを作成し、それをwithDelegate(delegate)メソッドでPeripheral型にセットすることです。

from bluepy import btle

class MyDelegate(btle.DefaultDelegate):
    """通知を受信した際の処理を記述するためのクラス
    """

    def __init__(self, params):
        """初期化時の処理を行う。
           btle.DefaultDelegateクラスに引数は無いが、
           必要ならparamsのように増やしてもいい
        """
        btle.DefaultDelegate.__init__(self)
        # 以下、初期化時の処理を書く

    def handleNotification(self, cHandle, data):
        """通知を受信した際の処理を行う。
           cHandleは通知を受信したキャラクタリスティックのハンドル、
           dataは送られてきたデータ(byte列)
        """
        # 以下、受信時の処理を書く


# 接続を開始する。その際、上記の通知用クラスをセットする
p = btle.Peripheral(MAC_ADDRESS)
p.withDelegate(MyDelegate(params))

# 通知を送ってもらうため、特定のハンドルにデータを書き込む。書き込む先のハンドルは
# 「Client Characteristic Configuration Descriptor(CCCD)」
# と呼ばれている。CCCDに特定のデータ(NOTIFYなら1、INDICATEなら2)を書き込む
# ことにより、それに対応したキャラクタリスティックから通知がセントラルに向けて
# 自動で送信される
handle = 18
p.writeCharacteristic(handle+1, b'\x01\x00', True)

# 通知を待機する
TIMEOUT = 1.0
while True:
    if p.waitForNotifications(TIMEOUT):
        # handleNotification()が呼び出された
        continue

    # handleNotification()がTIMEOUT秒だけ待っても呼び出されなかった
    print("待機中...")
    # 待機中になにか処理をしたい場合は行う

ソースコードのコメントにもある通り、CCCDに「1(b'\x01\x00')」ないし「2(b'\x02\x00')」を書き込むことが、通知を発信するためのトリガーとなります。逆に「0(b'\x00\x00')」を書き込むと、通知の発信を止めることができます。

また、TIMEOUTの数字を長くした場合、TIMEOUT秒だけ経過する前に通知が飛んでくることになりますので、ソースコードで言うところの「print("待機中...")」が全然表示されないことになります。

そして、「p.writeCharacteristic(handle+1, b'\x01\x00', True)」の第三引数は、公式ドキュメントによると「withResponse」……つまり、書き込みに成功したことをペリフェラルデバイスから返事が来るので、それを待つことになります。ONにしなくても通知は受信できますが、確実に通知をONにしたことが分かるのでTrueにしておくといいでしょう。

参考資料

はじめまして。プログラム素人です。
随分過去の記事ですが質問させてください。
今、Yahoo知恵袋で本ページのプログラムを元にラズパイを使ってやりたい事について質問させてもらってます。
親切な方がいろいろと教えてくれているのですが本ページ作成者の方に聞くのが早いと思い質問文を載せさせていただきました。
やりたい事はスキャンでiPhoneのMACアドレスを認識したら出力ON,認識しなかったらOFFというプログラムをループさせて常時監視したいです。
本ページ「デバイスをスキャンする」を元にプログラムを作成させていただきたいのですがindex=0からわかりません。
図々しいのを承知でお願いしますが「デバイスをスキャンする」の薄字説明文をもうちょっと初心者でも分かる様に補足文を入れていただけないでしょうか。
誠に勝手なお願いで恐縮ですがよろしくお願いします。

分かりやすいコメントを心がけていたつもりですが、一部分かりにくい点があったかもしれません。
以下、Python 3やBLE通信に関する基礎知識がある前提で、補足説明を箇条書きで述べさせていただきます。

  • 「引数(index=0)」とは、「引数"index"が存在し、そのデフォルト値が0」だという意味です
    • 例えばbtle.Scannerメソッドの場合、「def Scanner(index=0)」という定義がなされているとお考えください
  • 「/dev/hci0」とは、「/dev(デバイスのスペシャルファイルが置かれるディレクトリ)の中にある、Bluetooth通信用のスペシャルファイル」という意味です
    • /devやスペシャルファイルについては、 次の記事 を読まれると分かりやすいと思います
    • 動かすハードウェアによっては、hci1やhci2などが存在する場合もあります
  • 「ValuesView[ScanEntity]型」とは、「Pythonの辞書型に含まれる値の一覧で、値がScanEntity型であるもの」のことです。ScanEntity……もといScanEntry型はbluepyの内部で使用されている型です。詳しくは公式ドキュメントを参照してください
  • addrプロパティやaddrTypeプロパティなどの型は明示していませんが、実行すればすぐ分かると思いますので省略しています
  • device.getScanData()メソッドの戻り値はタプル型の配列ですので、「for (adTypeCode, description, valueText) in device.getScanData():」とすると、タプル型の各要素を「adTypeCode, description, valueText」の各変数で受け取ることが出来ます。詳しくは公式ドキュメントを参照してください

コメントを追加

プレーンテキスト

  • HTMLタグは利用できません。
  • 行と段落は自動的に折り返されます。
  • ウェブページのアドレスとメールアドレスは自動的にリンクに変換されます。
CAPTCHA
この質問はあなたが人間の訪問者であるかどうかをテストし、自動化されたスパム送信を防ぐためのものです。