ユーザ用ツール

サイト用ツール


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++を使用する。

なお、この文書やプログラムの作成に当たってはBuilding a Bluetooth GATT Client on the Pi Pico Wおよびbtstack-1.6.2のサンプルプログラムを参考にした。

また、Copilot、Gemini、およびLM Studio上の Gemma3 12BなどAIによる支援も利用した。

完全なコードはハイハイスクールアドベンチャー PicoCalc版に含まれている。

処理の流れ

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_client_discover_primary_services_by_uuid16()がデバイス側に受理されると、GATT_EVENT_SERVICE_QUERY_RESULTが発生する。

BLEデバイスについては、一つのデバイスであっても、複数のサービスを持っているのが普通なので、HID_SERVICE_UUID(0x1812)のサービスだけを扱うようにフィルタリングしている。

勿論、HIDサービス以外を処理できるように、コードを追加することもできる。

HIDサービスのCharacteristics取得要求

GATTのリクエストが完了すると、これも共有の GATT_EVENT_QUERY_COMPLETE が発生する。 このイベントは、次の要求を発呼できる状態になったという合図なので、これを待ってから次の要求を行うのが作法である。

AIなどに聞くと、前段の REPORTイベントの中でガンガン要求を発呼していたりするので、動かなかったりする。

またGATTにおいて、デバイスに何かを要求すると、その完了を伝えるためにこのイベントが発生する。 つまり、さまざまな要求がこのイベントを共有するので、今何をリクエストしているのかを覚えていなければならない。 btstack のサンプルなどでは state というグローバル変数を用意して、何を処理中なのかをここで管理するステートマシンを作っている。

それはそれで、おそらくスマートなんだろうけれど、イベントハンドラの中が state による場合分けで読みにくくなりそうなので、要求ごとにハンドラを分ける形をとっている。

なので、handle_gatt_client_event()ハンドラで処理するのは、gatt_client_discover_primary_services_by_uuid16()にかかわるCOMPLETEだけになっている。

HIDに対するサービスハンドルhid_serviceは取得済み8)なので、これに対して Characteristicsの取得を要求する。 この要求に対する処理は handle_gatt_characteristics_discovered()に渡される。

static gatt_client_service_t hid_service;
 
// 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に対する要求

Characteristics一覧の取得と保存

gatt_client_discover_characteristics_for_service()がデバイスに処理されると、GATT_EVENT_CHARACTERISTIC_QUERY_RESULTのイベントが発生する。

一般的にBLEデバイスでは、サービスごとに複数のCharacteristicsが紐づいている。

なので、一つの gatt_client_discover_characteristics_for_service()に対して、複数の GATT_EVENT_CHARACTERISTIC_QUERY_RESULTが渡される。

その際にひとつの characteristicが渡されるので、これを hid_characteristicsに保存しておく。 なお、キー入力を受け取るには HID_REPORT_DATA 9)だけを見ればいいので、ここでフィルタリングしてしまってもいいのかもしれない。

このフィルタリングは後で Descriptorをチェックしたり、Notificationを要求したりする段で行っている。

Notificationの要求処理開始

全ての Characteristics を受取り終わると、GATT_EVENT_QUERY_COMPLETEが発生する。 この先は、渡された Characteristics を走査して、HID_REPORT_DATAに関連する Characteristicで Notification可能なものがあれば、それに対して Notificationを送るように要求していく処理になる。

ここで、BLEというかGATTの面倒くさい部分になるのだが、Characteristicひとつに対して要求の処理を行うと、それが終わるまで次の Characteristicに対する要求に移れない。

何を言っているのかというと、感覚的に、次のようなループで処理をすすめたくなるが、これではうまく動かないのだ。

for(auto it = hid_characteristics.begin(); it != hid_characteristics.end(); ++it) 
{
  // HID ReportのUUIDを確認
  if (it->uuid16 == 0x2a4d) { // UUIDがHID_REPORT_DATAの場合
    uint16_t characteristic_handle = it->value_handle;
    uint8_t properties = it->properties;
    if (properties & ATT_PROPERTY_NOTIFY) { // Notificationをサポートしているか確認
      hci_con_handle_t connection_handle = gatt_event_query_complete_get_handle(packet);
      gatt_client_discover_characteristic_descriptors(&handle_gatt_descriptors_discovered, connection_handle, &*it);
    }
  }
}

