ユーザ用ツール

サイト用ツール


bleキーボードをつなごう_btstack編

文書の過去の版を表示しています。


BLEキーボードをつなごう(btstack編)

概要

Raspberry Pi Pico W/Pico 2Wや、 ESP32のようなマイクロコントローラを BLEセントラル(== BLEクライアント)として動作させ、BLEペリフェラル(== BLEサーバー)であるBLEキーボードを接続して、キー入力に使用するというサンプルは意外に少ない。 大体が、Raspberry Pi Pico W/Pico 2Wに何らかのスイッチをつないで、それを BLEキーボードとしてPCなどに接続するという話である。 勿論、これらマイクロコントローラが、電子工作の核になる部品であり、PCの周辺機器や、センサーをコントロールするためのものなのだから当然である。

とはいえ、昨今では、SPI接続のビットマップ液晶が安価に購入できるようになっており、これらマイクロコントローラを使ったゲームコンソールや、エミュレータなどの用途がないわけではない。 こういった用途を考えた場合に、必要となるのは、入力デバイスであるキーボードである。

OTGアダプタを経由してUSBキーボードを接続するという方法もあるが、せっかく小型のコントローラである。 BLEキーボードを使用して、余計なケーブルを必要としないスタイルがよりよいであろう。

ここでは、Raspberry Pi Pico W/Pico 2Wにpico-sdkに含まれる btstack を使用して、BLEキーボードを接続し、キー入力を得る方法について、コードを交えながら解説する。 なお、ここに記すことは、わたしの独自の調査に基づく情報を含んでいるため、間違っている可能性があり、この情報に基づいて実装したコードによって生じうるいかなる損害も、わたしは一切関知しないし責任も負わない。 とはいえ、実際に動作しているコードを使用しての解説であるため、少なくともキー入力を得ることができることについては間違いはない。

前提

開発環境として、Visual Studio上のPlatformIOを使用し、フレームワークには Arduinoを使用している。 つまりは、ユーザ側プログラムには main()はなく、setup()と loop()をエントリポイントとして使用している。

pico-sdkおよび、それに含まれる btstack 1.6.2の利用を前提として、ターゲットは Raspberry Pi Pico W(RP2040)および Raspberry Pi Pico 2W (RP2350)である。

言語は C++を使用する。

処理の流れ

BLEは、Bluetooth Low Energyのことであり、従来の Bluetoothより省電力で通信できるプロトコルである。 GATT1)とよばれる属性プロファイルを使用して、データのやり取りを行う。 まずは、BLEスタックを初期化し、HCI2)を起動する。

次に、BLEセントラル側は BLEペリフェラルによる Advertisement をスキャンして、接続したいデバイスとの間にコネクションを張る。 デバイスがなんであるのか、大まかな割り振りはUUIDで分類できる。 キーボード、ゲームパッド、およびマウスというような入力デバイスは、HIDに分類され、UUID16 == 0x1812を持つと決められているので、このUUIDをもつデバイスと接続すればよい。 一般的には、UUID16 == 0x1812であればとりあえず接続してしまうので、キーボードではないかもしれないが、キーボードやマウスの側で、明示的に接続モードに入っていない限りは Advertiseは行われないので、間違ったデバイスに接続することは稀有だろう。 そういう心配がある場合には、デバイス名などの情報を取得し、それに接続してもいいかというような選択肢を出すことも考えるべきだろうが、ここでは割愛する。

この時、デバイスによっては接続を暗号化してセキュアにする必要がある場合がある。 キーボードの場合は、その性質上打鍵情報を傍受される危険性があるためか、セキュアであることを要求しているものが少なくない。 手元にあるキーボードでは、バッファロー製のものと、Capdputerを購入したときに入っているサンプルのBLEキーボード化アプリがセキュア接続を要求しない3)が、他はすべてセキュア接続を要求する。 この要件は、キー入力を通知する Characteristic の属性に記されており、セキュア接続を必須とする場合に、セキュアでない接続上でキー入力のデータを要求しても要求が失敗する。

接続が確立したら、Characteristicsを取得する。 一つのデバイスは複数の Characteristicsを持ちうるので、デバイスに問い合わせるのだが、この時取得は一つずつ得て、終わるとCOMPETEのイベントが発生する。

