aptpod Advent Calendar 2021 の 16日目を担当する、プロジェクト開発グループの松下です。 本記事では、PythonでBluetooth Low Energy (BLE)のデバイスからデータを収集するGATTのクライアントアプリを実装したので紹介します。
背景
2018年の弊社のAdvent Calenderにて BlueZのAPI/サンプルコードのメモと題して、BlueZの簡単な紹介をしてみました。 おかげさまで、今でも定期的に LGTM を頂いている記事になっています。
この記事の最後で、
次回は、実際にこのサンプルコードを使って、ラズパイに接続した温度センサーの値を通知するGATT Serverを構築できればと思います。
と書いていたのですが、更新する暇もなく放置していたのが心残りでした。
その後、BLE の GATT を使って市販のセンサー(心拍とか)の値を取得する機会があり、BlueZでGATT Clientを実装する事にしました。
BlueZでの GATT Clientの実装について
BlueZを使ってGATTのclientを実装する場合、blueZの testフォルダにある example-gatt-clientがサンプルコードとして提供されています。 このコードを見れば、 dbus-pythonを使ってblueZ経由で GATTのServiceおよびCharacteristicを扱うテクニックがわかります。
モチベーション
しかし、このサンプルコードは bluetoothctl
等で事前にBluetooth自体が接続されている状態で、サンプルコードを実行する必要があります。
毎回手動でBluetooth接続してから、このサンプルを実行してもいいのですが、ラズパイの電源を入れたら自動で接続してデータを取得したいと考えました。 更に、複数のServiceを扱うのが難しい実装であり、複数のデバイスに対する同時接続もしたいと考えました。
結構ニーズがありそうなモチベーションなのですが、調べてみてもあまりサンプルコードがみつかりませんでした。 そこで、今回は実際にPythonでスクリプトを作成してみたので、スクリプトの簡単な動作の説明と、作成にあたって工夫した(ハマった)点を紹介したいと思います。
想定読者
- BluetoothのGATTの基礎知識がある方
- blueZの
test/example-gatt-client
をベースに実装したいが、更にProfileの追加、自動接続や同時接続を実装したい方 - blueZのdbus-pythonのコードがなんとなく読める方 1
- 筆者はあまりpythonは得意ではないので、書き方は初心者丸出しです。温かい目で見てくれる方
プログラムの紹介
ソースコードは github に公開していますので、参考にして頂ければと思います。
本プログラムの特徴
- 自動接続機能に対応
- 複数デバイスに同時接続可能
- 以下のProfileに対応
- Cycling Speed and Cadence Profile
- Heart Rate Profile
- 以下のServiceに対応
- Cycling Speed and Cadence Service
- Heart Rate Service
- Device ID Service
- Battery Service
- GATT の Read および Notify に対応
ただし、以下の制限事項があります。
- 同一プロファイルのデバイスを複数同時に接続できません。(各Profileに対してそれぞれ1台接続します)
- 一部のOptionのパラメータや Writeなどは非対応です。
- blueZ 5.50の場合、Batteryの通知を受け取れません。2
実行環境
筆者の環境は以下の通りです。
事前準備
今回のプログラムを実行する前に、事前に接続したいBLEデバイスを探索 (Scan) し、blueZのデバイスリストに登録しておく必要があります。
まずは bluetoothctl
を開きます5
$ bluetoothctl
次に、scan on
で周辺のデバイスを探索します。
[bluetooth]# scan on
お目当てのデバイスがみつかりました。
[NEW] Device F9:04:D8:2B:5E:8E SPD-BLE0047853 [bluetooth]# devices Device 00:12:A1:70:3F:26 bluetooth ... Device F9:04:D8:2B:5E:8E SPD-BLE0047853
発見したデバイスはペアリング済みではないため、一定時間が経過すると消えてしまいます。
[DEL] Device F9:04:D8:2B:5E:8E SPD-BLE0047853
そこで、 trust
を onにした上で
[bluetooth]# trust F9:04:D8:2B:5E:8E [CHG] Device F9:04:D8:2B:5E:8E Trusted: yes Changing F9:04:D8:2B:5E:8E trust succeeded
一回 connect
で接続しておきます。
[bluetooth]# connect F9:04:D8:2B:5E:8E Attempting to connect to F9:04:D8:2B:5E:8E [CHG] Device F9:04:D8:2B:5E:8E Connected: yes Connection successful [NEW] Primary Service /org/bluez/hci0/dev_F9_04_D8_2B_5E_8E/service000a 00001801-0000-1000-8000-00805f9b34fb Generic Attribute Profile [NEW] Characteristic /org/bluez/hci0/dev_F9_04_D8_2B_5E_8E/service000a/char000b 00002a05-0000-1000-8000-00805f9b34fb Service Changed ...
こうする事で、意図的に remove
しない限り、ホスト側のデバイスリストからは削除されなくなります。
本来は、ペアリングしたいのですが、agentを使った SSPの挙動が不安定で困っていたところ、この手順にたどり着きました。(デバイスリストから消えなければOKなので)
スクリプトの実行
以下の通り、スクリプトを開始します。6
$ ./ble_client.py
開始すると、最初にbluetoothのpower をOFF/ONして、諸々リセットします。(不要であれば、コメントアウトしてもいいです)
power off power on
接続処理
まずは、ホスト側に登録されているデバイスリストから、サポートしているServiceの属性を持つデバイスをピックアップし、接続先テーブルを構築します。
connection_table: {'HRM': '/org/bluez/hci0/dev_DF_78_9E_7E_D3_7F', 'SPEED': '/org/bluez/hci0/dev_F9_04_D8_2B_5E_8E'} HRM:/org/bluez/hci0/dev_DF_78_9E_7E_D3_7F SPEED:/org/bluez/hci0/dev_F9_04_D8_2B_5E_8E
その後、 device_connect_thread()
にて接続テーブルの中で未接続のデバイスを見つけて、自動で接続していきます。
mainloop.run() start SCAN *************** can't find device near side: /org/bluez/hci0/dev_DF_78_9E_7E_D3_7F can't find device near side: /org/bluez/hci0/dev_F9_04_D8_2B_5E_8E RSSI=-59 /org/bluez/hci0/dev_DF_78_9E_7E_D3_7F start connect /org/bluez/hci0/dev_DF_78_9E_7E_D3_7F connecting..../org/bluez/hci0/dev_DF_78_9E_7E_D3_7F connecting..../org/bluez/hci0/dev_DF_78_9E_7E_D3_7F
接続に成功してもすぐに処理は開始せず、Device Interface の Connected
が Trueになるのを待ちます。この時、blueZは SDPによって接続したデバイスのService & Characteristic の情報を取得しているようです。
connection successful Service resolved! :/org/bluez/hci0/dev_DF_78_9E_7E_D3_7F
その後、接続したデバイスの D-busのdeviceのpathを手がかりに、 BlueZの interface org.bluez.GattService1
に関係するobjectを取得します。
これが、接続したい対象のServiceであった場合は、更に interface org.bluez.GattCharacteristic1
に関係するobjectを取得し、Serviceが持っているCharacteristicの属性(uuidなどProperty)を取得していきます。
以下のログは、Device ID Service (uuid = 0x180a )と Heart Rate Service ( uuid = 0x180d )が取得でき、各Serviceの CharacteristicのUUIDとFlagを取得しています。
configure_service(/org/bluez/hci0/dev_DF_78_9E_7E_D3_7F HRM) ********************************************** detect service:0000180a-0000-1000-8000-00805f9b34fb SERVICE_DEVICE /org/bluez/hci0/dev_DF_78_9E_7E_D3_7F/service0018 ==================== ============ 00002a28-0000-1000-8000-00805f9b34fb DEV_SW_REV /org/bluez/hci0/dev_DF_78_9E_7E_D3_7F/service0018/char001f Flag: dbus.Array([dbus.String('read')], signature=dbus.Signature('s'), variant_level=1) ============ 00002a26-0000-1000-8000-00805f9b34fb DEV_FW_REV /org/bluez/hci0/dev_DF_78_9E_7E_D3_7F/service0018/char001d Flag: dbus.Array([dbus.String('read')], signature=dbus.Signature('s'), variant_level=1) ============ 00002a27-0000-1000-8000-00805f9b34fb DEV_HW_REV /org/bluez/hci0/dev_DF_78_9E_7E_D3_7F/service0018/char001b Flag: dbus.Array([dbus.String('read')], signature=dbus.Signature('s'), variant_level=1) ============ 00002a29-0000-1000-8000-00805f9b34fb DEV_MANFUC /org/bluez/hci0/dev_DF_78_9E_7E_D3_7F/service0018/char0019 Flag: dbus.Array([dbus.String('read')], signature=dbus.Signature('s'), variant_level=1) detect service:0000180d-0000-1000-8000-00805f9b34fb SERVICE_HRM /org/bluez/hci0/dev_DF_78_9E_7E_D3_7F/service000e ==================== ============ 00002a38-0000-1000-8000-00805f9b34fb SNSR_LOC /org/bluez/hci0/dev_DF_78_9E_7E_D3_7F/service000e/char0012 Flag: dbus.Array([dbus.String('read')], signature=dbus.Signature('s'), variant_level=1) ============ 00002a37-0000-1000-8000-00805f9b34fb HR_MEAS /org/bluez/hci0/dev_DF_78_9E_7E_D3_7F/service000e/char000f Flag: dbus.Array([dbus.String('notify')], signature=dbus.Signature('s'), variant_level=1) configured service success: /org/bluez/hci0/dev_DF_78_9E_7E_D3_7F
そして、dbus上のCharacteristic のpathとuuidの紐づけ情報を事前に保持しておきます。
接続後
接続に成功すると、 Readの属性を持つCharacteristicに対して Readを要求し、順次データを取得していきます。 同時に、Notifyの属性を持つCharacteristicに対しては Notify の開始を要求します。
以下の例では、各デバイスの device IDをReadして表示しています。
hrm_dev_sw_rev -> HR40 V1.0.0 speed_snsr_loc -> 0 /org/bluez/hci0/dev_F9_04_D8_2B_5E_8E/service0025/char0028 Notifying = 1 notifications enabled speed_csc_feat -> 1 speed_csc_meas -> wh_rev:13100 update_time:16.8134765625 all device is connected hrm_dev_fw_rev -> HR40 V1.0.0 speed_dev_sw_rev -> 2.30.0 speed_csc_meas -> wh_rev:13100 update_time:16.8134765625 speed_dev_fw_rev -> 2.30.0 speed_dev_hw_rev -> 3 speed_csc_meas -> wh_rev:13100 update_time:16.8134765625 hrm_dev_hw_rev -> HR40 V1.0.0 speed_dev_serial -> 3335568109 speed_csc_meas -> wh_rev:13100 update_time:16.8134765625 speed_dev_model -> 3192 speed_dev_manfuc -> Garmin speed_csc_meas -> wh_rev:13100 update_time:16.8134765625 speed_csc_meas -> wh_rev:13100 update_time:16.8134765625 hrm_dev_manfuc -> iGPSPORT speed_csc_meas -> wh_rev:13100 update_time:16.8134765625 speed_csc_meas -> wh_rev:13100 update_time:16.8134765625 hrm_snsr_loc -> 1 speed_csc_meas -> wh_rev:13100 update_time:16.8134765625 all device is connected
また、Notify の通知データをパースして、値をログで出力しています。
以下の例では、心拍センサーデバイスが通知してくる心拍数と、ケイデンスメータが通知してくる回転数を出力しています。
all device is connected hrm_hr_meas -> hr_meas = 99 bpm speed_csc_meas -> wh_rev:13093 update_time:10.0107421875 speed_csc_meas -> wh_rev:13094 update_time:10.9296875 hrm_hr_meas -> hr_meas = 97 bpm speed_csc_meas -> wh_rev:13094 update_time:10.9296875 hrm_hr_meas -> hr_meas = 97 bpm speed_csc_meas -> wh_rev:13095 update_time:11.98046875 speed_csc_meas -> wh_rev:13096 update_time:12.853515625 hrm_hr_meas -> hr_meas = 94 bpm speed_csc_meas -> wh_rev:13096 update_time:12.853515625 hrm_hr_meas -> hr_meas = 91 bpm speed_csc_meas -> wh_rev:13098 update_time:14.701171875 speed_csc_meas -> wh_rev:13098 update_time:14.701171875
ここまで動けば、後は煮るなり焼くなり好きにできます。
工夫したポイント
デバイス一覧の取得と、サポートしている GATT Serviceの判定
デバイスリストの取得は blueZの test/list-deviceを参考にしました。
最終的には、以下のようなコードを実装しました。 DeviceのInterfaceのUUIDを見ることで、このデバイスがサポートしている Serviceと Characteristicを特定できます。 7
下記のコードでは、 Heart Rateと Cycling and Cadence のServiceのUUIDを持つデバイスのobject pathを dict で保持しています。
objects = get_managed_objects() all_devices = (str(path) for path, interfaces in objects.items() if IFACE_DEVICE in interfaces.keys()) connection_table = {} for device_path in all_devices: dev = objects[device_path] properties = dev[IFACE_DEVICE] uuids = properties["UUIDs"] for uuid in uuids: if uuid == UUID.SERVICE_HRM: connection_table["HRM"] = device_path if uuid == UUID.SERVICE_SPEED: connection_table["SPEED"] = device_path
接続機能
そもそも、PythonからどうやってBluetoothを接続するのでしょう。 BlueZの doc/device-api.txt に答えがありました。
Service org.bluez Interface org.bluez.Device1 Object path [variable prefix]/{hci0,hci1,...}/dev_XX_XX_XX_XX_XX_XX Methods void Connect() This is a generic method to connect any profiles the remote device supports that can be connected to and have been flagged as auto-connectable on our side. If only subset of profiles is already connected it will try to connect currently disconnected ones.
実際に test/simple-agent を見ると、dbus-pythonでの呼び出し例が書いてあります。
def dev_connect(path): dev = dbus.Interface(bus.get_object("org.bluez", path), "org.bluez.Device1") dev.Connect()
なるほど、pathに紐づく org.bluez.Device1
のobjectを取得して Connect()
を呼べばよさそうです。
実際に試したところ、この書き方だと Connect()
が同期処理になってしまう8ため、非同期処理にしたいと考えました。
最終的に、以下のように実装しました。reply_handler
, error_handler
を指定する事で、接続処理が終了した時に非同期でhandlerが呼ばれます。
IFACE_DEVICE = 'org.bluez.Device1' def fetch_object(path): try: return bus.get_object(BLUEZ_SERVICE_NAME, path) except Exception as e: LOG.error("faital error in fetch_object({}): {}".format(path, str(e))) mainloop.quit() def device_connect(device_path): # Connect Device if no connected dev_object = fetch_object(device_path) dev_iface = dbus.Interface(dev_object, IFACE_DEVICE) dev_iface.Connect(reply_handler=device_connect_cb, error_handler=device_connect_error_cb, dbus_interface=IFACE_DEVICE) device_connect(dev_path)
自動接続機能
自動接続は接続対象のデバイスをテーブル化して、ループで順番に接続処理を投げ続ける。という方式を考えました。 しかし、電源が入っていないデバイスに対して、連続して Connectを要求し続けると、BlueZの内部状態がおかしくなってしまうのか、正常に動作しなくなってしまう(接続ができなくなる)問題が発生しました。
そこで、接続処理の前に scan を実行し、周辺にデバイスが存在する事を事前に確認してから、接続する処理に変更しました。
周辺にデバイスが存在する事の判断材料は、Device Interfaceで取得できる RSSI
に注目しました。
scanした結果、デバイスから応答があると、DeviceのPropertyに RSSI
が追加されます。すなわち、周辺に電源が入った(接続待ちの)状態で存在すると判定しています。
int16 RSSI [readonly, optional] Received Signal Strength Indicator of the remote device (inquiry or advertising).
最終的には、以下のようなコードになりました。
def is_alive_device(device_path): dev_props = fetch_property(device_path, IFACE_DEVICE) if dev_props is None: return False rssi = dev_props.get("RSSI", None) if rssi: return True else: return False return False while True: if is_connected_device(dev_path) is True: LOG.info("already connected {}".format(dev_path)) continue if not is_alive_device(dev_path): LOG.info("can't find device near side: {}".format(dev_path)) continue device_connect(dev_path, profile_key)
GATTの受信処理
BLEのGATTの受信メッセージ (Read, Notify)は、D-bus上では PropertiesChanged
で通知されるようです。
そこで、D-bus上の PropertisChanged をキャッチする Signal Receiver を登録します。
bus.add_signal_receiver(property_changed, bus_name="org.bluez", dbus_interface="org.freedesktop.DBus.Properties", signal_name="PropertiesChanged", path_keyword="path")
登録する関数は以下の通りです。
def property_changed(interface, changed, invalidated, path): if interface != IFACE_GATT_CHRC: return if not len(changed): return notify = changed.get('Notifying', None) value = changed.get('Value', None) if notify: LOG.info("{} Notifying = {}".format(path, notify)) return if not value: LOG.warning("value is None") return recv_message_queue.put((path, value))
このハンドラを経由して、BLEの通信で Read / Notify を受信すると、dbusのメッセージから interface, path, value が取得できます。 しかし、pathだけでは、この通知がどの Characteristic によるものなのかがわかりません。
取得したpathがどの Characteristic (UUID)を持っているのかを、都度 D-Bus経由でBlueZに問い合わせてもいいのですが、ちょっと無駄な処理に感じます。 そこで、接続時に事前にobjectを探索して、pathとuuidの関係を保持しています。
これにより、このSignalのpathからどの Characteristicの受信データなのかを簡単に紐づけています。
path_uuid_dict= { "/org/bluez/hci0/dev_DF_78_9E_7E_D3_7F/service0018/char001f": "00002a28-0000-1000-8000-00805f9b34fb", "/org/bluez/hci0/dev_DF_78_9E_7E_D3_7F/service0018/char001d": "00002a26-0000-1000-8000-00805f9b34fb", "/org/bluez/hci0/dev_DF_78_9E_7E_D3_7F/service0018/char001b": "00002a27-0000-1000-8000-00805f9b34fb", "/org/bluez/hci0/dev_DF_78_9E_7E_D3_7F/service0018/char0019": "00002a29-0000-1000-8000-00805f9b34fb", "/org/bluez/hci0/dev_DF_78_9E_7E_D3_7F/service000e/char0012": "00002a38-0000-1000-8000-00805f9b34fb", "/org/bluez/hci0/dev_DF_78_9E_7E_D3_7F/service000e/char000f": "00002a37-0000-1000-8000-00805f9b34fb" }
受け取ったPropertyChangeのSignalがGATT関連の通知の場合は、 queueにpathとvalueを入れておきます。
別のthreadでqueueからpathとvalueを取り出します。
path, value = recv_message_queue.get()
接続時に生成した pathに紐づくCharacteristicのUUIDを探索し、
uuid = path_uuid_dict.get(path, None)
そのCharacteristic毎に定義したパース用の関数を呼び出しています。
if uuid not in ble_parser.uuid_to_parser_dict: LOG.warning("Unknown uuid in cb_table: {}".format(uuid)) return parse_func = ble_parser.uuid_to_parser_dict[uuid] parse_func(id, value)
関数は関数テーブルの仕組みを使う事で、パース処理を共通化しています9。
uuid_to_parser_dict = { # Heart Rate UUID.CHRC_HRM_HR_MEAS: parse_hrm_meas, UUID.CHRC_HRM_SNSR_LOC: parse_integer, # Common Characteristic UUID.CHRC_SNSR_LOC: parse_integer, # Cycling Speed and Cadence UUID.CHRC_SPEED_CSC_MEAS: parse_speed_csc_meas, UUID.CHRC_SPEED_CSC_FEAT: parse_integer, # Device Information UUID.CHRC_DEVICE_SYSTEM_ID: parse_integer, UUID.CHRC_DEVICE_MODEL: parse_string, UUID.CHRC_DEVICE_SERIAL: parse_string, UUID.CHRC_DEVICE_FW_REV: parse_string, UUID.CHRC_DEVICE_HW_REV: parse_string, UUID.CHRC_DEVICE_SW_REV: parse_string, UUID.CHRC_DEVICE_MANFUC: parse_string, # Battery UUID.CHRC_BATTERY_LEVEL: parse_integer, }
免責事項
今回公開したサンプルコードは、あくまでも本記事の参考資料です。 弊社は本サンプルコードの動作保証・サポートは致しかねます。
まとめ
今回対応したProfileはHeart Rateと Cycling Speed and Cadence だけでしたが、他のProfileの追加もパース関数を実装して登録すれば可能かと思います。 少なくとも、自動接続や BlueZの使い方など、PythonでBluetoothを扱うプログラミングの参考になれば幸いです。
最後になりますが、BLEデバイスのデータ収集&遠隔からの可視化などのお仕事も募集中です!
お問い合わせはこちらまで!
-
最初にdbus-python tutorialでノリをつかんでから、blueZの
test/
のサンプルコードを見ると理解が進みます。↩ -
BatteryはD-Bus上独自の扱いになっていたのですが、5.56から
Add support for battery D-Bus interface.
と、他のキャラクタリスティックと同様に扱うことができるようになりました。↩ -
RaspbianのblueZでは、Notifyの通知を受け取れたのですが、Lenovo Thinkpad X1 CarbonのblueZでは Notifyを受信できませんでした… 謎。BlueZのversionを変えても違いが無かったので、別の要因だと思われます。だれか、原因わかる方がいれば教えてください…↩
-
$ sudo apt-get install python-dbus
で事前にインストールしてください。↩ -
Raspbianの場合は
sudo
が必要っぽいです。↩ -
Raspbianの場合、sudoで実行する必要があるかもしれません。↩
-
uuidの定義は bluetooth.com の 16-bit UUIDs から確認できます。↩
-
同期処理で実行すると、他のthreadなどが並列動作してくれませんでした。 GObjectってこういうものなのか..↩
-
Device IDやBatteryの各Characteristicは1個の数字 or 文字列のみというパターンが多いです。ただ、Notify時は1個のパケットに複数のパラメータをのせる仕様が多いため、個別にパース処理を実装する必要があります。↩