ラズパイと電子ペーパーで作る部屋ダッシュボード UI画面表示編

この記事は約 32 分で読めます

ラズパイと電子ペーパーで作る部屋ダッシュボード UI画面表示編 84d46030 image

前回の記事では各種センサーの単体テストや外部APIからの天気予報データ取得を行いました。

今回のダッシュボードに使ったパーツ一覧や組み立て手順は以下の記事を参考にしてください。

今回はPythonのPILライブラリを活用して文字やアイコンを描画し、ダッシュボードを表示するUI画面スクリプトを作成します。

日本語フォントの導入と表示設定

電子ペーパーに日本語を表示させるため、OSに日本語フォントを導入します。デフォルトの状態では日本語フォントが含まれておらず、文字化けや表示エラーの原因となります。

ターミナルで以下のコマンドを実行してfonts-noto-cjkパッケージをインストールします。

sudo apt update && sudo apt install -y fonts-noto-cjk

フォントの読み込みにはPILライブラリのImageFont機能を使用します。フォントファイルが存在するパスを指定することで、任意の文字サイズで日本語を描画できます。

画面UIの構成と表示項目

ダッシュボードの画面UIについて解説します。まず、電子ペーパーの画面を4つのエリアに分割し、室内環境・屋外環境・天気予報を表示しています。

今回使用している電子ペーパーディスプレイはこちらです。

ラズパイと電子ペーパーで作る部屋ダッシュボード UI画面表示編 84d46030 image

エリアごとの表示項目は以下のとおりです。

エリア表示項目表示内容
左上:INDOOR(室内環境)室温 / 湿度 / CO2濃度 / 状態アイコン室内センサーの実測値。CO2濃度に応じて顔アイコンが3段階(good / warn / alert)に変化。1500ppm以上(alert)で「換気」警告ブロックを表示。
左下:OUTDOOR(屋外環境)外気温 / 湿度 / 天気概要 / 内外温度差OpenWeatherAPIの現在データ。室内温度との「内外温度差(例:+1.3℃)」を自動計算して表示。
右上:本日の天気予報日付 / 天気ステータス / 最高・最低気温 / 湿度今日の天気・雨アイコン、最高気温(↑)と最低気温(↓)、予想湿度を表示。
右下:明日の天気予報翌日日付 / 天気ステータス / 最高・最低気温 / 湿度翌日の予報データを本日の天気予報と同じレイアウトで表示。
右下フッター最終更新日時(Upd)スクリプトが正常に動作しているか確認するための更新時刻(月/日 時:分)を表示。

工夫した点としては、最高気温や降水確率などの表記は文字ではなく、アイコンにすることでシンプルにして見やすくしました。また、枠線でグリッドを分割することで情報が混ざらないように分割しました。

ダッシュボード画面表示スクリプトの実装

それではダッシュボード画面表示スクリプトを実装していきましょう。

以下のコマンドを入力してファイルを開きます。

vim dashboard.py

{YOUR_API_KEY_HERE}には、前回の記事で取得したOpenWeatherAPIの値を入れてください。CITYには都市を入れてください。UPDATE_INTERVAL_SECは更新間隔です。今回は10分間隔で更新します。

dashboard.pyの全文コードを表示する(クリックで展開)

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import os
import sys
import time
import datetime
import requests
from PIL import Image, ImageDraw, ImageFont

# --- ライブラリのパス設定 ---
home_dir = os.path.expanduser("~")
libdir = os.path.join(home_dir, 'e-Paper/RaspberryPi_JetsonNano/python/lib')
if os.path.exists(libdir):
    sys.path.append(libdir)

from waveshare_epd import epd4in26
import board
import adafruit_dht
import mh_z19

# ==========================================
# 設定項目
# ==========================================
API_KEY = {YOUR_API_KEY_HERE}
CITY = "Tokyo,JP"             # 対象地域
UPDATE_INTERVAL_SEC = 600       # 日中の更新間隔(10分 = 600秒)
NIGHT_START_HOUR = 22           # 夜間休止開始(22時)
NIGHT_END_HOUR = 7              # 朝の再開時間(7時)

# ==========================================
# センサー&APIデータ取得関数
# ==========================================
dht_device = adafruit_dht.DHT22(board.D4)