全ての Characteristicsが取得され、GATT_EVENT_QUERY_COMPETEのイベントが発生したら、今度は個々のCharacteristicを調べ、Notificationをサポートしてるものを探す。 このため、上記で得られた Characteristicsの一覧をどこかに保持しておく必要がある。 ここでは std::list<gatt_client_characteristic_t>を用意して、そこに格納しておくことにする。

このリストを捜査しながら、Notification可能な Characteristicに対して、Descriptor一覧を要求し、中に含まれる CCCD4)を探し、0x00015)を書き込む。 この時の操作は、一つの Characteristicに対して、Descriptor一覧の要求を行い、CCCDを探して 0x0001を書き込み、この返事が来るまでは、他の Characteristicに対しての処理を行うことができない。

これは、処理がBLEセントラルとBLEペリフェラルの双方にまたがっており、一つの要求に対する一つの返事が完了するまで次の操作に移れないためである。 このことがコードを読みにくくし、処理を追いにくくしているが、デバイスをまたがったループの構築を行う必要があるということである。

これを実現するために、一般的にはループローカルで作成する std::list<gatt_client_characteristic_t>::iterator をグローバルに用意し、このイテレータで、現在どの Characteristicに対する処理が行われていて、すべてに対する処理が終わったかどうかを管理する。

全ての必要なCCCDに対して0x0001の書き込みが済んだら、Notificationを取得した際に呼び出されるコールバックを登録して、後はそちらでキー入力を待つ処理を行う。

実際の処理

初期化

Raspberry Pi Pico W/2Wについては、ファームウェアに cyw43関連の物理層が用意されており、board = rpipicow または rpipico2w としてビルドすると、自動的に組み込まれ初期化される。 16KBほどの領域がこのことにより消費されるが、その代わり hci_init()や、btstack_run_loop()などの一部の初期化やループからの呼び出し処理を必要としない。

static btstack_packet_callback_registration_t hci_event_callback_registration;
static btstack_packet_callback_registration_t sm_event_callback_registration;
 
void
setup()
{
	Serial.begin(115200);
	btstack_memory_init();
	l2cap_init();
	gatt_client_init();
	sm_init();
	btstack_crypto_init();
	// security manager
	sm_set_request_security(true);
	sm_set_io_capabilities(IO_CAPABILITY_NO_INPUT_NO_OUTPUT);
	sm_set_authentication_requirements(SM_AUTHREQ_SECURE_CONNECTION);
	sm_set_encryption_key_size_range(7, 16);
	// register event handlers
	hci_event_callback_registration.callback = &hci_event_handler;
	hci_add_event_handler(&hci_event_callback_registration);
	sm_even_callback_registration.callback = &sm_event_handler;
	sm_add_event_handler(&sm_event_callback_registration);
	// turn on HCI
	hci_power_control(HCI_POWER_ON);
}

ここでは、HCIやSecurity Managerといった必要なモジュールを初期化して、HCIを起動する。 sm_set_io_capabilities(IO_CAPABILITY_NO_INPUT_NO_OUTPUT)とsm_authentication_requirements(SM_AUTHREQ_SECURE_CONNECTION)との組み合わせは、パスキーなどの交換をしないで、暗号化キーを直接交換するJust Worksでの暗号化を要求する組み合わせである。 パスキーなどを表示したり交換したりする場合には別の組み合わせと適切なイベントハンドラの実装が必要となるがここでは割愛する。

接続処理

HCIの動作開始確認

HCIをONにすると、登録した hci_event_handler()にBTSTACK_STATE_EVENTパケットが、state==HCI_STATE_WORKINGで渡ってくる。 これがHCIが動作開始した合図になるので、ここで、Advertising をスキャン開始する。

gap_set_scan_parameter(0, 0x30, 0x30) はAdvertisingをスキャンするパラメータの設定である。 引数は scan type, interval, window になるが、サンプルからの引き写しである。

gap_start_scan()をかけると Advertisingのスキャンが開始される。

Advertisingのスキャン

スキャンが開始され、Advertiseしているデバイスがみつかると、hci_event_handler()に GAP_EVENT_ADVERTISING_REPORT パケットが渡される。 デバイスのアドレス、タイプ、rssi やデータ長などの基本データはこのハンドラの中で取り出している。 btstackにおけるイベントハンドラーでは、パケットからデータを取り出すための手続きが用意されているのでそれを使用する。 原則的に、<イベント名>_get_<取り出したいデータ名>という名前になっている。 Visual Studio Code を使用ていて、Intellisenseが動いているのであれば、<イベント名>_get_ と入力すれば、存在する名前がずらっと出てくるし、おそらく引数とかもサジェストされるので、いちいち調べないでもコーディングは可能だと思われる。

