BLE(HID)機器をEPS32に接続する
色々なキーボード、マウスをPC-98に接続しようという企画(?)も終盤です。PS/2、USBは何となく出来上がりました。
残りはBluetoothになります。やる気になった理由は
- EPS32にはBluetooth(Classic・BLE)がサポートされてる
- USBHostSheildが不要
- USBの時に作ったガジェットをそのまま流用できる
まぁ、1,2は後付け理由ですw。一番大きいのは3ですね!もうコテ握ってMiniDinを結線するのは老眼(初期と信じてる)にはつらい…。やりたくない。歯科拡大鏡が必要なレベル。
Bluetoothには3.0までのBluetooth Classic、それ以降のBLE(Bluetooth Low Energy)があります。この二つには互換性がなく別規格と思っていいみたいです。この4.0以降がすべてBLEというわけではないのですが、最近発売されているマウス、キーボードは、ほとんどBLEです。
EPS32はClassic、BLE両方に対応しています。最新のデバイスに対応すればよいですのでBLEで決め打ちしていきたいと思います。というかClassic使うにはESP-IDF使わんとダメっぽいからめんどくさいです。使うのはNimBLEというライブラリを用いて接続していきます。ESP32をマウス、キーボード化する例はたくさんあるのですが、ESP32にHIDデバイスの接続例がなかなかありません。下のサイトは一番詳しく説明されているサイトではないかと思います。ひな形となるコードはMoke Nakamuraさんのサイトより参考にしてすすめました。
この中にあるNimbleTest.inoを実行できるようにしておきましょう。そして、ダイソーに走ってBLEのシャッターを買いましょう。まずはサンプルを確実に動かせることが理解への早道です。僕?もちろん無駄に買いそろえてますw。しばらくカチカチ遊べば準備完了です
最初にまずGithubを張り付けておきます。ちなみに今回からVSCode + PlatformIOで開発しています。Arduino IDE 2.0のビルトが遅すぎでやる気が削がれていたので変えました。
なんで…今まで変えなかったんだろ…VSCode + PlatformIOサイコー!
開発が捗ります。時間を返してほしいです!
コードを追いながら読んでくださるとありがたいです。
BLE HIDとEPS32の接続
まずはBLEの基礎知識として、下のサイトを読んでおくのがいいと思います。
ペリフェラル、セントラル、アドバタイズ、サービス、キャラクタリスティックなどBLE用語がありますので理解していないと訳が分かりません。理解に努めましょう!
流れとしては
- セントラル(ESP32)がペリフェラル(マウスorキーボード)からのアドバタイズ受信
- HIDの接続したいマウスキーボードを判断し接続
- キャラクタリスティックのNotify属性(キーボード打った時やマウスを動かした時のデータ通知)のコールバック処理
- キーボードパーサーでNotify属性のデータ処理とPC-98へ送信
- マウスパーサーでNotify属性のデータ処理とPC-98へ送信
です。一つずつ追ってみます
セントラル(ESP32)がペリフェラル(マウスorキーボード)からのアドバタイズ受信
今回利用するBluetoothマウスは
キーボードは
この検証しか使う予定はないのでメルカリで777円だったかで購入したものです(後で後悔します)。
まずはこの機器の電源を投入してESP32でアドバタイズの様子を確認してみます。NimbleTest.inoのシリアル出力をみてみます。ずらずら出てきますが不要なものは消してあります。
1 2 3 4 5 |
Starting NimBLE Client Advertised Device found: name:,address:55:4a:da:14:ed:db UUID:0xfef3 Advertised Device found: name:,address:41:c9:7e:a0:f1:3b UUID:0xfe50 Advertised Device found: name:BT WORD,address:dd:77:54:7e:b2:98 UUID:none Advertised Device found: name:,address:dc:00:a8:2c:08:00 UUID:none |
アドレスってのはペリフェラルのMacアドレス。UUIDはサービスIDです。サービスIDは割り当てが決まっているものもあり、調べてみると0xfef3とか0xfe50はGoogle Incとなってますので関係ないデバイスです。キーボードとかマウスはHIDサービスですので0x1812です。
出力を見るとマウス、キーボードの電源をいれた直後の状態。つまり接続待機(ペアリングではなく)です。サービスIDに0x1812はないですね。キーボードであろう「BT WORD」デバイス名らしいものがわかりますが、マウスはデバイス名、アドレス、サービスがわからなけれは何なのかすらもわかりません。
ちなみに言ってしまうとdd:77:54:7e:b2:98がキーボード、00:a8:2c:08:00がマウスです。このMacアドレスを紐づけてマウス、キーボードして検知・保存する必要があるわけです。いろいろな解説サイトをみてみましたがこれをペアリングといのかどうなのかはわかりませんがペアリングと思ってますw。
マウス、キーボードにはペアリングのボタンがあると思います。それを押してみましょう。
1 2 |
Advertised Device found: name:ELECOM shellpha,address:dc:00:a8:2c:09:00 UUID:0x1812 Advertised Device found: name:BT WORD,address:dd:77:54:7f:b3:65 UUID:0x1812 |
とログ出力されます。今度はわかりやすいですね。サービスIDは0x1812でHID、マウスは「ELECOM shellpha」、キーボードは「BT WORD」となっています。しかし、Macアドレスが微妙に変わってます。ペアリングボタンを押すたびにMacアドレスが変わっているようでセキュリティ上の問題でしょうね。今どきのデバイスはMacアドレスはランダム化されており追跡できないようなってますし完全にユニークな識別子として利用はできないです。しかし、ペアリングを押さないで電源入れただけの待機状態だとMacアドレスは変更されませんのでペアリング時のMacアドレスを覚えておいて再接続していると予測できます。
Macアドレスの他にも恒久的に保存しておく情報があるので、int値を保存しておく汎用関数も作っておきます(後ほど説明)。LittileFSをつかっていきます。
せんせー!SPIFFSじゃだめなんですか?
SPIFFSは、とっくにDeprecatedになりました!次のひと~!
使用感はLittileFSも同じですので調べてください。
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 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 |
//LittleFSへのint値の保存 void saveValue(String F_Name,int value){ if(!isEnable_fs){ Serial.println("LittleFS is unable"); return; } File dataFile = LittleFS.open(F_Name, "w"); dataFile.println(String(value).c_str()); dataFile.close(); } //LittleFSへのint値の読み出し int loadValue(String F_Name,int def_value){ if(!isEnable_fs){ Serial.println("LittleFS is unable"); return def_value; } if(!LittleFS.exists(F_Name)) return def_value; File dataFile = LittleFS.open(F_Name, "r"); String buf = dataFile.readStringUntil('\n'); dataFile.close(); buf.trim(); //改行除去 if(buf.compareTo("0")==0){ return 0; }else{ int ret = buf.toInt(); if(ret==0){ return def_value; }else{ return ret; } } } //LittleFSへのマウスアドレスの保存・ロード void saveMouseAd(NimBLEAddress ad){ if(!isEnable_fs){ Serial.println("LittleFS is unable"); return; } File dataFile = LittleFS.open(MOUSE_AD_FILE, "w"); dataFile.println(ad.toString().c_str()); dataFile.close(); } NimBLEAddress getMouseAd(){ if(!isEnable_fs){ Serial.println("LittleFS is unable"); return NimBLEAddress(); } if(!LittleFS.exists(MOUSE_AD_FILE)) return NimBLEAddress(); File dataFile = LittleFS.open(MOUSE_AD_FILE, "r"); String buf = dataFile.readStringUntil('\n'); dataFile.close(); buf.trim(); //改行除去 //Serial.println("buf=" + buf); return NimBLEAddress(buf.c_str()); } //LittleFSへのキーボードアドレスの保存・ロード void saveKbAd(NimBLEAddress ad){ if(!isEnable_fs){ Serial.println("LittleFS is unable"); return; } File dataFile = LittleFS.open(KB_AD_FILE, "w"); dataFile.println(ad.toString().c_str()); dataFile.close(); } NimBLEAddress getKbAd(){ if(!isEnable_fs){ Serial.println("LittleFS is unable"); return NimBLEAddress(); } if(!LittleFS.exists(KB_AD_FILE)) return NimBLEAddress(); File dataFile = LittleFS.open(KB_AD_FILE, "r"); String buf = dataFile.readStringUntil('\n'); dataFile.close(); buf.trim(); //改行除去 //Serial.println("buf=" + buf); return NimBLEAddress(buf.c_str());; } |
冗長な感じで、もーちょい簡潔に書ける気もしますが、これでESP32の電源が切れてもペアリングしたMacアドレスやint値は保持されます。
次に、ペアリングしたときに「LECOM shellpha」なのか、「BT WORD」なのか、はたまた関係のないHIDデバイスなのかを判断しアドレスを保存しておく処理を行うようにします。まずはアドバタイズ取得したときのコールバック処理です。接続の部分に少しかかわってくる部分もあります。
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 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 |
//ペアリング時に通知されるマウス名(一部でもOK) 要事前調査 const char* MOUSE_NAME_WORD = "shellpha"; //ペアリング時に通知されるキーボード名(一部でもOK) 要事前調査 const char* KB_NAME_WORD = "BT WORD"; //アドバタイズ情報の取得時コールバック class AdvertisedDeviceCallbacks: public NimBLEAdvertisedDeviceCallbacks { void onResult(NimBLEAdvertisedDevice *advertisedDevice) { #ifdef ADVERTISE_DEV_DEBUG Serial.print("Advertised Device found: "); Serial.printf("name:%s,address:%s", advertisedDevice->getName().c_str(), advertisedDevice->getAddress().toString().c_str()); Serial.printf(" UUID:%s\n", advertisedDevice->haveServiceUUID() ? advertisedDevice->getServiceUUID().toString().c_str() : "none"); #endif //ペアリング済みマウスとaddressが一致するかどうか if(advertisedDevice->getAddress().equals(getMouseAd())){ Serial.println("Match Known Mouse Address"); /** stop scan before connecting */ NimBLEDevice::getScan()->stop(); /** Save the device reference in a global for the client to use*/ advDevice = advertisedDevice; /** Ready to connect now */ doConnect = DO_CONNECT_MOUSE; return; } //ペアリング済みキーボードとaddressが一致するかどうか if(advertisedDevice->getAddress().equals(getKbAd())){ Serial.println("Match Konwn Keyboard Address"); /** stop scan before connecting */ NimBLEDevice::getScan()->stop(); /** Save the device reference in a global for the client to use*/ advDevice = advertisedDevice; /** Ready to connect now */ doConnect = DO_CONNECT_KB; return; } // 新規のマウス・キーボードのペアリング HID UUIDかチェックしてペアリングの検出 if (advertisedDevice->isAdvertisingService(serviceUUID)) { Serial.println("Found HID Service"); //デバイス名でマウス・キーボードの接続をコントロール const char *ad_name = advertisedDevice->getName().c_str(); //新規マウスからのペアリング要求 char *mouse_f = strstr(ad_name, MOUSE_NAME_WORD); if(mouse_f != nullptr){ Serial.println("Found Mouse Name as Unkown Address"); //スキャンの停止 NimBLEDevice::getScan()->stop(); //グローバルインスタンスへ渡す advDevice = advertisedDevice; // Ready to connect now doConnect = DO_CONNECT_MOUSE; return; } //新規キーボードからのペアリング要求 char *kb_f = strstr(ad_name, KB_NAME_WORD); if(kb_f != nullptr){ Serial.println("Found Keyboard Name as Unkown Address"); //スキャンの停止 NimBLEDevice::getScan()->stop(); //グローバルインスタンスへ渡す advDevice = advertisedDevice; // Ready to connect now doConnect = DO_CONNECT_KB; return; } Serial.println("UnKown HID Device! Refuse Connection"); } } }; |
HIDの接続したいマウスキーボードを判断し接続
アドバタイズ時にMacアドレスが既知であれば接続、一致しなければHIDデバイスの中から名前が一致するデバイスを探し接続という流れです。接続処理自体はloop()内でdoConnectの値をみてconnectToServer()で行います。connectToServer()ではクライアント(ペリフェラル)と接続したときのコールバック処理を指定しています。抜粋です。
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 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 |
//updateConnParams通知内容保存用定義(xxxx_F + xxx_EXTのファイル名でint値の保存) static String ITVL_MIN_F = "/itvl_min"; static String ITVL_MAX_F = "/itvl_max"; static String LATENCY_F = "/latency"; static String TIMEOUT_F = "/timeout"; static String MOUSE_EXT = ".mouse"; static String KB_EXT = ".kb"; // クライアント(ペリフェラル)からのコールバック class ClientCallbacks : public NimBLEClientCallbacks { void onConnect(NimBLEClient* pClient) { if(pClient->getPeerAddress().equals(getMouseAd())){ //登録済マウスと接続 Serial.println("Known Mouse Connected"); int itvl_min = loadValue(ITVL_MIN_F + MOUSE_EXT,-1); int itvl_max = loadValue(ITVL_MAX_F + MOUSE_EXT,-1); int latency = loadValue(LATENCY_F + MOUSE_EXT,-1); int time_out = loadValue(TIMEOUT_F + MOUSE_EXT,-1); Serial.printf("itvl_min = %d, itvl_max = %d, latency = %d, timeout = %d\n",itvl_min,itvl_max,latency,time_out); if(itvl_min>=0 && itvl_max>=0 && latency>=0 && time_out>=0){ Serial.println("updateConnParams called : Save Value"); pClient->updateConnParams(itvl_min,itvl_max,latency,time_out); }else{ Serial.println("updateConnParams called : Def Value"); pClient->updateConnParams(120,120,0,60); } Mouse_Ad = pClient->getPeerAddress(); }else if(pClient->getPeerAddress().equals(getKbAd())){ //登録済キーボードと接続 Serial.println("Known Keyboard Connected"); int itvl_min = loadValue(ITVL_MIN_F + KB_EXT,-1); int itvl_max = loadValue(ITVL_MAX_F + KB_EXT,-1); int latency = loadValue(LATENCY_F + KB_EXT,-1); int time_out = loadValue(TIMEOUT_F + KB_EXT,-1); Serial.printf("itvl_min = %d, itvl_max = %d, latency = %d, timeout = %d\n",itvl_min,itvl_max,latency,time_out); if(itvl_min>=0 && itvl_max>=0 && latency>=0 && time_out>=0){ Serial.println("updateConnParams called : Save Value"); pClient->updateConnParams(itvl_min,itvl_max,latency,time_out); }else{ Serial.println("updateConnParams called : Def Value"); pClient->updateConnParams(120,120,0,60); } Kb_Ad = pClient->getPeerAddress(); }else{ //新規デバイスと接続 if(doConnect == DO_CONNECT_MOUSE){ //新規マウスと接続(初回ペアリング) Serial.println("New Mouse Connected"); //pClient->updateConnParams(6,6,23,200); //事前調査値 Mouse_Ad = pClient->getPeerAddress(); saveMouseAd(Mouse_Ad); //正常接続したマウスのアドレスの保持 }else if(doConnect == DO_CONNECT_KB){ //新規キーボードと接続(初回ペアリング) Serial.println("New Keyboard Connected"); //pClient->updateConnParams(7,7,0,300); //事前調査値 Kb_Ad = pClient->getPeerAddress(); saveKbAd(Kb_Ad); //正常接続したキーボードのアドレスの保持 }else{ //不明デバイスと接続 Serial.println("Unkown Device Connected"); pClient->updateConnParams(120,120,0,60); } } //接続後、高速な応答時間が必要ない場合は、パラメータを変更する必要があります。 //これらの設定は、インターバル150ms、レイテンシ0、タイムアウト450ms //タイムアウトはインターバルの倍数で、最小は100msです。 //タイムアウトはインターバルの3~5倍が素早いレスポンスと再接続に最適です。 //最小間隔:120 * 1.25ms = 150、最大間隔:120 * 1.25ms = 150、待ち時間0、タイムアウト60 * 10ms = 600ms pClient->updateConnParams(120,120,0,60); }; void onDisconnect(NimBLEClient* pClient) { Serial.print(pClient->getPeerAddress().toString().c_str()); Serial.println(" Disconnected"); if(pClient->getPeerAddress().equals(getMouseAd())){ //マウスとの接続が切断 ConnectMouse = false; Serial.println("Disconnected Mouse Device - Starting scan"); NimBLEDevice::getScan()->start(scanTime, scanEndedCB); } if(pClient->getPeerAddress().equals(getKbAd())){ //キーボードとの接続が切断 ConnectKB = false; Serial.println("Disconnected Keyboard Device - Starting scan"); NimBLEDevice::getScan()->start(scanTime, scanEndedCB); } }; //ペリフェラルが接続パラメータの変更を要求したときに呼び出される。 //受諾して適用する場合はtrueを返し、拒否して維持する場合はfalseを返します。 //デフォルトはtrueを返します。 bool onConnParamsUpdateRequest(NimBLEClient* pClient, const ble_gap_upd_params* params) { Serial.println("Call onConnParamsUpdateRequest"); Serial.printf("itvl_min= %u, itvl_max= %u, latency= %u, supervision_timeout= %u",params->itvl_min,params->itvl_max,params->latency,params->supervision_timeout); Serial.println(""); pClient->updateConnParams(params->itvl_min,params->itvl_max,params->latency,params->supervision_timeout); if(pClient->getPeerAddress().equals(getMouseAd())){ //マウスからの要求を保存 saveValue(ITVL_MIN_F + MOUSE_EXT,params->itvl_min); saveValue(ITVL_MAX_F + MOUSE_EXT,params->itvl_max); saveValue(LATENCY_F + MOUSE_EXT,params->latency); saveValue(TIMEOUT_F + MOUSE_EXT,params->supervision_timeout); } if(pClient->getPeerAddress().equals(getKbAd())){ //キーボードからの要求を保存 saveValue(ITVL_MIN_F + KB_EXT,params->itvl_min); saveValue(ITVL_MAX_F + KB_EXT,params->itvl_max); saveValue(LATENCY_F + KB_EXT,params->latency); saveValue(TIMEOUT_F + KB_EXT,params->supervision_timeout); } return true; }; //ペアリングが完了し、ble_gap_conn_descで結果を確認可能 void onAuthenticationComplete(ble_gap_conn_desc* desc){ Serial.println("Call onAuthenticationComplete"); if(!desc->sec_state.encrypted) { Serial.println("Encrypt connection failed - disconnecting"); //descで指定された接続ハンドルを持つクライアントを検索し切断 NimBLEDevice::getClientByID(desc->conn_handle)->disconnect(); return; } /* //ここでのitvl,latency,timeoutはonConnParamsUpdateRequestと異なることがある… Serial.printf("ble_gap_conn_desc : itvl = %d, latency = %d, timeout = %d\n ",desc->conn_itvl,desc->conn_latency,desc->supervision_timeout); */ }; }; |
初回ペアリング(ペアリングは初回だけなので馬から落馬感…)の時にはMacアドレスを保存します。これで一度ペアリングすれば次からはマウス・キーボードの電源を入れたり、スリープから復帰すれば、すぐ接続できます。
接続の際重要な設定部分はupdateConnParams(x,x,x,x)です。これは通知の更新間隔やスリープのタイムアウト設定なんですね。onConnParamsUpdateRequestで要求された設定でupdateConnParamsしないと正しくデバイスが動きません。マウスだと正しい分解能で操作できなかったり、キーボードだと接続自体が不安定でした。
いろいろ試してみると、初回ペアリング時(ペアリングボタンを押したとき)にはconnectToServer()で接続した瞬間コールバックでonConnParamsUpdateRequestが、エレコムマウス、3COINキーボード共に呼ばれました。2回目接続以降ではonConnParamsUpdateRequestのコールバックは、エレコムマウスでは呼ばれるんですが、3COINのキーボードは呼ばれないんですね。機材によって異なる挙動をしました。
2回目以降の接続時にupdateConnParamsを正しく送るには初回ペアリング時のonConnParamsUpdateRequestで要求されるitvl_min,itvl_max,latency,timeoutを保存しておく必要があるわけです。というわけで上のlittleFSでloadValue()、saveValue()を使ってマウス、キーボードそれぞれの値を保存しておきます。2回目以降は保存値を用いてupdateConnParamsを送ればOKとなるわけです。
onAuthenticationCompleteでもitvl,latency,timeoutが送られてきていまして、その値を使おうかとも思ったんですが、onConnParamsUpdateRequestと違う値もあるし、itvl_min,itvl_maxはどうやって計算するのかも不明なので無視してます。
いろいろ説明しましたが、事前にわかってないとダメなものはデバイス名のみということになります。ここはハードコーディングで問題ないと思いますw
ここまで接続の話でした。次はBLE機器からの情報処理とマウス・キーボードパーサーのデータ引き渡しと解析となります。記事を改めます。