====== ハイハイスクールアドベンチャー Raspberry Pico + LCD版 ======
===== 概要 =====
[[ハイハイスクールアドベンチャー PicoCalc版]]を作ったついでといったら何ですが、[[https://www.clockworkpi.com/picocalc|PicoCalc]] のコアである [[https://www.raspberrypi.com/products/raspberry-pi-pico/|Raspberry Pi Pico]]にLCDとかSDカードスロットとかキーボードとか繋げたら、別に [[https://www.clockworkpi.com/picocalc|PicoCalc]]じゃなくても遊べるじゃん?
と、いうことで、ソースコードにちょいちょい手を入れて、仕立て上げたのが[[https://github.com/wildtree/HHSAdvPico.git|これ]]です。
{{::picocalc:lcd28.jpeg?400|}}
pico1_lcd28 または pico2_lcd28 を選んでビルドすれば uf2ファイルが出来上がりますので適宜インストールしてください。
現状、[[https://www.waveshare.com/wiki/Pico-ResTouch-LCD-2.8|Pico-ResTouch-LCD-2.8]]に、OTGアダプタをかませたUSBキーボード((US配列))をサポートしています。
===== あれこれ =====
例によって、すぐ動くはずが、なかなか動かなかったあれこれを。
==== Pico-ResTouch-LCD-2.8 ====
STT7789 という、よくあるコントローラで 240x320の液晶(タッチパネル/TFカードスロット込み)です。
[[https://github.com/lovyan03/LovyanGFX|LovyanGFX]] であっさり動くだろうと思っていたら、全然表示が来なくて大変苦労しました。
ちなみにピン配置は以下の通り。
^名前^番号^
|LCD_SLCK/SD_SCK|10|
|LCD_MOSI/SD_TX|11|
|LCD_MISO/SD_RX|12|
|LCD_BL|13|
|LCD_DC|14|
|LCD_RST|15|
|SD_CS|22|
ちなみに、液晶パネルとSDカードはSPI1を共有しています((なので一部ピンが共用されている。))
原因はバックライトのコントロールにありました。
LovyanGFXが用意してくれているのはGPIOピンからPWM制御で明るさをいじれる、そういうインターフェイスだけなのですが、こいつは、ずばりONかOFFかしかない様子。
今でもこのやり方が正しいのかわかりませんが、ピンにデジタルでHIGH/LOWを出すだけのバックライトインターフェイスを作成してそれを使うようにしたら、あっさり表示が来ました。
まあ、描画はされていたけれど、バックライトが真っ暗で何も見えていなかっただけというのが実際だったわけです。
namespace lgfx
{
inline namespace v1
{
//----------------------------------------------------------------------------
class Light : public ILight
{
public:
struct config_t
{
uint32_t freq = 1200;
int16_t pin_bl = -1;
uint8_t pwm_channel = 0;
bool invert = false;
};
const config_t& config(void) const { return _cfg; }
void config(const config_t &cfg) { _cfg = cfg; }
bool init(uint8_t brightness) override;
void setBrightness(uint8_t brightness) override;
private:
config_t _cfg;
uint8_t _slice_num;
};
}
}
namespace lgfx
{
inline namespace v1
{
//----------------------------------------------------------------------------
bool Light::init( uint8_t brightness )
{
gpio_init( _cfg.pin_bl );
gpio_set_dir( _cfg.pin_bl, GPIO_OUT );
gpio_put( _cfg.pin_bl, 1 );
setBrightness(brightness);
return true;
}
void Light::setBrightness( uint8_t brightness )
{
if (_cfg.invert) brightness = ~brightness;
// uint32_t duty = brightness + (brightness >> 7);
if (brightness == 0)
digitalWrite(_cfg.pin_bl, LOW);
else
digitalWrite(_cfg.pin_bl, HIGH);
}
//----------------------------------------------------------------------------
}
}
SPI1を共用しているので、LCD/SDの初期化には注意が必要です。
LCDを先に初期化している前提で話しますが、この場合SDカードインターフェイスの初期化時に SPI1.setCS(22)のように、CSピンをセットしに行くと固まってしまいます。
SDカードのCSピンは SD.begin(22,SPI1)のように、SDインターフェイスの開始時に渡せばいいので、setCS()は呼ばないようにしてください。
==== USBキーボード ====
実は真っ先に考えたのはBLEキーボードを使う方法。
M5ATOM他ではうまくいっているし、NimBLEが使えるんじゃないかという甘い見込みがあったので始めて見たものの、NimBLEはrp2040をサポートしていないのであえなく玉砕。
btstackで地道にやろうかと思ったけれどリンクがうまくいかず玉砕。((シンボルの宣言が必要だった。BLEキーボードのセクション参照
。))
じゃあTinyUSBでUSBキーボードでいいじゃん。
電源供給できるOTGケーブルもあるしさ!
ということで、USBキーボードで逃げようと思ったら、最終的には動いたものの、それはそれで面倒なあれこれがありました。
=== 間違ったWEAK宣言の呪縛 ===
コンパイラというかリンカーへの指示として、WEAKシンボルというのが太古の昔から存在しています。
何かというと、そのシンボルはリンク時に他にWEAKではない同名のシンボルがあればそれで置き換えるというものです。
普通同じシンボルが別々に実体をもっていたらリンク時に重複エラーになるんですが、WEAKがついていれば、それが起きません。
で、こんなもの何に使うのかというと、たとえばライブラリの中で、ある処理をする下請け関数があったときに、それを自分の独自のもので置き換えたい、なんて欲求を満たすためなんです。
要するにライブラリの側で、テンプレとかデフォルトの処理を用意しているけれど、ユーザがそれを自前の処理で置き換えられますよーっていうものです。
TinyUSBは、コールバックの実装にこのフレームワークを使っていて、決まった名前の関数を実装することで、特にコールバックを登録とかしないでも、自動的に呼び出されるようになるという便利な仕様になっているのです。
HIDデバイスを接続するので、
* tuh_hid_mount_cb()
* tuh_hid_umount_cb()
* tuh_hid_report_received_cb()
の三つを実装すればコールバックを受け取ってキー入力をもらえるという寸法です。
とても簡単。
間違いようがない。
というわけで早速実装したのですが、まったく、このコールバックが呼び出されません。
デバッガつないでトレースしていくと、どうやってもライブラリ内の空のダミーの方が呼び出されて、わたしの実装が呼び出されません。
ちょっとWeb界隈を漁ると、同じ現象で困っている人もいらっしゃる。
で、さらに調べていったら、**ヘッダファイルの中でコールバックのテンプレート宣言に_ _attribute( (weak) )_ _つけてる**じゃありませんか!
ダメ、絶対!
WEAK属性は実装の方にだけつけて宣言につけちゃダメ!
つまり、TinyUSBのヘッダを includeしたら、もれなく自前のコールバックもWEAKになっちゃうんです。
WEAKとWEAKがあったら、まあリンカー次第ですが、大体の場合先に現れた方が採用されるようです。
手でリンクの順番を指定すれば回避できそうですが、platformio にお任せの身としてはちょっと難しい、多分。
じゃあこのライブラリの間違ったWEAKの使い方が直るのを待つしかないのか?
いや、そうじゃない。
つまり、TinyUSBのヘッダに汚染されないところで、実装してやればいいんです。
そして、その中から、TinyUSBのヘッダに汚染されている実装を呼び出すだけのラッパーを作れば回避可能です。
それが usbkbd.cppなのです。
// Tiny USB HID callback entry points to avoid wrong weak attribute usabe in Adafruit TinyUSB
#include
#if defined(USBKBD)
void my_hid_mount_cb(uint8_t, uint8_t, uint8_t const *, uint16_t);
void my_hid_umount_cb(uint8_t, uint8_t);
void my_hid_report_received_cb(uint8_t, uint8_t, uint8_t const *, uint16_t);
extern "C"
void
tuh_hid_mount_cb(uint8_t dev_addr, uint8_t instance, uint8_t const *desc_report, uint16_t desc_len)
{
my_hid_mount_cb(dev_addr, instance, desc_report, desc_len);
}
extern "C"
void
tuh_hid_umount_cb(uint8_t dev_addr, uint8_t instance)
{
my_hid_umount_cb(dev_addr, instance);
}
extern "C"
void
tuh_hid_report_received_cb(uint8_t dev_addr, uint8_t instance, uint8_t const *report, uint16_t len)
{
my_hid_report_received_cb(dev_addr, instance, report, len);
}
#endif
あとは、Keyboard.cpp の側で my_hid_mount_cb(), my_hid_umount_cb(), my_hid_report_received_cb()をそれぞれ実装してやれば完了です。
あっさり動きました。
=== キーが数秒に一回しか入力できない呪い ===
さて、TinyUSBでUSBキーボードをつなぐサンプルはさすがにあちこちにあったので、基本的にそれをまねて実装しているわけですが、なぜかキーが数秒に一回しか入らないという、事実上使い物にならない現象が出ました。
何故?
btstackと違い、TinyUSBではloop()の中でtuh_task()を呼び出してやらなければなりません。
ただ、普通に呼べばいいだけですし、別段loop()が遅いというわけでもなさそうです。
一度、multicore機能を使って、core1に処理をさせてみたんですが特に変わりはありませんでした。
さて何が原因でしょう?
Geminiに聞いてみました。
直接原因は教えてもらえませんでしたが、提示してきたサンプルに、わたしが引き写したサンプルと違う部分が一か所あります。
tuh_hid_report_received_cb()の最後に次のコールバックを要求する処理が入っているのです。
if (!tuh_hid_received_report(dev_addr, instance))
{
// エラー処理
}
恐る恐るわたしのmy_hid_received_report_cb()の最後にもつけてみたら、あっさり普通にキー入力ができるようになりました。
たったこれだけ。
されどこれだけ。
=== キーマッピング ===
US配列のキーボードがターゲットなのは、Keyboard.cpp内の keycode2asciiという配列がそうなっているからです。
HID_KEYCODE_TO_ASCIIというTinyUSB由来のマッピングを横着して使っているのでこうなっています。
これを、JISキーボードにあうように直せばJISキーボードでも使えます。
static uint8_t const keycode2ascii[128][2] = { HID_KEYCODE_TO_ASCII };
==== BLEキーボード ====
Pico W/Pico 2WはWiFiとBTをサポートしていて、SDKにも btstack-1.6.2 ((2025年4月14日現在))が同梱されているので、理屈の上ではBLEのキーボードを、M5ATOMなんかと同じように接続できるはずなんです。
線がのたくりまわるのは好きではないので、最初にこっちを実装しようとしたのですが、サンプル見ても、Web界隈で調べても、例によってマイコンをBLEセントラルとして使っている事例は少ないんですよね。
実は未だ動いてないので、コードは同梱していませんが、とりあえず、色々実験はしているので、ここまでで得た知見を。
=== ビルド ===
そもそも、btstackはSDKに同梱になっているので、ヘッダだけインクルードしときゃ良しなにやってくれるだろうと思っていたら大間違いでした。
いえ、コンパイルは大体通ります。
ただ、山ほどリンクエラーが出て、最初ここで心が折れました。
ビルドするには以下シンボルを定義してないとダメらしいです。
-DPIO_FRAMEWORK_ARDUINO_ENABLE_BLUETOOTH
-DPIO_FRAMEWORK_ARDUINO_ENABLE_BLE
=== 接続の手順 ===
まず、いろんなことがいろんなところに書いてあったりAIにいわれたりしますが、Pico W/Pico 2Wの場合、loop()の中ですることは特にはありません。
勝手に良しなにやってくれるので、やることは、初期化とあとはコールバック伝いに、Notificationを受け取るまでがやるべきことです。
手順としては以下のようになります。
- 初期化
- GATT Advertisementのスキャン-接続要求
- Characteristicsの取得
- CharacteristicsのDescriptorの取得
- 必要なサービスのCCCDに対して、Notification を要求する。
- Notificationの処理
簡単に見えるのですが、ちょっとメンドクサイのが、何かを要求すると、大体の場合、レポートがコールバック経由で渡され、これが COMPLETEのイベントが発生するまで続くので、次の要求はCOMPLETEを待たないといけない、ということと、COMPLETEのイベントが、GATTの場合はGATT_EVENT_QUERY_COMPLETE で大体が共有されいてるので、statusをどっかにとっておいて、今何をしているのか見て、処理を分けるか、リクエストごとに処理するコールバックを変えるかしないと何がなんだかわからなくなります。
[[https://vanhunteradams.com/Pico/BLE/GATT_Client.html|Building a Bluetooth GATT Client on the Pi Pico W]]という大変参考になるサイトがあるのですが、ここではGATTのコールバックを一つにまとめる代わりに state という変数で、今何の処理をやっているのかを管理して、イベントハンドラをコントロールしています。
わたしは、あんまりこういうやり方が好きじゃない((コードの可読性が下がる気がするから。))ので、コールバックを分ける方法にしましたが、要するに、要求→レポート→COMPLETE→次の要求→……という流れなのを押さえておけば大丈夫です。
AIに聞くと、なんか動きそうなコードをくれるんですが、レポートの中で次の要求を出してしまうので大体動きません。
最初、AIとわたしで組んでいたのでわからなかったのですが、上のサイトのおかげでようやく原因がわかりました。
=== セキュア接続 ===
さて、BLEキーボードの大半はセキュアな接続でないとキー入力のNotificationを送ってくれません。
要求を出すと、state=15((セキュア接続が必要))で失敗してしまいます。
わたしの手元にあるバッファローのBLEキーボードとM5 CardputerのおまけでついてくるBLEキーボードだけがセキュア接続でなくてもつながるキーボードなので、つまりは、大体の場合セキュアじゃないとダメなのです。((ちなみにCardputerのそれはセキュアでは接続できません。バッファローはセキュアでもOK))
とりあえず、ボンディングは行わないで単純にコネクションだけをセキュアにできればいいので、そのように設定をします。
sm_init(); // セキュリティマネージャの初期化
sm_set_request_security(true); // セキュリティ要求を有効化
sm_set_io_capabilities(IO_CAPABILITY_NO_INPUT_NO_OUTPUT); // IOキャパビリティ設定
sm_set_authentication_requirements(SM_AUTHREQ_SECURE_CONNECTION); // 認証要件を設定
sm_set_encryption_key_size_range(7, 16); // 暗号化キーサイズの範囲を設定
sm_event_callback_registration.callback = &sm_event_handler;
sm_add_event_handler(&sm_event_callback_registration);
IO_CAPABILITY_NO_INPUT_NO_OUTPUTとSM_AUtHREQ_SECURE_CONNECTIONのみがセットされている場合、Just in Workでの接続が行われるようです。なので、sm_event_handler側でそれを処理すればいいようです。
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を自動的に承認
bd_addr_t addr;
sm_event_just_works_request_get_address(packet, addr); // アドレスを取得
Serial.printf("Just works request from %s\n", bd_addr_to_str(addr));
sm_just_works_confirm(sm_event_just_works_request_get_handle(packet));
break;
}
case SM_EVENT_PAIRING_STARTED:
Serial.println("Pairing started.");
break;
case SM_EVENT_PAIRING_COMPLETE:
{
uint8_t status = sm_event_pairing_complete_get_status(packet);
if (status == ERROR_CODE_SUCCESS)
{
Serial.println("Pairing complete. Connection is now secure.");
hci_con_handle_t connection_handle = sm_event_pairing_complete_get_handle(packet);
uint16_t hids_cid;
イスに接続
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_PASSKEY_DISPLAY_NUMBER:
// パスキー表示が必要な場合の処理 (今回は No Input No Output なので基本的には発生しない)
display.printf("Passkey: %06u\n", sm_event_passkey_display_number_get_passkey(packet));
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);
Serial.printf("Re-encryption started for address: %s (%d)\n", bd_addr_to_str(addr), addr_type);
break;
}
case SM_EVENT_REENCRYPTION_COMPLETE:
{
// 再暗号化が完了した場合の処理
uint8_t status = sm_event_reencryption_complete_get_status(packet);
if(status == ERROR_CODE_SUCCESS) {
Serial.println("Re-encryption successful.");
} else {
Serial.printf("Re-encryption failed.(%d)\n", status);
}
break;
}
default:
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);
sm_request_pairing(connection_handle);
//gatt_client_discover_primary_services_by_uuid16(&handle_gatt_client_event, connection_handle, HID_SERVICE_UUID); // GATT event handler
Serial.printf("Connected to HID device: %s (%08x)\n", bd_addr_to_str(keyboard_address), connection_handle);
}
break;
これでコネクションはセキュアになりました。
ただ、まだキーボードから通知が拾えていません。
ble/hids_client.c というのがあるので、これを使えば簡単かもしれませんが、サンプルが見当たらないので、ソースを読みながら手探りで調査しています。
ついに、文字が拾えるようになりました。
まだ、テスト用のコードなので、これを整理して、こちらに組み込まないといけないのですが、長かった。
ポイントは、Notificationを受け取りたいCharacteristicに対して、ディスクリプタを検索してCCCDを見つけたら、Notifyを送信するように要求する((0x0001 -- little endian なら {0x01, 0x00}を送信する。))のと、最後に gatt_client_listen_for_charcteristic_value_updates()でGATT_EVENT_NOTIFICATIONを受け取るようにすることの両方。
更に gatt_client_listen_for_characteristic_value_updates()のcharacteristicにはnullptrを渡して一括でやる方がいいということですかね。((サンプルに従って notification_listenerを一つだけ作っているので、個別の characteristicに対してやりたいなら、多分これもcharacteristicごとにわけないといけないんだと思いますが、一括で動いているのでよしとします。))