なお、サンプルによっては、関係ないイベントのスクレイパーを呼び出していたりするが、それはたまたまうまく動いているにすぎないので、正しいものを使用するようにしたい。 勿論、わたしの書いているサンプルにも、コードの切り貼りなどをしている過程で不適切なものが混入したままになっている可能性は大いにあるため、コピペなどで適当に済ませずに、検証して使用されたい。

Advertise情報は、パケット中に配列の形で埋め込まれているので、scan_advertisements()関数でその部分を処理している。

キーボードなのでHIDデバイス(UUID16 == 0x1812)のみをターゲットとして受信するために、フィルタリングをここで行っている。 勿論、マウスやゲームパッドも 0x1812なので、本来はもっときちんと検証してキーボードだけを確実に捕まえるようにするべきなのだが、ハイハイスクールアドベンチャーフルーツフィールドといったゲームなどの場合はそこまで厳密にやらないでも、無関係なHIDデバイスを捕まえてしまうリスクは少ないと考えているのでサボっている。

Advertise情報は、先頭にデータ長が 1byteで、次にデータのタイプが 1byteで、その後ろにデータ長分のデータが並んでいる格好になる。 UUIDは BLUETOOTH_DATA_TYPE_COMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS または BLUETOOTH_DATA_TYPE_INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS で渡されるので、いずれかがあった場合にUUIDを取り出している。

BLEのパケットはリトルエンディアンでデータが並ぶ原則になっているので、読み出しもそのように指定して読みだしている。

BLUETOOTH_DATA_TYPE_SHORTENED_LOCAL_NAMEまたは BLUETOOTH_DATA_TYPE_COMPLETE_LOCAL_NAMEをチェックしているのは、M5-Keyboard というデバイス名でやってくる Cardputer のデモプログラムのBLEキーボードがセキュア接続をサポートしていないため、これをセキュア接続の対象から除外するための処理である。

本来は、これも、Characteristics をチェックして、Notificationを要求するのにセキュア接続が必要かどうかを見た上で、セキュア接続するかどうかを判別するべきで、デバイス名でやるべきではないのだが、実用上これでも問題がないので、現在はこの方法で逃げている。

実際大半のキーボードはセキュア接続をサポートしている6)ので、今後対象が増えるとも思いにくいのでよしとしておく。

キーボード(HIDデバイス)が見つかったら、gap_stop_scan()でAdvertisingのスキャンを停止し、gap_connect()で接続を行う。

接続完了とセキュア接続の開始

デバイスと接続すると、HCI_EVENT_LE_META イベントが state == HCI_SUBEVENT_LE_CONNECTION_COMPLETE で渡される。 所々フランス語が混ざっているのが何とも言えない。

コネクションが確立したところで、セキュア接続を開始する。 7)

sm_request_pairing(connection_handle)がセキュア接続要求の開始で、この処理以降は、sm_event_handler()に処理が移る。

static bd_addr_t keyboard_address;
static int found_hid_device = 0;
 
// 広告パケットを解析してHID UUIDを確認
static void 
scan_advertisements(const uint8_t *packet, uint16_t size) 
{
    ad_data_iterator_t ad_iter;
    uint16_t uuid;
 
    // 広告データの反復処理
    for (ad_iter.data = packet, ad_iter.size = size, ad_iter.offset = 0 ; ad_iter.offset < ad_iter.size ; ad_data_iterator_next(&ad_iter)) 
    {
      if (ad_iter.data[ad_iter.offset + 1] == BLUETOOTH_DATA_TYPE_SHORTENED_LOCAL_NAME||
          ad_iter.data[ad_iter.offset + 1] == BLUETOOTH_DATA_TYPE_COMPLETE_LOCAL_NAME) 
      {
        // デバイス名を取得
        char device_name[32];
        memcpy(device_name, &ad_iter.data[ad_iter.offset + 2], ad_iter.data[ad_iter.offset] - 1);
        device_name[ad_iter.data[ad_iter.offset] - 1] = '\0';
        if (strstr(device_name, "M5-Keyboard") != NULL) 
        {
          secure_connection = false; // セキュアコネクションを無効化
          continue;
        }
      }
      // 16ビットUUIDを確認
      if (ad_iter.data[ad_iter.offset + 1] == BLUETOOTH_DATA_TYPE_COMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS ||
          ad_iter.data[ad_iter.offset + 1] == BLUETOOTH_DATA_TYPE_INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS) 
      {
        uuid = little_endian_read_16(&ad_iter.data[ad_iter.offset + 2], 0);
        if (uuid == HID_SERVICE_UUID) 
        {
          found_hid_device = 1;
          continue;
        }
      }
    }
}
 
