目次

BLEについて

概要

BLE1)はBluetooth の規格の一部であり、比較的新しいデバイスで採用されている。 物理的な通信は従来通り2.4GHz帯の電波を使って行うが、プロトコルなどについては Bluetooth Classicとの互換性はない。 なので、BLEデバイスに特化したコードを書いたら、デバイスは BLEをサポートしているであろう、BT4.2などよりも新しいものである必要がある。

ここでは、周辺機器としてのキーボードを利用する、セントラル2)の実装についてあれこれ記しておく。

世間で、「ESP32 BLE キーボード」なんていう雑なキーワードでひっかけると、USBキーボードなどをESP32につないで、ESP32を使って、BLEキーボードとして仕立て上げる記事ばかりが出てくる。

M5Stack や M5ATOMを使って、PCもどきのように遊ぼうという場合には、ここに記したように、ESP32にセントラルの機能を実装して、キーボードを利用する必要がある。

なお、この記事は、M5版ハイハイスクールアドベンチャーのところに記してあったものを再編して追加してあるものである。

BLEキーボードをつなごう

ESP32を使っているのだから、BLEキーボードをつなげられたら、FACESがなくてもハイハイスクールアドベンチャーを遊べる。 これが、BLEセントラル沼にずぶずぶとはまっていく発端である。

BTキーボードをM5 Stackに繋いだ話はしかるのちさんのところにあったので、多分つながる。

問題は、上記の話以外はESP32とBTキーボードで探すと、USBキーボードをESP32を使ってBTキーボードに仕立てる話ばかりで、肝心のBTキーボードをESP32に接続する話は見当たらない。 需要がないのか?

しかるのちさんによれば、esp_8_bit ではBLE3)キーボードがつながらないらしいが、今更作るなら BLEキーボードがつながればいい。 大体が、PlatformIOで引っかかってくるライブラリは NimBLEなので、BLEで行く方がいい。 他人のプロジェクトをぺろっと自分のプロジェクトに張り付けるのはなんともいえない座りの悪さもあるし、あと、esp_8_bitのコードはちょっと気になる部分もあったので、使いたくなかったのだ。

BT(BLE)のお約束

BTの約束事として、接続するものの片方をサーバ、残りをクライアントと呼ぶ。 さて、M5 StackとBLEキーボード、どっちがサーバでどっちがクライアントでしょう?

答えはM5 Stackがクライアントで、BLEキーボード側がサーバになります。 最初、サーバ側のサンプル読んで始めようとしていたので、全然間違ってたわけです。 esp_8_bitで気になるところというのは、hid_serverとかhci_serverとか、サーバ側であるかのような名前を使っているところが気になったのです。

クライアント側はサーバー側の Advertise を拾って、クライアントとして接続をかけに行きます。

接続が成立したら、サーバ側から、Characteristic の一覧をもらって、キーボードの場合なら、キーボード側からの通知を拾ってキーを受け取るという動作になります。

HID_DEVICEのUUIDは 0x1812と決まっています。 これを見つけて接続していきます。

バッテリーの残量やらなんやらを渡してくる Characteristic なんかもあるんですが、それはここでは関係ないので割愛します。 簡単。

キーボードからのデータは HID_REPORT_DATA == 0x2A4D で渡されます。

キーボードの挙動

キーボードが持っている Characteristic については、結構たくさんあるのですが、要は、キーを拾えればいいのです。 調べたところ、手元のキーボードではHandle == 41 で通常のキーが、 Handle == 51でメディアキー4)を受け取るようになっています。

キーボードが返してくる通常キーについては11バイトのデータ構成で、先頭が modifiers 5)で、残り10バイトがキーコードになります。全部使えば10キーロールオーバーになるんでしょうが、多分その辺はキーボードによって違うようです。

キーが押されたり離されたりすると、そのたびに通知がやってきます。

キーが押されたのか離されたのかをどっかのフラグに持っていてくれるとよかったのですがそうではないようです。

キーが modifiers 以外には一文字ずつしか押されないと仮定すると、キーが押されたときにはキーコードが渡され、キーが離されると0 がデータとしてわたってきます。

Nキーが同時に押されているなら、押されている全部のキーのキーコードが入ってくるのですが、離されると離されたキーの値が0になって残りのキーコードは入ったままになってきます。 この時、0でないキーコードは先頭側に詰められてきます。

