本記事は「Develop fun!」を体現する! Works Human Intelligence Advent Calendar 2023 シリーズ2の17日目の記事です。
本記事の概要(TL;DR)
- Raspberry Pi Pico WH + MicroPython + 電子ペーパーで卓上カレンダーを作ったよ
- 毎日自動的に日付と日本の祝日をインターネットから取得して、カレンダーに反映するよ
- 電子工作や組み込み系が素人な自分でも、RaspberryPi Picoシリーズを使うことでPythonで比較的お手軽にIoTを体験できたよ
はじめに
年の瀬になると、つい「来年のカレンダー、どうしようかなあ……」なんて考えてしまいますよね。
筆者は毎年購入するカレンダーに比較的こだわりが強く1、なかなか満足できるカレンダーに出会えません。
更に、紙のカレンダーだとどうしても「今日って何日の何曜日だっけ?」がパッと分からないことが多く、不便に感じることが多々あります。
なので、電子ペーパーとRaspberry Pi Picoを使って、自分が満足できる卓上カレンダーを自作することにしました2。
本記事が目指すゴール
本記事では、下記のゴールを達成することを目的とします。
- Raspberry Pi Pico(以下、Pico)の開発環境をセットアップできる
- Picoに接続した電子ペーパー(Waveshare 7.5inch E-Paper E-Ink Display Module (B))に描画するコードが実行できる
- 任意の月のカレンダーを作成・描画できる
- WiFiからNTPサーバにアクセスし、現在の日付を取得できる
- 日本の祝日をネット上のAPIから取得し、カレンダーに反映できる
用意したもの
Raspberry Pi Pico WH
こちらのRaspberry Pi Pico WHを購入しました。
Raspberry Pi Zeroなど、他のRasPiシリーズも検討したのですが、
- 現在時刻の取得+電子ペーパーへのカレンダーの描画ぐらいであればPicoのマイコンでも可能だろうという判断
- 卓上に置くものなので、コンパクトな方が望ましい
- 機能としては電子ペーパーを1日1回書き換えるだけなので、低電力な方が望ましい
- Picoは価格が1000円台からと、非常に安価に購入できて気軽に遊べる
といった理由からPicoにしました。
また、ネットワーク経由で現在の時刻や日本の祝日を取得したかったので、無線LANが使用可能なWを、ピンヘッダ3を自分でハンダ付けするのは自信が無かったので、ピンヘッダが最初からハンダ付けされているHを、という感じでWH選びました。
電子ペーパー
こちらのPico用電子ペーパーを購入しました。
電子ペーパーを選んだ理由ですが、電子ペーパーは通常のディスプレイに比べて描画の更新に時間が掛かりますが、「1回描画すると、電源を切っても画面上の表示はそのまま維持される」という特性があります。また、バックライト等もないので目に優しく、視野角も広いので、卓上カレンダーにはピッタリだと考えました。
こちらの電子ペーパーには画面サイズのラインナップがあるようですが、カレンダーの文字が大きく表示できるよう、販売されている中で最大サイズの7.5インチを選択しました。
実際に届いてみると、ハガキよりも2回りほど大きいサイズで、卓上カレンダーとしては丁度良いサイズではないかと思います。
また、日曜日と祝日を分かりやすく表示したいので、白黒に加えて赤色も表示できるものにしました。
その他
その他に必要なケーブルなどは、元々家にあったものを使用するなどしました。
- PCと接続するMicroUSB(USB A - Micro B)ケーブル(家にあったものをそのまま流用)
- スタンドアロンで動作させるためのACアダプタ(いわゆるUSB充電器、家にあったものをそのまま流用)
- 飾るためのケース(100円ショップにて購入、後述)
- ケースに似合ったMicroUSBケーブル(100円ショップにて購入、後述)
開発環境セットアップ
今回は開発用PCにWindows 11、開発言語にMicroPython、エディタにVS Code + MicroPicoを使用します。
Picoの開発に使用できる言語としては、他にC言語など様々な言語が利用可能なようですが、今回は電子ペーパーの開発元がMicroPython用のコードを提供していることもあり、MicroPythonを使用することにしました。
エディタは使い慣れたVS Codeで開発ができるということで、VS Codeの拡張機能であるMicroPicoを使用しました。
PicoへのMicroPythonファームウェアの書き込み
こちらのPico W用ファームウェアのページから、MicroPythonファームウェアをダウンロードします。
筆者が使用したのは、記事執筆時点での最新版のv1.21.0 (2023-10-05) .uf2でした。
ダウンロードが完了したら、次にPicoにMicroUSBケーブルを接続し、PicoのBOOTSELボタンを押しながらケーブルをPCと接続します。
接続されると、RPI-RP2
という名前のUSBストレージとして認識されるはずなので、そのストレージをエクスプローラなどで開きます。
開いたら、先ほどダウンロードしたuf2ファイルをストレージにドラッグ&ドロップします。
するとUSBが切断+再接続され、ファームウェアの書き込みが完了します(USBストレージとしては認識されなくなります)。
VS Codeへの拡張機能のインストール
VSCodeの拡張機能画面から「MicroPico」と検索するか、こちらのMarketplaceから拡張機能をインストールします。
インストールできたら任意の作業に使用したいディレクトリを開き、Ctrl + Shift + P
などで開くコマンドパレットから
MicroPico: Configure Project
を選択し、拡張機能に「ここが作業するプロジェクトですよー」と教えてあげましょう。
拡張機能の動作確認
拡張機能が動作することを確認するために、試しにPicoに付いているLEDを光らせてみましょう。
拡張機能の説明欄にある"Getting started"に従い、下記のコードをエディタに貼り付けます。
from machine import Pin
from utime import sleep
pin = Pin("LED", Pin.OUT)
print("LED starts flashing...")
while True:
try:
pin.toggle()
sleep(1) # sleep 1sec
except KeyboardInterrupt:
break
pin.off()
print("Finished.")
VS Codeの最下部にあるステータスバーに「✔ Pico Connected」の表示があるのを確認したら、「▷ Run」をクリックしましょう。
PicoのLEDがチカチカしたら成功です。
main.py
について
MicroPythonは、main.py
という名前で保存されたファイルがあると、電源接続時にその中のコードを自動的に実行するようになります。
試しに先ほどのLEDを光らせるコードをmain.py
という名前で保存し、コマンドパレットからMicroPico: Upload project to Pico
を選択してみましょう。保存したmain.py
がPico側に保存され、以降はPCに接続しなくても、ACアダプタなどで電力を供給すれば自動的にLEDを光らせるコードが実行されるはずです。
デモコードを動かす
Picoでコードを実行できることが確認できたら、次は電子ペーパーに描画できることを確認してみましょう。
Picoに電子ペーパーを接続したら、こちらの電子ペーパーの開発元メーカーが公開しているデモコードを実行してみます。
しばらく画面のリフレッシュなどが行われ、内容が徐々に描画される4ので気長に待ち、数十秒ほどで写真のような黒と赤の画面が描画されたら成功です。
実装
ここからはゴリゴリとカレンダーを実装していきましょう。
下記の全てのPythonコードを全て同じディレクトリに配置し、Picoにアップロードします。
使用させていただく既存のコード
今回実装するにあたり、いくつかの既存のコードを使用させていただきます。
これらのコードは全てMITライセンスであることを確認済みです。
電子ペーパーを制御するEPD_7in5_B.py
今回はこちらの電子ペーパーの開発元メーカーが公開しているデモコードに実装されているEPD_7in5_B
クラスをそのまま使用させていただきました。ファイル名はEPD_7in5_B.py
とし、後述の理由から
import framebuf
の部分を
import framebuf2 as framebuf
としたほか、245行目の
if __name__=='__main__':
以降の行は今回は使用しないため、削除しています。
文字を拡大して描画するframebuf2.py
こちらのframebuf2.py
を使用させていただいています。
MicroPythonのFrameBuffer
クラスは文字列の描画が8ピクセル固定であり、これだと文字が非常に小さくて見にくい5ため、このクラスを拡張して任意の倍率の文字を描画できるようにしたlarge_text
メソッドを利用させていただきます。
今回実装したコード
画面上に描画する文字列や、その描画位置などの情報を持つDrawableString.py
class DrawableString:
def __init__(self, x: int, y: int, string: str, width: int, height: int,
size_multiply: int = 1, is_red: bool = False, is_filled: bool = False) -> None:
self.x = x
self.y = y
self.string = string
self.width = width
self.height = height
self.size_multiply = size_multiply
self.is_red = is_red
self.is_filled = is_filled
カレンダーを計算し、DrawableString
のリストを返すCalendar.py
MicroPythonにはCPythonなど一般的なPythonの標準ライブラリにあるようなdatetime
クラスが無いため、うるう年の判定や月あたりの日数、曜日の計算を自力で行っています。
from DrawableString import DrawableString
class Calendar:
__FONT_BASIC_WIDTH = 8
__FONT_BASIC_HEIGHT = 8
__MARGIN_BASIC_LEFT = 4
__MARGIN_BASIC_BOTTOM = 4
def __init__(self, year: int, month: int, today: int, x: int, y: int, size_multiply: int,
holidays: list, additional_margin_left: int = 0,
additional_margin_bottom: int = 0) -> None:
self.__year = year
self.__month = month
self.__today = today
self.__x = x
self.__y = y
self.__size_multiply = size_multiply
self.__holidays = Calendar.__get_holiday_day_list(
year, month, holidays)
self.__additional_margin_left = additional_margin_left
self.__additional_margin_bottom = additional_margin_bottom
def __get_holiday_day_list(year: int, month: int, holidays: list) -> list:
days = []
for date in holidays:
date_year, date_month, date_day = date.split('-')
if date_year == str(year) and date_month == f"{month:02}":
days.append(int(date_day))
return days
def __get_is_leap_year(year: int) -> bool:
return year % 4 == 0 and (year % 100 != 0 or year % 400 == 0)
def __get_days_in_month(year: int, month: int) -> int:
if month in [4, 6, 9, 11]:
return 30
elif month == 2:
return 29 if Calendar.__get_is_leap_year(year) else 28
else:
return 31
def __get_day_of_week_number(year: int, month: int, day: int) -> int:
# See: [Zeller's congruence](https://en.wikipedia.org/wiki/Zeller%27s_congruence)
if month < 3:
month += 12
year -= 1
k = year % 100
j = year // 100
h = day + ((13 * (month + 1)) // 5) + k + (k // 4) + (j // 4) - (2 * j)
return (h + 5) % 7
def generate(self) -> list:
first_weekday_no = Calendar.__get_day_of_week_number(
self.__year, self.__month, 1)
days = Calendar.__get_days_in_month(self.__year, self.__month)
today = self.__today
x = self.__x
y = self.__y
char_size = self.__size_multiply
char_width = self.__FONT_BASIC_WIDTH * self.__size_multiply
char_height = self.__FONT_BASIC_HEIGHT * self.__size_multiply
margin_left = self.__MARGIN_BASIC_LEFT * \
self.__size_multiply + self.__additional_margin_left
margin_bottom = self.__MARGIN_BASIC_BOTTOM * \
self.__size_multiply + self.__additional_margin_bottom
result = []
weekday_abbr = ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"]
weekday_str_size = 2
width = char_width * weekday_str_size + margin_left
height = char_height + margin_bottom
for wd in weekday_abbr:
result.append(DrawableString(x, y, wd, char_width * weekday_str_size,
char_height, char_size, True if wd == "Su" else False, False))
x += width
x = self.__x + width * first_weekday_no
y += height
for day in range(1, days + 1):
weekday_no = (first_weekday_no + day - 1) % 7
is_red = True if weekday_no == 6 or day in self.__holidays else False
is_filled = True if today is not None and day == today else False
result.append(DrawableString(
x, y, f"{day:2d}", char_width * 2, char_height, char_size, is_red, is_filled))
x += width
if weekday_no == 6:
x = self.__x
y += height
return result
NTPサーバから現在日時を取得するDateTimeGetter.py
NTPサーバには任意のサーバを指定できるようになっています。
今回はNICTのNTPサーバを利用させていただいています。
import network
import time
import ntptime
def local_date_time_getter(ssid: str, password: str,
host: str = "ntp.nict.jp",
offset_min: int = 9 * 60) -> time.struct_time:
wlan = network.WLAN(network.STA_IF)
wlan.active(True)
wlan.connect(ssid, password)
while not wlan.isconnected():
print("Waiting for connection...")
time.sleep(1)
ntptime.host = host
ntptime.settime()
return time.localtime(time.time() + offset_min * 60)
日本の祝日を取得するHolidayGetter.py
有志の方が公開されている、Holidays JP APIというとても便利なAPIを利用させていただきました。
import network
import time
import requests
def get_holiday_json(ssid: str, password: str):
URL = "https://holidays-jp.github.io/api/v1/date.json"
wlan = network.WLAN(network.STA_IF)
wlan.active(True)
wlan.connect(ssid, password)
while not wlan.isconnected():
print("Waiting for connection...")
time.sleep(1)
return requests.get(URL).json()
def get_holidays_date(ssid: str, password: str) -> list:
json = get_holiday_json(ssid, password)
return list(json.keys())
WiFiのSSID / パスワードを保存するsecret.py
プロジェクトをGitで管理する場合、間違ってGitHubなどに上げてしまわないよう、.gitignore
ファイルにsecret.py
の1行を追加しておきましょう。
SSID = "接続したいWiFiのSSID"
PASSWORD = "接続したいWiFiのパスワード"
エントリポイントとなるmain.py
割とごちゃっとしてしまったので、もっとファイルを分割すべきだったかも知れません。
カレンダーなど各パーツの配置は、それぞれのパーツの大きさを手で計算しながら地道に配置&調整しました。
import machine
import time
from EPD_7in5_B import EPD_7in5_B
import framebuf2 as framebuf
from Calendar import Calendar
from DateTimeGetter import local_date_time_getter
from secret import SSID, PASSWORD
from HolidayGetter import get_holidays_date
def draw_calendar(image_black: framebuf.FrameBuffer, image_red: framebuf.FrameBuffer,
calendar_drawable_string_list: list) -> None:
for s in calendar_drawable_string_list:
if s.is_filled:
if s.is_red:
image_red.rect(s.x - s.width//8, s.y - s.height//4, s.width + s.width//4,
s.height + s.height//2, 0xff, True)
image_red.large_text(
s.string, s.x, s.y, s.size_multiply, 0x00)
else:
image_black.rect(s.x - s.width//8, s.y - s.height//4, s.width + s.width//4,
s.height + s.height//2, 0x00, True)
image_black.large_text(
s.string, s.x, s.y, s.size_multiply, 0xff)
else:
if s.is_red:
image_red.large_text(
s.string, s.x, s.y, s.size_multiply, 0xff)
else:
image_black.large_text(
s.string, s.x, s.y, s.size_multiply, 0x00)
def set_rtc_from_ntp(rtc, led, ssid: str, password: str) -> (int, int, int, int) | None:
year = 2000
month = 1
day = 1
hour = 0
min = 0
sec = 0
weekday_num = 5
try:
toggle_led(led, True)
(year, month, day, hour, min, sec, weekday_num,
_) = local_date_time_getter(ssid, password, "ntp.nict.jp", 9 * 60)
print(
f"Successfully got datetime: {year:04}-{month:02}-{day:02} {hour:02}:{min:02}:{sec:02}")
toggle_led(led, False)
except Exception as e:
print(f"Failed to get datetime: {e}")
blink_led(led)
return None
try:
rtc.datetime((year, month, day, weekday_num, hour, min, sec, 0))
print(f"Successfully set RTC: {rtc.datetime()}")
except Exception as e:
print(f"Failed to set RTC: {e}")
blink_led(led)
return None
return (year, month, day, weekday_num)
def set_rtc_from_ntp_with_retry(rtc, led, ssid: str, password: str) -> (int, int, int, int) | None:
SLEEP_SEC = 5
RETRY_TIMES = 5
result = None
for i in range(RETRY_TIMES):
result = set_rtc_from_ntp(rtc, led, ssid, password)
if result:
break
print("Setting RTC from NTP seems to have been failed...")
print(f"Retry in {SLEEP_SEC} seconds. ({i+1}/{RETRY_TIMES})")
time.sleep(SLEEP_SEC)
return result
def get_holidays(led, ssid: str, password: str) -> list | None:
holidays = None
try:
toggle_led(led, True)
holidays = get_holidays_date(ssid, password)
print(f"Successfully got holidays: {holidays}")
toggle_led(led, False)
except Exception as e:
print(f"Failed to get holidays: {e}")
blink_led(led)
return holidays
def get_holidays_with_retry(led, ssid: str, password: str) -> list | None:
SLEEP_SEC = 5
RETRY_TIMES = 5
result = None
for i in range(RETRY_TIMES):
result = get_holidays(led, ssid, password)
if result:
break
print("Getting holidays seems to have been failed...")
print(f"Retry in {SLEEP_SEC} seconds. ({i+1}/{RETRY_TIMES})")
time.sleep(SLEEP_SEC)
return result
def toggle_led(led, is_on: bool) -> None:
if is_on:
led.on()
else:
led.off()
def blink_led(led) -> None:
for i in range(10):
toggle_led(led, True if i % 2 == 0 else False)
time.sleep_ms(100)
def draw_main(epd: EPD_7in5_B, date: (int, int, int, int), holidays: list) -> None:
(year, month, day, weekday_num) = date or (2000, 1, 1, 5)
if not holidays:
holidays = []
epd.Clear()
epd.imageblack.fill(0xff)
epd.imagered.fill(0x00)
weekday_abbr = ["Mon", "Tue", "Wed", "Thu",
"Fri", "Sat", "Sun"][weekday_num]
epd.imageblack.large_text(
f"{year:04}-", 16, 38, 3, 0x00)
epd.imageblack.large_text(
f"{month:02}-{day:02} {weekday_abbr}", 136, 16, 6, 0x00)
print("Completed drawing of today's date.")
epd.imageblack.hline(16, 80, 768, 0x00)
print("Completed drawing of horizontal line.")
this_month_calendar = Calendar(
year, month, day, 16, 96, 3, holidays, 12, 6)
draw_calendar(epd.imageblack, epd.imagered,
this_month_calendar.generate())
print("Completed drawing of this month's calendar.")
epd.imageblack.vline(504, 80, 384, 0x00)
print("Completed drawing of vertical line.")
prev_year = year if month > 1 else year - 1
prev_month = month - 1 if month > 1 else 12
epd.imageblack.large_text(
f"{prev_year:04}-{prev_month:02}", 512, 96, 2, 0x00)
prev_calendar = Calendar(prev_year, prev_month,
None, 512, 120, 2, holidays)
draw_calendar(epd.imageblack, epd.imagered, prev_calendar.generate())
print("Completed drawing of last month's calendar.")
next_year = year if month < 12 else year + 1
next_month = month + 1 if month < 12 else 1
epd.imageblack.large_text(
f"{next_year:04}-{next_month:02}", 512, 288, 2, 0x00)
next_calendar = Calendar(next_year, next_month,
None, 512, 312, 2, holidays)
draw_calendar(epd.imageblack, epd.imagered, next_calendar.generate())
print("Completed drawing of next month's calendar.")
epd.display()
print("Drawing done.")
epd.sleep()
def do_main_sequence(rtc, ssid: str, password: str) -> (int, int, int, int):
epd = EPD_7in5_B()
led = machine.Pin("LED", machine.Pin.OUT)
calendar_date = set_rtc_from_ntp_with_retry(
rtc, led, ssid, password) or (2000, 1, 1, 5)
holidays = get_holidays_with_retry(led, ssid, password) or []
draw_main(epd, calendar_date, holidays)
return calendar_date
if __name__ == '__main__':
rtc = machine.RTC()
calendar_date = do_main_sequence(rtc, SSID, PASSWORD)
SLEEP_MINUTES = 10
while True:
rtc_datetime = rtc.datetime()
if (calendar_date[0] != rtc_datetime[0]
or calendar_date[1] != rtc_datetime[1]
or calendar_date[2] != rtc_datetime[2]
):
print("Redraw as the date seems to have changed.")
calendar_date = do_main_sequence(rtc, SSID, PASSWORD)
else:
print(f"RTC datetime: {rtc_datetime[0]}-{rtc_datetime[1]}-{rtc_datetime[2]} " +
f"{rtc_datetime[4]}:{rtc_datetime[5]}:{rtc_datetime[6]}")
print(
f"Showing calendar date: {calendar_date[0]}-{calendar_date[1]}-{calendar_date[2]}")
print(f"Sleep {SLEEP_MINUTES} minutes until the date changes...")
time.sleep(SLEEP_MINUTES * 60)
実行してみる
実行すると、VSCodeのターミナル(REPL)に現在の状況が出力されます。
また、Pico本体もネットワーク通信を行っている間はLEDが光ります(エラーが起きると点滅し、各5回までリトライします)。
実行してしばらく待っていると、写真のように現在の日付と今月のカレンダー、右側に小さめに先月と来月のカレンダーが描画されます。
カレンダーは今日の日付の部分について色が反転表示されて分かりやすくなっているほか、日曜日と日本の祝日は赤色で描画されています。
2023年12月は残念ながら祝日が無いので分かりにくいですが、写真右側の先月来月のカレンダーを見ると、11月3日の文化の日、23日の勤労感謝の日、2024年1月1日の元日、8日の成人の日が赤く表示されていることが分かります。
以上で、本記事のゴールが達成できたことが確認できました。
ケースの作成
現在のままでは基板等が剥き出しで、机の上に置くことができません。
なので、良さそうな額縁やケースを100円ショップで探したところ、下記の商品が電子ペーパーのサイズにピッタリでした。
この木箱のフタ部分にマスキングテープで電子ペーパーを取り付け、側面に木工用ドリルで穴を空けてUSBケーブルを通して、中にRaspPi Picoなどを収納したところ、良い感じのケースになりました。
また、せっかくなのでケーブルも木に合いそうなゴールドカラーのものを同様に100円ショップで購入しました。
おわりに
筆者は今回扱ったような電子工作や組み込み系、マイコンといったジャンルには全く詳しくないのですが、そんな素人同然の自分でも、普段比較的親しみのあるPythonを使って、あまり迷うことなく目的のコードを書くことができました。
このカレンダーも、アイディア次第で
- もっと省電力化して、バッテリ駆動にしてみる
- 動作の安定性を高める
- 天気予報やGoogleカレンダーの予定を表示してみる
のような、発展や工夫の余地があると感じています。これらにも挑戦してみたいですね。
またそれ以外にも、Picoは非常に安価で気軽に買いやすいので、これからも色々作ってみたいですね。
今年のアドベントカレンダーも例年に引き続き、業務とは一切関係ない自分の趣味全開の記事になってしまいましたが、自分自身がとても楽しんで開発を行うことができました。
この記事が皆様の創作意欲を刺激し、ひいては「Develop fun!」に繋がる、そんな楽しいエンジニアライフの一助になれば幸いです。
参考文献
- Pico e-Paper 7.5 B - Waveshare Wiki
- waveshareteamPico_ePaper_Code Waveshrae Pico e-Paper driver code
- MicroPython ライブラリ — MicroPython latest ドキュメント
- Raspberry Pi Pico W の LED を VS Code を使って、点滅させてみた。(RP-Pico チュートリアル)
- Raspberry Pi Pico W を Wi-Fi に接続してみる。
-
「身の回りのカレンダーは全て月曜始まりに統一したい」「シンプルで視認性が高いものがいい」「日本の祝日が確認できるものがいい」「先月と来月のカレンダーも一緒に確認できるといい」など ↩
-
あとはアドベントカレンダー用の記事ということで、カレンダーを作ったのも正直ちょっとあります↩ -
Picoの左右に付いている、ムカデの脚のような金属製の棒状をした接続端子。今回はこれを介して電子ペーパーを制御しています。 ↩
-
画面が時間を掛けて徐々に描画されるのはこの電子ペーパーの仕様ではなく、デモコードの側で描画とスリープを繰り返しているためです。試しに一番最後以外の
epd.display()
とepd.delay_ms(5000)
以外をコメントアウトすると、一気に描画されるようになります。 ↩ -
デモコードを動かすの写真の、左上に書かれている小さな文字のサイズが8ピクセルです。MicroPythonの
FrameBuffer
クラス単体では、このサイズでしか文字を描画できません。 ↩