last_data = {
    "in_temp": 25.0,
    "in_hum": 50.0,
    "co2": 800,
    "out_temp": "--",
    "out_hum": "--",
    "out_weather": "不明",
    "forecast": []
}

def get_dht22_data():
    """DHT22から温湿度を取得(最大3回リトライ)"""
    for _ in range(3):
        try:
            temp = dht_device.temperature
            hum = dht_device.humidity
            if temp is not None and hum is not None:
                return temp, hum
        except Exception:
            pass
        time.sleep(1.0)
    return last_data["in_temp"], last_data["in_hum"]

def get_co2_data():
    """MH-Z19CからCO2濃度を取得(最大3回リトライ)"""
    for i in range(3):
        try:
            data = mh_z19.read_all()
            if data and 'co2' in data and data['co2'] > 0:
                return data['co2']
        except Exception as e:
            print(f" [CO2 Error {i+1}/3]: {e}")
        time.sleep(2.0)
    return last_data["co2"]

def get_weather_data():
    """OpenWeatherMapから天気と予報を取得"""
    cur_url = f"http://api.openweathermap.org/data/2.5/weather?q={CITY}&appid={API_KEY}&units=metric&lang=ja"
    fore_url = f"http://api.openweathermap.org/data/2.5/forecast?q={CITY}&appid={API_KEY}&units=metric&lang=ja"

    out_temp = last_data["out_temp"]
    out_hum = last_data["out_hum"]
    out_weather = last_data["out_weather"]
    forecast_list = last_data["forecast"]

    try:
        res_cur = requests.get(cur_url, timeout=10).json()
        if str(res_cur.get("cod")) == "200":
            out_temp = res_cur["main"]["temp"]
            out_hum = res_cur["main"]["humidity"]
            out_weather = res_cur["weather"][0]["description"]

        res_fore = requests.get(fore_url, timeout=10).json()
        if str(res_fore.get("cod")) == "200":
            daily_data = {}
            for item in res_fore["list"]:
                date_str = item["dt_txt"].split(" ")[0]
                temp = item["main"]["temp"]
                weather = item["weather"][0]["description"]
                pop = int(item.get("pop", 0) * 100)

                if date_str not in daily_data:
                    daily_data[date_str] = {"temps": [], "weathers": [], "pops": []}
                daily_data[date_str]["temps"].append(temp)
                daily_data[date_str]["weathers"].append(weather)
                daily_data[date_str]["pops"].append(pop)

            forecast_list = []
            for date in list(daily_data.keys())[:2]:
                temps = daily_data[date]["temps"]
                main_w = max(set(daily_data[date]["weathers"]), key=daily_data[date]["weathers"].count)
                forecast_list.append({
                    "date": date[5:].replace("-", "/"),
                    "weather": main_w,
                    "max_temp": max(temps),
                    "min_temp": min(temps),
                    "pop": max(daily_data[date]["pops"])
                })
    except Exception as e:
        print(f"Weather API Error: {e}")

    return out_temp, out_hum, out_weather, forecast_list

FONT_CACHE = {}

def get_font(size):
    if size in FONT_CACHE:
        return FONT_CACHE[size]

    font_paths = [
        "/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc",
        "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
        "/usr/share/fonts/truetype/fonts-japanese-gothic.ttf",
        "/usr/share/fonts/truetype/ipaexg.ttf"
    ]
    for path in font_paths:
        if os.path.exists(path):
            try:
                font = ImageFont.truetype(path, size)
                FONT_CACHE[size] = font
                return font
            except Exception:
                pass
    font = ImageFont.load_default()
    FONT_CACHE[size] = font
    return font

# ==========================================
# 描画関連
# ==========================================
def draw_temp_icon(draw, x, y):
    draw.ellipse((x + 4, y + 18, x + 18, y + 32), outline=0, width=2)
    draw.rectangle((x + 7, y, x + 15, y + 22), outline=0, width=2)
    draw.rectangle((x + 9, y + 17, x + 13, y + 21), fill=255)
    draw.ellipse((x + 8, y + 22, x + 14, y + 28), fill=0)
    draw.line((x + 11, y + 8, x + 11, y + 24), fill=0, width=2)