真面目にドライバーを書くなら、いちいちキーコードがどう変化したのかを記録してやらないとダメそうですが、基本的に複数キーが押されることはないだろうハイハイスクールアドベンチャーなら、あんまり真剣に考えないでも良さそうです。

0じゃない文字数を数えて、前回より増えていたら新しくキーが押されたとして扱います。 本当はこれだと色々具合が悪いのですが、今回はよしとします。 まあキーリピートもききませんが、今回はまあいいでしょう。

新たにキーが押されたと判定したら、渡されてきたバッファのキーコードを modifiersを考慮して、対応するASCIIコードに変換してキューに格納し、ゲーム側から求められたら先頭から順に渡していきます。

この辺も手抜きで、modifiersについてはCTRLが押されていたらそれを優先してあとはSHIFTしか考慮していません。AltやCmdは無視しています。

とりあえずこれでBTキーボードが使えるようになりました。 ゲームには支障はない感じですが、アクションげーんとかを考えてる人はもう少し真剣に考えないといけないと思います。

コード片

ハイハイスクールアドベンチャーはキーボード周りを KeyBoardクラスの派生クラスとして扱っている。 M5 Stackグレー, M5 Stack Core2, Cardputer 用のキーボードクラスがそれぞれ定義されているが、ここにBTキーボードクラスも追加する。

但し、NimBLEのAPIは通知を受け取る notify callback 関数を渡さないといけないので、完全にこのクラス内に閉じることができない。

なので、keyboard.cpp 内に閉じた関数や変数にそこそこ依存している。

変換テーブル

キーコードの変換テーブルは英字配列を想定している。 0~95の96種類のキーコードを扱えるが、抜けているところは手元のキーボードにはないキーである。 三組定義されていて、modifiersなし、CTRL、SHIFTの組み合わせになっている。 JIS配列に対応させたい場合はこれを書き換えることになる。

const uint8_t BTKeyBoard::_keymap[][96] = {
    {    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,  29,
        28,  31,  30,   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,  29,
        28,  31,  30,   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,  29,
        28,  31,  30,   0,   0,   0,   0,   0,   0,   0,   0,   0,   0,   0,   0,   0,
    },
};

通知処理

手抜きの極みのキー処理である。 キーボードからの通知が来るたびに、これが呼び出される。 Handle == 41は modifiers + 10文字分のキーバッファだが Handle == 29は modifiers + res + 6文字分のキーバッファとなっている。 そもそも Handle で見分けるのが正しいのかさえよくわかっていないがとにかく試して動いたキーボードはこのどちらかだった。

動作は単純で、前回渡されたデータと、今回渡ってきたデータとでキーバッファ部分を比べて、新しいのがあったらそれが今回入力されたキーだとみなして、keybufに積んでいく。 これは、例えば、A, B, Cのキーを押しっぱなしにして、新しく D を押したときに、バッファには A, B, C, Dの四文字が入ってくるからである。古いバッファは A, B, Cのみだったので Dが追加で押されたことがわかる。

データは keybufに格納され、あとで、BTKeyBoard::update()が呼び出されるときに処理される。

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<uint16_t> keybuf;

static void
notifyCallback(NimBLERemoteCharacteristic *pRemoteCharacteristic, uint8_t *pData, size_t length, bool isNotify)
{
    // handle: 41 -- key / 51 -- media key
    switch (pRemoteCharacteristic->getHandle())
    {
        case 22:
        case 29:
        case 41:
            if (length != 8 && length != 11) return; // not key data interface (maybe)
            keyboard_t *newKeyReport = (keyboard_t*)pData;
            int buflen = 6;
            uint8_t *buf = keyboardReport.k2.keys;
            uint8_t *input = newKeyReport->k2.keys;
            uint8_t mod = newKeyReport->k2.modifiers;
            if (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 (memchr(buf, c, buflen) == NULL) keybuf.push(((uint16_t)mod << 8)|c);
            }
            memcpy(&keyboardReport, pData, length);
            break;
    }
}

つながるキーボードつながらないキーボード

BLEキーボードっていっているのにつながるやつとつながらないやつがありまして、なんでだろうと思っていたのです。 思えば、最初にテストに使った、バッファローのBSKBB335がつながる方のやつだったので、深く考えないで、かつさっさと実装を終えられたのですが。

それでもつながらないのがあるというのが気になります。 BLEの実装に関しては、ペリフェラル側6)に多く、セントラル側7)については、ほぼ、NimBLEのサンプルとその亜種しかないのです。

ハイハイスクールアドベンチャーについても、もちろん、亜種の一つです。

