目覚まし時計の時刻をディスプレイ表示させてより時計らしくさせる

スポンサーリンク
Uncategorized

はじめに

以前、ポケモンの目覚ましを作ったという記事を掲載した。以下の2つである。

ここで作成したものは、あくまで時間が来たら音楽が流れるというものだった。

今回は実際の起動時に時間が画面に表示される、時計らしいものを作ってみることにした。

前回までの内容の改修版になる。

実際に動いている様子はXに投稿した。

Xに載せた動画にもあるが、以下のような画面が表示されるという実装になっている。

また、今回の実装では、曜日ごとの曲を選択できるようにして、画面に表示させる画像も選べるようにした。

用意するもの

今回は音楽として映画「天空の城ラピュタ」でパズーが朝演奏する「ハトと少年」を使用する。

元の音楽はティム・モリソンによるハトと少年の演奏である。以下が動画

画像はジブリがフリー画像として提供しているものを使わせていただいた。

天空の城ラピュタ - スタジオジブリ|STUDIO GHIBLI
天空の城ラピュタ。 原作・脚本・監督 宮﨑 駿 プロデューサー 高畑 勲 音楽 久石 譲 主題歌 井上あずみ 声の出演 田中真弓 ⋅ 横沢啓子 ⋅ 初井言榮 ⋅ 寺田 農 ⋅

使用するのは前回とも同様にラズパイ zero Wで、ディスプレイは「OSOYOO HDMI 3.5インチLCDディスプレイ モニター タッチスクリーン」を使用している。

実装

今回のプログラムは以下のような構成。

  • display_clock.py
    • pygame/SDL2使ってフルスクリーン描画する
    • main()内のwhile runningループで1秒ごとに画面更新する
      • now.strftime(“%H:%M:%S”)で時刻、年月日、曜日をレンダリングして右下に配置する
  • kiosk.sh
    • labwc起動時にこのスクリプトが呼ばれる
  • ~/.config/labwc/autostart(Pi実機側、git管理外)
    • labwcがセッション開始時にシェルスクリプトとして実行
    • 「kiosk.sh &」が書かれている
  • alarm.pyのset_hdmi_power(表示自体のON/OFF)
    • wlr-randr –output HDMI-A-1 –on/–offをsubprocess経由で叩く。play_sound()でON、do_stop()/do_snooze()でOFF。
    • ブログ説明: 時計は常時裏で描画し続けてるが、物理HDMI出力自体をアラーム鳴動と連動させて省電力・画面焼き付き対策にしてる、という設計判断。「表示内容(何を描くか)」と「出力のON/OFF(いつ見せるか)」を分離した点がポイント

まずフォントを入れておく。

sudo apt install fonts-ipafont-gothic

新規ファイル

display_clock.py

display_clock.pyは以下。

#!/usr/bin/env python3
import os
from datetime import datetime

import pygame

import alarm

BASE_DIR = os.path.dirname(os.path.abspath(__file__))
FONT_PATH = "/usr/share/fonts/truetype/fonts-japanese-gothic.ttf"
WEEKDAY_JA = ["月", "火", "水", "木", "金", "土", "日"]  # datetime.weekday(): Mon=0
OVERLAY_ALPHA = 90  # ~ CSS rgba(0,0,0,0.35)


def get_today_image_path():
    try:
        config = alarm.load_config()
        day_name = alarm.DAY_NAMES[datetime.now().weekday()]
        return config["images"].get(day_name, alarm.DEFAULT_IMAGE)
    except (OSError, ValueError, KeyError) as e:
        print(f"[display] failed to read image config, using default: {e!r}")
        return alarm.DEFAULT_IMAGE