def draw_hum_icon(draw, x, y):
    draw.polygon([(x + 10, y), (x + 1, y + 18), (x + 19, y + 18)], fill=0)
    draw.ellipse((x + 1, y + 10, x + 19, y + 30), fill=0)

def draw_rain_drop_icon(draw, x, y):
    draw.polygon([(x + 8, y), (x + 1, y + 14), (x + 15, y + 14)], fill=0)
    draw.ellipse((x + 1, y + 8, x + 15, y + 24), fill=0)

def draw_face_icon(draw, x, y, size, status):
    r = size // 2
    cx, cy = x + r, y + r
    draw.ellipse((x, y, x + size, y + size), outline=0, width=2)

    if status == "good":
        draw.ellipse((cx - 11, cy - 9, cx - 4, cy - 2), fill=0)
        draw.ellipse((cx + 4, cy - 9, cx + 11, cy - 2), fill=0)
        draw.arc((cx - 11, cy - 4, cx + 11, cy + 11), start=0, end=180, fill=0, width=2)
    elif status == "warn":
        draw.ellipse((cx - 11, cy - 7, cx - 4, cy - 0), fill=0)
        draw.ellipse((cx + 4, cy - 7, cx + 11, cy - 0), fill=0)
        draw.line((cx - 9, cy + 9, cx + 9, cy + 9), fill=0, width=2)
    else:
        draw.line((cx - 11, cy - 9, cx - 2, cy - 0), fill=0, width=2)
        draw.line((cx - 11, cy - 0, cx - 2, cy - 9), fill=0, width=2)
        draw.line((cx + 2, cy - 9, cx + 11, cy - 0), fill=0, width=2)
        draw.line((cx + 2, cy - 0, cx + 11, cy - 9), fill=0, width=2)
        draw.line((cx - 9, cy + 11, cx, cy + 5), fill=0, width=2)
        draw.line((cx, cy + 5, cx + 9, cy + 11), fill=0, width=2)

def draw_weather_icon(draw, x, y, weather_str):
    if "晴" in weather_str:
        draw.ellipse((x + 12, y + 12, x + 50, y + 50), outline=0, width=2)
        lines = [
            (31, 0, 31, 8), (31, 54, 31, 62),
            (0, 31, 8, 31), (54, 31, 62, 31),
            (9, 9, 15, 15), (47, 47, 53, 53),
            (9, 53, 15, 47), (47, 15, 53, 9)
        ]
        for l in lines:
            draw.line((x + l[0], y + l[1], x + l[2], y + l[3]), fill=0, width=2)
    elif "雨" in weather_str:
        draw.ellipse((x + 5, y + 20, x + 38, y + 46), outline=0, width=2)
        draw.ellipse((x + 22, y + 12, x + 58, y + 46), outline=0, width=2)
        draw.line((x + 16, y + 50, x + 10, y + 62), fill=0, width=2)
        draw.line((x + 31, y + 50, x + 25, y + 62), fill=0, width=2)
        draw.line((x + 46, y + 50, x + 40, y + 62), fill=0, width=2)
    else:
        draw.ellipse((x + 5, y + 22, x + 38, y + 48), outline=0, width=2)
        draw.ellipse((x + 22, y + 12, x + 60, y + 48), outline=0, width=2)

