2025-12-21 ,

タッチパネル・タッチペン両対応モバイルディスプレイEHOMEWEI LO-133PF購入

Amazonのタイムセールで安く売っていたのでモバイルディスプレイを買いました。

-23%offで30785円と書いてありました(12/18)。今見たら39980円。

細かいモデル違いがあって、FHDか4Kか、タッチパネルに対応か非対応かの組み合わせで4モデルあるみたいです。私が買ったのはその中でFHDでタッチパネル対応のPFです。

一緒に買ったもの:

届いた後に「あ、ディスプレイ出力ポートが足りない!」とか「HDMIケーブルの長さがちょっと足りないなぁ」などと気がついて追加注文しました。

「ひょっとして電源スイッチがついてない?」とも思ったのですが、マニュアルを読んだら上下に操作できるつまみを2秒押すと電源のON/OFFができると書いてありました。これは分かりづらい。何かスイッチ付きケーブルが必要かと思って注文してしまいました……いや、電源OFFでも(画面は消えますが)タッチパネルが反応していますね。タブレットとして使うなら良いのかもしれませんがやっぱり電源スイッチの付きケーブルやハブはあった方が良いかも。

有機ELということで発色も綺麗ですしタッチやペンもしっかり機能してこれが3万円で買える時代になったことに驚くばかりです。

特殊なドライバーやユーティリティソフトなどは一切不要です。

グラカを追加してこれまでのディスプレイと併用したときに、タッチやペンが別のディスプレイに入力されてしまう問題が発生しましたが、コントロールパネル→ハードウェアとサウンド→タブレットPC設定から解決できました(Windows11)。普通の設定画面には関係しそうな項目は見当たりませんでした。

画面保護フィルムがグレアとノングレアの2枚が付属していました。その他付属品は充実していて必要そうな物は全部入っています。しかし13.3インチのパネルにフィルムを貼るのは滅茶苦茶難しいですね。ホコリがどうしても入ってしまいます。こりゃ風呂場で全裸かな。PDA工房で追加のフィルムを注文。

Emacsではちゃんとタッチイベント(touchscreen-begin、touchscreen-update、touchscreen-end)が発生しました。

ペン入力もEmacsはタッチイベントとして認識しているようでした。edrawをスタイラスで使ったときにおかしなマウスイベントが発生するという問題を聞いていたのでマウスイベントが発生するのかと思っていましたがこのあたりは環境によって変わってくるのだろうと思います。

Photoshopではちゃんと筆圧を認識しています。Emacsでは今のところ筆圧を取得する術は無いようです。もし取得できるようになってしまったら面倒ですね……。

何はともあれ、これでタッチイベントを使ったプログラムがメインのデスクトップPCでも効率よく開発できるようになりました。

メインのディスプレイ(24インチ)もタッチパネル付きだったらいいのになとも思っています。私はキーボード原理主義者ではないので目の前にボタンがあったら指で直接押せばいいじゃん、といつも思っています。とは言え今使っているディスプレイはまだまだ使えるので勿体ないですし余ったディスプレイを処分するのも面倒です。しばらくはこのままでいきましょう。

2025-12-13

Edebugチートシート

デバッグの準備

  • 関数内で C-u C-M-x
  • M-x edebug-all-defs を実行した上で eval-buffer, eval-region, eval-defun
  • M-x edebug-all-forms を実行した上で eval-buffer, eval-region を使うと(範囲内にある)定義以外のフォームもデバッグする
  • M-x edebug-eval-top-level-form を使うとポイントがある場所(直後またはフォーム内であればそのトップレベル)のフォームをデバッグする

(参考: Instrumenting)

ストップポイント

