電子ペーパーとラズパイでお天気情報を表示してみた

# はじめに
ラズパイでOpen Meteo APIから明日の天気予報を取得して、電子ペーパーに表示する。クーロンで毎日18時に更新するように設定をして、テレビ台に置いているのだけど結構便利です。最初、一週間分の天気予報を表示しようかと思ったけど電子ペーパーのサイズがスマホよりひと回りくらい大きい程度なので視認性を優先して明日の天気のみにした。

電子ペーパーは画面更新時しか電力を消費しないのが良いですね。ただ、ラズパイは起動しっぱなしですが…
![miffy.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/157490/33ae8cd6-b2a0-67f5-1e00-00f5fc8bbfd7.jpeg)

この電子ペーパーの後ろにラズパイが隠れています。なお、スタンドは100均。

# システム構成
ラズパイは昔購入してホコリをかぶってたRaspberry Pi3 Model Bを、電子ペーパーはwaveshareの5.65インチ 7色カラー電子ペーパーを使用しました。

天気予報を取得するAPIは色々あるけど、今回はユーザ登録不要で使える[Open Meteo API](https://open-meteo.com/en/docs)を使用しました。Webサイトからでも緯度経度や欲しい情報などを選択してURLを組み立てたり、結果をチャートで見れるので便利です。

実行はPython3.5.3です。

# 構築手順
以下、構築手順ですが基本的に[waveshare wiki](https://www.waveshare.com/wiki/5.65inch_e-Paper_Module_(F))の手順通りに実施すればOK。補足部分だけ以下に記載します。

### ハードウェア接続（補足）

まず、ラズパイのGPIOと電子ペーパーを[waveshare wiki]()のピン対応表をもとに接続します。ラズパイのピン配列は以下の`pinout`コマンドで表示できます。
```shell
pi@raspberrypi:~ $ pinout
,--------------------------------.
| oooooooooooooooooooo J8     +====
| 1ooooooooooooooooooo        | USB
|                             +====
|      Pi Model 3B  V1.2         |
|      +----+                 +====
| |D|  |SoC |                 | USB
| |S|  |    |                 +====
| |I|  +----+                    |
|                   |C|     +======
|                   |S|     |   Net
| pwr        |HDMI| |I||A|  +======
`-| |--------|    |----|V|-------'

J8:
   3V3  (1) (2)  5V    
 GPIO2  (3) (4)  5V    
 GPIO3  (5) (6)  GND   
 GPIO4  (7) (8)  GPIO14
   GND  (9) (10) GPIO15
GPIO17 (11) (12) GPIO18
GPIO27 (13) (14) GND   
GPIO22 (15) (16) GPIO23
   3V3 (17) (18) GPIO24
GPIO10 (19) (20) GND   
 GPIO9 (21) (22) GPIO25
GPIO11 (23) (24) GPIO8 
   GND (25) (26) GPIO7 
 GPIO0 (27) (28) GPIO1 
 GPIO5 (29) (30) GND   
 GPIO6 (31) (32) GPIO12
GPIO13 (33) (34) GND   
GPIO19 (35) (36) GPIO16
GPIO26 (37) (38) GPIO20
   GND (39) (40) GPIO21
```

### Python3 ライブラリインストール（補足）

```shellsudo apt-get update
sudo apt-get install python3-pip
sudo apt-get install python3-pil
sudo apt-get install python3-numpy
sudo pip3 install RPi.GPIO
sudo pip3 install spidev
```

[waveshare wiki](https://www.waveshare.com/wiki/5.65inch_e-Paper_Module_(F))の手順通りにインストール後にサンプルプログラム（epd_5in65f_test.py）を実行したところ以下のエラーが発生。
```
pi@raspberrypi:~/e-Paper/RaspberryPi_JetsonNano/python/examples $ python3 epd_5in65f_test.py 
INFO:root:epd5in65f Demo
INFO:root:init and Clear
DEBUG:waveshare_epd.epd5in65f:e-Paper busy
DEBUG:waveshare_epd.epd5in65f:e-Paper busy release
Traceback (most recent call last):
  File "epd_5in65f_test.py", line 24, in <module>
    epd.Clear()
  File "/home/pi/e-Paper/RaspberryPi_JetsonNano/python/lib/waveshare_epd/epd5in65f.py", line 200, in Clear
    self.send_data2(buf)
  File "/home/pi/e-Paper/RaspberryPi_JetsonNano/python/lib/waveshare_epd/epd5in65f.py", line 87, in send_data2
    epdconfig.spi_writebyte2(data)
  File "/home/pi/e-Paper/RaspberryPi_JetsonNano/python/lib/waveshare_epd/epdconfig.py", line 66, in spi_writebyte2
    self.SPI.writebytes2(data)
AttributeError: 'SpiDev' object has no attribute 'writebytes2'
```

調べたところ、どうもSpiDevのバージョンが古いらしい。

以下のコマンドでSpiDevをアップグレードして再度サンプル実行したところ問題なく動作した。
```shell
sudo python3 -m pip install --upgrade --force-reinstall spidev
```
参考：https://usermanual.wiki/m/3ea49fd57852754503056b2484a528b9fb71fb55a606b644c9c5f9178b1c0957.pdf

以上で電子ペーパーの表示テストを完了。

# お天気情報を取得してBMP画像に保存するプログラム
次に、お天気情報を取得してBMP画像に保存するプログラムを作成しました。
APIから一週間分（使うのは明日分のみ）の日時とお天気コードと最高気温と最低気温の情報を取得。APIからはお天気コードしか取得できないため、日本語に変換するために変換用の辞書（WEATHER_CODE2TXT）を定義しています。

また、日本語フォントはIPAフォントを使用。以下のページを参考にインストールしました。
[Ubuntu18.0.4での日本語フォントの確認](https://reffect.co.jp/windows/ubuntu18-0-4-font)

```python:create_weatherbmp.py
#!/usr/bin/python3
# -*- coding:utf-8 -*-

import datetime
import locale
import json
from PIL import Image, ImageFont, ImageDraw
from urllib.request import urlopen, Request


# 設定値
WEATHER_API_URL  = 'https://api.open-meteo.com/v1/forecast?latitude=35.69&longitude=139.69&daily=weathercode,temperature_2m_max,temperature_2m_min&timezone=Asia%2FTokyo'
OUTPUT_IMG_PATH  = '/home/pi/20221113/weather.bmp'
FONT_FILE_PATH   = 'ipagp.ttf'
WEATHER_CODE2TXT = {
    0:'晴れ', 1:'晴れ時々曇り', 2:'晴れ時々曇り', 3:'晴れ時々曇り', 
    45:'霧', 48:'霧', 51:'霧雨', 53:'霧雨', 55:'霧雨', 
    56:'雨氷', 57:'雨氷', 61:'小雨', 63:'雨', 65:'大雨', 66:'凍てつく雨', 67:'凍てつく雨', 
    71:'小雪', 73:'雪', 75:'大雪', 77:'雪のつぶ', 
    80:'弱いにわか雨', 81:'にわか雨', 82:'激しいにわか雨', 85:'にわか雪', 86:'にわか雪'
}


# 一週間のお天気情報を取得
def get_weather():
    headers = {
        "accept" :"application/json",
        "Content-Type" :"application/x-www-form-urlencoded"
    }

    request= Request(WEATHER_API_URL, headers=headers)
    with urlopen(request) as response:
        body= response.read()

    w = json.loads(body.decode('utf-8'))
    return w['daily']['time'], w['daily']['weathercode'], w['daily']['temperature_2m_max'], w['daily']['temperature_2m_min']

# 今日のお天気情報を取得
def get_weather_today():
    time, wcode, tmax, tmin, = get_weather()
    return time[0], wcode[0], tmax[0], tmin[0]

# 明日のお天気情報を取得
def get_weather_tomorrow():
    time, wcode, tmax, tmin, = get_weather()
    return time[1], wcode[1], tmax[1], tmin[1]

# 日付から曜日を取得
def get_dayofweek(time):
    locale.setlocale(locale.LC_TIME, 'ja_JP.UTF-8')
    time = datetime.datetime.strptime(time, '%Y-%m-%d')
    return time.strftime('%a')

# BMP画像作成
def create_bmp(time, wcode, tmax, tmin):
    # 表示形式を整える
    dtime = datetime.datetime.strptime(time, '%Y-%m-%d')
    img_date = dtime.strftime('%m/%d') + '(' + get_dayofweek(time) + ')'
    img_weather = WEATHER_CODE2TXT[wcode]
    img_tmax = str(round(tmax)) + '℃'
    img_tmin = str(round(tmin)) + '℃'
    img_t = str(round(tmax)) + '℃' + '/' + str(round(tmin)) + '℃'

    # 描画サイズ設定
    canvasSize    = (600, 448)
    backgroundRGB = (255, 255, 255)
    img = Image.new("RGB", canvasSize, backgroundRGB)
    draw = ImageDraw.Draw(img)

    # 上段に日付を描画
    font = ImageFont.truetype(FONT_FILE_PATH, 80)
    textWidth, textHeight = draw.textsize(img_date,font=font)
    textTopLeft = (canvasSize[0]//2-textWidth//2, canvasSize[1]//5-textHeight//2)
    draw.text((113, 47), img_date, fill=(0,0,0), font=font)
    draw.text((114, 48), img_date, fill=(0,0,0), font=font)
    draw.text((114, 46), img_date, fill=(0,0,0), font=font)
    draw.text((112, 46), img_date, fill=(0,0,0), font=font)
    draw.text((112, 48), img_date, fill=(0,0,0), font=font)

    # 中段に天気を描画
    font = ImageFont.truetype(FONT_FILE_PATH, 90)
    textWidth, textHeight = draw.textsize(img_weather,font=font)
    textTopLeft = (canvasSize[0]//2-textWidth//2, canvasSize[1]//2-textHeight//2)
    draw.text((textTopLeft[0], 180), img_weather, fill=(0,0,0), font=font)
    draw.text((textTopLeft[0], 181), img_weather, fill=(0,0,0), font=font)
    draw.text((textTopLeft[0], 179), img_weather, fill=(0,0,0), font=font)
    draw.text((textTopLeft[0], 179), img_weather, fill=(0,0,0), font=font)
    draw.text((textTopLeft[0], 181), img_weather, fill=(0,0,0), font=font)

    # 下段に最高気温と最低気温を描画
    font = ImageFont.truetype(FONT_FILE_PATH, 80)
    draw.text((290, 320), '/', fill=(0,0,0), font=font, stroke_width=2, stroke_fill="#0f0")
    draw.text((291, 321), '/', fill=(0,0,0), font=font, stroke_width=2, stroke_fill="#0f0")
    draw.text((291, 319), '/', fill=(0,0,0), font=font, stroke_width=2, stroke_fill="#0f0")
    draw.text((289, 319), '/', fill=(0,0,0), font=font, stroke_width=2, stroke_fill="#0f0")
    draw.text((289, 321), '/', fill=(0,0,0), font=font, stroke_width=2, stroke_fill="#0f0")

    draw.text((70, 320), img_tmax, fill=(255,0,0), font=font, stroke_width=2, stroke_fill="#0f0")
    draw.text((71, 321), img_tmax, fill=(255,0,0), font=font, stroke_width=2, stroke_fill="#0f0")
    draw.text((71, 319), img_tmax, fill=(255,0,0), font=font, stroke_width=2, stroke_fill="#0f0")
    draw.text((69, 319), img_tmax, fill=(255,0,0), font=font, stroke_width=2, stroke_fill="#0f0")
    draw.text((69, 321), img_tmax, fill=(255,0,0), font=font, stroke_width=2, stroke_fill="#0f0")

    draw.text((360, 320), img_tmin, fill=(0,0,255), font=font, stroke_width=2, stroke_fill="#0f0")
    draw.text((359, 319), img_tmin, fill=(0,0,255), font=font, stroke_width=2, stroke_fill="#0f0")
    draw.text((359, 321), img_tmin, fill=(0,0,255), font=font, stroke_width=2, stroke_fill="#0f0")
    draw.text((361, 321), img_tmin, fill=(0,0,255), font=font, stroke_width=2, stroke_fill="#0f0")
    draw.text((361, 319), img_tmin, fill=(0,0,255), font=font, stroke_width=2, stroke_fill="#0f0")

    img.save(OUTPUT_IMG_PATH)


# エントリポイント
if __name__ == '__main__':

    # 明日の 日付、お天気、最高気温、最低気温を取得
    time, wcode, tmax, tmin, = get_weather_tomorrow()
    #time, wcode, tmax, tmin, = get_weather_today()

    # BMP画像を作成
    create_bmp(time, wcode, tmax, tmin)

    print(time, get_dayofweek(time), WEATHER_CODE2TXT[wcode], tmax, tmin)
```
※create_bmp関数の中が手抜き過ぎですが、本当はキャンバスサイズやフォントサイズから描画位置を求めた方が良いですね。

# 作成したお天気BMP画像を電子ペーパーに表示する
次に先ほど作成したお天気BMP画像を電子ペーパーに表示するプログラムです。
基本的にはダウンロードしたサンプルプログラム（epd_5in65f_test.py）をコピーして、余計な箇所を削除して、表示する画像ファイルのパスを書き換えるだけ。
```python:disp_epaper.py
#!/usr/bin/python3
# -*- coding:utf-8 -*-


# 設定値
LIB_PATH = '/home/pi/e-Paper/RaspberryPi_JetsonNano/python/lib/'
IMG_PATH = '/home/pi/20221113/weather.bmp'


import sys
import os
if os.path.exists(LIB_PATH):
    sys.path.append(LIB_PATH)

import logging
from waveshare_epd import epd5in65f
import time
from PIL import Image,ImageDraw,ImageFont
import traceback

logging.basicConfig(level=logging.DEBUG)

try:
    logging.info("epd5in65f Demo")
    
    epd = epd5in65f.EPD()

    logging.info("init and Clear")
    epd.init()
    epd.Clear()
    
    Himage = Image.open(IMG_PATH)
    epd.display(epd.getbuffer(Himage))

    logging.info("Goto Sleep...")
    epd.sleep()
    
except IOError as e:
    logging.info(e)
    
except KeyboardInterrupt:    
    logging.info("ctrl + c:")
    epd5in65f.epdconfig.module_exit()
    exit()
```
# 定期実行の設定
最後にクーロンで上記2つのプログラムを連続して毎日18時に実行するように設定して完成です！

（エラー処理は一切してないけど、おそらくエラー発生時は更新が滞るだけなので問題ないかと…）

```shell
crontab -e

0 18 * * * /usr/bin/python3 /home/pi/20221113/create_weatherbmp.py; /usr/bin/python3 /home/pi/20221113/disp_epaper.py
```