通知可能のCharacteristicは端から notfy()をかけてみても、まったく一つも callbackを返してきません。 悶々とします。

で、gatttool を使ってもう少し掘ってみることにしました。

$ gatttool -I -b xx:xx:xx:xx:xx:xx
...
[xx:xx:xx:xx:xx:xx][LE]> char-read-uuid 2a4d
Error: Read characteristics by UUID failed: Encryption required before read/write
[xx:xx:xx:xx:xx:xx][LE]>

え、これって、セキュアな接続をはらないとダメってことですか?

なんで、コードにごそごそと追加を行います。

        pClient = NimBLEDevice::createClient();
        Serial.println("A new client created.");
        pClient->setClientCallbacks(new ClientCallbacks(), false);
        pClient->setConnectionParams(12, 12, 0, 51);
        pClient->setConnectTimeout(5); // 5sec
        if (!pClient->connect(advDevice))
        {
            NimBLEDevice::deleteClient(pClient);
            Serial.println("Failed to connect.");
            return false;
        }
        if (!pClient->secureConnection())
        {
            Serial.println("Failed to establish secure connection.");
            return false;
        }

そうそう。 初期化もちょいと変えてやります。

    NimBLEDevice::init("");
    NimBLEDevice::setSecurityAuth(true, true, true); // all true if requires secure connection.
    NimBLEDevice::setPower(ESP_PWR_LVL_P9);
    NimBLEScan *pScan = NimBLEDevice::getScan();
    pScan->setAdvertisedDeviceCallbacks(new AdvertisedDeviceCallbacks());
    pScan->setInterval(45);
    pScan->setWindow(15);
    pScan->setActiveScan(true);
    pScan->start(scanTime);

NimBLEDevice::setSecurityAuth()は全部trueにしてやります。 これで無事につながらなかったキーボードがつながるようになりました。

キーボードが再接続できない

BLEキーボードを接続した状態でしばらく何もしないで放置していると、キーボード側がパワーセーブモードに入って接続が切れます。 クライアント側には onDisconnect() の通知が送られて、接続が切れたことが認識されます。

ここで、デバイスの再検索を始めて、打鍵するなどして復帰したキーボードから Advertiseがやってくればめでたく再接続になります。

NimBLE のサンプルやそれを参考にした多くのコードが、次のような実装をしていたので私もそうしていました。

void
ClientCallbacks::onDisconnect(NimBLEClient* pClient)
{
    Serial.print(pClient->getPeerAddress().toString().c_str());
    Serial.println(" disconnected - Starting scan");
    NimBLEDevice::getScan()->start(BTKeyBoard::scanTime);
}

一見すると何の問題もありません。 実際、シリアルコンソールにも disconnected - Starting scan と表示されて、いかにも動いているように見えますが実は全然動いていません。 一向にデバイスが再接続されないのです。 困った。 これでは、一気通貫にハイハイスクールアドベンチャーを最後までプレイするのでなければ、ゲームの途中でにっちもさっちもいかなくなってしまいます。

さて、原因はなんでしょう? もしかすると、一度接続したデバイスからの Advertiseを無視しているのかもしれません。 ならば

NimBLEDevice::getScan()->setDuplicateFilter(true);

とかすればいいのかもしれないと思って試してみました。 再接続はうまくいかないし、そもそも、最初の接続が完了するまで何度も Advertiseのコールバックがかかってきてしまいました。 筋違いのようです。

じゃあ、なんなんだろう?

ふと、callbackの中から NimBLEDevice::getScan()→start(0); とか呼んじゃダメなんじゃないか?ということに気づきました。 これ、つまりコールバック処理が完了しなくなって、おかしなことになっている可能性があるんじゃないかと。

で、次のようにしました。

static volatile bool connected = false;

void
ClientCallbacks::onDisconnect(NimBLEClient* pClient)
{
    Serial.print(pClient->getPeerAddress().toString().c_str());
    Serial.println(" disconnected");
    connected = false;
}

で、メイン処理の中で、

    if (!connected)
    {
        Serial.println("Start scan.");
        NimBLEDevice::getScan()->start(scanTime);
    }

という処理を足してやりました。

結論。

あっさり再接続しました_ノ乙(、ン、)_