最初に、Notification可能な Characteristicを見つけて、gatt_client_discover_characteristic_descriptors()に要求した時点で、このループの後続のリクエストは10)無視される。

これを回避するためにループ制御をローカルのiteratorではなくて、hid_characteristic_itをグローバルに用意して、中断した場所を覚えておいて、最初のリクエストが完了した時点で次に移れるようにしておく。

gatt_client_discover_characteristic_descriptors()の戻りについてはhandle_gatt_descriptors_discovered()に処理が移る。

Notificationの要求について

デバイスに対して Notificationを要求するには、Characteristicの Descriptorsの中からCCCD11)を見つけて、それに対してNotificationの要求 0x0001を書き込む必要がある。

このため、Characteristicの Descriptor一覧を取得する必要がある。

static std::list<gatt_client_characteristic_t> hid_characteristics;
static std::list<gatt_client_characteristic_t>::iterator hid_characteristic_it;
 
 
// Characteristicを見つけた。
static void
handle_gatt_characteristics_discovered(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 GATT_EVENT_CHARACTERISTIC_QUERY_RESULT:
      {
        gatt_client_characteristic_t characteristic;
        gatt_event_characteristic_query_result_get_characteristic(packet, &characteristic);
        uint16_t characteristic_handle = characteristic.value_handle;
        uint8_t properties = characteristic.properties;
        hid_characteristics.push_back(characteristic); // HID Reportを保存
      }
      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 Characteristcis query failed with status %u\n", status);
        } else {
          for(hid_characteristic_it = hid_characteristics.begin(); hid_characteristic_it != hid_characteristics.end(); ++hid_characteristic_it) 
          {
            // HID ReportのUUIDを確認
            if (hid_characteristic_it->uuid16 == 0x2a4d) { // UUIDがHID_REPORT_DATAの場合
              uint16_t characteristic_handle = hid_characteristic_it->value_handle;
              uint8_t properties = hid_characteristic_it->properties;
              if (properties & ATT_PROPERTY_NOTIFY) { // Notificationをサポートしているか確認
                hci_con_handle_t connection_handle = gatt_event_query_complete_get_handle(packet);
                gatt_client_discover_characteristic_descriptors(&handle_gatt_descriptors_discovered, connection_handle, &*hid_characteristic_it++);
                break;
              }
            }
          }
        }
      }
      break;
    default:
      break;
  }
}

Descriptor一覧の取得とNotificationの要求

Descriptorの取得

gatt_client_discover_characteristic_descriptors()をデバイスが受け取ると、GATT_EVENT_ALL_CHARACTERISTIC_DESCRIPTORS_QUERY_RESULTが発生する。 ALL_CHARACTERISTIC_DESCRIPTORS_QUERY_RESULT って書いてあるけれど、全部が一気に渡されるわけではなく、GATT_EVENT_CHARACTERISTIC_QUERY_RESULT同様に一つずつ渡ってくる。

なので、 hid_descriptors に記録しておく。 勿論、ここで CCCD 12)のみをフィルタリングしてもいいし、意識高く、NimBLEのように再利用可能なラッパーライブラリーを目指すなら、characteristicごとに descriptor一覧を保存しておくというのもありだろう。

ここではCCCDにNotification要求をしたら、あとは特に descriptorを使う用もないので、捨ててしまう前提で組んでいる。 それでも globalに staticなリストとして保存するのは、これも例によって、一つの要求を出したらそれが COMPLETEするのを待たねばならないためである。

CCCDの検索とNotificationの要求

全ての descriptorがわたし終わると、GATT_EVENT_QUERY_COMPLETEが発生する。 hid_descriptorsを走査して、UUID16 == 0x2902 の descriptorを探し、そこに Notification要求を書き込む。

notification_enableはバイト型の配列で 0x01, 0x00 の順でデータが書き込まれている。 GATTのデータは little endian でやり取りされるので、この順で書き込む必要がある。 勿論、手元の処理系が little endianである場合には普通に 0x0001の uint16_tデータを渡しても構わないが、汎用化するためにこのようにしてある。

gatt_client_write_value_of_characteristic()には handle_gatt_noification_activated()をイベントハンドラとして登録し、そちらで完了の処理を行う。

処理の続きの部分について

ここまでは、要求を出したら原則処理はそこで終わりだったが、ここでは続きの部分がある。 Descriptorを最後まで捜査して、CCCDが見つからなかった場合には、次のCharacteristicに対して gatt_client_discover_characteristic_descriptors()を要求しなければならない。 なので、hid_descriptorsをクリアして、再取得に備える。