def draw_dashboard(in_temp, in_hum, co2, out_temp, out_hum, out_weather, forecast):
    image = Image.new('1', (800, 480), 255)
    draw = ImageDraw.Draw(image)

    f_huge = get_font(48)
    f_hum = get_font(48)
    f_co2 = get_font(34)
    f_large = get_font(30)
    f_wtxt = get_font(35)
    f_header = get_font(24)
    f_temp = get_font(32)
    f_med = get_font(22)
    f_small = get_font(18)

    draw.line((390, 10, 390, 470), fill=0, width=2)

    # INDOOR
    draw.rectangle((20, 10, 370, 52), fill=0)
    draw.text((30, 18), "INDOOR", font=f_header, fill=255)

    draw_temp_icon(draw, 25, 76)
    draw.text((55, 58), f"{in_temp:.1f}℃", font=f_huge, fill=0)

    draw_hum_icon(draw, 25, 130)
    draw.text((55, 112), f"{in_hum:.1f}%", font=f_hum, fill=0)

    co2_status = "good" if co2 < 1000 else ("warn" if co2 < 1500 else "alert")
    draw.text((25, 170), f"CO2 {co2}", font=f_co2, fill=0)
    draw.text((200, 182), "ppm", font=f_small, fill=0)
    draw_face_icon(draw, 250, 166, 40, co2_status)

    if co2_status == "alert":
        draw.rectangle((305, 166, 345, 206), fill=0)
        draw.text((309, 174), "換気", font=f_small, fill=255)

    # OUTDOOR
    out_start_y = 230
    draw.rectangle((20, out_start_y, 370, out_start_y + 42), fill=0)
    draw.text((30, out_start_y + 8), "OUTDOOR", font=f_header, fill=255)

    try:
        out_temp_val = float(out_temp)
        out_temp_str = f"{out_temp_val:.1f}℃"
    except (ValueError, TypeError):
        out_temp_val = None
        out_temp_str = f"{out_temp}℃"

    draw_temp_icon(draw, 25, out_start_y + 66)
    draw.text((55, out_start_y + 48), out_temp_str, font=f_huge, fill=0)

    draw_hum_icon(draw, 25, out_start_y + 120)
    draw.text((55, out_start_y + 102), f"{out_hum}%", font=f_hum, fill=0)

    draw.text((25, out_start_y + 158), f"{out_weather}", font=f_wtxt, fill=0)

    if out_temp_val is not None:
        temp_diff = in_temp - out_temp_val
        diff_sign = "+" if temp_diff > 0 else ""
        draw.text((25, out_start_y + 200), f"内外温度差: {diff_sign}{temp_diff:.1f}℃", font=f_med, fill=0)

    # 右側:天気予報
    y_offset = 15
    card_height = 205

    for item in forecast[:2]:
        date_txt = item["date"]
        w_txt = item["weather"]
        t_max = item["max_temp"]
        t_min = item["min_temp"]
        pop = item["pop"]

        draw.rectangle((410, y_offset, 780, y_offset + card_height), outline=0, width=2)
        draw.rectangle((410, y_offset, 780, y_offset + 42), fill=0)
        draw.text((425, y_offset + 4), f"{date_txt}", font=f_large, fill=255)

        draw_weather_icon(draw, 700, y_offset + 54, w_txt)
        draw.text((425, y_offset + 52), f"{w_txt}", font=f_wtxt, fill=0)
        draw.text((425, y_offset + 100), f"↑ {t_max:.1f}℃    ↓ {t_min:.1f}℃", font=f_temp, fill=0)
        draw_rain_drop_icon(draw, 425, y_offset + 155)
        draw.text((450, y_offset + 150), f"{pop}%", font=f_large, fill=0)

        y_offset += card_height + 15

    now_str = datetime.datetime.now().strftime("%m/%d %H:%M")
    draw.text((650, 452), f"Upd: {now_str}", font=f_small, fill=0)

    return image.rotate(180)

