文書の過去の版を表示しています。
目次
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での暗号化を要求する組み合わせである。 パスキーなどを表示したり交換したりする場合には別の組み合わせと適切なイベントハンドラの実装が必要となるがここでは割愛する。
接続処理
// 広告パケットを解析して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) { bd_addr_t local_address; gap_local_bd_addr(local_address); 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; } }