// HCIイベントハンドラ
static void
hci_event_handler(uint8_t packet_type, uint16_t channel, uint8_t *packet, uint16_t size) 
{
  if (packet_type != HCI_EVENT_PACKET) return;
  switch (hci_event_packet_get_type(packet)) 
  {
    case BTSTACK_EVENT_STATE:
      if (btstack_event_state_get_state(packet) == HCI_STATE_WORKING) {
        gap_set_scan_parameters(0, 0x0030, 0x0030);
        gap_start_scan();
      }
      break;
    case GAP_EVENT_ADVERTISING_REPORT:
      {
        gap_event_advertising_report_get_address(packet, keyboard_address);
        bd_addr_type_t address_type = static_cast<bd_addr_type_t>(gap_event_advertising_report_get_address_type(packet));
        uint8_t rssi = gap_event_advertising_report_get_rssi(packet);
        uint8_t data_length = gap_event_advertising_report_get_data_length(packet);
        scan_advertisements(gap_event_advertising_report_get_data(packet), gap_event_advertising_report_get_data_length(packet));
        if (found_hid_device) {
          gap_stop_scan();
          gap_connect(keyboard_address, address_type);
          found_hid_device = 0; // リセット
        }
        break;
      }
    case HCI_EVENT_DISCONNECTION_COMPLETE:
      gap_start_scan(); // 再スキャン開始
      break;
    case HCI_EVENT_LE_META:
      if (hci_event_le_meta_get_subevent_code(packet) == HCI_SUBEVENT_LE_CONNECTION_COMPLETE) 
      {
        // 暗号化を有効化
        hci_con_handle_t connection_handle = gap_subevent_le_connection_complete_get_connection_handle(packet);
        gap_request_security_level(connection_handle, LEVEL_2);
        if (secure_connection) 
        {
          // セキュアコネクションを有効化
          sm_request_pairing(connection_handle);
        } 
        else 
        {
          // get primary service
          gatt_client_discover_primary_services_by_uuid16(&handle_gatt_client_event, connection_handle, HID_SERVICE_UUID);  // GATT event handler
        }
      }
      break;
    case HCI_EVENT_ENCRYPTION_CHANGE:
      if (!hci_event_encryption_change_get_encryption_enabled(packet)) 
      {
        Serial.printf("Encryption failed.\n");
      }
      break;
    default: 
      break;
  }
}

セキュア化

セキュア接続開始

sm_request_pairing()がデバイス側に届くと、SM_EVENT_PAIRING_STARTED イベントが発生する。 これはセントラル側で特に行うことはない。 サンプルではただアドレスを表示したりしている。

セキュア接続リクエストの処理

これがセキュア接続の肝になる部分である。 初期化時に行った、sm_set_io_capabilities()とsm_set_authentication_requirements()の組み合わせによって、パスキーを受け取ったり、渡したり、受け取ったキーを表示して、Yes/Noを受け取ったりという処理が必要になる場合があるが、ここでは Just Worksになるように組み合わせているので、SM_EVENT_JUST_WORKS_REQUEST が発生する。

Just Worksなので、自動的に承認を返すだけでよい。 sm_just_works_confirm(sm_event_just_works_request_get_handle(packet)); を呼び出せばデバイス側に承認が伝わる。

  sm_set_io_capabilities(IO_CAPABILITY_NO_INPUT_NO_OUTPUT); // IOキャパビリティ設定
  sm_set_authentication_requirements(SM_AUTHREQ_SECURE_CONNECTION); // 認証要件を設定

セキュア接続の完了とプライマリサービスの取得要求

デバイスにセキュア接続の承認が伝わると、SM_EVENT_PAIRING_COMPLETEが発生するので、gatt_client_discover_primary_services_by_uuid16()を呼び出し、処理をGATTの処理に移行する。 この関数で、コールバックを handle_gatt_client_event()に切り替える。