hid_characteristics_it が hid_characteristeics.end()に到達していたら、もう必要な処理はないので、おしまいである。

CCCDに対する要求完了

CCCDに対する要求が完了したら GATT_EVENT_QUERY_COMPLETEが発生する。 中断したところから次の CCCDを探し、終端まで行ったら次の Characteristicに対して、gatt_client_discover_characteristic_descriptors()をかける。

そして、characteristicsも終端まで行ったらおしまいである。

Notificationの処理登録

処理が終端に到達したら、BLEセントラル側で Notificationの受け取り準備をする。

gatt_client_listen_for_characteristic_value_updates(&notification_listener, &notification_handler, connection_handle, nullptr);

Characteristics一つ一つに対して処理を要求することもできるようだが、ここでは一括で行う。 最後の nullptrが、全部の Characteristicsに対しての要求を意味している。

これであとは notification_handler でGATT_EVENT_NOTIFICATIONのイベントを処理すれば目的達成である。

static std::list<gatt_client_characteristic_t> hid_characteristics;
static std::list<gatt_client_characteristic_descriptor_t> hid_descriptors;
static std::list<gatt_client_characteristic_t>::iterator hid_characteristic_it;
static std::list<gatt_client_characteristic_descriptor_t>::iterator hid_descriptor_it;
 
static uint8_t notification_enable[] = {0x01, 0x00}; // 通知を有効化する値
static gatt_client_notification_t notification_listener;
 
// CCCDにNotificationを要求し完了した
static void
handle_gatt_noification_activated(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 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 Notification activation failed with status %u\n", status);
        } else {
          uint16_t service_id = gatt_event_query_complete_get_service_id(packet); // Retrieve service ID
          uint16_t handle = gatt_event_query_complete_get_handle(packet); // Retrieve handle
          hci_con_handle_t connection_handle = gatt_event_query_complete_get_handle(packet); // Retrieve connection handle
          while (hid_descriptor_it != hid_descriptors.end())
          {
            uint16_t uuid16 = hid_descriptor_it->uuid16;
            if (uuid16 == 0)
            {
              uuid16 = little_endian_read_16(hid_descriptor_it->uuid128, 0);
            }
            if (uuid16 == 0x2902) // UUIDがClient Characteristic Configurationの場合
            {
              gatt_cccd_descriptor = *hid_descriptor_it++; // Client Characteristic Configuration Descriptorを保存
              // Notificationを有効化する値を書き込む
              gatt_client_write_value_of_characteristic(&handle_gatt_noification_activated, connection_handle, gatt_cccd_descriptor.handle, sizeof(notification_enable), notification_enable);
              break;
            }
            hid_descriptor_it++; // イテレータを保存
          }
          if (hid_descriptor_it == hid_descriptors.end()) // HID Reportのイテレータを進める
          {
            hid_descriptors.clear(); // HID Reportのイテレータをクリア
            while (hid_characteristic_it != hid_characteristics.end())
            {
              // HID ReportのUUIDを確認
              if (hid_characteristic_it->uuid16 == 0x2a4d) { // UUIDがHID_REPORT_DATAの場合
                uint16_t characteristic_handle = hid_characteristic_it->value_handle;
                uint8_t properties = hid_characteristic_it->properties;
                //display.printf("Found HID Report characteristic: %04x\n", it->value_handle);
                if (properties & ATT_PROPERTY_NOTIFY) { // Notificationをサポートしているか確認
                  cur_characteristic = *hid_characteristic_it; // HID Reportを保存
                  gatt_client_discover_characteristic_descriptors(&handle_gatt_descriptors_discovered, connection_handle, &*hid_characteristic_it++);
                  break;
                }
              }
              hid_characteristic_it++; // イテレータを保存
            }
            if (hid_characteristic_it == hid_characteristics.end())
            {
              gatt_client_listen_for_characteristic_value_updates(&notification_listener, 
                &notification_handler, 
                connection_handle, 
                nullptr);
            }
          }
        }
      }
      break;
    default:
      break;
 
  }
}
 