def load_background(path, size):
    try:
        img = pygame.image.load(path).convert()
    except (pygame.error, FileNotFoundError) as e:
        print(f"[display] failed to load image {path}, falling back to default: {e!r}")
        img = pygame.image.load(alarm.DEFAULT_IMAGE).convert()
    iw, ih = img.get_size()
    w, h = size
    scale = max(w / iw, h / ih)
    scaled = pygame.transform.smoothscale(img, (int(iw * scale), int(ih * scale)))
    x = (scaled.get_width() - w) // 2
    y = (scaled.get_height() - h) // 2
    return scaled.subsurface(pygame.Rect(x, y, w, h)).copy()


def main():
    os.environ.setdefault("SDL_VIDEODRIVER", "wayland")
    os.environ.setdefault("SDL_AUDIODRIVER", "dummy")
    pygame.init()
    pygame.mouse.set_visible(False)
    screen = pygame.display.set_mode((0, 0), pygame.FULLSCREEN)
    w, h = screen.get_size()

    current_image_path = get_today_image_path()
    background = load_background(current_image_path, (w, h))
    overlay = pygame.Surface((w, h), pygame.SRCALPHA)
    overlay.fill((0, 0, 0, OVERLAY_ALPHA))

    time_font = pygame.font.Font(FONT_PATH, int(h * 0.30))
    date_font = pygame.font.Font(FONT_PATH, int(h * 0.07))
    weekday_font = pygame.font.Font(FONT_PATH, int(h * 0.06))

    clock = pygame.time.Clock()
    last_second = None
    last_checked_minute = None

    ringing = alarm.is_ringing()
    alarm.set_hdmi_power(ringing)

    running = True
    while running:
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                running = False
            elif event.type == pygame.KEYDOWN and event.key == pygame.K_ESCAPE:
                running = False

        now = datetime.now()

        if now.minute != last_checked_minute:
            last_checked_minute = now.minute
            new_image_path = get_today_image_path()
            if new_image_path != current_image_path:
                current_image_path = new_image_path
                background = load_background(current_image_path, (w, h))

        if now.second != last_second:
            last_second = now.second

            currently_ringing = alarm.is_ringing()
            if currently_ringing != ringing:
                ringing = currently_ringing
                alarm.set_hdmi_power(ringing)

            screen.blit(background, (0, 0))
            screen.blit(overlay, (0, 0))

            right_x = w - int(w * 0.05)
            rows = (
                (now.strftime("%H:%M:%S"), time_font, 0.72),
                (f"{now.year}年{now.month}月{now.day}日", date_font, 0.85),
                (f"{WEEKDAY_JA[now.weekday()]}曜日", weekday_font, 0.92),
            )
            for text, font, y_ratio in rows:
                surf = font.render(text, True, (255, 255, 255))
                rect = surf.get_rect(midright=(right_x, int(h * y_ratio)))
                screen.blit(surf, rect)

            pygame.display.flip()

        clock.tick(10)

    pygame.quit()


if __name__ == "__main__":
    main()
  • get_today_image_path()
    • 今日の曜日に設定されてる画像パス、config.jsonから取得する
  • load_background(path, size)
    • 画像ファイル読み込んで、画面サイズにぴったり合わせて切り出す
  • main()
    • フルスクリーン画面作って、背景画像・時刻・日付・曜日を毎秒描画する
    • 1分毎に画像設定変わってないかチェック、アラーム鳴動中かどうかでHDMI電源も切替する

kiosk.sh

kiosk.shは以下。

#!/bin/bash
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
exec python3 "$DIR/display_clock.py"

実行するだけのbashファイル。

元ファイルの変更箇所

alarm.pyの変更箇所

・・・【略】・・・

## 全体の変数は以下のように更新
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
SOUNDS_DIR = os.path.join(BASE_DIR, "sounds")
IMAGES_DIR = os.path.join(BASE_DIR, "static", "images")
CONFIG_FILE = os.path.join(BASE_DIR, "config.json")
PID_FILE = "/tmp/alarm_clock.pid"
SNOOZE_FILE = "/tmp/alarm_clock_snooze.json"
SNOOZE_MINUTES = 5
RING_TIMEOUT_MINUTES = 60
DEFAULT_SOUND = os.path.join(BASE_DIR, "sounds", "alarm.mp3")
DEFAULT_IMAGE = os.path.join(IMAGES_DIR, "laputa004.jpg")
IMAGE_EXTENSIONS = (".jpg", ".jpeg", ".png")
DAY_NAMES = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]
CATEGORIES = ["weekday", "weekend", "holiday"]

