文書の過去の版を表示しています。
ハイハイスクールアドベンチャー Raspberry Pico + LCD版
概要
ハイハイスクールアドベンチャー PicoCalc版を作ったついでといったら何ですが、PicoCalc のコアである Raspberry Pi PicoにLCDとかSDカードスロットとかキーボードとか繋げたら、別に PicoCalcじゃなくても遊べるじゃん? と、いうことで、ソースコードにちょいちょい手を入れて、仕立て上げたのがこれです。
pico1_lcd28 または pico2_lcd28 を選んでビルドすれば uf2ファイルが出来上がりますので適宜インストールしてください。
現状、Pico-ResTouch-LCD-2.8に、OTGアダプタをかませたUSBキーボード1)をサポートしています。
あれこれ
例によって、すぐ動くはずが、なかなか動かなかったあれこれを。
Pico-ResTouch-LCD-2.8
STT7789 という、よくあるコントローラで 240×320の液晶(タッチパネル/TFカードスロット込み)です。 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を共有しています2)
原因はバックライトのコントロールにありました。 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で地道にやろうかと思ったけれどリンクがうまくいかず玉砕。3)
じゃあ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 <Arduino.h> #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()の最後にもつけてみたら、あっさり普通にキー入力ができるようになりました。 たったこれだけ。 されどこれだけ。
BLEキーボード
Pico W/Pico 2WはWiFiとBTをサポートしていて、SDKにも btstack-1.6.2 4)が同梱されているので、理屈の上では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をどっかにとっておいて、今何をしているのか見て、処理を分けるか、リクエストごとに処理するコールバックを変えるかしないと何がなんだかわからなくなります。
Building a Bluetooth GATT Client on the Pi Pico Wという大変参考になるサイトがあるのですが、ここではGATTのコールバックを一つにまとめる代わりに state という変数で、今何の処理をやっているのかを管理して、イベントハンドラをコントロールしています。
わたしは、あんまりこういうやり方が好きじゃない5)ので、コールバックを分ける方法にしましたが、要するに、要求→レポート→COMPLETE→次の要求→……という流れなのを押さえておけば大丈夫です。
AIに聞くと、なんか動きそうなコードをくれるんですが、レポートの中で次の要求を出してしまうので大体動きません。 最初、AIとわたしで組んでいたのでわからなかったのですが、上のサイトのおかげでようやく原因がわかりました。
セキュア接続
さて、BLEキーボードの大半はセキュアな接続でないとキー入力のNotificationを送ってくれません。 要求を出すと、state=156)で失敗してしまいます。
わたしの手元にあるバッファローのBLEキーボードとM5 CardputerのおまけでついてくるBLEキーボードだけがセキュア接続でなくてもつながるキーボードなので、つまりは、大体の場合セキュアじゃないとダメなのです。7)
さて、世間には、実は Pico W + btstack でセキュア接続をして BLE Centralとして動作させているという事例があまりないようだし、AIもいろいろ教えてくれるんですが、簡単にいうと、セキュア接続要求がstate=8で失敗して先へ進めないのが現状です。
まあ、セキュアじゃなくてもつながるキーボードなら多分つながるのでそれでよしとするというのもありでしょうけれど、なんかあとちょっとの工夫で動くんじゃないかっていう気がするので、あきらめがつきません。
識者の見解を待ちます!