(defun fun (a b) .(if .(< a. b.). .(insert .(format "%s %s %s" '(1 2 3) [1 2 3] .`(1 2 ,.(+ a. b.).).).). .(fun b. a.).).) 

要するにリストの前後と変数参照の後。リテラル的な物は除く。

(参考:Using Edebug)

コマンド一覧

分類 キー コマンド 説明
ポイントの移動 w edebug-where 現在のストップポイントへポイントを移動する
  B edebug-next-breakpoint 次のブレークポイントがある位置へポイントを移動する(関数内で循環移動)
実行モード S edebug-stop どこかのストップポイントで止める
  SPC edebug-step-mode 次のストップポイントまで進んで止める
  n edebug-next-mode 式の後にあるストップポイントまで進んで止める(式の前にあるストップポイントでは止まらない)
  g edebug-go-mode 次のブレークポイントまで進んで止める
  G edebug-Go-nonstop-mode ブレークポイントを無視して進む
  t edebug-trace-mode ストップポイント毎に1秒停止しながら進む
  T edebug-Trace-fast-mode ストップポイント毎に表示を更新しながら進む
  c edebug-continue-mode ブレークポイント毎に1秒停止しながら進む
  C edebug-Continue-fast-mode ブレークポイント毎に表示を更新しながら進む
  C-x C-a RET edebug-set-initial-mode 最初の実行モードを設定する
指定位置まで実行 h edebug-goto-here ポイントがある場所まで進む
  f edebug-forward-sexp ポイントから 式一つ分(もしくはprefix指定数)先まで進む (現在のストップポイントからではない)
  o edebug-step-out ポイントを包む フォームの直後まで進む
  i edebug-step-in ポイントの直後 の式が関数またはマクロ呼び出しの場合、その呼び出し先に入る(引数の評価は飛ばす)
デバッグの準備 I edebug-instrument-callee ポイントの直後の式が関数またはマクロ呼び出しの場合、その呼び出し先をデバッグ可能にする
脱出 q top-level quitする。途中ハンドラでデバッグに入る
  Q edebug-top-level-nonstop quitする。ハンドラがあってもデバッグに入らない
  a / C-] abort-recursive-edit 再帰編集を1段階戻る
ブレークポイント b edebug-set-breakpoint ポイントの場所にブレークポイントを設定する(prefix付きで一時的)
  C-c C-t (edebug-set-breakpoint t) ポイントの場所にブレークポイントを一時的に設定する
  u edebug-unset-breakpoint ポイントの場所のブレークポイントを解除する
  U edebug-unset-breakpoints 現在のフォーム内の全てのブレークポイントを解除する
  x edebug-set-conditional-breakpoint 条件付きブレークポイントを設定する
  D edebug-toggle-disable-breakpoint ブレークポイントの無効化状態をトグルする
ブレーク条件 X edebug-set-global-break-condition 場所に関係なく成立したら止まる条件を設定する
情報の表示 r edebug-previous-result 直前の式の評価結果を表示する
  d edebug-pop-to-backtrace バックトレースを表示する
  = edebug-temp-display-freq-count 通過回数を一時的に表示する
  C-x X = edebug-display-freq-count 通過回数を表示(挿入)する
  ? edebug-help ヘルプを表示する
  p edebug-bounce-point 一時的(1秒間)に実行中のカレントバッファのポイント位置を表示する
評価 e edebug-eval-expression ミニバッファから式を入力して評価する
  C-x C-e edebug-eval-last-sexp ポイントの直前の式を評価する
  E edebug-visit-eval-list 評価リストバッファを開く
画面構成 v edebug-view-outside edebugではないウィンドウ構成に切り替える(C-x X wで戻る)
  P edebug-view-outside  
  W edebug-toggle-save-windows  

Tips:

  • 上記キーの内、実行中以外でも使えそうなコマンドは C-x X の後に打つことで実行できる。
  • iを使うと引数の評価を飛ばしてしまう。それが嫌ならばIを押してからステップ実行する。一度関数をデバッグ対象にしてしまえば次からは何もしなくても中に入る。中に入りたくない場合はf、o、hを使う。nではダメ。hがあれば他要らなくね?

練習

前進:

;; (scratchバッファ上を想定) (defun my-callee-function (a b) (message "a=%s b=%s" a b)) ;; ↓ここで C-u C-M-x または M-x edebug-eval-top-level-form (let (a b c d) ;; ■ここにいた場合  (my-callee-function ;; ←i(関数の中)  ;; ↓SPC (最初のストップポイント)  (cons a ;; ←n(最初の後方ストップポイント)  b) (cons c d) ) ;; ←f(my-callee-functionを呼び出す式の直後)  (list a b c d) ) ;; ←o(let式の直後) 

無限ループ:

(defun my-edebug-practice-infinite-loop () (let ((count 0) (end nil)) ;; この変数を書き替えられれば無限ループを脱出できるのだが……  (unwind-protect ;; 無限ループはt、T、c、C、g、Gの効果を試すのにうってつけ。  ;; Sで停止。b、C-u b、u、U、x、Dでブレークポイント操作。  (while (not end) ;; 1行進む。進めなかったらバッファの先頭に移動。  (unless (zerop (forward-line)) (goto-char (point-min))) ;; pを押すと今ポイントがどこに進んだかが分かる(1秒間)。  (cl-incf count)) ;; ループ中qを押すとここに来る。Qでは来ない。  (message "count=%s" count)))) ;;←ここでC-u C-M-x  ;; (my-edebug-practice-infinite-loop) ;;←ここでC-x C-e 
2025-12-10 ,

org-captureをタッチで使いやすくする

Android版のEmacsを使ってみて、org-captureが使いづらいというのはすぐに気がつきました。私はorg-captureコマンドを C-c r に割り当てていますが、Ctrlを押さなければならない時点でタッチパネルからでは使いづらいです。その後もテンプレートを選ぶのにキー入力が必要ですし、最後の C-c C-cC-c C-k もタッチでは打ちづらいです。org-captureは「キーボードで素早くノートを取る」ことに最適化されていますが、これがタッチでは逆に障害となっています。

なので、まずはテンプレートの選択はGUIメニューで行うようにします。

テンプレート選択メニュー
テンプレート選択メニュー

そして入力が終わった後の C-c C-cC-c C-wC-c C-k と書いてある部分を押せるようにします。

ヘッダーラインにキャプチャ終了アクションが表示されている
ヘッダーラインにキャプチャ終了アクションが表示されている

作成したコードは次の通りです。

;;; org-captureをタッチで使いやすくする。  ;; マウス/タッチ使用時はテンプレート選択をx-popup-menuにする。  (defun my-org-mks (table _title &optional prompt specials) (unless prompt (setq prompt "Select: ")) (x-popup-menu t (list prompt (cons "" (nconc (cl-loop for item in table when (cddr item) ;; Prefixの説明を除く  collect (cons (cadr item) item)) (cl-loop for item in specials collect (cons (cadr item) (car item)))))))) (defun my-org-mks-around (old-fun &rest args) (if (use-dialog-box-p) (apply #'my-org-mks args) (apply old-fun args))) (advice-add 'org-mks :around 'my-org-mks-around) ;; header-lineのキーヘルプをボタンにする。  (defun my-org-capture-make-clickable (str keymap) "STRの中にあるfaceがhelp-key-bindingである部分をボタンにして押せるようにします。" (setq str (copy-sequence str)) (let ((pos 0)) (while pos (let ((face (get-text-property pos 'font-lock-face str)) (next (next-property-change pos str))) (when (eq face 'help-key-binding) (let* ((end (or next (length str))) (key-str (substring str pos end)) ;; ↓ここはKEY-STRによっては良くないかもしれない。  (command (lookup-key keymap (kbd key-str)))) (when command (let ((km (make-sparse-keymap))) (define-key km [down-mouse-1] command) (define-key km [header-line down-mouse-1] command) (put-text-property pos end 'keymap km str) (put-text-property pos end 'pointer 'hand str))))) (setq pos next)))) str) ;; EXAMPLE: (my-org-capture-make-clickable (substitute-command-keys "\\<org-capture-mode-map>Capture buffer. Finish `\\[org-capture-finalize]', refile `\\[org-capture-refile]', abort `\\[org-capture-kill]'.") org-capture-mode-map)  (defun my-org-capture-init () (defvar org-capture-mode-map) (setq header-line-format (my-org-capture-make-clickable ;; ここはheader-line-formatを指定しても良いのですが、そのままだと  ;; 長すぎてC-c C-kが画面の右からはみ出して押せないので短くします。  (substitute-command-keys "\\<org-capture-mode-map> Finish:\\[org-capture-finalize] Refile:\\[org-capture-refile] Abort:\\[org-capture-kill]") org-capture-mode-map))) (add-hook 'org-capture-mode-hook 'my-org-capture-init) 

テンプレート選択のGUIメニュー化はorg-mks関数にadviceを仕込むことで実現しました。そこではy-or-n-p等がやっているように、use-dialog-box-pを使ってマウス・タッチ操作からのコマンド起動かを判断して、必要ならGUIでメニューを表示します。 my-org-mksx-popup-menuを使ってテンプレート選択メニューを表示するorg-mksの代替物です。私はあまり複雑なテンプレートの指定をしていないので、これでorg-mksの全てのユースケースをカバーしているかは分かりません。org-mksはざっと調べた限りorg-captureorg-insert-structure-templateでしか使われていないみたいです。

終了アクションをタッチでできるようにするために header-line 上の C-c C-c などと書かれている部分をボタン化することにしました。 window-tool-bar を使って上に○×ボタンを表示するのも面白いかなと思ったのですが、面倒なのでこの方法にしました。 my-org-capture-make-clickable 関数は、文字列(header-line-format)中の特殊なface(help-key-binding)が指定されている部分に、keymapテキストプロパティを追加してdown-mouse-1イベントに反応するようにします。kbdを使っている部分がキー設定によっては正しく動かない可能性はありますが(キー割り当てが M-x org-capture-finalize のように表示されている場合など)、デフォルトの状態なら問題は無いでしょう。

org-captureコマンドはすでにメニューバーにも追加していましたが、ツールバーにも追加しておきます。

メニューバーに「+」ボタンを追加したところ
メニューバーに「+」ボタンを追加したところ

そのあたりのコードは以前の記事に追記しておきました。

Emacsのツールバーをカスタマイズする | Misohena Blog

これでスマートフォンからの入力がさらに便利になりました。

org-captureは前々から少し疑問を感じていた仕組みではありました。私は特定のファイルに新規エントリーを追加するという単純な目的にしか使っていません。それならもっと単純なやり方がある気がします。テンプレートを選んだら、即特定のファイルを開いて新規エントリーを挿入し、ポイントを適切な位置に移動すれば十分です。キャンセルしたければundoすれば良いのですから。なので今回の改良ではノートのキャプチャを行う仕組み自体を書き直そうかとも思いました。

しかしorg-captureにはノートを取った後に何事もなかったかのように元の状態に戻るという特徴があります。思いついた時にノートを取って、またすぐに元の作業に戻る。それがorg-captureの良いところなのかもしれないなと思い、今回はorg-captureを活かす改良で留めてみました。また不満を感じたら全体を書き直すかもしれません。

2025-12-09

Emacsのコンテキストメニューに「Close」を追加する

AndroidのEmacsを使っていると、なぜかふとバッファを長押ししてコンテキストメニューを表示し、そこから「Close」を選びたくなるときがあるんです。もちろんそこには「Close」なんて無いので「あっ……」と思うわけですが。

バッファを閉じたいならツールバーに×ボタンがありますし、メニューバーも出しているので「File」→「Close」を選ぶという手もあります。でもなぜかコンテキストメニューの中から「Close」を選びたくなるときがあるのです。

それがなぜかはよく分からないのですが、とりあえず追加してみます。

私はcontext-menu-mode(Emacs 28で追加)を有効にしていて、以前書いたようにタッチの長押しでそれが表示されるようにしているのでコンテキストメニュー自体はすでに表示できます。

長押しでコンテキストメニューを開く(Emacs 30) | Misohena Blog

なので後は単純にコンテキストメニューに項目を追加すればいいだけです。

コンテキストメニューに項目を追加するにはcontext-menu-functions変数に関数を追加すれば良いのでした。

(defun my-close-buffer-and-window () (interactive) (let ((window (selected-window))) (when (kill-buffer) ;; すぐに削除するとタッチ処理部分のwith-selected-windowが削除  ;; したウィンドウを選択しようとしてエラーになる。  ;; なのでタイマーで遅延する。  (run-with-timer 0 nil (lambda () (when (and (window-live-p window) (window-deletable-p window)) (delete-window window))))))) (defun my-context-menu-function (menu _click) (define-key menu [my-close-buffer-and-window] '(menu-item "Close" my-close-buffer-and-window)) menu) (add-hook 'context-menu-functions 'my-context-menu-function 0) 

望んでいる動作というのはおそらくただバッファを閉じるだけでなくウィンドウも閉じたいのだと思います。

ウィンドウを閉じるだけなら以前モードラインをドラッグしてウィンドウを消せるようにしましたが、それだとバッファが残ってしまいますからね。

モードラインをドラッグしてウィンドウを消す | Misohena Blog

それに今はサイドウィンドウで仮想キーボードを表示しているので、うまく狙ったウィンドウが消せないことがあるのです。

最初はプレフィックス付きでquit-windowを呼び出そうと思いました。私はデスクトップ上では表示専用のバッファをよくC-u qで閉じることがあります。ポップアップしたウィンドウをバッファも含めて消してくれるので。

でもこの「Close」にquit-windowを割り当ててみてもどうもしっくりこないのです。quit-windowは前の状態を復元しようとしますが必ずウィンドウを閉じてくれませんからね。

なので上のコードでは素直にバッファとウィンドウを同時に削除することにしました。

ただし、長押しで無理矢理コンテキストメニューを出した弊害で、そこからウィンドウを削除するとどこからともなくwindowがらみのエラーが発生します。調べてみると、touch-screen.elの中でwith-selected-windowを使用しているところがあって、それが私が削除したウィンドウを選択しようとしてエラーが出るみたいなのです。なのでkill-buffer-and-windowは使えません。仕方が無いのでタイマーで後から削除するようにしました。

コンテキストメニューに「Close」を追加したところ
図1: コンテキストメニューに「Close」を追加したところ
2025-12-05

Emacsの中で動く仮想キーボードを作る

Emacs 30からmodifier-bar-modeというのが追加されました。これはツールバーにControl、Shift、Meta、Alt、Super、Hyperといった修飾キーに相当するボタンを追加するモードです。つまりツールバーでそれらのボタンを押してから通常のキーを押すと、その通常のキーがControlキーやらShiftキーやらと一緒に押したことになるわけです。これはおそらくAndroidの仮想キーボード(Emacsではオンスクリーンキーボードという呼び方で統一されていますしソフトウェアキーボードという呼び方も普通だと思いますが、ここでは仮想キーボードと呼ぶことにします)と併用するために追加されたのではないでしょうか。普通の仮想キーボードにはそういった修飾キーはありませんからね。

modifier-bar-mode
図1: modifier-bar-mode

私も今年Android版のEmacsを利用し始めましたが、このmodifier-bar-modeは当然試しました。

しかし単に修飾キーが欲しいだけならHacker's Keyboardのような豪華な仮想キーボードを使えば十分です。

このように手軽に押せるツールバー上のキーが欲しくなるシチュエーションは、日本語用の仮想キーボードを表示しているときや、そもそも仮想キーボードを非表示にしているときではないでしょうか。それならもっと色々なキーがツールバー上にあっても良いでしょう。

ということで作ったのがこちらのツールバー。

my-tool-bar-kbd-mode
図2: my-tool-bar-kbd-mode

modifier-bar-modeを真似た上で他にも様々なキーを追加しました。これで仮想キーボードをONにしたり切り替えたりしなくてもよく使うキーが押せるようになりました。

しかしこのツールバーを使っていると不満も出てきます。アルファベットがqwerty配列ではないので使いづらいです。キーのサイズも小さくて押しづらい。そして一番はやはり「もっと沢山キーが欲しいよ!」ということです。人間の欲望に限りはありません。

それならいっそのこと……というわけで、Emacsの中で動く仮想キーボードを作ることにしました(以前どこかで見たような気もしたのですが、ちょっと探したくらいでは見つからなかったんですよね)。

Emacs Virtual Keyboard(el-vkbd)

それでできたのがこちら。

misohena/el-vkbd: A software keyboard implemented in Emacs Lisp that runs inside Emacs.

次のスクリーンショットで上側に表示しているのがその仮想キーボードです。

vkbdと通常の仮想キーボードを両方表示
図3: vkbdと通常の仮想キーボードを両方表示

こうやって既存の仮想キーボードと併用することで日本語もフリック入力で打てますし切り替え無しでほぼ全てのキーが入力できます。(この配列は括弧がシフト無しで入力できるようになっています。素晴らしくないですか!?)

キー配列も数種類用意してあります。次のように特殊キーのみの配列を使えばmodifier-bar-modeの代わりとしても使えます。

vkbdは特殊キーのみの表示で通常の仮想キーボードと併用する(関係ないけどこの後ろのBASIC世代にはたまらなく懐かしくありません?)
図4: vkbdは特殊キーのみの表示で通常の仮想キーボードと併用する(関係ないけどこの後ろのBASIC世代にはたまらなく懐かしくありません?)

もちろん配列は自由にカスタマイズ可能です。標準的なカスタマイズ方法としては、 M-x customize-variable vkbd-layout-list というのを用意してあります。

仮想キーボードの表示場所はサイドウィンドウ、子フレーム、独立フレームから選べるようになっています(内部的にはcontainer-typeと呼んでいます)。最初は子フレームとして作ったのですが、後からサイドウィンドウとして表示できるようにもしました。結局同じフレームにウィンドウを分割して表示した方が使いやすいと思います。

子フレームとして表示している状態
図5: 子フレームとして表示している状態

仮想キーボードのタイトルバーにあるボタンは左から「閉じる」「メニュー」「10x7配列へ切替」「特殊キーのみ配列へ切替」「ネイティブ仮想キーボード無効化トグル(Android時のみ)」です。もちろんこれらタイトルバーの構成は自由にカスタマイズ可能です(M-x customize-group vkbd-title-bar)。

タイトルバーのボタン
図6: タイトルバーのボタン

メニューからは「キー配列の変更」「フレームタイプ変更(ウィンドウ、子フレーム、独立フレーム)」「サイドウィンドウの方向(ウィンドウ表示時のみ)」「カスタマイズバッファの表示」が選べます。

キーボードメニュー
図7: キーボードメニュー

メニューやボタンから選んだ状態は即座に反映され、デフォルトでは ~/.emacs.d/vkbd ファイルに保存され次回起動時には同じ状態からスタートします(保存先は M-x customize-variable vkbd-global-keyboard-user-data-storage で変更可能。項目毎にどこに保存するかをカスタマイズできる過度に複雑な仕組みをご用意しました)。

Android環境では通常の仮想キーボードを無効化するボタンも付けました。それを押すと通常の仮想キーボードは表示されなくなり、もう一度押すと元の設定に戻ります(どのようなときに仮想キーボードを表示するかは元々Emacsの設定である程度制御できます)。

通常の仮想キーボードを無効化すると、完全にこの仮想キーボードだけでEmacsを操作することも可能です。

vkbdのみ使用
図8: vkbdのみ使用

また、vkbd-replace-osk-modeというグローバルマイナーモードも作りました。これは通常の仮想キーボード(Emacsではオンスクリーンキーボードと呼びます)をできるだけこの仮想キーボードで置き換えるモードです。Emacsでは(設定によりますが)画面をタッチしたときに、そのタッチした場所が編集可能なら自動的に仮想キーボードを表示するしくみがあります。他にもミニバッファからの入力を開始したときなど、仮想キーボードを自動的に立ち上げるタイミングが複数あります。vkbd-replace-osk-modeを有効にすると、それらのタイミングで通常の仮想キーボードは出ずに代わりにこの仮想キーボードが出現します。

もちろん100% Emacs Lispで実装されていますので、動作はAndroidに限りません。デスクトップ上でも使用できます。

Windowsで使う
図9: Windowsで使う

マウスから手を離したくない、キーボードが遠い、といった時には使ってみても良いかもしれません。(私はあまり使わないと思いますが)

実装について

実装の一番のキモはやはりどうやってマウス/タッチイベントをキー入力に変換するのかという点でしょう。

このプロジェクトはmodifier-bar-modeに端を発していますから、その方法を参考にするのが自然な流れでした。

それはinput-decode-mapという変数を使うことです。

Translation Keymaps (GNU Emacs Lisp Reference Manual) (ayatakesi氏の日本語訳)

相変わらずマニュアルを読んでも頭に入ってこないので色々試して理解したこと:

  • input-decode-mapというキーマップがある。
  • input-decode-mapにはキーシーケンスに対してコマンドでは無く変換関数を割り当てる。
  • (read_key_sequence 中に)イベント(キーやマウス、タッチ等)が発生したらその組み合わせ(キーシーケンス)に対応するinput-decode-mapに割り当てられた変換関数が呼ばれる。
  • 変換関数はpromptという引数一つだけを受け取る。これは元々は read_key_sequence の引数。ほとんどの場合無視して良いらしい。
  • 変換対象のイベントはcurrent-key-remap-sequence変数から取得できる。その値はベクトルで、基本的にキーマップに設定した(変換関数を呼び出すトリガーとなった)キーシーケンスと同じものが入っているはずなので必ずしも参照しなくてよいが、マウスやタッチイベントでは座標等の細かい情報はここからしか入手できない。
  • 変換関数は変換後のイベントをベクトルで返す。
    • 空ベクトル([])を返すと変換元のイベントは無かったことになるみたい。
    • nilを返すと変換無しとなり、何も変換せずに元のイベントを返すのと同じになるみたい。
    • 返すベクトルに沢山のイベントを詰め込んでも、それが全部使われるわけではない。あくまで1回のキーシーケンス読み取りに使われる分だけが処理対象。

というわけで、まずはinput-decode-map[down-mouse-1] やら [touchscreen-begin] やらに対して独自の変換関数を登録(例えば (define-key input-decode-map [down-mouse-1] #'my-translate-event) みたいにして)。そして変換関数は、変換元イベントが仮想キーボードのキー上で発生したのであればそのキーに対応するキーイベントを生成して返します(実際には押し下げから離すまでの待ち、修飾キーの適用、キーリピートなど様々な処理がこの間に必要になります)。もし仮想キーボードと関係ない場所のイベントであれば他に悪影響が及ばないように速やかにnilを返します。

実際にやってみるとC-xの後に down-mouse-1 が来ないで mouse-1 は来るとか frame-switch イベントが邪魔とかよく分からない挙動が色々とありましたが、どうにかうまくマウス/タッチからキーイベントを生成することが出来ました。

しかしこれだけでは入力できないシチュエーションがチラホラありました。それもそのはず。input-decode-mapread_key_sequence 関数からしか使われないからです。その他の入力関数を使用している場合はinput-decode-mapによる変換は行われません。その他の入力関数とはread-eventread-charread-char-exclusiveのことです。

このあたりのEmacsの入力関数には何があるのかということは前回図解しました。

よく分かるEmacs Lisp入力関数関連図 | Misohena Blog

つまり入力関数には read_filtered_event 系(read-eventread-charread-char-exclusive)と read_key_sequence 系の2系統があり、その上に read_char という関数があるという構造でした。

read_char のレベルで何かイベントを変換するような仕組みがあれば良かったのですが、私には見つけられませんでした(長いのでろくに読んでいません)。

そこでread-eventread-charread-char-exclusiveadvice-addで:around adviceを仕掛けて動作を上書きし無理矢理返ってきたイベントを変換することに。read-charread-char-exclusiveは元の挙動ではマウス/タッチイベントが返ってこないので、read-eventを呼んで返ってきたイベントが文字で無ければリトライしたりエラーにするといった方法で対処しました。要するにread-charread-char-exclusive、そこから呼ばれる read_filtered_event が内部でやることを、Emacs Lispレベルで再現して対処しました。それなりにうまく再現できたと思いますが、不安は残るので(特にtext-conversionの切り替えまわり)仮想キーボードが表示されていない場合はちゃんとadviceを取り除くようにしてあります。

これでほとんどのケースで仮想キーボードに対するマウス/タッチイベントをキーイベントに変換できるようになりました。

これらはできるだけ表示方法とは切り離して実装してあります。なので表示部分だけをごっそり別なものに入れ替えることも理論上は可能です。その入れ替えるための仕組みも一応styleという名称ですでに入っています。ただし、その実装は現在のところテキストで表示するvkbd-text01-styleという一種類のみになっています。SVGで表示するスタイルも作りたいなと思ったのですが、思いのほかテキストだけで十分綺麗な物が出来てしまったので、作る意欲はどこかへ飛んで行ってしまいました。

Emacs Lisp実装の仮想キーボードの限界

現段階でもうまく入力できないシチュエーションがいくつか確認できています。

一つは仮想キーボードを専用フレーム(子フレームまたは独立フレーム)で表示している場合。この時、仮想キーボードをクリックするとフォーカスが仮想キーボードを表示しているフレームに移り switch-frame イベントが発生します。そして様々なフレーム切り替え処理が行われます。その影響なのか詳しくは調べていませんが、そのタイミングで入力受け付け状態が終了してしまうプログラムがいくつかありました。transient.el(Magit等)やset-transient-map(text-scale-adjust等)が典型的です。ウィンドウ表示にするとフレーム遷移が発生しないので問題が発生しません。基本的に生成したキーイベントは入力対象のバッファ、ウィンドウ、フレームに選択やフォーカスを戻してから返さないといけないので、必然的にフレーム選択やフォーカスの遷移は打鍵毎に2回生じることになります。別フレームで表示していると他にも様々な問題があります。基本的にフォーカスが当たっていないフレームではマウスイベントが発生しないようなので、キーリピート中のmouse upイベントが発生せずキーが押しっぱなしになってしまうという問題もありました(従って現状ではキーリピートはウィンドウ表示時のみ可能です)。

もう一つはウィンドウ表示時の問題。現在ウィンドウ表示時はサイドウィンドウを使用して仮想キーボードを表示しますが、既存のプログラムの中にはサイドウィンドウを使うものが数多くあります。例えばtransient.el(Magit等)はキーメニューをデフォルトでは下側にサイドウィンドウとして表示します。which-key-modeもヘルプを下側にサイドウィンドウとして表示していました。もし下側に仮想キーボードを表示していると、それらがサイドウィンドウを表示したときに消されてしまい、キー入力が出来なくなってしまいます。どちらもキーを受け付ける専用の状態の時に起こるので、その時にキーが打てないのは困ります。現在はサイドウィンドウのスロット番号にランダムな数字を入れておいて完全に消されないようにしていますが、それでも一部が隠れてしまい狙ったキーが入力できないときがあります。私はサイドウィンドウをあまり積極的に使っていないので、仮想キーボードの配置場所を上側に移すことで回避できました。上下両方に何かのサイドウィンドウを表示している人は困るかもしれません。

他にもたまにキーを押したつもりがマウスイベントとして解釈されてエラーが発生する場合があるような気がします。

いくつか問題はありますが、とは言え実用的にはほとんど困らない程度にはなかったかなと思っています。

2025-11-29

よく分かるEmacs Lisp入力関数関連図

Emacsのキー入力関数ってなんか似たような名前が多くてすぐに理解できませんよね。調べてだいたい分かったつもりになっていても、しばらくしたら忘れている自信があります。なので図にしておきました。自分のために。

read_charread_key_sequenceread_key_sequence_vsread-key-sequenceread-key-sequence-vectorread_filtered_eventread-eventread-charread-char-exclusivecommand_looprecursive_edit_1read_minibufread-from-minibuffercompleting-read-defaultcompleting-readread-multiple-choiceread-keyread-char-choiceread-quoted-charx-popup-dialoglread.ckeyboard.csubr.elminibuf.c/minibuffer.elrmc.elsimple.elcompleting-read-functionmenu.c

Emacs Lisp リファレンスマニュアルで言うと「Reading Input (GNU Emacs Lisp Reference Manual)」に書いてある関数のことです。

Reading Input (GNU Emacs Lisp Reference Manual)

大別すると read_filtered_event を介して直接的に一つのイベントを読み取る関数群と、 read_key_sequence を介してキーマップによって決まる一続きのキー列を読み取る関数群とに分けられるようです。普段バッファの中で使っているのは後者ですね。コマンドループを通じて read_key_sequence を呼び出しています。それだけに read_key_sequence の方が複雑で難しいです。

read_filtered_event 系には三つの関数がありますが、文字入力イベントのみに限定するバージョン(read-charread-char-exclusive)と全てのイベントを読み取るバージョン(read-event)に分かれます。 read-charread-char-exclusive の違いは、文字以外のイベントが来たときにエラーにするか、排除して続行するかの違いです。

  • read_filtered_event
    • 文字のみ
      • 文字以外でエラー : read-char
      • 文字以外は無視 : read-char-exclusive
    • 全て : read-event

read-charread-char-exclusive は、 read-event に比べると次の処理が加わっています。

  • text-conversionの無効化と復元 (Androidの場合IMEによって直接バッファを書き替える仕組みが存在します)
  • 非文字イベント発生時のエラー(read-char)またはリトライ(read-char-exclusive)
  • switch-frameイベントの遅延 (非文字イベントだが特別扱い)
  • イベントタイプシンボルの文字コード化 (例えばtabを9にします)
  • 修飾キービットの正規化 (主にshiftとcontrolの処理です。例えば25ビット目(?\S-\0)が立っていてベース文字がアルファベット小文字なら大文字にしてビットを消します。control(26ビット目)が立っている文字を制御文字へ変換したりもします)

当然ですが read_filtered_event 系関数にはキーマップは作用しません。

read_filtered_eventread_key_sequence に共通な処理は read_char の中に色々入っています。 unread-command-events の処理とかキーマクロの再現に関するものとか。

2025-11-13

タッチスクリーンで慣性スクロールする

Android版のEmacsを使っているとすぐに慣性スクロールが無いことに気がつくでしょう。つまり画面をスワイプしてスクロールするときに、弾くように指を離したらそのまましばらく勢いでスクロールが継続して欲しいわけです。これが無いと指を離すたびに「ピタッ」とスクロールが止まるので、何画面分もスクロールしなければならないときにとても疲れます。

これは慣性スクロールを実装しなきゃダメかな……と憂鬱になりながら少し調べてみたところ、pixel-scroll.elpixel-scroll-*-momentum という名前の変数や関数が存在することに気がつきました。喜び勇んですぐに pixel-scroll-precision-use-momentumt にして、 pixel-scroll-precision-mode を有効にしてみましたが……あれ、何も変わりません。うーん、どうなっているんだろう。

Googleで検索したら次のredditの投稿が見つかりました。

How to config to enable pixel scroll precision momentum-based scrolling on Android? : r/emacs

おおー、素晴らしい!

というわけで次の設定をしたらちゃんと慣性が働くようになりました。

;; タッチによるスクロールをピクセル単位にする(必要?) (setq touch-screen-precision-scroll t) ;; 慣性スクロールを有効にする ;; https://www.reddit.com/r/emacs/comments/1mtouxh/how_to_config_to_enable_pixel_scroll_precision/ (defun touch-scroll-momentum (_dx dy) (pixel-scroll-accumulate-velocity (- dy))) (advice-add 'touch-screen-handle-scroll :before 'touch-scroll-momentum) ;; (2025-11-14追記:長押しスクロールが効かなくなってしまったので修正) ;;(keymap-global-set "<touchscreen-end>" 'pixel-scroll-start-momentum) (defun my-touch-screen-handle-touch:scroll-end (event &rest _) (when (eq (car event) 'touchscreen-end) (pixel-scroll-start-momentum event))) (advice-add 'touch-screen-handle-touch :before ;;afterではダメ  'my-touch-screen-handle-touch:scroll-end) (setq pixel-scroll-precision-use-momentum t) (pixel-scroll-precision-mode) 

ただ、私の環境だとorg-mode文書のスクロールはかなりカクつきます。org-modernやphscrollで色々凝ったことをしているせいかもしれませんが。

2025-11-12 ,

Emacsのツールバーをカスタマイズする

Android版のEmacsのためにツールバーをカスタマイズしました。

修正前の状態は次図の通り。

ツールバー修正前
図1: ツールバー修正前

今回はあくまで一番上の標準的なツールバー(tool-bar-mode)のお話しです。その下にあるキー入力用のバーは無視してください(これもそのうち何とかしなきゃいけませんが(2025-12-09追記:何とかしました))。

上図はデフォルトの状態なのですが、どのボタンが何をするか分かるでしょうか。私は正直よく分かっていませんでした。一番左からファイル新規作成、ファイルを開く、ディレクトリを開く、バッファを閉じる、セーブ、アンドゥ、カット、コピー、ペースト、インクリメンタル検索となっています。

一番問題なのは最初の「ファイル新規作成、ファイルを開く、ディレクトリを開く」の部分です。これって実際に何をするか分かりますか? Emacsでは通常全てfind-fileで行うものだと思います。実際に割り当てられているコマンドは左から、find-file、menu-find-file-existing、diredとなっています。find-fileだけでいいじゃん! (だいたい新規作成なら既存のファイルを選んだら「上書きしますか?」と聞かなければいけませんよ。しかし当然ですがfind-fileはそんなことはしません)(2025-12-10追記: このあたりはOSごとのファイル・ディレクトリ選択ダイアログによって状況が異なるかもしれません。Androidではダイアログが実装されておらず単にミニバッファからファイルやディレクトリを選択する流れになります。MS-Windowsではfind-fileで出現するダイアログではディレクトリが選択出来ません。たしかMacだと既存のファイルを選択するときの挙動も異なっていたような)

それとバッファを閉じるための×が押しにくいんですよね。一番左に配置しましょう。

カット、コピーは長押しのコンテキストメニューでやっているのでここには要らないかなーと思います。ペーストくらいは残しておいても良いかな? この辺りはまた後で変えるかも。

他にもいくつかよく使う操作をツールバーのボタンにしたいと思います。

それと全体的にアイコンが小さすぎません? いや、これはデフォルトフォントサイズの設定に合わせて変わってしまっています。私はデフォルトフォントを少し小さめにしてしまったので、ツールバーのボタンも一緒に小さくなって押しづらくなってしまいました。そしてツールバーのボタンのサイズだけを調整する方法が見当たりません。tool-barフェイスの:heightを変更してみてもこれはテキストのみにしか効果が無いらしくアイコンのサイズは変わりませんでした。うーん困った。

で、色々やってみた結果がこちら。

ツールバー修正後
図2: ツールバー修正後

ボタンの数が減ってシンプルで分かりやすくなりました。ボタンのサイズも大きくなって押しやすくなりました。

一番右に追加したボタンはbeginning-of-bufferとend-of-bufferです。長いファイルの途中にいるときにタッチによるスクロールだけで大きく移動するのは大変です。かといってM-<やM->を押すのもなかなか面倒なので追加してみました。

作成したコードは次のようになりました。

;; 項目のカスタマイズ(既存項目の削除、並び順の変更) ;; tool-bar-mapを直接変更します。 ;; tool-bar-setupが呼び出された後じゃないと正しく動作しません。  (defun my-tool-bar-map--customize-items (map) "tool-bar-mapの項目をカスタマイズします。" ;; new-file、open-file、diredは一つに統合する  ;; (OSによってはファイル選択ダイアログで狙ったファイルやディレクトリ  ;; が選択出来ない場合もあるが)  (define-key map [find-file] (list 'menu-item "Open file" #'find-file :image (tool-bar--image-expression "open"))) ;; 不要なアイコンを削除  ;; - new-file、open-file、diredは新しく定義し直す  ;; - cut、copy、pasteはメニューのEditの押しやすい位置にあるから不要  (dolist (key '(new-file open-file dired cut copy paste)) ;; 削除は (define-key map (vector key) nil t) でもよいが、remove引  ;; 数はEmacs29が必要  (setf (alist-get key (cdr map) nil t) nil)) ;; separator-2と3を削除  (setf (alist-get 'separator-2 (cdr map) nil t) nil) (setf (alist-get 'separator-3 (cdr map) nil t) nil) ;; kill-bufferをツールバーの先頭に移動  ;; kill-bufferの右にseparator  (let ((item (assq 'kill-buffer (cdr map)))) (setf (alist-get 'kill-buffer (cdr map) nil t) nil) (setf (alist-get 'separator-0 (cdr map) nil t) nil) (setcdr map (cons item (cons (list 'separator-0 "--") (cdr map)))))) ;; 項目の追加  (defconst my-tool-bar-map--additional-items '(;; 2025-12-10追記: org-captureボタンを追加  (org-capture "Org Capture" org-capture "<svg xmlns=\"http://www.w3.org/2000/svg\" version=\"1.1\" width=\"24\" height=\"24\" viewBox=\"0 0 240 240\"><path d=\"M102 40H138V102H200V138H138V200H102V138H40V102H102Z\" fill=\"#444\" stroke=\"none\" /></svg>") (top "Top" beginning-of-buffer "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" version=\"1.1\"><path stroke=\"none\" d=\"M4 2H20V4H12L18 10H14V22H10V10H6L12 4H4Z\" fill=\"#444\" /></svg>") (bottom "Bottom" end-of-buffer "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" version=\"1.1\"><path stroke=\"none\" d=\"M4 22H20V20H12L18 14H14V2H10V14H6L12 20H4Z\" fill=\"#444\" /></svg>"))) (defun my-tool-bar--add-item (map id text cmd image-data) ;; 2025-12-10修正: define-key-afterを使うように修正  (define-key-after map (vector id) (list 'menu-item text cmd :image (list 'quote (create-image image-data 'svg t :scale 'default))))) (defun my-tool-bar-map--customize-additional (map) (dolist (spec my-tool-bar-map--additional-items) (apply #'my-tool-bar--add-item map spec))) ;; アイコンサイズの調整 ;; image descriptorに:scaleを無理矢理追加して調整します。  (defconst my-tool-bar--icon-scale 2.8) ;; ★要調整  (defun my-tool-bar--adjust-image-scale (image) (when image (setf (plist-get (cdr image) :scale) my-tool-bar--icon-scale)) image) (defun my-tool-bar--adjust-map-image-scale (map) (dolist (item (cdr map)) (when-let* ((image (plist-get (cddddr item) :image))) (setf (plist-get (cddddr item) :image) (list 'my-tool-bar--adjust-image-scale image))))) (defun my-tool-bar-map--customize-icon-size (map) (my-tool-bar--adjust-map-image-scale map)) ;; 初期化  (defun my-tool-bar-map--customize () (let ((map (default-value 'tool-bar-map))) (my-tool-bar-map--customize-items map) (my-tool-bar-map--customize-additional map) (my-tool-bar-map--customize-icon-size map))) (if (and (boundp 'tool-bar-map) (cdr-safe (default-value 'tool-bar-map))) ;; すでにtool-bar-mapが初期化されているときは更新  (progn (my-tool-bar-map--customize) (tool-bar--flush-cache) (force-mode-line-update)) ;; まだの時は初期化されるまで待つ  (advice-add 'tool-bar-setup :after #'my-tool-bar-map--customize)) 

Emacsのツールバーはカスタマイズ性が悪いですね。どうせみんな使ってないんでしょう? いや、私もPC上では使っていませんが。まさかこの期に及んでツールバーをカスタマイズすることになるとは思いませんでした。

(2025-11-13:追記)org-mode時の折りたたみ操作もやりづらいので、org-mode用の項目も追加しました。

(defvar my-org-tool-bar-map (let ((map (make-sparse-keymap))) (define-key-after map [separator-org-1] menu-bar-separator) (define-key-after map [fold] `(menu-item "Fold" my-org-fold-current-subtree :help "Fold current subtree" :image (create-image "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" version=\"1.1\"><path d=\"M12 14 6 20H18\" fill=\"#444\" stroke=\"none\" /><path d=\"M12 10 18 4H6\" fill=\"#444\" stroke=\"none\" /></svg>" 'svg t :scale 'default))) map)) (defun my-org-tool-bar-map () ;; make-composed-keymapは機能しない。  ;; :imageの部分が展開されないから。(See: `tool-bar-make-keymap-1')  ;; (make-composed-keymap (default-value 'tool-bar-map)  ;; my-org-tool-bar-map)  (let ((map (copy-keymap (default-value 'tool-bar-map)))) (set-keymap-parent map my-org-tool-bar-map) map)) (defun my-org-fold-current-subtree () "現在のサブツリーを折りたたみます。 ポイントが見出しにあり、その見出しがすでに折りたたまれている場合は、それ を含む一つ上のサブツリーを折りたたみます。" (interactive) (if (org-at-heading-p) (if (org-fold-core-folded-p (pos-eol)) ;; 折りたたまれている見出し上にいる場合  (progn (outline-up-heading 1) (outline-hide-subtree)) ;; 折りたたまれていない見出し上にいる場合  (outline-hide-subtree) (unless (org-fold-core-folded-p (pos-eol)) ;; 折りたためなかった場合、一つ上を試す  ;; (空のエントリーの場合は折りたためない)  (outline-up-heading 1) (outline-hide-subtree))) ;; 見出し以外にいる場合  (outline-previous-heading) (outline-hide-subtree))) (defun my-org-tool-bar-setup () (setq-local tool-bar-map (my-org-tool-bar-map))) (add-hook 'org-mode-hook 'my-org-tool-bar-setup) 

org-modeの折りたたみ操作はキー操作においても常々不満があります。見出し上なら単に TAB で折りたためますが、エントリーの中では C-c C-p TAB としなければなりませんし、見出しの上でそれを含むサブツリーを折りたたむには C-c C-u TAB としなければならなかったりします。上に書いた my-org-fold-current-subtree コマンドは、どこであっても概ね狙った通りに折りたたむ(閉じる)ことが出来ます。

2025-11-07 , , ,

PCから電子ペーパーに予定表を表示する(Waveshare13.3インチ6色)

前回、PCからUSB-SPI変換基板を通じて3.5インチ(4色)の電子ペーパーモジュールに画像を表示できました。

しかしやはり3.5インチでは小さくて物足りません。ここはもっと大きなパネルに表示させてみたいところ。

もっと大きなパネルを購入

しかし大きな電子ペーパーモジュールはどこで買えるのでしょうか。検索してもなかなか良いものが見つかりません。国内の電子工作部品を扱っているショップには10インチを超える製品の取扱が少ないようです。そんな中で見つけたのがAmazonで売っていた13.3インチ(6色)の製品。

13.3インチ SPI カラー 1600×1200 Full-Color 電子ペーパー モジュール E-ink Epaper ディスプレイ スクリーン HAT スターターキット for Arduino RPI Raspberry Pi Zero 2 W 3 4B 4 Model B 5 ボード 互換性 8GB 16GB RAM ラズベリーパイ 基板 ラズパイゼロ 2W 電子工作 部品 アクセサリー

出荷元/販売元はSTEMDIYという住所が中国の業者。おそらく海外から発送されてくるパターンでしょう。実際に表示できたという写真付きのレビューもありました。なのでいったんはここで注文したのですが、少々勘違いがあってすぐにキャンセルしてしまいました。またすぐに勘違いに気がつきましたが、キャンセルのキャンセルは出来ないので普通にキャンセル確定。更にすぐにもう一度注文するのも気が引けたので、落ち着いてネットで再度探してみるとAliExpressでもう少し安い値段で売られているのを見つけました。

13.3インチE Ink Spectra 6 (E6)フルカラー電子ペーパーディスプレイ 1600x1200ピクセル 価格タグや棚ラベルに最適 - AliExpress 44

もうこれでいいやと思い「with driver HAT」と書かれている方を注文。支払いはコンビニ払いで。ファミリーマートに行ってレジでバーコードを見せて38757円を支払いました。AliExpressは初めてです。大丈夫なのかなこれ。

翌日の15時くらいに発送の連絡が来て、中国での集荷、空港着、日本の空港着、通関完了、配送業者着と逐一連絡が届き、最終的には佐川急便の手によって自宅に届いたのが発送から8日後でした。

開封の儀

到着した箱はビニールテープでグルグル巻きにされていました。テープの接着剤の臭いがします。

到着した箱
図1: 到着した箱

何この綺麗な箱!

Waveshare 13.3インチ e-Paper(E6) 外箱
図2: Waveshare 13.3インチ e-Paper(E6) 外箱

中も丁寧に梱包されています。

Waveshare 13.3インチ e-Paper(E6) 内容
図3: Waveshare 13.3インチ e-Paper(E6) 内容

パネルは、薄くて軽い金属板のようです。「紙」とはいってもしなやかさはありません。

Waveshare 13.3インチ e-Paper(E6) E-Inkパネル
図4: Waveshare 13.3インチ e-Paper(E6) E-Inkパネル

付属品。「with Driver HAT」を選んでいたのでちゃんと駆動用のボード(HAT+)が付いてきました。間違えてたらどうしようと心配だったんです。よかった。

Waveshare 13.3インチ e-Paper(E6) Driver HAT+
図5: Waveshare 13.3インチ e-Paper(E6) Driver HAT+

ケーブル接続

マイコンボードに接続するのに最適なピンコネクタ付きケーブルは今回も付属しています。

Waveshare 13.3インチ e-Paper(E6) Driver HAT+
図6: Waveshare 13.3インチ e-Paper(E6) Driver HAT+

ただしピンの本数は10本。3.5インチモジュールは9本だったので1本増えています。CSがCS_MとCS_Sの二つになっています。

一方パネルと接続する側は、フィルム基板と接続するためのコネクタが付いています。

バックフリップ型FPCコネクタ
図7: バックフリップ型FPCコネクタ

このコネクタは後ろ側の黒い部分を持ち上げると緩むようになっており、フィルムを差し込んでから下ろすとロックされます。

FPCコネクタ接続後
図8: FPCコネクタ接続後

さて、これらをPCと接続するのには前回と同様にUSB-SPI変換基板を使用します。

USB-SPI変換基板 — スイッチサイエンス

HAT+とUSB-SPI変換基板との接続はCS信号が2本になっているためPmodコネクタだけではピンの数が足りません。USB-SPI変換基板にピンヘッダーを半田付けして、そこのIO7にCS_Sを接続することにしました。今回唯一の半田付け箇所です。

接続表:

e-Paper Pmod 2A MCP2210 Dir
#1(赤)VCC #6(VCC) VCC  
#2(黒)GND #5(GND) GND  
#3(青)DIN #2(MOSI) MOSI Out
#4(黄)SCLK #4(SCLK) SCLK Out
#5(橙)CS_M #1(CS) IO1 Out
#6(緑)CS_S - IO7 Out
#7(白)DC #9(CS2) IO2 Out
#8(紫)RST #8(RESET) IO0 Out
#9(茶)BUSY #7(INT) IO6 In
#10(灰)PWR #10(CS3) IO4 Out

画像の表示

早速PCと接続してプログラムを手直しして実行してみましたが、画像を表示させようとするとリフレッシュが始まって画面が少し書き換わった後に例外が発生して終了してしまいました。画面を白一色で塗りつぶすだけならうまく行くのですが、写真を表示させようとするとダメです。

Waveshareの資料を見ると3.3V/1A以上の電源を推奨と書かれていました。(https://www.waveshare.com/wiki/13.3inch_e-Paper_HAT+_(E)_Manual#Resources )

Question: When the main control board such as STM32, ESP32, and Arduino drives the e-Paper screen, it cannot be driven, and the main control board keeps restarting

Answer: The power supply is insufficient, the current required for the e-Paper screen and the driver board to run is relatively large, it is recommended to use a power supply of 3.3V/1A or more to power it

どうもバスパワーでは足りなそうです。幸いHAT+もUSB-SPI変換基板も5Vに対応しているので、適当なUSB電源アダプタから5Vを取ることにしました(電源の入れっぱなしが気になったので、後にPC連動型のセルフパワーUSBハブを使うようにしました)。こんなこともあろうかと先日スイッチサイエンスでUSB QI Cableを買っておいたのでそれを使います。GNDは共通にして、HAT+のVCCはUSB電源アダプタから取るようにしました。USB-SPI変換基板には5Vと3.5Vを切り替えるスイッチがあるので、それも5V側にしておきます。この辺りは電気に詳しい人なら色々と注意点があるところかもしれません。全ては自己責任でお願いします。

配線
図9: 配線

そうして再度実行すると、無事に画面の書き換えが完了しました。

写真の表示例
図10: 写真の表示例

転送には3~4分 、リフレッシュには20秒くらいかかりますが。

表示に使ったコードは次の通り。色々作りかけです。

# Usage: python epaper_print.py <imagefile> import argparse import time import math import logging import mcp2210 import hid from abc import ABC, abstractmethod from typing import Sequence, Iterable from PIL import Image, ImagePalette logger = logging.getLogger(__name__) logger.setLevel(logging.DEBUG) # Bridge  class EPaperBridge(ABC): """ホストコンピュータからEPaperディスプレイへの通信を担うブリッジ""" @abstractmethod def close(self): ... @abstractmethod def power_on(self): ... @abstractmethod def reset(self): ... @abstractmethod def power_off(self): ... @abstractmethod def wait_until_not_busy(self): ... @abstractmethod def select_driver_chips(self, chip_numbers: Iterable[int]): ... @abstractmethod def send_command_code(self, _command_code: int): ... @abstractmethod def send_data_bytes(self, _data_bytes: bytes): ... def send_data(self, data: int | Sequence[int]): if isinstance(data, int): self.send_data_bytes(bytes([data])) elif isinstance(data, bytes): self.send_data_bytes(data) else: self.send_data_bytes(bytes(data)) def send_command(self, command_code: int, *params: int | Sequence[int]): self.on_command_start() self.send_command_code(command_code) for p in params: self.send_data(p) self.on_command_end() def on_command_start(self): pass def on_command_end(self): pass class EPaperBridgeMCP2210(EPaperBridge): """MCP2210を使用したブリッジ""" @staticmethod def is_mcp2210(device_dict): MCP2210_VENDOR_ID = 0x04d8 MCP2210_PRODUCT_ID = 0x00de return (device_dict.get("product_id", -1) == MCP2210_PRODUCT_ID and device_dict.get("vendor_id", -1) == MCP2210_VENDOR_ID) @staticmethod def enumerate_mcp2210_devices(): return list(filter(EPaperBridgeMCP2210.is_mcp2210, hid.enumerate())) @staticmethod def find_mcp2210_device(): device_dicts = EPaperBridgeMCP2210.enumerate_mcp2210_devices() if len(device_dicts) == 0: raise RuntimeError("No MCP2210 devices found") elif len(device_dicts) >= 2: raise RuntimeError("Multiple MCP2210 devices found") return device_dicts[0]["serial_number"] def __init__(self): serial_number = EPaperBridgeMCP2210.find_mcp2210_device() self._pin_dc = 2 self._pin_com_led = 3 self._pin_busy = 6 self._pin_rst = 0 self._pin_pwr = 4 self._cs_pins = [1, 7] self._cs_pins_selected = 1 << self._cs_pins[0] # Select chip#0  #self._reset_wait_times = (0.2, 0.002, 0.2)  self._reset_wait_times = (0.03, 0.03, 0.03, 0.03, 0.03) self._mcp = mcp2210.Mcp2210(serial_number, immediate_gpio_update=False) # self._mcp._spi_settings.bit_rate = 12000000  self.init_mcp2210_for_paper() def close(self): self._mcp._hid.close() def init_mcp2210_for_paper(self): self._mcp.configure_spi_timing(chip_select_to_data_delay=0, last_data_byte_to_cs=0, delay_between_bytes=0) self.clear_mcp2210_gpio_status() # 13in3eはMCP2210のチップセレクト機能を使うと動作しない。  # 明示的にチップセレクト信号を制御する。  # for pin in self._cs_pins:  # self._mcp.set_gpio_designation(  # pin, mcp2210.Mcp2210GpioDesignation.CHIP_SELECT)  for pin in self._cs_pins: self._mcp.set_gpio_output_value(pin, True) self._mcp.set_gpio_direction(self._pin_busy, mcp2210.Mcp2210GpioDirection.INPUT) self._mcp.set_gpio_output_value(self._pin_rst, True) self._mcp.set_gpio_output_value(self._pin_com_led, True) self._mcp.gpio_update() def clear_mcp2210_gpio_status(self): for pin_number in range(9): self._mcp.set_gpio_designation(pin_number, mcp2210.Mcp2210GpioDesignation.GPIO) self._mcp.set_gpio_direction(pin_number, mcp2210.Mcp2210GpioDirection.OUTPUT) self._mcp.set_gpio_output_value(pin_number, False) def power_on(self): self._mcp.set_gpio_output_value(self._pin_pwr, True) self._mcp.gpio_update() time.sleep(0.2) def reset(self): reset_value = True for wait_time in self._reset_wait_times: self._mcp.set_gpio_output_value(self._pin_rst, reset_value) self._mcp.gpio_update() reset_value = not reset_value time.sleep(wait_time) self.wait_until_not_busy() def power_off(self): self._mcp.set_gpio_output_value(self._pin_rst, False) self._mcp.set_gpio_output_value(self._pin_dc, False) self._mcp.set_gpio_output_value(self._pin_pwr, False) self._mcp.gpio_update() # self.clear_mcp2210_gpio_status(mcp)  # self._mcp.gpio_update()  def wait_until_not_busy(self): start_time = time.time() logger.debug("Start waiting (busy=%s)" % self._mcp.get_gpio_value(self._pin_busy)) # time.sleep(0.1)  while(self._mcp.get_gpio_value(self._pin_busy) == False): time.sleep(0.005) logger.debug("End waiting (wait time=%s seconds)" % (time.time() - start_time)) def select_driver_chips(self, chip_numbers: Iterable[int]): bits = 0 for chip in chip_numbers: bits |= 1 << self._cs_pins[chip] self._cs_pins_selected = bits def send_command_code(self, command_code: int): self._mcp.set_gpio_output_value(self._pin_dc, False) self._mcp.gpio_update() self.my_spi_exchange(bytes([command_code])) def send_data_bytes(self, data_bytes: bytes): self._mcp.set_gpio_output_value(self._pin_dc, True) self._mcp.gpio_update() self.my_spi_exchange(data_bytes) def on_command_start(self): for pin in range(9): if self._cs_pins_selected & (1 << pin): self._mcp.set_gpio_output_value(pin, False) self._mcp.gpio_update() def on_command_end(self): for pin in range(9): if self._cs_pins_selected & (1 << pin): self._mcp.set_gpio_output_value(pin, True) self._mcp.gpio_update() # -------------------------------------------------------------------  # Derived from mcp2210.py  # mcp2210.pyのspi_exchangeは一つのCSを指定しなければならないので改造する  # Mcp2210Commands  TRANSFER_SPI_DATA = 0x42 # Mcp2210CommandResult  SUCCESS = 0x00 SPI_DATA_NOT_ACCEPTED = 0xF7 TRANSFER_IN_PROGRESS = 0xF8 # Mcp2210SpiTransferStatus  SPI_TRANSFER_COMPLETE = 0x10 SPI_TRANSFER_PENDING_NO_RECEIVED_DATA = 0x20 SPI_TRANSFER_PENDING_RECEIVED_DATA_AVAILABLE = 0x30 # Derived from spi_exchange()  def my_spi_exchange(self, payload: bytes) -> bytes: # -- CHANGE BEGIN --  mcp = self._mcp # cs_pin_bits = self._cs_pins_selected  # mcp._spi_settings.active_chip_select_value = 0x01FF ^ cs_pin_bits  mcp._spi_settings.active_chip_select_value = 0x01FF # -- CHANGE END --  mcp._spi_settings.transfer_size = len(payload) mcp._set_spi_configuration() chunked_payload = [] for i in range(math.ceil(len(payload) / 60)): start_index = i * 60 stop_index = (i + 1) * 60 chunk = bytes(payload[start_index:stop_index]) chunked_payload.append(chunk) chunk_index = 0 received_data = [] while 1: if chunk_index == len(chunked_payload): next_chunk = b'' else: next_chunk = chunked_payload[chunk_index] request = [EPaperBridgeMCP2210.TRANSFER_SPI_DATA, len(next_chunk), 0x00, 0x00] response = mcp._execute_command(bytes(request) + next_chunk, check_return_code=False) if response[1] == EPaperBridgeMCP2210.SPI_DATA_NOT_ACCEPTED: raise mcp2210.Mcp2210SpiBusLockedException elif response[1] == EPaperBridgeMCP2210.TRANSFER_IN_PROGRESS: # TODO: このあたりでC-cによって例外が起きると、続くshutdownが機能しない。  time.sleep(0.005) continue elif response[1] == EPaperBridgeMCP2210.SUCCESS: # data was accepted, move to next chunk  chunk_index += 1 receive_data_size = response[2] spi_transfer_status = response[3] if spi_transfer_status == EPaperBridgeMCP2210.SPI_TRANSFER_PENDING_NO_RECEIVED_DATA: continue elif spi_transfer_status == EPaperBridgeMCP2210.SPI_TRANSFER_PENDING_RECEIVED_DATA_AVAILABLE: received_data.append(response[4:receive_data_size + 4]) continue elif spi_transfer_status == EPaperBridgeMCP2210.SPI_TRANSFER_COMPLETE: received_data.append(response[4:receive_data_size + 4]) break else: raise mcp2210.Mcp2210CommandFailedException("Encountered unknown SPI transfer status") else: raise mcp2210.Mcp2210CommandFailedException("Received return code 0x{:02X} from device".format(response[1])) combined_receive_data = b''.join(bytes(x) for x in received_data) if len(combined_receive_data) != len(payload): raise RuntimeError("Length of receive data does not match transmit data") return combined_receive_data # -------------------------------------------------------------------  # EPaperDisplay  class EPaperDisplayWaveshare(ABC): "Waveshare製EPaperディスプレイモジュール(HAT)の基底クラス" def __init__(self, bridge: EPaperBridge, width: int, height: int, bits_per_pixel: int, palette: bytes, default_pixel_value: int, all_driver_chip_numbers: Sequence[int]): self._bridge = bridge self._width = width self._height = height self._bits_per_pixel = bits_per_pixel self._palette = palette self._default_pixel_value = default_pixel_value self._all_driver_chip_numbers = all_driver_chip_numbers self.init() # Initialization  def init(self): try: self._bridge.power_on() self._bridge.reset() self.init_driver_chips() except Exception: self._bridge.power_off() raise @abstractmethod def init_driver_chips(self): ... # Shutdown  def shutdown(self): try: self.shutdown_driver_chips() finally: self._bridge.power_off() def shutdown_driver_chips(self): logger.debug("Shutdown driver chips") self._bridge.wait_until_not_busy() self.select_all_driver_chips() # # 02:Power OFF Command  # self.power_off_panel()  # 07:Deep Sleep Command  self._bridge.send_command(0x07, 0xA5) time.sleep(2) # Panel Power Control  def power_on_panel(self): # 04:Power ON Command  self._bridge.send_command(0x04) self._bridge.wait_until_not_busy() def power_off_panel(self): # 02:Power OFF Command  self._bridge.send_command(0x02, 0x00) self._bridge.wait_until_not_busy() # Driver Chip Selection  def select_all_driver_chips(self): """コマンドの送信先を全ての駆動チップとします。""" self._bridge.select_driver_chips(self._all_driver_chip_numbers) def select_driver_chip(self, driver_chip_number): """コマンドの送信先を指定されたチップのみとします。""" self._bridge.select_driver_chips([driver_chip_number]) # Frame  @abstractmethod def set_frame_bytes(self, frame_bytes: bytes): ... @property def width(self) -> int: """パネル全体の水平方向のピクセル数です。""" return self._width @property def height(self) -> int: """パネル全体の垂直方向のピクセル数です。""" return self._height @property def default_pixel_value(self) -> int: """デフォルトのピクセル値です。基本的に「白」を意味する値です。""" return self._default_pixel_value @property def frame_bits_per_pixel(self) -> int: """ピクセルあたりのビット数です。基本的に8以下の値です。""" return self._bits_per_pixel @property def frame_pixels_per_byte(self) -> int: """1バイトあたりのピクセル数です。""" return 8 // self._bits_per_pixel def make_filled_frame_byte(self, pixel_value: int): """1バイトの中を指定されたpixel_valueで満たしたものを返します。""" bpp = self.frame_bits_per_pixel pixel_value = pixel_value & ((1 << bpp) - 1) frame_byte = 0 bitpos = 8 - bpp while bitpos >= 0: frame_byte |= pixel_value << bitpos bitpos = bitpos - bpp return frame_byte @property def frame_line_nbytes(self) -> int: """フレームバッファの1行のバイト数を返します。""" return ((self._width + self.frame_pixels_per_byte - 1) // self.frame_pixels_per_byte) @property def frame_nbytes(self) -> int: """フレームバッファのバイト数を返します。""" return self.frame_line_nbytes * self.height def fill_frame_with_byte(self, frame_byte: int): """フレームの全バイトをframe_byteで満たします。""" self.set_frame_bytes(bytes([frame_byte]) * self.frame_nbytes) def fill_frame(self, pixel_value: int): """フレームの全ピクセルをpixel_valueで塗りつぶします。""" self.fill_frame_with_byte(self.make_filled_frame_byte(pixel_value)) def clear_frame(self): """フレームの全ピクセルを白一色で塗りつぶします。""" self.fill_frame(self.default_pixel_value) def frame_image_palette(self) -> ImagePalette.ImagePalette: return ImagePalette.ImagePalette("RGB", self._palette) def convert_image_to_frame_bytes(self, image: Image.Image) -> bytes: """imageをselfの仕様に合わせたフレームバイト列へ変換します。""" palette_image = Image.new("P", (1, 1)) palette_image.putpalette(self.frame_image_palette()) quantized_image = image.convert("RGB").quantize(palette=palette_image) src_bytes = quantized_image.getdata() src_w, src_h = quantized_image.size return bytes(EPaperDisplayWaveshare.convert_pimage_bytes_to_frame_bytes( src_bytes, src_w, 0, 0, src_w, src_h, self.width, self.height, self.frame_bits_per_pixel)) @staticmethod def convert_pimage_bytes_to_frame_bytes( src_bytes, src_pitch: int, src_x: int, src_y: int, src_w: int, src_h: int, dst_w: int, dst_h: int, dst_bits_per_pixel: int) -> bytearray: scan_w = min(src_w, dst_w) scan_h = min(src_h, dst_h) dst_line_nbytes = (dst_w // (8 // dst_bits_per_pixel)) dst_pitch = dst_line_nbytes dst_bytes = bytearray(dst_line_nbytes * dst_h) for y in range(scan_h): src = src_pitch * (src_y + y) + src_x dst = dst_pitch * y dst_bitpos = 8 dst_byte = 0 dst_bitmask = (1 << dst_bits_per_pixel) - 1 for x in range(scan_w): dst_bitpos -= dst_bits_per_pixel dst_byte |= ((int(src_bytes[src + x]) & dst_bitmask) << dst_bitpos) if dst_bitpos < dst_bits_per_pixel: dst_bytes[dst] = dst_byte dst = dst + 1 dst_bitpos = 8 dst_byte = 0 if dst_bitpos < 8: dst_bytes[dst] = dst_byte return dst_bytes # Panel Update  def update_panel(self): """現在のフレームをディスプレイの表示に反映します。""" logger.debug("Refresh Start") self.select_all_driver_chips() # 04:Power ON Command  self.power_on_panel() # 12:Display Refresh Command  self._bridge.send_command(0x12, 0x00) self._bridge.wait_until_not_busy() # 02:Power OFF Command  self.power_off_panel() logger.debug("Refresh End") def show_image(self, image: Image.Image): """imageを表示します。""" self.set_frame_bytes(self.convert_image_to_frame_bytes(image)) self.update_panel() class EPaperDisplayWaveshare3in5G(EPaperDisplayWaveshare): """Waveshare 3.5インチ(G)""" def __init__(self, bridge): super().__init__( bridge = bridge, width = 184, height = 384, bits_per_pixel = 2, palette = bytes((0,0,0)+(255,255,255)+(255,255,0)+(255,0,0)+ (0,0,0)*252), default_pixel_value = 1, all_driver_chip_numbers = [0] ) def init_driver_chips(self): # See: epd3in5g.py -> EPD -> init  # ??  self._bridge.send_command(0x66, 0x49, 0x55, 0x13, 0x5D, 0x05, 0x10) # ??  self._bridge.send_command(0x4D, 0x78) # 00:Panel setting Register  self._bridge.send_command(0x00, 0x0F, 0x29) # 01:Power setting Register  self._bridge.send_command(0x01, 0x07, 0x00) # 03:Power OFF Sequence Setting Register  self._bridge.send_command(0x03, 0x10, 0x54, 0x44) # 06:Booster Soft Start Command  self._bridge.send_command(0x06, 0x0F, 0x0A, 0x2F, 0x25, 0x22, 0x2E, 0x21) # 50:VCOM and DATA Interval setting Register  self._bridge.send_command(0x50, 0x37) # ??  self._bridge.send_command(0x60, 0x02, 0x02) # 61:Resolution setting  self._bridge.send_command(0x61, self._width>>8, self._width&255, self._height>>8, self._height&255) # ??  self._bridge.send_command(0xE7, 0x1C) # E3:Power Saving Register  self._bridge.send_command(0xE3, 0x22) # ??  self._bridge.send_command(0xB6, 0x6F) self._bridge.send_command(0xB4, 0xD0) self._bridge.send_command(0xE9, 0x01) # 30:PLL Control Register  self._bridge.send_command(0x30, 0x08) # # 04:Power ON Command  # self.power_on_panel()  # Frame  def set_frame_bytes(self, frame_bytes: bytes): """frame_bytesを電子ペーパーに転送します(表示しない)。""" # 10:Data Start Transmission Register  self._bridge.send_command(0x10, frame_bytes) class EPaperDisplayWaveshare13in3E(EPaperDisplayWaveshare): """Waveshare 13.3インチ(E)""" def __init__(self, bridge): super().__init__( bridge = bridge, width = 1200, height = 1600, bits_per_pixel = 4, palette = bytes((0,0,0)+ (255,255,255)+ (255,255,0)+ (255,0,0)+ (0,0,0)+ (0,0,255)+ (0,255,0)+ (0,0,0)*249), default_pixel_value = 1, all_driver_chip_numbers = [0, 1] ) def init_driver_chips(self): # See: epd13in3E.py -> EPD -> init  # (Master)  self.select_driver_chip(0) self._bridge.send_command(0x74, 0xC0, 0x1C, 0x1C, 0xCC, 0xCC, 0xCC, 0x15, 0x15, 0x55) # (Master and Slave)  self.select_all_driver_chips() self._bridge.send_command(0xF0, 0x49, 0x55, 0x13, 0x5D, 0x05, 0x10) self._bridge.send_command(0x00, 0xDF, 0x69) self._bridge.send_command(0x50, 0xF7) self._bridge.send_command(0x60, 0x03, 0x03) self._bridge.send_command(0x86, 0x10) self._bridge.send_command(0xE3, 0x22) # E3:Power Saving Register  self._bridge.send_command(0xE0, 0x01) self._bridge.send_command(0x61, 0x04, 0xB0, 0x03, 0x20) # 61:Resolution setting  # (Master)  self.select_driver_chip(0) self._bridge.send_command(0x01, 0x0F, 0x00, 0x28, 0x2C, 0x28, 0x38) self._bridge.send_command(0xB6, 0x07) self._bridge.send_command(0x06, 0xE8, 0x28) self._bridge.send_command(0xB7, 0x01) self._bridge.send_command(0x05, 0xE8, 0x28) self._bridge.send_command(0xB0, 0x01) self._bridge.send_command(0xB1, 0x02) # Frame  def set_frame_bytes(self, frame_bytes: bytes): """frame_bytesを電子ペーパーに転送します(表示しない)。""" line_pitch = self.frame_line_nbytes line_half = self.frame_line_nbytes // 2 height = self.height frame_nbytes = self.frame_nbytes # 10:Data Start Transmission Register  self.select_driver_chip(0) self._bridge.on_command_start() self._bridge.send_command_code(0x10) for y in range(height): print(f"\r{line_half*y}/{frame_nbytes}", end="", flush=True) self._bridge.send_data( frame_bytes[y * line_pitch : y * line_pitch + line_half]) self._bridge.on_command_end() self.select_driver_chip(1) self._bridge.on_command_start() self._bridge.send_command_code(0x10) for y in range(height): print(f"\r{line_half*(height+y)}/{frame_nbytes}", end="", flush=True) self._bridge.send_data( frame_bytes[y * line_pitch + line_half : y * line_pitch + line_half + line_half]) self._bridge.on_command_end() print(f"\r{frame_nbytes}/{frame_nbytes}", flush=True) # Main  def main(): parser = argparse.ArgumentParser( prog="epaper_print", usage="python epaper_print.py [options]") parser.add_argument("filename", help="image file name") # TODO: Add options for specify device types  # parser.add_argument("--bridge") #--bridge=mcp2210:serial=0002193217:pin_dc=2:pin_pwr=4  # parser.add_argument("--epaper") #--epaper=waveshare13in3e  # TODO: Add panel clear option  # parser.add_argument("--clear")  # TODO: Add forced power off option  # parser.add_argument("--poweroff")  # TODO: Add image rotation option  # parser.add_argument("--rotate")  # TODO: Add verbose option  # parser.add_argument("--verbose")  args = parser.parse_args() logger.debug("Load image") im = Image.open(args.filename) logger.debug("Rotate image") im = im.rotate(-90, expand=True) logger.debug("Create bridge") bridge = EPaperBridgeMCP2210() logger.debug("Create EPaperDisplay") # epaper = EPaperDisplayWaveshare3in5G(bridge)  epaper = EPaperDisplayWaveshare13in3E(bridge) try: logger.debug("Resize image") im = im.resize((epaper.width, epaper.height)) logger.debug("Show image") epaper.show_image(im) # epaper.fill_frame_with_byte(0xaf)  # epaper.update_panel()  # epaper.clear_frame()  # epaper.update_panel()  finally: logger.debug("Shutdown") try: # TODO: 転送中等にC-cで中断するとうまくshutdownできないことがある。パネルの電源が入りっぱなしになると良くない。  epaper.shutdown() finally: bridge.close() logger.debug("End") if __name__ == "__main__": main() 

前回の3.5インチモジュールとの一番の違いは、画面が左右二つに分割されていて制御するチップも二つに分かれていることです。そのためCS信号は二つ(CS_MとCS_S)あります。どちらかを選んでコマンドを送信することもありますし、二つのチップに同時に送信することもあります。詳しくはDouble-IC Programming Analysisを参照してください。

また、6色ディスプレイなので画像データの形式も違います。有効なピクセル値は 0:黒, 1:白, 2:黄, 3:赤, 4:青, 5:緑 の6通りです。1ピクセル4ビットで1バイトに2ピクセル入ります(左詰)。パネル全体の解像度は1200×1600ですが、上述のように左右で分割されているため600×1600を2回に分けて(CSを変えて)送信する必要があります。全体のバイト数は1200×1600/2=960000バイトになります。

細かい点では、初期化シーケンス、リセットタイミング、CS信号の出し方にも違いがありました。パネルの電源のON/OFFタイミングもサンプルコードレベルでは違いましたが、3.5インチでも13.3インチのやり方で問題なかったので13.3インチのやり方で統一しました(リフレッシュ前後でON/OFFする)。CS信号の出し方はmcp2210-pythonライブラリでは対応できない部分があったので、上のソースコードでは無理矢理解決しています。

ドキュメント化されていない仕様はWaveshareのサンプルコードが参考になります。

e-Paper/E-paper_Separate_Program/13.3inch_e-Paper_E/RaspberryPi/python at master · waveshareteam/e-Paper

ケース作り

無事に表示できたのは良いのですが、パネルや基板がむき出しでは扱いづらくて仕方ありません。何か手頃なケースは無いかとダイソーに探しに行きました。

最初はスチレンボードを切り貼りして額を作ろうかなと思ったのですが、A4のフォトフレームがちょうど良さそうな大きさでした。

フォトフレーム(A4、クリアファイル対応、白・黒) - 100均 通販 ダイソーネットストア【公式】

試しに買って帰ってはめ込んでみたところほぼピッタリでした。

ただし電子ペーパーパネルはA4よりも若干長辺が短いので窓からフィルム基板部分が見えてしまいます。この辺りは後でカバーでも作りましょう。

それとフィルム基板のコネクタ部分が枠内に収まりません。無理矢理曲げても良いのかもしれませんが、心配だったので枠をカットしました。この素材はMDF(中密度繊維板)というのでしょうか? カッターでサクサク切れました。

ダイソーのフォトレームを削った様子
図11: ダイソーのフォトレームを削った様子
ダイソーのフォトフレームに収めた様子
図12: ダイソーのフォトフレームに収めた様子

こうなると基板がプラプラしているのが大変邪魔です。テープでべたっと覆ってしまっても良いのですが、これもダイソーに手頃なケースがあったのでそれに入れてみました。

プチプラケース L - 100均 通販 ダイソーネットストア【公式】

若干高さが足りなかったのでCS_Sのピンを折り曲げて無理矢理収めました。

ケース内
図13: ケース内

後はポリプロピレンに使える両面テープでケースごと裏面に貼り付け。

フォトフレーム背面
図14: フォトフレーム背面

org-modeのagendaを表示する

で、一番やりたかったのがorg-agendaを表示すること。

色々調整した結果、次のように無事表示できました。

Org Agendaを表示した例
図15: Org Agendaを表示した例

写真だとそれほどでもありませんが、肉眼で見るとコントラスト比の低さは気になりますね。白が結構黒いです。

Org Agendaを表示した例
図16: Org Agendaを表示した例

次のMakefileでagendaのHTML生成、作業ディレクトリへコピー、画像化、表示までを行えます。(あらかじめorg-agenda-custom-commandsにhtmlファイル名を指定してorg-batch-store-agenda-viewsでhtmlファイルがエクスポートされるようにしておく必要があります)

EMACS = C:/my-program-dir/emacs-30.2/bin/emacs CHROME = "C:/Program Files/Google/Chrome/Application/chrome.exe" AGENDA_HTML_SRC = ~/my-org-agenda-html/agenda.html MAGICK = magick EPAPER_PRINT = python epaper_print.py .PHONY: all all: update-agenda upload .PHONY: update-agenda update-agenda: $(EMACS) -batch -l ~/.emacs.d/init.el -eval '(org-batch-store-agenda-views)' tmp-agenda.html: $(AGENDA_HTML_SRC) cp $< $@ tmp-agenda.png: tmp-agenda.html $(CHROME) --headless --screenshot=$(abspath tmp-agenda.png) --window-size=1600,1300 --force-device-scale-factor=1 --hide-scrollbars $(abspath tmp-agenda.html) $(MAGICK) $@ -crop 1600x1200+0+0 +repage $@ # ↑下に空白が空いてしまう(95px)ので大きめに作ってImageMagickでカットする。 # https://www.reddit.com/r/chrome/comments/1jsa174/chrome_headless_screenshot_omitting_bottom_95/ # https://issues.chromium.org/issues/405165895  .PHONY: upload upload: tmp-agenda.png $(EPAPER_PRINT) tmp-agenda.png .PHONE: clean clean: rm tmp-agenda.png rm tmp-agenda.html 

予定表の見た目はEmacsとCSSの双方をうまく調整してやる必要があります。

私のEmacs側の設定は Org Agendaに天気・日の出日の入・月の状態を表示する に書きました。

そこではorg-agenda-custom-commands(org-agenda-export-html-style "<link rel=\"stylesheet\" type=\"text/css\" href=\"agenda.css\">") という指定を入れてあるので、htmlと同じディレクトリにある agenda.css が参照されます。

電子ペーパーに特化した調整をしたかったので、Makefileがあるディレクトリに電子ペーパー用CSSを配置し、そこに一時的にhtmlをコピーしてからChromeで画像化しています。

実際に使ったCSSは次の通りです。

/* agenda.css: org-agenda電子ペーパー用CSS */ html { margin: 0; padding: 0; } body { margin: 0; padding: 0; overflow: hidden; line-height: 1.5; /* 文字 */ /*-webkit-font-smoothing: none;*/ font-family:"MS Gothic", "Noto Sans JP", monospace; font-size: 26px; color: #000000; background-color: #ffffff; } body>pre { margin: 0; padding: 10px; font-family: inherit; /* 高さ固定2段組 */ height: 100vh; box-sizing: border-box; column-width: calc((100vw - 10px - 20px - 10px) / 2); column-gap: 20px; /* 折り返しの回避 */ white-space: pre; text-overflow: ellipsis; overflow: hidden; } /* ハイパーリンクの装飾を取消 */ a { color: inherit; background-color: inherit; font: inherit; text-decoration: none; } /* タイトル */ .org-agenda-structure { font-weight: bold; color: #ffffff; background-color: #102e80; padding: 4px 10px; } /* アジェンダ行TODOキーワード・タグ・優先度等 */ .org-todo, .org-modern-todo { color: #c00000; font-size: 75%; display: none; } .org-done, .org-modern-done { color: #20c060; font-size: 75%; } .org-tag, .org-modern-tag { border: 1px solid #555; font-size: 60%; color: #555; } .org-priority, .org-modern-priority { color: #ff00ff; } .org-hide { /* color: #ffffff; font-size: 8px; */ display: none; } /* アジェンダ行内容 */ .org-agenda-done {color: #20c060;} .org-agenda-diary {color: #c00040;} .org-agenda-calendar-sexp {color: #506090;} .org-scheduled {} .org-scheduled-today {} .org-scheduled-previously {color: #c00000;} .org-upcoming-deadline {color: #ff0000;} .org-imminent-deadline {color: #ff0000; font-weight: bold;} /* タイムグリッド */ .org-time-grid {color: #506090;} .org-agenda-current-time {color: #506090; font-weight: bold;} /* 日付行(日付・曜日・付加情報) */ :root { --date-color: #000; --date-color-sat: #04f; --date-color-sun: #c00; } .org-agenda-date, .my-org-agenda-date-saturday, .org-agenda-date-weekend { font-size: 125%; font-weight: bold; border-left: 10px solid currentColor; padding-left: 4px; /* padding-top: 4px; */ } .org-agenda-date {color: var(--date-color);} .my-org-agenda-date-saturday {color: var(--date-color-sat);} .org-agenda-date-weekend {color: var(--date-color-sun);} .my-org-agenda-dow, .my-org-agenda-dow-saturday, .my-org-agenda-dow-weekend { font-size: 70%; font-weight: bold; padding-left: 2px; vertical-align: 2px; } .my-org-agenda-dow {color: var(--date-color);} .my-org-agenda-dow-saturday {color: var(--date-color-sat);} .my-org-agenda-dow-weekend {color: var(--date-color-sun);} .my-org-agenda-date-info { /* 付加情報 */ font-size: 77%; margin-left: 8px; } .my-org-agenda-date-info img[src*="/.jma-weather-cache/"] { /* 天気画像 */ height: 2em; vertical-align: -30%; } .my-org-agenda-date-info img { /* 月画像 */ height: 1.3em; vertical-align: -8%; } 

フォントはMS Gothicを使うとアンチエイリアシングが無くてシャープだったのでそれを使っています。

org-agendaがエクスポートするHTMLは一つの巨大なpre要素なので、文書構造を意識したスタイル指定は困難です。ただ、Emacs側のface名はHTMLのspan class=として反映されます。見た目を変えるためにEmacs側の挙動を変更する必要が多々ありました。

課題

一番の課題は転送速度の遅さでしょうか。3~4分はさすがに長すぎるような気もします。まぁ、1時間に1回更新するくらいならさほど問題にはなりませんが。MCP2210でも使い方によっては改善できるでしょうか。それともFT232Hのようなものを使った方が良いでしょうか。

2025-11-06 ,

PCから電子ペーパーを制御して遊ぶ(Waveshare 3.5インチ4色)

前々から電子ペーパーには興味があったのですが、なかなか手軽に遊べる製品が無くて二の足を踏んでいました。

とにかく高すぎるんですよ。ペーパー、紙というイメージとはほど遠い価格です。まぁ、所詮は微細加工技術で作った特殊なディスプレイに過ぎません。

それに良い製品があまりないということもあります。これも紙のように何にでも使えるというイメージからはほど遠い状態です。

私が欲しかったのは紙の代わりに電子的に「印刷」できるデバイスです。単にPCにUSBで接続して、任意の画像が表示できればそれだけで良いのです。それに予定表とかカレンダーとかを表示させてPCの電源が入っていなくても常に表示させておけるようなものが欲しかったのです。たかだかそれだけのために高価なペンデバイス付きのタブレットを買おうとは思いません。最近は7インチくらいであればデジタルフォトフレームとして使える電子ペーパー製品も探せば見つかるようにはなってきました。もうしばらく待てば安価で良い製品が手に入るようになるかもしれませんが、現状ではまだまだといったところです。

手に入らないのであれば自分で作るしかありません。幸い電子工作に使うための電子ペーパーモジュールはいろんな所で見かけます。

それらはRaspberry Pi等のマイコンボードから制御することを想定しているようですが、私はいつも疑問に思います。目の前にPCがあるのになんでそんなものから制御しなきゃならないのかと。PCのUSBに接続して制御できないものかと。

お買い物

というわけで条件に合う製品を探したところ、次のものが見つかりました。

Waveshareというメーカーの電子ペーパーモジュール(ドライバー基板付き)はSPIというシリアル通信方式(+いくつかの汎用IO)で制御できるみたいです。まずは手始めということで少し小さい3.5インチ(4色タイプ)のを選んでみました。失敗しても出費が痛くないので。

そしてそれをPCから制御するために選んだのがこのUSB-SPI変換基板です。PCからUSB経由で9つのGPIOと1つのSPIポートが制御可能です。この基板はMCP2210というチップを積んでいてPCからはHIDデバイスとして認識されるので専用のデバイスドライバーは不要なのだとか。その代わり大きなデータは64バイトのHIDレポートに分割して送信しなければならないので転送速度はあまり出ないようです。あまりスピードを重視しない目的であれば十分使えるでしょう。

ピンヘッダはUSB-SPI変換基板と電子ペーパーモジュール(Waveshare3.5インチ(G))を接続するのに必要になるものです。USB-SPI変換基板のPmodコネクタはメスで、電子ペーパーモジュールに付属するケーブルのコネクタもメスなので、間に挟むものが必要になります。

カートに入れて注文、支払いはGoogle Payで。営業日の午後に発送されて翌日ポストに入っていました。

Waveshare 3.5inch e-Paper Module(G)とCrescent USB-SPI変換基板
図1: Waveshare 3.5inch e-Paper Module(G)とCrescent USB-SPI変換基板

接続

USB-SPI変換基板上には二つのコネクタがあります。一つは普通のUSB Type-Cコネクタです。PCと接続するために使います。もう一つはPmodという規格(Digilent Pmod Interface Specification)のコネクタです(Type 2A)。MCP2210から制御対象へ接続する信号線のほとんどはこのPmodコネクタに繋がっています。一部余っている信号線(GPIOの一部)は基板に穴だけあってピンやコネクタはありません。3.5インチパネルを制御するだけならPmodコネクタに出ている分だけで足ります。(ちなみに13.3インチ6色カラー(E)だと1本足りません)

USB-SPI変換基板のPmodコネクタ:

#6:VDD #5:GND #4:SCK #3:MISO #2:MOSI #1:IO1
#12:VDD #11:GND #10:IO4 #9:IO2 #8:IO0 #7:IO6

Waveshare側のコネクタ(3.5inch e-Paper Module (G) - Waveshare Wiki):

#9:PWR #8:BUSY #7:RST #6:DC #5:CS #4:SCLK #3:DIN #2:GND #1:VCC

ピンの意味:

ピン名 役割
VCC 電源(3.3 V / 5 V 入力)
GND グラウンド
DIN SPI MOSI
SCLK SPIクロック
CS SPIチップ選択ピン(Lowでアクティブ)
DC データ / コマンド選択(Highでデータ、Lowでコマンド)
RST 外部リセットピン(Lowでアクティブ)
BUSY Busyステータスアウトプットピン
PWR Power on/off 制御
Waveshare 3.5inch e-Paper Module(G) 裏面
図2: Waveshare 3.5inch e-Paper Module(G) 裏面

Waveshareの電子ペーパーモジュールにはいかにもマイコンボードと接続しやすそうなケーブルが付属していました。

そのケーブル(+両方長いピンヘッダ)を使ってこの二つを次のように接続してみました。できるだけPmod規格の意味論に合わせた結線にしてあります。

接続表:

MCP2210 Pmod 2A e-Paper Dir
IO1 #1(CS) #5(CS) Out
MOSI #2(MOSI) #3(DIN) Out
MISO #3(MISO) - -
SCLK #4(SCLK) #4(SCLK) Out
GND #5(GND) #2(GND) -
VCC #6(VCC) #1(VCC) -
IO6 #7(INT) #8(BUSY) In
IO0 #8(RESET) #7(RST) Out
IO2 #9(CS2) #6(DC) Out
IO4 #10(CS3) #9(PWR) Out

となると後はソフトウェアですね。

Python用ライブラリのインストール

今回はPythonを使うことにします。ライブラリが揃っているみたいなので。MCP2210を制御するライブラリがすでにあり、ザッと見た感じ悪く無さそうなのでそれを使うことにしました。

 pip install mcp2210-python 

を実行したら依存しているhidapiパッケージのインストールでエラーが発生しました。(事前にVisual Studio Community 2022がインストール済みです)

 >pip install mcp2210-python Collecting mcp2210-python Downloading mcp2210_python-1.0.8-py3-none-any.whl.metadata (732 bytes) Collecting hidapi (from mcp2210-python) Downloading hidapi-0.14.0.post4.tar.gz (174 kB) Installing build dependencies ... done Getting requirements to build wheel ... done Preparing metadata (pyproject.toml) ... done Collecting setuptools>=19.0 (from hidapi->mcp2210-python) Using cached setuptools-80.9.0-py3-none-any.whl.metadata (6.6 kB) Downloading mcp2210_python-1.0.8-py3-none-any.whl (10 kB) Using cached setuptools-80.9.0-py3-none-any.whl (1.2 MB) Building wheels for collected packages: hidapi Building wheel for hidapi (pyproject.toml) ... error error: subprocess-exited-with-error × Building wheel for hidapi (pyproject.toml) did not run successfully. │ exit code: 1 ╰─> [29 lines of output] C:\Users\misohena\AppData\Local\Temp\pip-build-env-1spmig8n\overlay\Lib\site-packages\setuptools\dist.py:759: SetuptoolsDeprecationWarning: License classifiers are deprecated. !! ******************************************************************************** Please consider removing the following classifiers in favor of a SPDX license expression: License :: OSI Approved :: BSD License License :: OSI Approved :: GNU General Public License v3 (GPLv3) See https://packaging.python.org/en/latest/guides/writing-pyproject-toml/#license for details. ******************************************************************************** !! self._finalize_license_expression() running bdist_wheel running build running build_ext building 'hid' extension creating build\temp.win-amd64-cpython-314\Release\Users\misohena\AppData\Local\Temp\pip-install-ual6i7nx\hidapi_12f0f10a24b04ec4959d911d09e2c27e\hidapi\windows "C:\Program Files\Microsoft Visual Studio\2022\Community\VC\Tools\MSVC\14.44.35207\bin\HostX86\x64\cl.exe" /c /nologo /O2 /W3 /GL /DNDEBUG /MD -IC:\Users\misohena\AppData\Local\Temp\pip-install-ual6i7nx\hidapi_12f0f10a24b04ec4959d911d09e2c27e\hidapi\hidapi -IC:\app\Python314\include -IC:\app\Python314\Include "-IC:\Program Files\Microsoft Visual Studio\2022\Community\VC\Tools\MSVC\14.44.35207\include" "-IC:\Program Files\Microsoft Visual Studio\2022\Community\VC\Tools\MSVC\14.44.35207\ATLMFC\include" "-IC:\Program Files\Microsoft Visual Studio\2022\Community\VC\Auxiliary\VS\include" "-IC:\Program Files (x86)\Windows Kits\10\include\10.0.26100.0\ucrt" "-IC:\Program Files (x86)\Windows Kits\10\\include\10.0.26100.0\\um" "-IC:\Program Files (x86)\Windows Kits\10\\include\10.0.26100.0\\shared" "-IC:\Program Files (x86)\Windows Kits\10\\include\10.0.26100.0\\winrt" "-IC:\Program Files (x86)\Windows Kits\10\\include\10.0.26100.0\\cppwinrt" /TcC:\Users\misohena\AppData\Local\Temp\pip-install-ual6i7nx\hidapi_12f0f10a24b04ec4959d911d09e2c27e\hidapi\windows\hid.c /Fobuild\temp.win-amd64-cpython-314\Release\Users\misohena\AppData\Local\Temp\pip-install-ual6i7nx\hidapi_12f0f10a24b04ec4959d911d09e2c27e\hidapi\windows\hid.obj -DHID_API_NO_EXPORT_DEFINE hid.c "C:\Program Files\Microsoft Visual Studio\2022\Community\VC\Tools\MSVC\14.44.35207\bin\HostX86\x64\cl.exe" /c /nologo /O2 /W3 /GL /DNDEBUG /MD -IC:\Users\misohena\AppData\Local\Temp\pip-install-ual6i7nx\hidapi_12f0f10a24b04ec4959d911d09e2c27e\hidapi\hidapi -IC:\app\Python314\include -IC:\app\Python314\Include "-IC:\Program Files\Microsoft Visual Studio\2022\Community\VC\Tools\MSVC\14.44.35207\include" "-IC:\Program Files\Microsoft Visual Studio\2022\Community\VC\Tools\MSVC\14.44.35207\ATLMFC\include" "-IC:\Program Files\Microsoft Visual Studio\2022\Community\VC\Auxiliary\VS\include" "-IC:\Program Files (x86)\Windows Kits\10\include\10.0.26100.0\ucrt" "-IC:\Program Files (x86)\Windows Kits\10\\include\10.0.26100.0\\um" "-IC:\Program Files (x86)\Windows Kits\10\\include\10.0.26100.0\\shared" "-IC:\Program Files (x86)\Windows Kits\10\\include\10.0.26100.0\\winrt" "-IC:\Program Files (x86)\Windows Kits\10\\include\10.0.26100.0\\cppwinrt" /Tchid.c /Fobuild\temp.win-amd64-cpython-314\Release\hid.obj -DHID_API_NO_EXPORT_DEFINE hid.c hid.c(3161): warning C4267: '=': 'size_t' から 'int' に変換しました。データが失われているかもしれません。 hid.c(4285): warning C4244: '=': 'Py_ssize_t' から 'int' への変換です。データが失われる可能性があります。 creating C:\Users\misohena\AppData\Local\Temp\pip-install-ual6i7nx\hidapi_12f0f10a24b04ec4959d911d09e2c27e\build\lib.win-amd64-cpython-314 "C:\Program Files\Microsoft Visual Studio\2022\Community\VC\Tools\MSVC\14.44.35207\bin\HostX86\x64\link.exe" /nologo /INCREMENTAL:NO /LTCG /DLL /MANIFEST:EMBED,ID=2 /MANIFESTUAC:NO /LIBPATH:C:\app\Python314\libs /LIBPATH:C:\app\Python314 /LIBPATH:C:\app\Python314\PCbuild\amd64 "/LIBPATH:C:\Program Files\Microsoft Visual Studio\2022\Community\VC\Tools\MSVC\14.44.35207\ATLMFC\lib\x64" "/LIBPATH:C:\Program Files\Microsoft Visual Studio\2022\Community\VC\Tools\MSVC\14.44.35207\lib\x64" "/LIBPATH:C:\Program Files (x86)\Windows Kits\10\lib\10.0.26100.0\ucrt\x64" "/LIBPATH:C:\Program Files (x86)\Windows Kits\10\\lib\10.0.26100.0\\um\x64" setupapi.lib /EXPORT:PyInit_hid build\temp.win-amd64-cpython-314\Release\Users\misohena\AppData\Local\Temp\pip-install-ual6i7nx\hidapi_12f0f10a24b04ec4959d911d09e2c27e\hidapi\windows\hid.obj build\temp.win-amd64-cpython-314\Release\hid.obj /OUT:build\lib.win-amd64-cpython-314\hid.cp314-win_amd64.pyd /IMPLIB:build\temp.win-amd64-cpython-314\Release\Users\misohena\AppData\Local\Temp\pip-install-ual6i7nx\hidapi_12f0f10a24b04ec4959d911d09e2c27e\hidapi\windows\hid.cp314-win_amd64.lib LINK : fatal error LNK1104: ファイル 'build\temp.win-amd64-cpython-314\Release\Users\misohena\AppData\Local\Temp\pip-install-ual6i7nx\hidapi_12f0f10a24b04ec4959d911d09e2c27e\hidapi\windows\hid.cp314-win_amd64.exp' を開くことができません。 error: command 'C:\\Program Files\\Microsoft Visual Studio\\2022\\Community\\VC\\Tools\\MSVC\\14.44.35207\\bin\\HostX86\\x64\\link.exe' failed with exit code 1104 [end of output] note: This error originates from a subprocess, and is likely not a problem with pip. ERROR: Failed building wheel for hidapi Failed to build hidapi error: failed-wheel-build-for-install × Failed to build installable wheels for some pyproject.toml based projects ╰─> hidapi 

何かをVC++でビルドしようとして.expファイルが無いからエラーになっているようですね。そしてそのパスが極めて奇妙。テンポラリディレクトリっぽいのに、あり得ないパスになっています。ははぁ、これは C:\ のせいだな……。

 mkdir C:\tmp set TEMP=\tmp set TMP=\tmp pip install hidapi 

としたら無事にインストール成功。続く

 pip install mcp2210-python 

も成功しました。

デバイスの列挙

試しにUSB-SPI変換基板にアクセスしてみます。まずはHIDデバイスの列挙。

import hid for device_dict in hid.enumerate(): keys = list(device_dict.keys()) keys.sort() for key in keys: print("%s : %s" % (key, device_dict[key])) print() 

実行してみるとHIDデバイスの一覧が表示されます。マウスやキーボードなんかが出てきますね。その中に次のものがありました。

 bus_type : 1 interface_number : 0 manufacturer_string : Microchip Technology Inc. path : b'\\\\?\\HID#VID_04D8&PID_00DE#6&10c7683d&0&0000#{4d1e55b2-f16f-11cf-88cb-001111000030}' product_id : 222 product_string : MCP2210 USB to SPI Master release_number : 2 serial_number : 0002193217 usage : 1 usage_page : 65280 vendor_id : 1240 

mcpP2210-pythonでIO3に接続されているLEDを点滅させる

USB-SPI変換基板のIO3にはLEDが接続されているので、ためしにそれを点滅させてみましょう。

import time from mcp2210 import Mcp2210, Mcp2210GpioDesignation, Mcp2210GpioDirection mcp = Mcp2210(serial_number="0002193217") # 上で見つけたシリアル番号を指定  mcp.configure_spi_timing(chip_select_to_data_delay=0, last_data_byte_to_cs=0, delay_between_bytes=0) for i in range(9): mcp.set_gpio_designation(i, Mcp2210GpioDesignation.GPIO) mcp.set_gpio_direction(i, Mcp2210GpioDirection.OUTPUT) mcp.set_gpio_output_value(3, True) time.sleep(0.5) mcp.set_gpio_output_value(3, False) time.sleep(0.5) mcp.set_gpio_output_value(3, True) time.sleep(0.5) mcp.set_gpio_output_value(3, False) time.sleep(0.5) mcp.set_gpio_output_value(3, True) 

3.5インチ電子ペーパーパネルを制御してみる(塗りつぶし)

Waveshareが提供するサンプルコード( https://files.waveshare.com/wiki/3.5inch_e-Paper_Module_G/3in5_e-Paper_G.zip )を参考にしつつ次のようなコードを作成しました。

import time from mcp2210 import Mcp2210, Mcp2210GpioDesignation, Mcp2210GpioDirection PIN_CS = 1 PIN_DC = 2 PIN_COM_LED = 3 PIN_BUSY = 6 PIN_RST = 0 PIN_PWR = 4 PAPER_WIDTH = 184 PAPER_HEIGHT = 384 def init_mcp2210_for_paper(mcp): mcp.configure_spi_timing(chip_select_to_data_delay=0, last_data_byte_to_cs=0, delay_between_bytes=0) clear_mcp2210_gpio_status(mcp) mcp.set_gpio_designation(PIN_CS, Mcp2210GpioDesignation.CHIP_SELECT) mcp.set_gpio_direction(PIN_BUSY, Mcp2210GpioDirection.INPUT) mcp.set_gpio_output_value(PIN_RST, True) mcp.set_gpio_output_value(PIN_COM_LED, True) mcp.gpio_update() def clear_mcp2210_gpio_status(mcp): for pin_number in range(9): mcp.set_gpio_designation(pin_number, Mcp2210GpioDesignation.GPIO) mcp.set_gpio_direction(pin_number, Mcp2210GpioDirection.OUTPUT) mcp.set_gpio_output_value(pin_number, False) def power_on(mcp): mcp.set_gpio_output_value(PIN_PWR, True) mcp.gpio_update() time.sleep(0.2) def reset(mcp): mcp.set_gpio_output_value(PIN_RST, True) mcp.gpio_update() time.sleep(0.2) mcp.set_gpio_output_value(PIN_RST, False) mcp.gpio_update() time.sleep(0.002) mcp.set_gpio_output_value(PIN_RST, True) mcp.gpio_update() time.sleep(0.2) wait_until_not_busy(mcp) def wait_until_not_busy(mcp): time.sleep(0.1) while(mcp.get_gpio_value(PIN_BUSY) == False): time.sleep(0.005) def send_command_code(mcp, command_code: int): mcp.set_gpio_output_value(PIN_DC, False) mcp.gpio_update() mcp.spi_exchange(bytes([command_code]), PIN_CS) def send_data_bytes(mcp, data_bytes: bytes): mcp.set_gpio_output_value(PIN_DC, True) mcp.gpio_update() mcp.spi_exchange(data_bytes, PIN_CS) def send_data(mcp, data: int | bytes | bytearray | tuple[int] | list[int]): if isinstance(data, int): send_data_bytes(mcp, bytes([data])) elif isinstance(data, bytes): send_data_bytes(mcp, data) else: send_data_bytes(mcp, bytes(data)) def send_command(mcp, command_code: int, *params: int | bytes | bytearray | tuple[int] | list[int]): send_command_code(mcp, command_code) for p in params: send_data(mcp, p) def init_paper(mcp): send_command(mcp, 0x66, 0x49, 0x55, 0x13, 0x5D, 0x05, 0x10) send_command(mcp, 0x4D, 0x78) send_command(mcp, 0x00, 0x0F, 0x29) send_command(mcp, 0x01, 0x07, 0x00) send_command(mcp, 0x03, 0x10, 0x54, 0x44) send_command(mcp, 0x06, 0x0F, 0x0A, 0x2F, 0x25, 0x22, 0x2E, 0x21) send_command(mcp, 0x50, 0x37) send_command(mcp, 0x60, 0x02, 0x02) send_command(mcp, 0x61, PAPER_WIDTH//256, PAPER_WIDTH%256, PAPER_HEIGHT//256, PAPER_HEIGHT%256) send_command(mcp, 0xE7, 0x1C) send_command(mcp, 0xE3, 0x22) send_command(mcp, 0xB6, 0x6F) send_command(mcp, 0xB4, 0xD0) send_command(mcp, 0xE9, 0x01) send_command(mcp, 0x30, 0x08) send_command(mcp, 0x04) wait_until_not_busy(mcp) def fill_all_pixels(mcp, color_byte: int = 0x55): size = ((PAPER_WIDTH + 3) // 4) * PAPER_HEIGHT send_command(mcp, 0x10, [color_byte] * size) def refresh_display(mcp): send_command(mcp, 0x12, 0x00) wait_until_not_busy(mcp) def shutdown_paper(mcp): send_command(mcp, 0x02, 0x00) # POWER_OFF  time.sleep(0.1) send_command(mcp, 0x07, 0xa5) # DEEP_SLEEP  time.sleep(2); mcp.set_gpio_output_value(PIN_RST, False) mcp.set_gpio_output_value(PIN_DC, False) mcp.set_gpio_output_value(PIN_PWR, False) mcp.gpio_update() # clear_mcp2210_gpio_status(mcp)  # mcp.gpio_update()  mcp = Mcp2210(serial_number="0002193217", immediate_gpio_update=False) print("Start") init_mcp2210_for_paper(mcp) print("Power ON") power_on(mcp) print("Reset") reset(mcp) print("Init Paper") init_paper(mcp) print("Fill") fill_all_pixels(mcp, 0xaf) # 10(Yellow) 10(Yellow) 11(Red) 11(Red) print("Refresh") refresh_display(mcp) print("Sleep") time.sleep(5) print("Fill") fill_all_pixels(mcp) print("Refresh") refresh_display(mcp) print("Shutdown") shutdown_paper(mcp) print("End") 

黄色と赤のストライプが一面に表示されます。5秒待ってから、白一色に戻してから終了します。

画像を表示してみる

Pythonで画像を扱うにはpillowというライブラリを使うと良いみたいですね。

 pip install pillow 

ソースコードの方は次のようになります。既に他の電子ペーパーモジュール用に改良した後のものを3.5インチ用に一部戻したので過度に複雑になっています。電子ペーパーモジュールとの通信部分と電子ペーパーモジュールそのものに対する処理をクラスで分けてあります。

import time import logging import mcp2210 from abc import ABC, abstractmethod from typing import Sequence from PIL import Image, ImagePalette logger = logging.getLogger(__name__) logger.setLevel(logging.DEBUG) class EPaperBridgeMCP2210: """MCP2210を使用したブリッジ""" def __init__(self, serial_number: str): self._pin_dc = 2 self._pin_com_led = 3 self._pin_busy = 6 self._pin_rst = 0 self._pin_pwr = 4 self._cs_pin = 1 self._reset_wait_times = (0.2, 0.002, 0.2) self._mcp = mcp2210.Mcp2210(serial_number, immediate_gpio_update=False) # self._mcp._spi_settings.bit_rate = 12000000  self.init_mcp2210_for_paper() def init_mcp2210_for_paper(self): self._mcp.configure_spi_timing(chip_select_to_data_delay=0, last_data_byte_to_cs=0, delay_between_bytes=0) self.clear_mcp2210_gpio_status() self._mcp.set_gpio_designation(self._cs_pin, mcp2210.Mcp2210GpioDesignation.CHIP_SELECT) self._mcp.set_gpio_direction(self._pin_busy, mcp2210.Mcp2210GpioDirection.INPUT) self._mcp.set_gpio_output_value(self._pin_rst, True) self._mcp.set_gpio_output_value(self._pin_com_led, True) self._mcp.gpio_update() def clear_mcp2210_gpio_status(self): for pin_number in range(9): self._mcp.set_gpio_designation(pin_number, mcp2210.Mcp2210GpioDesignation.GPIO) self._mcp.set_gpio_direction(pin_number, mcp2210.Mcp2210GpioDirection.OUTPUT) self._mcp.set_gpio_output_value(pin_number, False) def power_on(self): self._mcp.set_gpio_output_value(self._pin_pwr, True) self._mcp.gpio_update() time.sleep(0.2) def reset(self): reset_value = True for wait_time in self._reset_wait_times: self._mcp.set_gpio_output_value(self._pin_rst, reset_value) self._mcp.gpio_update() reset_value = not reset_value time.sleep(wait_time) self.wait_until_not_busy() def power_off(self): self._mcp.set_gpio_output_value(self._pin_rst, False) self._mcp.set_gpio_output_value(self._pin_dc, False) self._mcp.set_gpio_output_value(self._pin_pwr, False) self._mcp.gpio_update() # self.clear_mcp2210_gpio_status(mcp)  # self._mcp.gpio_update()  def wait_until_not_busy(self): start_time = time.time() logger.debug("Start waiting (busy=%s)" % self._mcp.get_gpio_value(self._pin_busy)) # time.sleep(0.1)  while(self._mcp.get_gpio_value(self._pin_busy) == False): time.sleep(0.005) logger.debug("End waiting (wait time=%s seconds)" % (time.time() - start_time)) def send_command_code(self, command_code: int): self._mcp.set_gpio_output_value(self._pin_dc, False) self._mcp.gpio_update() self._mcp.spi_exchange(bytes([command_code]), self._cs_pin) def send_data_bytes(self, data_bytes: bytes): self._mcp.set_gpio_output_value(self._pin_dc, True) self._mcp.gpio_update() self._mcp.spi_exchange(data_bytes, self._cs_pin) def send_data(self, data: int | Sequence[int]): if isinstance(data, int): self.send_data_bytes(bytes([data])) elif isinstance(data, bytes): self.send_data_bytes(data) else: self.send_data_bytes(bytes(data)) def send_command(self, command_code: int, *params: int | Sequence[int]): self.send_command_code(command_code) for p in params: self.send_data(p) class EPaperDisplayWaveshare(ABC): "Waveshare製EPaperディスプレイモジュール(HAT)の基底クラス" def __init__(self, bridge: EPaperBridgeMCP2210, width: int, height: int, bits_per_pixel: int, palette: bytes, default_pixel_value: int, all_driver_chip_numbers: Sequence[int]): self._bridge = bridge self._width = width self._height = height self._bits_per_pixel = bits_per_pixel self._palette = palette self._default_pixel_value = default_pixel_value self._all_driver_chip_numbers = all_driver_chip_numbers self.init() # Initialization  def init(self): try: self._bridge.power_on() self._bridge.reset() self.init_driver_chips() except Exception: self._bridge.power_off() raise @abstractmethod def init_driver_chips(self): ... # Shutdown  def shutdown(self): try: self.shutdown_driver_chips() finally: self._bridge.power_off() def shutdown_driver_chips(self): # self._bridge.wait_until_not_busy() ?  # 07:Deep Sleep Command  self._bridge.send_command(0x07, 0xA5) time.sleep(2) # Panel Power Control  def power_on_panel(self): # 04:Power ON Command  self._bridge.send_command(0x04) self._bridge.wait_until_not_busy() def power_off_panel(self): # 02:Power OFF Command  self._bridge.send_command(0x02, 0x00) self._bridge.wait_until_not_busy() # Frame  @abstractmethod def set_frame_bytes(self, frame_bytes: bytes): ... @property def width(self) -> int: """パネル全体の水平方向のピクセル数です。""" return self._width @property def height(self) -> int: """パネル全体の垂直方向のピクセル数です。""" return self._height @property def default_pixel_value(self) -> int: """デフォルトのピクセル値です。基本的に「白」を意味する値です。""" return self._default_pixel_value @property def frame_bits_per_pixel(self) -> int: """ピクセルあたりのビット数です。基本的に8以下の値です。""" return self._bits_per_pixel @property def frame_pixels_per_byte(self) -> int: """1バイトあたりのピクセル数です。""" return 8 // self._bits_per_pixel def make_filled_frame_byte(self, pixel_value: int): """1バイトの中を指定されたpixel_valueで満たしたものを返します。""" bpp = self.frame_bits_per_pixel pixel_value = pixel_value & ((1 << bpp) - 1) frame_byte = 0 bitpos = 8 - bpp while bitpos >= 0: frame_byte |= pixel_value << bitpos bitpos = bitpos - bpp return frame_byte @property def frame_line_nbytes(self) -> int: """フレームバッファの1行のバイト数を返します。""" return ((self._width + self.frame_pixels_per_byte - 1) // self.frame_pixels_per_byte) @property def frame_nbytes(self) -> int: """フレームバッファのバイト数を返します。""" return self.frame_line_nbytes * self.height def fill_frame_with_byte(self, frame_byte: int): """フレームの全バイトをframe_byteで満たします。""" self.set_frame_bytes(bytes([frame_byte]) * self.frame_nbytes) def fill_frame(self, pixel_value: int): """フレームの全ピクセルをpixel_valueで塗りつぶします。""" self.fill_frame_with_byte(self.make_filled_frame_byte(pixel_value)) def clear_frame(self): """フレームの全ピクセルを白一色で塗りつぶします。""" self.fill_frame(self.default_pixel_value) def frame_image_palette(self) -> ImagePalette.ImagePalette: return ImagePalette.ImagePalette("RGB", self._palette) def convert_image_to_frame_bytes(self, image: Image.Image) -> bytes: """imageをselfの仕様に合わせたフレームバイト列へ変換します。""" palette_image = Image.new("P", (1, 1)) palette_image.putpalette(self.frame_image_palette()) quantized_image = image.convert("RGB").quantize(palette=palette_image) src_bytes = quantized_image.getdata() src_w, src_h = quantized_image.size return bytes(EPaperDisplayWaveshare.convert_pimage_bytes_to_frame_bytes( src_bytes, src_w, 0, 0, src_w, src_h, self.width, self.height, self.frame_bits_per_pixel)) @staticmethod def convert_pimage_bytes_to_frame_bytes( src_bytes, src_pitch: int, src_x: int, src_y: int, src_w: int, src_h: int, dst_w: int, dst_h: int, dst_bits_per_pixel: int) -> bytearray: scan_w = min(src_w, dst_w) scan_h = min(src_h, dst_h) dst_line_nbytes = (dst_w // (8 // dst_bits_per_pixel)) dst_pitch = dst_line_nbytes dst_bytes = bytearray(dst_line_nbytes * dst_h) for y in range(scan_h): src = src_pitch * (src_y + y) + src_x dst = dst_pitch * y dst_bitpos = 8 dst_byte = 0 dst_bitmask = (1 << dst_bits_per_pixel) - 1 for x in range(scan_w): dst_bitpos -= dst_bits_per_pixel dst_byte |= ((int(src_bytes[src + x]) & dst_bitmask) << dst_bitpos) if dst_bitpos < dst_bits_per_pixel: dst_bytes[dst] = dst_byte dst = dst + 1 dst_bitpos = 8 dst_byte = 0 if dst_bitpos < 8: dst_bytes[dst] = dst_byte return dst_bytes # Panel Update  def update_panel(self): """現在のフレームをディスプレイの表示に反映します。""" # 04:Power ON Command  self.power_on_panel() # 12:Display Refresh Command  self._bridge.send_command(0x12, 0x00) self._bridge.wait_until_not_busy() # 02:Power OFF Command  self.power_off_panel() def show_image(self, image: Image.Image): """imageを表示します。""" self.set_frame_bytes(self.convert_image_to_frame_bytes(image)) print("Refresh") self.update_panel() class EPaperDisplayWaveshare3in5G(EPaperDisplayWaveshare): """Waveshare 3.5インチ(G)""" def __init__(self, bridge): super().__init__( bridge = bridge, width = 184, height = 384, bits_per_pixel = 2, palette = bytes((0,0,0)+(255,255,255)+(255,255,0)+(255,0,0)+ (0,0,0)*252), default_pixel_value = 1, all_driver_chip_numbers = [0] ) def init_driver_chips(self): self._bridge.send_command(0x66, 0x49, 0x55, 0x13, 0x5D, 0x05, 0x10) self._bridge.send_command(0x4D, 0x78) self._bridge.send_command(0x00, 0x0F, 0x29) self._bridge.send_command(0x01, 0x07, 0x00) self._bridge.send_command(0x03, 0x10, 0x54, 0x44) self._bridge.send_command(0x06, 0x0F, 0x0A, 0x2F, 0x25, 0x22, 0x2E, 0x21) self._bridge.send_command(0x50, 0x37) self._bridge.send_command(0x60, 0x02, 0x02) self._bridge.send_command(0x61, self._width>>8, self._width&255, self._height>>8, self._height&255) self._bridge.send_command(0xE7, 0x1C) self._bridge.send_command(0xE3, 0x22) self._bridge.send_command(0xB6, 0x6F) self._bridge.send_command(0xB4, 0xD0) self._bridge.send_command(0xE9, 0x01) self._bridge.send_command(0x30, 0x08) # Frame  def set_frame_bytes(self, frame_bytes: bytes): """frame_bytesを電子ペーパーに転送します(まだ表示しない)。""" self._bridge.send_command(0x10, frame_bytes) print("Load image") #im = Image.open("3in5g.bmp") im = Image.open("PXL_20251008_015247023~2.jpg") print("Rotate") im = im.rotate(-90, expand=True) print("Create EPaper") epaper = EPaperDisplayWaveshare3in5G( EPaperBridgeMCP2210(serial_number="0002193217")) try: print("Resize") im = im.resize((epaper.width, epaper.height)) print("Show Image") epaper.show_image(im) print("Sleep") time.sleep(5) # print("Fill")  # epaper.fill_frame_with_byte(0xaf)  # print("Refresh")  # epaper.update_panel()  # print("Sleep")  # time.sleep(5)  print("Fill") epaper.clear_frame() print("Refresh") epaper.update_panel() finally: print("Shutdown") epaper.shutdown() print("End") 

パネルの解像度は184×384です(縦長)。1ピクセルは2ビットでMSBから詰めていき1バイトで4ピクセル入ります。つまり1行は184/4=46バイトです。全体では46*384=17664バイトとなります。これをコマンド0x10のパラメータとして送ってやり、その後パネルの電源(0x04)をON→リフレッシュ(0x12)→パネルの電源OFF(0x02)とすると実際の画面が更新されます。なお、転送に3秒、リフレッシュに15秒くらいかかります。

というわけで表示させてみたのがこちら。

紅葉の写真を表示した例
図3: 紅葉の写真を表示した例

これは先日撮った紅葉の写真なのですが、黒白黄赤の四色しか無いのに案外色が再現できています。さすがに青空は灰色ですけど。

ちなみに元の写真はこんな感じです。

元の写真
図4: 元の写真

Pillowにはディザリングで減色する機能があるのでそれを利用しています。

テキストを含む画像を作成して表示させてみたのが次の例。

文字を表示した例
図5: 文字を表示した例

結構シャープに表示できています。

でも3.5インチだと大したものは表示できませんね。

……となると、もう少し大きいパネルが欲しくなってきますが、それはまた次回