// descriptor を見つけた
static void
handle_gatt_descriptors_discovered(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 GATT_EVENT_ALL_CHARACTERISTIC_DESCRIPTORS_QUERY_RESULT:
      {
        // Descriptorの情報を取得
        gatt_client_characteristic_descriptor_t descriptor;
        gatt_event_all_characteristic_descriptors_query_result_get_characteristic_descriptor(packet, &descriptor);
        hid_descriptors.push_back(descriptor); // HID Reportを保存
      }
      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 Descriptor query failed with status %u\n", status);
        } 
        else 
        {
          // Notificationを有効化する値を書き込む
          hci_con_handle_t connection_handle = gatt_event_query_complete_get_handle(packet);
          for(hid_descriptor_it = hid_descriptors.begin(); hid_descriptor_it != hid_descriptors.end(); ++hid_descriptor_it) 
          {
            uint16_t uuid16 = hid_descriptor_it->uuid16;
            if (uuid16 == 0)
            {
              uuid16 = little_endian_read_16(hid_descriptor_it->uuid128, 0);
            }
            if (uuid16 == 0x2902) 
            { // UUIDがClient Characteristic Configurationの場合
              gatt_cccd_descriptor = *hid_descriptor_it; // Client Characteristic Configuration Descriptorを保存
              hid_descriptor_it++; // イテレータを保存
              gatt_client_write_value_of_characteristic(&handle_gatt_noification_activated, connection_handle, gatt_cccd_descriptor.handle, sizeof(notification_enable), notification_enable);
              break;
            }
          }
          if (hid_descriptor_it == hid_descriptors.end()) 
          {
            hid_descriptors.clear(); // HID Reportのイテレータをクリア
            while (hid_characteristic_it != hid_characteristics.end())
            {
              if (hid_characteristic_it->uuid16 == 0x2a4d) { // UUIDがHID_REPORT_DATAの場合
                uint16_t characteristic_handle = hid_characteristic_it->value_handle;
                uint8_t properties = hid_characteristic_it->properties;
                if (properties & ATT_PROPERTY_NOTIFY) { // Notificationをサポートしているか確認
                  cur_characteristic = *hid_characteristic_it; // HID Reportを保存
                  hci_con_handle_t connection_handle = gatt_event_query_complete_get_handle(packet);
                  gatt_client_discover_characteristic_descriptors(&handle_gatt_descriptors_discovered, connection_handle, &*hid_characteristic_it++);
                  break;
                }
              }
              ++hid_characteristic_it; // イテレータを保存
            }
            if (hid_characteristic_it == hid_characteristics.end())
            {
              gatt_client_listen_for_characteristic_value_updates(&notification_listener, 
                &notification_handler, 
                connection_handle, 
                nullptr);
            }
          }
        }
      }
      break;
    default:
      break;
  }
}

Notification handler

ほぼ蛇足であるが、Notification handlerについても少しだけ記しておく。 Notificationについては、キーボードからの入力は 0x0040 のハンドルで渡されるようである。 メディアキーなどの特殊キーはさらに別のハンドルとなるがここでは割愛する。

ほとんどのキーボードの場合はデータ長は 8bytes で先頭が modifierで、1byteのパディングがあって、6bytesのキーコードがわたされる。

但し、バッファローのキーボードで11bytes (modifier + 10bytesのキーコード)というものがあったのでどちらでも扱えるようにデザインしてある。

キーコードについては、キーボードごとに違いはないので、キーコードを文字にマッピングしてやって、keybuf というキューにデータを登録している。

キーの状態に変化がないと Notificationは来ない。 キーが押しっぱなしでは状態が変化したわけではないので、押しっぱなしの通知は来ない。 通知がなければ、押しっぱなしとみることで、キーリピートやゲームでのキー処理にも対応できるが、ここではそのあたりの処理はしない。

キーを押し続けているときに新しくキーが押されたらその分が新しく押されたキーコードとして渡されるが、その時、押しっぱなしになっているキーも渡されるので、差分を見なければ、新しく押されたキーを見つけることができない。

そのため前回渡されたキーバッファの内容を保存している。

押しっぱなしのキーが離されたときにも、通知が来るが、この時他に押しっぱなしのキーがあればそれも渡されるため、同様に、新しく押されたキーがあるかどうか、離されただけなのか、というチェックを前回のデータと比較して行う。

キーボードについては概ねこのような処理を行っている。

typedef union {
  struct __attribute__((__packed__))
  {
      uint8_t modifiers;
      uint8_t keys[10];
  } k1;
  struct __attribute__((__packed__))
  {
      uint8_t modifiers;
      uint8_t reserved;
      uint8_t keys[6];
      uint8_t padding[3];
  } k2;
  uint8_t raw[11];
} keyboard_t;
 