// SMイベントハンドラ
static void
sm_event_handler(uint8_t packet_type, uint16_t channel, uint8_t *packet, uint16_t size) {
  if (packet_type != HCI_EVENT_PACKET) return;
 
  switch (hci_event_packet_get_type(packet)) 
  {
    case SM_EVENT_JUST_WORKS_REQUEST:
      {
        // Just Works Confirmを自動的に承認
        sm_just_works_confirm(sm_event_just_works_request_get_handle(packet));
        break;
      }
    case SM_EVENT_PAIRING_STARTED:
      break;
    case SM_EVENT_PAIRING_COMPLETE: 
    {
      uint8_t status = sm_event_pairing_complete_get_status(packet);
      if (status == ERROR_CODE_SUCCESS) 
      {
        hci_con_handle_t connection_handle = sm_event_pairing_complete_get_handle(packet);
        gatt_client_discover_primary_services_by_uuid16(&handle_gatt_client_event, connection_handle, HID_SERVICE_UUID);  // GATT event handler
      } 
      else 
      {
        Serial.printf("Pairing failed with status %u.\n", status);
      }
      break;
    }
    case SM_EVENT_REENCRYPTION_STARTED:
      {
        // 再暗号化が開始された場合の処理
        bd_addr_t addr;
        sm_event_reencryption_started_get_address(packet, addr); // アドレスを取得
        uint8_t addr_type = sm_event_reencryption_started_get_addr_type(packet);
        break;
      }
    case SM_EVENT_REENCRYPTION_COMPLETE:
      {
        // 再暗号化が完了した場合の処理
        uint8_t status = sm_event_reencryption_complete_get_status(packet);
        if(status != ERROR_CODE_SUCCESS) {
          Serial.printf("Re-encryption failed.(%d)\n", status);
        }
        break;
      }
    default:
      break;
  }
}

プライマリサービスの取得

// GATT関連のイベントの入り口
static void
handle_gatt_client_event(uint8_t packet_type, uint16_t channel, uint8_t *packet, uint16_t size)
{
  switch(hci_event_packet_get_type(packet)) {
    case GATT_EVENT_SERVICE_QUERY_RESULT:
        {
          // サービスのUUIDを取得
          gatt_client_service_t service;
          gatt_event_service_query_result_get_service(packet, &service);
          hci_con_handle_t connection_handle = gatt_event_service_query_result_get_handle(packet);
          uint16_t uuid16 = service.uuid16;
          const uint8_t *uuid128 = service.uuid128;
 
          if (uuid16 == 0)
          {
            uuid16 = little_endian_read_16(uuid128, 0);
          }
          if (uuid16 == HID_SERVICE_UUID) {
            // characteristicを探索するのはGATT_EVENT_QUERY_COMPLETEで行う必要がある。
            hid_service = service; // HIDサービスを保存
          }
        }
        break;
    case GATT_EVENT_QUERY_COMPLETE:
        {
          uint16_t status = gatt_event_query_complete_get_att_status(packet); // Replace with the correct function
          if (status != 0) {
            Serial.printf("GATT query failed with status %u\n", status);
          } else {
            if (hid_characteristics_scan_completed) break; // HID Reportの探索が完了している場合はスキップ
            // GATTクライアントのサービスを探索する
            hci_con_handle_t connection_handle = gatt_event_query_complete_get_handle(packet); // Retrieve connection handle
            gatt_client_discover_characteristics_for_service(&handle_gatt_characteristics_discovered, connection_handle, &hid_service); // GATT event handler
          }
        }
        break;
    default:
      break;
  }
}

Characteristicsの取得からCCCDに対する要求

Notificationの処理準備

Notification handler

1)
Generic Attirbute Profile
2)
Host Controller Interface
3)
前者はセキュアでもOKだが後者はセキュア接続がそもそもできない
4)
Client Characteristic Configuration Descriptor
5)
16bit little-endian なので 0x00, 0x01のバイト並びとなる。
6)
キーボードというデバイスの性質上当然だろう。
7)
但し、M5-Keyboard の場合には、セキュア接続の処理を飛ばして、プライマリサービスの取得要求を行う。
bleキーボードをつなごう_btstack編.1745283200.txt.gz · 最終更新: by araki