DEFAULT_CONFIG = {
    "schedules": {
        "weekday": {"time": "07:00", "enabled": True},
        "weekend": {"time": "09:00", "enabled": True},
        "holiday": {"time": "09:00", "enabled": True},
    },
    "sounds": {day: DEFAULT_SOUND for day in DAY_NAMES},
    "images": {day: DEFAULT_IMAGE for day in DAY_NAMES},
}

## load_config()は以下のように更新(configから画像の設定を読み込むようにしている)
def load_config():
    if not os.path.exists(CONFIG_FILE):
        return json.loads(json.dumps(DEFAULT_CONFIG))
    with open(CONFIG_FILE, "r") as f:
        config = json.load(f)
    # backfill any missing keys (safe for older config files)
    for cat in CATEGORIES:
        config["schedules"].setdefault(cat, DEFAULT_CONFIG["schedules"][cat])
    for day in DAY_NAMES:
        config["sounds"].setdefault(day, DEFAULT_SOUND)
    config.setdefault("images", {})
    for day in DAY_NAMES:
        config["images"].setdefault(day, DEFAULT_IMAGE)
    return config

## ★追加分の関数は以下全部(各関数の説明は後述)
def list_images():
    """Return sorted list of image filenames available in static/images/."""
    if not os.path.isdir(IMAGES_DIR):
        return []
    return sorted(f for f in os.listdir(IMAGES_DIR) if f.lower().endswith(IMAGE_EXTENSIONS))

def set_day_image(day, filename):
    if day not in DAY_NAMES:
        raise ValueError(f"day must be one of {DAY_NAMES}")
    if filename not in list_images():
        raise ValueError(f"unknown image: {filename}")
    config = load_config()
    config["images"][day] = os.path.join(IMAGES_DIR, filename)
    save_config(config)

def set_hdmi_power(on):
    env = os.environ.copy()
    env["XDG_RUNTIME_DIR"] = f"/run/user/{os.getuid()}"
    env.setdefault("WAYLAND_DISPLAY", "wayland-0")
    try:
        subprocess.run(
            ["wlr-randr", "--output", HDMI_OUTPUT, "--on" if on else "--off"],
            env=env,
            check=True,
            stdout=subprocess.DEVNULL,
            stderr=subprocess.DEVNULL,
        )
    except (subprocess.CalledProcessError, FileNotFoundError) as e:
        print(f"[hdmi] failed to set power {'on' if on else 'off'}: {e!r}")

各関数の説明

  • list_images()
    • static/images/内の候補ファイル一覧作る
  • set_day_image(day, filename)

web.pyの変更箇所

@app.route("/api/images")
def images():
    config = alarm.load_config()
    return jsonify(
        {
            "available": alarm.list_images(),
            "images": {day: os.path.basename(path) for day, path in config["images"].items()},
        }
    )


@app.route("/api/image", methods=["POST"])
def set_image():
    body = request.get_json(silent=True) or {}
    try:
        alarm.set_day_image(body.get("day"), body.get("filename"))
    except ValueError as e:
        return jsonify({"error": str(e)}), 400
    return jsonify({"ok": True})
  • set_image()
    • 曜日ごとの背景画像を設定する
  • images()
    • 曜日別画像と候補一覧をUIに返す

これをラズパイzero上でサービスとして起動しているので、これを再起動することで反映がされる。

最後に

ディスプレイに表示させるだけかと思っていたんだけど、曜日によって曲を変えられたり、画像を変えられたりするようにしていたらコードが思っていた以上に複雑になった。

ただ、一度作ってしまえば、あとはそれを修正して改善させていけばいいので、まずは作ってみることが大事なんだと思う。

タイトルとURLをコピーしました