Connect to BLE Keybord.
Advertised HID Device found: Name: Notepad8, Address: XX:XX:XX:XX:XX:XX, appearance: 961, manufacturer data: YYYYYYYYYYYYYYYYYYYYYY, serviceUUID: 0x1812
Start connecting to server...
A new client created.
BLE Device connected.
Connected to XX:XX:XX:XX:XX:XX
RSSI: -60
Done.
XX:XX:XX:XX:XX:XX disconnected
Start scan.
Advertised HID Device found: Name: Notepad8, Address: XX:XX:XX:XX:XX:XX, appearance: 961, manufacturer data: YYYYYYYYYYYYYYYYYYYYYY, serviceUUID: 0x1812
Start connecting to server...
BLE Device connected.
Reconnected.
Connected to XX:XX:XX:XX:XX:XX
RSSI: -62
Done.

キーバッファ

キーバッファの仕様は何種類か存在しているようである。

最も多い実装は8bytesで、modifiers + padding + key buffer (6bytes) という構成で、手元ではバッファローのBSKBB335だけが11bytes で modifiers + key buffer (10bytes)というものである。

キーバッファのサイズだとか、多分アラインメントを考慮したであろうパディングの有無や全体長などの差異はあれど、渡ってくる情報(modifiersやキーコード)に違いはない。

が、キーバッファーの部分の扱いには、解釈違いのものがあるようである。

当初、試したキーボード8)では、キーが押されたときには先頭からキーコードが入って送られてきた。 なので、常にキーが押されればキーバッファの先頭にはキーコードがあるという想定だった。

他のキーボードでもそれで問題は特にはなかった。

が、ちょっと色気(?)を出して、シフトキーを押したりコントロールキーを押したりしたときにそれは起きた。

文字が入力されない。

仕方がないので、デバッグです。 調べてみたら、キーは渡ってきてます。 但し、なぜか二文字目で先頭は0です。 どうやら、キーボードによっては、modifiers が押されたのもキーバッファを消費する9)ようです。 なので、0ならそこで打ち切りとしていた処理を、0なら飛ばすように変更しました。

オートリピート

ハイハイスクールアドベンチャーには必要ないが、キーのオートリピートはあると便利な機能です。 フルーツフィールドをM5 Stackで遊ぼうと思ったら、ないと、REM君の移動にキーをたくさん連打しないといけません。

キーボード側でキーブレイクが発生すれば、notification が発生するのですが、押しっぱなしでは何も発生しません。 一定時間押しっぱなしだったら、キーボード側でキーブレイク処理を勝手に挿入して、疑似的にキーを連打しているかのようにしてくれればこちらが何もしなくてもいいのですが10)現実はそんなに甘くありません。

なので、タイマー割り込みを使って、押しっぱなしのキーを拾ってキューイングすることにします。

タイマー割り込みの実装

ESP32には4つのタイマーがあり、それぞれに割り込みを設定できます。

hw_timer_t *timer = nullptr;

void IRAM_ATTR 
onTimer()
{
    // 割り込み処理のコード
}

...

void
setup()
{
    auto cfg = M5.config();
    cfg.clear_display = true;
    M5.begin(cfg);
    Serial.begin(115200);
    if ((timer = timerBegin(0, 80, true)) != nullptr)
    {
        timerAttachInterrupt(timer, &onTimer, true);
        timerAlarmWrite(timer, 100000, true);
        timerAlarmEnable(timer);
    }
}

要するに、タイマー割り込みがかかるたびに onTimer() が呼び出されますってことです。 最初の timerBegin(0, 80, true) はタイマー#0 は divider 80で、カウントアップしろっていうことを告げています。 タイマーは0-3の四本あります。 dividerは、クロックのチックをいくつで1とみなすかって話です。 ESP32のシステムのクロックは80MHzだそうですので、80で割って、1usを1チックにするってことです。

これが、この後の timerAlarmWrite(timer, 100000, true) の、100000と関連していて、100000カウントごとに割り込めってことで、divider = 80なので、100000カウントはすなわち 100000us で 100ms == 0.1秒ごとに割り込めっていうことになります。

勿論、もっと頻度上げることもできますが、割り込み処理がいい感じに終わる範囲でないと意味がないですし、キーボードのリピート処理のデザインとしては、「大体0.5秒押しっぱなしだったら、以後0.1秒ごとにそのキーが押されているものとみなす」としたいので、0.1秒間隔で割り込んでくれていれば十分実用の範囲なのです。

リピート処理

あとは、onTimer() の中身を書くことになります。

といっても、大したことはしていません。 そもそも、今の手抜き実装は、後から来た Notification で渡されたものと比較して、新しく押されたキーがないか確認するために、直近の Notification で持ってきたキーコードデータを保持しています。