static keyboard_t keyboardReport;
static std::queue<uint8_t> keybuf;
static const int MAX_KEYCODE = 96;
const uint8_t keymap[][MAX_KEYCODE] = {
  {    0,   0,   0,   0, 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l',
     'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', '1', '2',
     '3', '4', '5', '6', '7', '8', '9', '0',  13,  27,   8,   9, ' ', '-', '=', '[',
     ']','\\',   0, ';','\'', '`', ',', '.', '/',   0,   0,   0,   0,   0,   0,   0,
       0,   0,   0,   0,   0,   0,   0,   0,   0,   0,   0,   0, 127,   0,   0,   0,
       0,   0,   0,   0,   0,   0,   0,   0,   0,   0,   0,   0,   0,   0,   0,   0,
  },
  {
       0,   0,   0,   0,   1,   2,   3,   4,   5,   6,   7,   8,   9,  10,  11,  12,
      13,  14,  15,  16,  17,  18,  19,  20,  21,  22,  23,  24,  25,  26,   0,   0,
       0,   0,   0,   0,   0,   0,   0,   0,   0,   0,   0,   0,   0,   0,   0,   0,
       0,   0,   0,   0,   0,   0,   0,   0,   0,   0,   0,   0,   0,   0,   0,   0,
       0,   0,   0,   0,   0,   0,   0,   0,   0,   0,   0,   0,   0,   0,   0,   0,
       0,   0,   0,   0,   0,   0,   0,   0,   0,   0,   0,   0,   0,   0,   0,   0,
  },
  {
       0,   0,   0,   0, 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L',
     'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', '!', '@',
     '#', '$', '%', '^', '&', '*', '(', ')',  13,  27,   8,   9, ' ', '_', '+', '{',
     '}', '|',   0, ':', '"', '~', '<', '>', '?',   0,   0,   0,   0,   0,   0,   0,
       0,   0,   0,   0,   0,   0,   0,   0,   0,   0,   0,   0, 127,   0,   0,   0,
       0,   0,   0,   0,   0,   0,   0,   0,   0,   0,   0,   0,   0,   0,   0,   0,
  },
};
// 通知を受け取るハンドラ
static void 
notification_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 GATT_EVENT_NOTIFICATION:
      {
        // 通知を受信した場合の処理
        uint16_t attribute_handle = gatt_event_notification_get_handle(packet);
        uint16_t value_length = gatt_event_notification_get_value_length(packet);
        const uint8_t *value = gatt_event_notification_get_value(packet);
        uint16_t value_handle = gatt_event_notification_get_value_handle(packet);
        uint16_t service_id = gatt_event_notification_get_service_id(packet);
        if (attribute_handle == 0x0040)
        {
          if (value_length == 0) return; // データがない場合は無視
          if (value_length == 8 || value_length == 11)
          {
            keyboard_t *newKeyReport = (keyboard_t*)value;
            int buflen = 6;
            uint8_t *buf = keyboardReport.k2.keys;
            uint8_t *input = newKeyReport->k2.keys;
            uint8_t mod = newKeyReport->k2.modifiers;
            if (value_length == 11)
            {
              buflen = 10;
              buf = keyboardReport.k1.keys;
              input = newKeyReport->k1.keys;
              mod = newKeyReport->k1.modifiers;     
            }
            for (int i = 0 ; i < buflen ; i++)
            {
              uint8_t c = input[i];
              if (c == 0) continue;
              if (mod == 3) mod = 1;
              uint8_t ch = keymap[mod][c];
              if (ch == 0) continue;
              if (memchr(buf, c, buflen) == NULL) keybuf.push(ch);
            }
            memcpy(&keyboardReport, value, value_length);
          }
        }
      }
      break;
    default:
      break;
  }
}
1)
Generic Attirbute Profile
2)
Host Controller Interface
3)
前者はセキュアでもOKだが後者はセキュア接続がそもそもできない
4) , 11)
Client Characteristic Configuration Descriptor
5)
16bit little-endian なので 0x00, 0x01のバイト並びとなる。
6)
キーボードというデバイスの性質上当然だろう。
7)
但し、M5-Keyboard の場合には、セキュア接続の処理を飛ばして、プライマリサービスの取得要求を行う。
8)
であるはず
9)
0x2a4d
10)
おそらく
12)
0x2902
bleキーボードをつなごう_btstack編.txt · 最終更新: 2025/04/22 06:13 by araki