はじめに
以前、ポケモンの目覚ましを作ったという記事を掲載した。以下の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上でサービスとして起動しているので、これを再起動することで反映がされる。
最後に
ディスプレイに表示させるだけかと思っていたんだけど、曜日によって曲を変えられたり、画像を変えられたりするようにしていたらコードが思っていた以上に複雑になった。
ただ、一度作ってしまえば、あとはそれを修正して改善させていけばいいので、まずは作ってみることが大事なんだと思う。