# ==========================================
# メイン処理(ループ)
# ==========================================
def main():
    print("=== Dashboard System Starting ===")
    epd = epd4in26.EPD()

    try:
        while True:
            now = datetime.datetime.now()

            # --------------------------------------------------
            # 夜間休止処理 (22:00 ~ 07:00 は画面更新をスキップ)
            # --------------------------------------------------
            if now.hour >= NIGHT_START_HOUR or now.hour < NIGHT_END_HOUR:
                # 次の朝7時の日時を計算
                if now.hour >= NIGHT_START_HOUR:
                    next_wakeup = (now + datetime.timedelta(days=1)).replace(
                        hour=NIGHT_END_HOUR, minute=0, second=0, microsecond=0
                    )
                else:
                    next_wakeup = now.replace(
                        hour=NIGHT_END_HOUR, minute=0, second=0, microsecond=0
                    )

                sleep_seconds = (next_wakeup - now).total_seconds()
                hours = sleep_seconds / 3600
                print(f"[{now.strftime('%Y-%m-%d %H:%M:%S')}] ?? 朝{NIGHT_END_HOUR} 時(約{hours:.1f}時間後)までスリープします...")

                # 朝7時までスリープ
                time.sleep(sleep_seconds)
                print(f"[{datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] ?? 更新を再開します。")
                continue

            # --------------------------------------------------
            # 通常更新処理
            # --------------------------------------------------
            print(f"\n[{now.strftime('%Y-%m-%d %H:%M:%S')}] データ更新開始...")

            # 1. 各種データ取得
            in_temp, in_hum = get_dht22_data()
            co2 = get_co2_data()
            out_temp, out_hum, out_weather, forecast = get_weather_data()

            # 前回値を更新
            last_data.update({
                "in_temp": in_temp, "in_hum": in_hum, "co2": co2,
                "out_temp": out_temp, "out_hum": out_hum,
                "out_weather": out_weather, "forecast": forecast
            })

            print(f" 室内: {in_temp:.1f}℃ / {in_hum:.1f}% / CO2: {co2}ppm")
            print(f" 屋外: {out_weather} / {out_temp}℃ / {out_hum}%")

            # 2. 画面描画イメージ作成
            frame_image = draw_dashboard(in_temp, in_hum, co2, out_temp, out_hum, out_weather, forecast)

            # 3. e-Paperへ出力
            print(" e-Paper画面更新中...")
            epd.init()
            epd.display(epd.getbuffer(frame_image))
            epd.sleep()
            print(" 画面更新完了!")

            # 4. 指定待機時間(10分間 = 600秒)
            time.sleep(UPDATE_INTERVAL_SEC)

    except KeyboardInterrupt:
        print("\nユーザー停止")
        epd.init()
        epd.sleep()
        dht_device.exit()
        print("終了")

if __name__ == "__main__":
    main()
  

以下のコマンドで実行して画面が表示されれば成功です。

python3 dashboard.py

また、電子ペーパーの寿命を考慮して、22:00 ~ 07:00の夜間は更新を停止しています。

更新間隔を10分に設定している理由

電子ペーパー(E-Paper)は消費電力が極めて低い反面、画面を頻繁に書き換えると画面の焼き付き(残像)やディスプレイの寿命低下を引き起こす特性があります。今回のダッシュボードでは、情報のリアルタイム性と画面の劣化防止のバランスを考慮し、更新間隔を10分に設定しています。

ダッシュボード画面表示スクリプトではセンサーの通信失敗に備えて最大3回のリトライ処理を実装しています。

リトライを行っても取得できない場合は直前の保持データを表示します。この値が表示された場合は、単体でセンサーの値を表示するテストスクリプトを実行して、エラーを確認してください。

systemdによる自動起動と常時運用の設定

RaspberryPiの電源を入れた際に自動でダッシュボードプログラムが起動し、万が一エラーで終了しても自動再起動する仕組みを構築します。systemd機能を利用してサービス登録を行います。

サービスファイルを作成します。

sudo vim /etc/systemd/system/dashboard.service

以下の内容を記述して保存します。{USER_NAME}にはユーザー名を入れます。

[Unit]
Description=E-Paper Dashboard Service
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User={USER_NAME}
WorkingDirectory=/home/{USER_NAME}/monitor
ExecStart=/usr/bin/python3 /home/{USER_NAME}/monitor/dashboard.py
Restart=always
RestartSec=10

[Install]
WantedBy=multi-user.target

ユーザー名やファイルパスは各自の環境に合わせて変更が必要です。

作成したサービスを有効化して起動テストを行います。

sudo systemctl daemon-reload
sudo systemctl enable dashboard.service
sudo systemctl start dashboard.service

ステータスを確認して稼働状態を検証します。

sudo systemctl status dashboard.service

正常に動作していれば設定完了です。これで再起動しても自動で画面が更新され続けます。

systemdのステータス確認画面

動作確認のポイント

コマンド実行後、ステータス画面で Active: active (running) と緑色で表示されていれば、バックグラウンドでの自動起動が正常に成功しています。もし failedinactive になっている場合は、ファイルパスやユーザー名({USER_NAME})の記述に間違いがないか確認してください。

まとめ

今回はPILライブラリを活用したアイコン描画と日本語表示の設定、そして全機能をひとつにまとめたダッシュボード画面表示スクリプトの実装を行いました。

自作のグラフィック描画とOpenWeather API、各種センサーを組み合わせることで、欲しい情報がひと目でわかる視認性の高いダッシュボード画面が完成しました。ぜひダッシュボードを作成して、快適な部屋環境の指標にしてください。