(以前に書いた記事の構成部品が古くなっていたので、最新状況に合わせて書き直しました。2024年2月25日)
はじめに
Dialy Portal Zの「電子ペーパーを額装して飾るとやたらカッコいい」の記事(以下「元記事」)に触発され、自分用の情報を表示できたら便利だなと思い自作しましたのでご紹介いたします。
作成した情報表示額縁
使うもの
- ペーパー液晶「WaveShare 13504 7.5インチ 640×384 E-Ink 電子ペーパーモジュール for RaspberryPi
」(税込8,450円) - ラズベリーパイ Zero2 W(税込み3千円強)+ SDカード(16GB)
- Google App Script(GAS)(無料枠)
- 百均で買った額縁(税込110円)、幅30mmの板(税込108円)
なお、元記事のものは、キット販売されているようです。そちらでもうまく組み合わせれば同じことができるかもしれません。
全体の処理の流れ
今回は天気予報情報を表示させる例で説明いたします。1.のHTMLをご自分の好みの情報を入れる形にすれば任意の情報を表示させることができます。
- Google App Scriptで天気予報サイトから天気予報情報をスクレイピングし、HTMLの形で返す。
- ラズベリーパイで上記HTMLデータをpuppeteerで読み込み、画像化する。
- 上記画像をimagemagickで二値画像のBMPファイルに変換する。
- 二値画像のBMPファイルをラズベリーパイに接続したペーパー液晶で表示させる。
- 上記の1.から4.までの処理を一定時間毎に実行させる。
#部材
ペーパー液晶
元記事と同様に7.5インチくらいの大きさのものがよいなと思って探したところ、千石電商さんのサイトで元記事と同じものと思われる下記を見つけました。白黒のもの、白黒赤のもの、白黒黄のものとバリエーションがあるようです。ひとまず下記の白黒のものを使うことにしました。
WaveShare 13504 7.5インチ 640×384 E-Ink 電子ペーパーモジュール for RaspberryPi
ラズベリーパイ
以前に書いた記事ではZero WHを使っていました。
しかしさすがにCPU(Armv6)が古すぎてphantomjsのバイナリパッケージがインストールできないとか、puppeteerとともに動かす最新のchromiumブラウザが動作しないとかの問題が発生していたので、Zero2 Wに変更しました。
なお、元記事は「ESP32」という低消費電力のマイコンを使うことでバッテリー駆動を実現しています。
ESP32では複雑な処理は厳しいのでラズベリーパイを使っています。
百均で買った額縁、幅30mmの板
私はリビングに置きたいので違和感が無いよう、元記事同様に額縁・フォトフレームを使うことにしました。
ペーパー液晶の外形寸法は170.2mm×111.2mm、ディスプレイサイズは163.2mm×97.92mmです。ダイソーでこれに合うフォトフレームを探したところ2L版タイプのものがディスプレイサイズとしてはぴったしです。ただしペーパー液晶から延びるフレキシブルケーブルの部分はあまり曲げたくないので、もう少し余裕が欲しいところ。
結局、2L版タイプより一回り大きいサイズのものを購入しました。
足をつけたラズベリーパイ Zero2 Wにペーパー液晶から延びるラズベリーパイ接続HATを接続すると、高さは27mm程になります。これをフォトフレームのどこに配置するか。横に箱をつけてはみ出させることも考えましたが、見栄えが悪そうです。
結局、フォトフレームの厚みを増す形で30mm幅の板を四辺から後ろに延ばし、背面の空間にラズベリーパイを収めることにしました。板はコーナンで98円でした。
ペーパー液晶での画像表示
ラズベリーパイとペーパー液晶の接続
ペーパー液晶から延びるフラットケーブルをラズベリーパイのHATボードに接続します。コネクタの黒いロックを上げ、フラットケーブルを奥までしっかり挿してロックします。
HATボードをラズベリーパイのGPIO40ピンに挿します。HATボードがラズベリーパイ本体に重なる方の向きです。
Raspbian Stretch環境の準備
詳細手順の説明は省いて概要だけご説明します(必要な方はガイド記事を探してください)。既に環境構築済みの方は飛ばしてください。
Raspberry Pi Imagerを使ってSDカードにRaspbian StretchのDesktop版を書き込み、ネット接続設定をし、下記で最新環境にしておきます。
$ sudo apt update
$ sudo apt upgrade
なお、初期設定のままだと、ラズベリーパイのWi-Fi通信は不安定なことが多いです。Wi-Fiチップが省電力モードとなっているのが原因のようです。Wi-Fi通信は下記で安定しました。
$ sudo iwconfig wlan0 power off
リブートしても戻らないようにさらに/etc/rc.localのexit行の前に下記を追加しておきます。(参考記事「Raspberry Pi のネットワークが不安定」)
$ iwconfig wlan0 power off
SSH接続も同様に不安定なことが多いです。下記の対策をしてsshdを再起動することで安定しました。
-
/etc/ssh/sshd_config
にClientAliveInterval 10
を追加。(参考記事「Raspberry Piで無線LANの反応が悪い時の対処法」) -
/etc/ssh/sshd_config
にIPQoS cs0 cs0
を追加。(参考記事「Raspberry Pi 3の環境構築(9/3 ssh不安定化対策追記)」)
画像表示プログラム
千石電商さんのサイトには「詳細はメーカーサイトにて公開されているマニュアルをご参照ください。」と書いてあります。リンクも無いので探してみたところ、どうもこちらのWikiページ「7.5inch e-Paper HAT (B)」が7.5インチペーパー液晶のサポートサイトのようです。
上記サポートサイトの「Hardware/Software setup」のタブの「Raspberry Pi」説明の「Enable SPI interface」に従い、下記を実行してSPIを有効にしておきます。
$ sudo raspi-config
Choose Interfacing Options -> SPI -> Yes to enable SPI interface
「Resouces」のタブに「Demo Code」としてGithubへのリンクがあります。
gitをインストールし、Demo Code一式をDLします。
$ sudo apt install git
$ git clone https://github.com/waveshare/e-Paper.git
Demo Code一式にライブラリやサンプルコードが入っています。ただ、どのファイルがどのペーパー液晶向けなのかの詳しい説明がないため、分かりづらいです。試行錯誤の結果、このペーパー液晶ではe-Paper/RaspberryPi&JetsonNano/python
のフォルダで下記を実行するとデモが動くことが分かりました。
$ python examples/epd_7in5_test.py
これをベースに加工して、二値画像BMPファイル(/tmp/bin.bmp
)を表示させるだけの下記のpythonコードscirpt/disp_info.py
を作ります。(一部のimportやログはなくてもよいですが)
#!/usr/bin/python
# -*- coding:utf-8 -*-
import sys
import os
libdir = os.path.join(os.path.dirname(os.path.dirname(os.path.realpath(__file__))), 'lib')
if os.path.exists(libdir):
sys.path.append(libdir)
import logging
from waveshare_epd import epd7in5
import time
from PIL import Image,ImageDraw,ImageFont
import traceback
logging.basicConfig(level=logging.DEBUG)
try:
epd = epd7in5.EPD()
epd.init()
Himage = Image.open('/tmp/bin.bmp')
epd.display(epd.getbuffer(Himage))
epd.sleep()
except IOError as e:
logging.info(e)
except KeyboardInterrupt:
logging.info("ctrl + c:")
epd7in5.epdconfig.module_exit()
exit()
上記スクリプトファイルscript/disp_info.py
をコンパイルしたい場合は、下記でコンパイルします(この程度のスクリプトだと速度は変わらないですが)。
$ python -m compileall script/disp_info.py
Demo Code一式から必要そうなファイルをコピーして、上記スクリプトファイルと合わせて、下記の構成を作ります。
lib
+ waveshare_epd
+ __init__.py
+ __init__.pyc
+ epd7in5.py
+ epd7in5.pyc
+ epdconfig.py
+ epdconfig.pyc
script
+ disp_info.py
+ disp_info.pyc
下記コマンドで二値画像形式のBMPファイル/tmp/bin.bmp
をペーパー液晶に表示させられます。表示完了までには1分ほどかかります。
$ python scirpt/disp_info.py
Google App Scriptを使った情報表示用Webページの作成
天気予報の情報をスクレイピングして、情報表示用のWebページを下記手順で作成します。
(1)Google Driveで「新規」/「その他」/「Google App Script」を選択します。「無題のプロジェクト」は何か適当な名前をつけておきます。
(2)メニューの「リソース」/「ライブラリ」を選択します。
(3)表示されるダイアログの「Add a library」の右のテキストボックスに「M1lugvAXKKtUxn_vdAG9JZleS6DrsjUUV」を入力し、「追加」ボタンを押します。
(4)「Parser」というライブラリが追加されることを確認します。「バージョン」は指定しなくてもいいのかもしれないですが、一応「7」を指定しておきます。
(5)下記のコードを入力し、「ファイル」/「保存」します。(不要なゴミコードも残ってしまっています。気になる方は適宜削除してください。)
var highTemp;
var lowTemp;
var rainProbability_06_12;
var rainProbability_12_18;
var rainProbability_18_24;
var rawText;
var UseKafun = false;
var Hour;
var KafunNum;
var Kion;
function getKion() {
var html = UrlFetchApp.fetch('http://weathernews.jp/onebox/34.746846/135.796013/temp=c').getContentText();
var kion = Parser.data(html)
.from("<span class=\"tit\">Temp.</span>")
.to("<img")
.build();
Kion = kion;
}
function getWeatherData() {
getKion();
var html = UrlFetchApp.fetch('https://tenki.jp/forecast/6/29/6110/26366/').getContentText();
var todayWeather = Parser.data(html)
.from("<section class=\"today-weather\">")
.to("<section class=\"tomorrow-weather\">")
.build();
highTemp = Parser.data(todayWeather)
.from("<dd class=\"high-temp temp\"><span class=\"value\">")
.to("</span>")
.build();
Logger.log( highTemp );
lowTemp = Parser.data(todayWeather)
.from("<dd class=\"low-temp temp\"><span class=\"value\">")
.to("</span>")
.build();
Logger.log( lowTemp );
var rainProbability = Parser.data(todayWeather)
.from("<tr class=\"rain-probability\">")
.to("</table>")
.build();
Logger.log( rainProbability );
var rainProbability_list = Parser.data(rainProbability)
.from("<td>")
.to("</td>")
.iterate();
rainProbability_06_12 = rainProbability_list[1];
var pos = rainProbability_06_12.indexOf("<");
if ( pos != -1 ) {
rainProbability_06_12 = rainProbability_06_12.substring( 0, pos );
}
rainProbability_12_18 = rainProbability_list[2];
pos = rainProbability_12_18.indexOf("<");
if ( pos != -1 ) {
rainProbability_12_18 = rainProbability_12_18.substring( 0, pos );
}
rainProbability_18_24 = rainProbability_list[3];
pos = rainProbability_18_24.indexOf("<");
if ( pos != -1 ) {
rainProbability_18_24 = rainProbability_18_24.substring( 0, pos );
}
Logger.log( rainProbability_06_12 );
Logger.log( rainProbability_12_18 );
Logger.log( rainProbability_18_24 );
}
function doGet() {
getWeatherData();
var date1 = new Date();
var dateStr = (date1.getMonth() + 1) + "月" +
date1.getDate() + "日" +
date1.getHours() + "時" +
date1.getMinutes() + "分";
var s;
s = "<head>" +
"<meta charset='UTF-8'>" +
"<title>App</title>" +
"<link rel='stylesheet' href='/css/render-components.css'>" +
"<style type='text/css'>" +
"body { font-size: 220%; font-family: sans-serif; }" +
"table {border-spacing:2px; border-collapse: collapse;}" +
"th { font-weight: normal; font-size: 70%; border-width:6px;}" +
"td { text-align: center; border-width:6px; }" +
".data { font-weight: bold; }" +
".center { text-align:center; }" +
"#rain_warning { font-size:230%; bold; color:white; background-color: black; text-align:center; }" +
"</style>" +
"</head>" +
"<body class='center'>" +
"<div>気温:<span id='low'>" + lowTemp + "</span>度 - <span id='high'>" + highTemp + "</span>度" + "<br/>(現在" + Kion + ")</div>" +
"<table border='2' align='center'>" +
"<tr>" +
"<th>時間</th><th>06-12</th><th>12-18</th><th>18-24</th>" +
"</tr>" +
"<tr>" +
"<td style='font-size: 70%;'>降水<br/>確率</td>" +
"<td id='06_12' class='data'>" + rainProbability_06_12 + "</td>" +
"<td id='12_18' class='data'>" + rainProbability_12_18 + "</td>" +
"<td id='18_24' class='data'>" + rainProbability_18_24 + "</td>" +
"</tr>" +
"</table>";
if ( 50 <= rainProbability_06_12 ) {
s += "<br/><div id='rain_warning'><span>朝雨注意!</span></div>";
}
s += "<br/><div style='font-size: 70%;'>更新日時:" + dateStr + "</div>" + "</body>";
Logger.log( s );
return HtmlService.createHtmlOutput(s);
}
上記コード中のhttp://weathernews.jp/onebox/34.746846/135.796013/temp=c
やhttps://tenki.jp/forecast/6/29/6110/26366/
の部分は天気予報情報を表示させたい地域に合わせて、適宜URLを変更してください。
( https://weathernews.jp/onebox/ や https://tenki.jp/ で郵便番号で検索すればURLが取得できます。)
なお、お天気のWebページの構成が変わると上記のコードのままではうまくスクレイピング処理できないかもしれません。その場合はスクレイピング処理を適宜変更してください。
(6)メニューの「公開」/「ウェブアプリケーションとして導入...」を選択します。
(7)「Who has access to the app:」を「Anyone, even anonymous」にします。(URLを知っていると誰でもアクセスできるのでご注意ください。自分のアカウントの権限で実行するので、個人情報などをスクリプトで出力しないようにご注意ください)。
(8)「Deploy」ボタンを押します。
(9)「Authorization Requied」というダイアログがでますので、「許可を確認」ボタンを押します。
(10)GoogleのAuth認証ダイアログがでますので、自分のアカウントを選択します。
(11)「このアプリは確認されていません」というダイアログが出ますので、下の「xxxxxxx(安全ではないページ)に移動」のリンクを押します。(「xxxxxxxx」は(1)のプロジェクト名)
(12)「xxxxxxxxが Google アカウントへのアクセスをリクエストしています」というダイアログが出ますので、下の「許可ボタン」を押します。
(13)「Current web app URL」のURLを控えておきます。これがHTML取得のURLとなります。
(14)ブラウザで(13)のURLをアクセスして表示されるかを確認します。
puppeteerでHTMLページを画像化する
GASで作成したHTMLページを下記手順で画像ファイル化します。
(1)ラズベリーパイでpuppeteer-core, fs, timersをインストールします。
$ sudo apt install puppeteer-core
$ sudo apt install fs
$ sudo apt install timers
(2)キャプチャするスクリプトを作成します。
画像表示用のdisp_info.py
と同じscirpt
フォルダに下記のスクリプトscript/capture.js
を作ります。
なお、スクリプト中のhttps://script.google.com/macros/s/xxxxxxx/exec
の部分はGoogle App Scriptの(13)の公開URLに差し替えてください。
const puppeteer = require("puppeteer-core");
const { setTimeout } = require('timers/promises');
const fs =require('fs');
const LAUNCH_OPTION = {
headless: true,
executablePath: "chromium-browser",
args: ["--no-sandbox", "--disable-setuid-sandbox"],
};
(async () => {
const browser = await puppeteer.launch(LAUNCH_OPTION);
try {
const page = await browser.newPage();
page.setViewport({ width: 384, height: 763 })
await page.goto("https://script.google.com/macros/s/xxxxxxx/exec");
await page.waitForNetworkIdle();
await setTimeout(60000);
let resultSelector = await page.$('body');
let value = await (await resultSelector.getProperty('textContent')).jsonValue();
await page.screenshot({ path: '/tmp/htmlpage.png', clip: {x: 0, y: 123, width: 384, height: 640} })
} catch (e) {
console.error(e);
} finally {
await browser.close();
}
})();
Google App ScriptでWebページを作成すると、「このアプリケーションは、Google ではなく、別のユーザーによって作成されたものです。」という表示がGoogle側から割り込まれます。この部分をクリップするため、y: 123
としています。この値は縦長画面の場合であり、横長画面の場合は別途調整が必要です。
$ node script/capture.js
とすると、キャプチャ画像が/tmp/htmlpage.png
として出力されます。
imagemagickで二値画像に変換する
puppeteerでキャプチャしたWebページ画像はカラーなので、imagemagickを使って二値化BMPファイルに変換します。
ラズベリーパイでimagemagickをインストールします。
$ sudo apt install imagemagick
今回は白黒ペーパー液晶であり、表示する内容も文字ベースなので単純に二値化させます。下記コマンドで/tmp/htmlpage.png
を/tmp/bin.bmp
に変換しています。縦長画面で表示させたいので270度回転もさせています。
$ convert -monochrome +dither -rotate 270 /tmp/htmlpage.png /tmp/bin.bmp
画像を表示したい場合はディザ無効指定+dither
を外した方がよいでしょう。
GASからのHTML取得からペーパー液晶表示までを処理するシェルスクリプト
一連の処理を実行する下記のシェルスクリプトscript/disp_info.sh
を作成します。
下記例はdisp_info
フォルダに一式を置いて実行する例です。実行フォルダは環境に合わせて適宜書き換えてください。
#! /bin/bash
cd /home/pi/disp_info
/usr/local/bin/node script/capture.js 2> /dev/null
/usr/bin/convert -monochrome +dither -rotate 270 /tmp/htmlpage.png /tmp/bin.bmp
/usr/bin/python script/disp_info.pyc
下記のように実行権限を立ててdisp_info.sh
を実行して、ペーパー液晶が更新されるかを確認してください。(1分ぐらいかかります)
$ chmod 755 script/disp_info.sh
$ script/disp_info.sh
情報の自動更新設定
systemdで一定時間間隔毎にdisp_info.sh
を実行させます。
(1)/etc/systemd/system/disp_info.service
を作成します。
[Unit]
Description= Display information on the e-paper display
[Service]
Type=simple
ExecStart=/home/pi/disp_info/script/disp_info.sh
[Install]
WantedBy=multi-user.target
(2)下記コマンドでdisp_info.service
を有効にします。
$ sudo systemctl enable disp_info.service
(3)/etc/systemd/system/disp_info.timer
を作成します。
下記例ではラズベリーパイ起動後3分で最初に実行し、以降は59分毎に実行します。
(disp_info.sh実行に1分ぐらいかかるので60分ではなくて59分にしています。決まった時刻に正確に実行させたいなら時分指定の方がよいかも)。
[Unit]
Description= Display information on the e-paper display
[Service]
Type=simple
ExecStart=/home/pi/disp_info/script/disp_info.sh
[Timer]
OnBootSec=3min
OnUnitActiveSec=59min
Unit=disp_info.service
[Install]
WantedBy=multi-user.target
(4)下記コマンドでdisp_info.timer
を有効にして、開始させます。
$ sudo systemctl enable disp_info.timer
$ sudo systemctl start disp_info.timer
組み立て
(1)フォトフレームの裏の四辺に30mm幅の板をボンドで貼り付けます。固まるまで数時間おいてください。
(2)USBケーブルを通す穴をドリルなどで開けます。コネクタを通すには直径10mmほどの穴が必要です。
大きな穴を開けるのが大変でしたら、板の端にケーブル太さ分だけの小さな穴や切込みを入れるのでもよいかもしれません。浮いて構わないなら穴もなくてもよいかもしれません。
(3)フォトフレームを裏から開けて、中身を取り出します。
(4)フレキシブルケーブルの配置も考えつつ、フォトフレーム上でのペーパー液晶の配置を決めます。決めたらフォトフレームについていた台紙にペーパー液晶の表示部分の穴(矩形)をカッターで開けます。
(5)写真を裏から抑える写真抑えの部分にペーパー液晶のフレキシブルケーブルを出す穴を開けます。フレキシブルケーブルに過剰な曲げ負荷がかからないように注意して穴の位置決めをしてください。
(6)裏からフォトフレームの表面プラスチック、穴を開けた台紙、ペーパー液晶、写真抑えの順に重ねます。表から見てずれていないか、フレキシブルケーブルが無理なくでているかを確認します。
(7)写真抑えを固定します(固定方法はフォトフレームによって異なります)。
(8)ラズベリーパイを固定します。
ラズベリーパイは接着剤やネジで固定してもよいのですが、私は横着して養生テープで固定しています。
(9)USBケーブル用の穴からUSBケーブルを通し、ラズベリーパイに接続します。
コードの重力で引っ張られてラズベリーパイが剥がれないように、輪を作ってあります。
(10)吊り用の金具をつけて壁に固定
SDカードを長持ちさせる
この対応は必須ではありませんが、長時間動かす場合は対応しておいた方がよいと思います。
ラズベリーパイを常時動かしておくといずれSDカードの書き換え限界が発生します。
できるだけSDカードを長持ちさせるため、こちらの記事「Raspberry PiのSDカードが壊れた!寿命を延ばす方法 5+1選!【運用編を追加】」を参考に下記の対応を実施しました。
(1)スワップファイルを無くす
ラズベリーパイを再起動し、下記コマンドでswapしていないかを確認します。
$ sudo free -h
swap
の行のused
が0B
となっていたら、下記コマンドでスワップをOFFにします。
$ sudo swapoff --all
念の為、スワップがOFFになったかをfreeコマンドで確認します。swap
の行のtotal
が0B
となっていればOKです。
下記コマンドで再起動でswap領域が再度作られないように設定しておきます。
$ sudo systemctl stop dphys-swapfile
$ sudo systemctl disable dphys-swapfile
(2)テンポラリ領域をtmpfs(Ramdisk上)に設定する
/etc/fstab
に下記の2行を追加します。
tmpfs /tmp tmpfs defaults,size=32m,noatime,mode=1777 0 0
tmpfs /var/tmp tmpfs defaults,size=16m,noatime,mode=1777 0 0
下記コマンドでSDカード上の /tmp
と/var/tmp
を消して、再起動します。
$ sudo rm -rf /tmp
$ sudo rm -rf /var/tmp
$ sudo reboot
運用の仕方によっては/var/log
もRamdiskにおいてしまってもよいかもしれません。その際はログが消えてしまうので、ログのバックアップとかは別途した方がよいかもしれません。
(4)ログを減らす
/etc/rsyslog.conf
で出力を抑制したいログを「#」でコメントアウトします。
なお、長時間動作をさらに安定させようとするならば、1週間や1ヶ月間隔でsystemd
で自動リブートさせるとさらに安定するかもしれません。
所感
ペーパー液晶は初めて取り扱いましたが、思ったよりも簡単にできました(実働1.5日)。
実は以前にもDISPLIOで天気予報のHTMLページを表示させることをしていました。DISPLIOはペーパー液晶で所定時間毎に自動起動して処理して終了する、ということをバッテリー駆動で行っています。そのため充電は数週間に1回するぐらいで済みました。(しかし、DISPLIOも何年か使っていたらバッテリーが弱くなって使えなくなってしまいました)。
今回、DISPLIO代わりのものを自分で作成することができ、大画面化もできたので満足です。
追記
[続編] Line Bot+GAS+ペーパー液晶でLineからリマインダーを送って情報表示額縁に表示させた も書きました。