Bluetooth Low EnergyのclientアプリをBlueZとpythonで作ってみた

f:id:aptpod_tech:20211215170048p:plain

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

実行環境

筆者の環境は以下の通りです。

  • RaspberryPi 4B+ (Raspbian OS) 3
  • blueZ 5.50 / 5.58
  • python 3.5.5
  • dbus-python4

事前準備

今回のプログラムを実行する前に、事前に接続したい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デバイスのデータ収集&遠隔からの可視化などのお仕事も募集中です!

お問い合わせはこちらまで!


  1. 最初にdbus-python tutorialでノリをつかんでから、blueZの test/のサンプルコードを見ると理解が進みます。

  2. BatteryはD-Bus上独自の扱いになっていたのですが、5.56から Add support for battery D-Bus interface. と、他のキャラクタリスティックと同様に扱うことができるようになりました。

  3. RaspbianのblueZでは、Notifyの通知を受け取れたのですが、Lenovo Thinkpad X1 CarbonのblueZでは Notifyを受信できませんでした… 謎。BlueZのversionを変えても違いが無かったので、別の要因だと思われます。だれか、原因わかる方がいれば教えてください…

  4. $ sudo apt-get install python-dbus で事前にインストールしてください。

  5. Raspbianの場合は sudoが必要っぽいです。

  6. Raspbianの場合、sudoで実行する必要があるかもしれません。

  7. uuidの定義は bluetooth.com の 16-bit UUIDs から確認できます。

  8. 同期処理で実行すると、他のthreadなどが並列動作してくれませんでした。 GObjectってこういうものなのか..

  9. Device IDやBatteryの各Characteristicは1個の数字 or 文字列のみというパターンが多いです。ただ、Notify時は1個のパケットに複数のパラメータをのせる仕様が多いため、個別にパース処理を実装する必要があります。