なので、新しい Notification が来ないまま0.5秒たったのなら、それはもうオートリピートすればいいでしょうっていう判断ができるので、あたかも保持しているキーコードが押されたかのような処理を組めば完了です。

割り込み間隔を0.1秒に設定した天才的ひらめき11)により、リピート開始後の「以後0.1秒ごとにキーが押されているものとする」の部分はほぼ何もしないでもいいくらいに簡単に実現しています。

Notification が来たところからカウントが始まるので、カウンターを一つ新設します。

static volatile int keyboardCount = 0;

既存のNotification処理しているところで、これを 0 にするコードを足します。

    keyboardCount = 0;
    keyboard_t *newKeyReport = (keyboard_t*)pData;

のように、pDataの処理をする前あたりにでも入れておけばいいでしょう。

割り込み側では、カウントアップ処理とリピートが始まったらリピート処理を書きます。

void IRAM_ATTR
onTimer()
{
    if (!connected || keybuf.size() > 30 || keyboardCount++ < 5) // every 0.5sec
    {
        return;
    }
    int buflen = 6;
    uint8_t *buf = keyboardReport.k2.keys;
    uint8_t  mod = keyboardReport.k2.modifiers;
    if (keyboardBufferType == 1)
    {
        buflen = 10;
        buf = keyboardReport.k1.keys;
        mod = keyboardReport.k1.modifiers;     
    }
    for (int i = 0 ; i < buflen ; i++)
    {
        uint8_t c = buf[i];
        if (c == 0) continue;
        if (keybuf.size() > 30) break;
        keybuf.push(((uint16_t)mod << 8)|c);
    }
}

一応、リピートできるけれど、無制限にやるとメモリ食い尽くすので、30文字バッファにたまったら止めるようにしています。 (keybuf.size() > 30 などのチェックです。) トリガーがかかるのは、カウンターが5になったときです。 なので、それ未満なら何もせずに戻ります。 キーボードが未接続の時も何もする必要はありません。 バッファが一杯でも同じです。 リピートが始まったら、あとはカウンターは常に 5以上なので、割り込みが発生する0.1秒ごとにリピート処理されます。 カウンターがオーバーフローした場合の処理を入れていませんが、32bitでも2500日くらい押し続けないと溢れないので、実用上問題はないと思っています。頑張って2500日押し続けてオーバーフローしたら教えてください。 直します。

キーが押されているか?

まあ、フルーツフィールドについては、オートリピートでもゲームになりますが、結局ゲームの場合、キーが押されているかどうかを読んで、動作させる必要性があります。

実際、PC-6001mkII版だって、キーが押されているかどうかを調べるシステムコールを使って動作させているので、BLE版だってそうさせたい。

実装は簡単なのですが、どういう実装にするのかが問題。

PC-6001mkII版のように、上下左右+SPC+ESC+何か二つ、をuint8_tのビットマップにパックするのも考えたんですが、ビットマップに畳み込んで、ビットマップを展開してとかやるんだったら、BLEの通信バッファの方を直接スキャンしても大差ないだろうということで、

キーコード, 文字コード

のペアの配列を用意して、そこに定義されているキーが押されているかどうかをチェックして、押されてたら文字コードの方を返すようにしました。

static uint8_t _keymap[] = { 
    0x50, 28, 
    0x4f, 29, 
    0x52, 30,  
    0x51, 31, 
    0x2c, ' ',  
    0x29, 27,  
    0x16, 's', 
    0x1f, '@', 
    0, 0 
};

bool
keyscan(uint8_t &code)
{
    for (int i = 0 ; _keymap[2 * i] != 0 ; i++)
    {
        if (_keyboard->is_pressed(_keymap[2 * i]))
        {
            code = _keymap[2 * i + 1];
            return true;
        }
    }
    return false;
}
1) , 3)
Bluetooth Low Energy
2)
クライアント
4)
音量をいじったり、液晶の明るさをいじったり
5)
1:CTRL, 2:SHIFT, 4:Alt, 8:Cmd
6)
サーバー側
7)
クライアント側
8)
BSKBB335/NimBLE版 Cardputer BT キーボード
9)
ただしよりにもよってキーコードは0
10)
実は、CardputerをBLEキーボードにするアプリは一定間隔で、勝手にキーブレイクを挿入してくるので、試してませんが、勝手にキーリピートされると思われます。
11)
そうか?