はじめに
ポケモン30周年だからという理由でポケモンの目覚まし時計を作った。
作ったときの記事は以下。
①
②
この目覚ましは時間になったら曲が流れるんだけど、流れ終わったらそれで終わってしまう。
つまり、その間に起きれなければ寝過ごしてしまう。
この問題を解消するべく、本日はスマホで停止や、スヌーズをすることができる画面を実装する。
これで、sshでラズパイに入らなくても、スマホから操作が可能になる。
そして、朝もより起きれるようになるだろう。
実装
今回の実装内容は以下のようになっている。
- alarm.py
- ⇒ snoozeコマンド追加
- web.pyを新規で用意
- アラームループ
- index.html
- スマホ用UIのためのもの
- alarm-clock.service
- alarm.py run → web.pyに変更
まず、alarm-clock.serviceのalarm.py run → web.pyに変更した。
ExecStart=/usr/bin/python3 /home/user1/alarm_clock/alarm.py run👇
ExecStart=/home/user1/tools/alarm_clock/.venv/bin/python3 /home/user1/tools/alarm_clock/web.pyもともとあった、alarm.pyでは、以下を追加した。
alarm.py
スヌーズ機能とスマホからアクセスできるようにするために、コードとしては以下のような修正結果となった。
#!/usr/bin/env python3
import argparse
import json
import os
import signal
import subprocess
import sys
import time
from datetime import datetime, timedelta
import jpholiday
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
SOUNDS_DIR = os.path.join(BASE_DIR, "sounds")
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")
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},
}
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)
return config
def save_config(config):
tmp_path = CONFIG_FILE + ".tmp"
with open(tmp_path, "w") as f:
json.dump(config, f, indent=2, ensure_ascii=False)
os.replace(tmp_path, CONFIG_FILE)
def category_for_date(d):
if jpholiday.is_holiday(d):
return "holiday"
return "weekday" if d.weekday() < 5 else "weekend"
def set_schedule_time(category, time_str):
if category not in CATEGORIES:
raise ValueError(f"category must be one of {CATEGORIES}")
try:
datetime.strptime(time_str, "%H:%M")
except ValueError:
raise ValueError("time must be HH:MM")
config = load_config()
config["schedules"][category]["time"] = time_str
save_config(config)
def set_schedule_enabled(category, enabled):
if category not in CATEGORIES:
raise ValueError(f"category must be one of {CATEGORIES}")
config = load_config()
config["schedules"][category]["enabled"] = bool(enabled)
save_config(config)
def cmd_set(args):
try:
set_schedule_time(args.category, args.time)
except ValueError as e:
print(e)
sys.exit(1)
print(f"{args.category} alarm set to {args.time}")
def cmd_toggle(args, enabled):
try:
set_schedule_enabled(args.category, enabled)
except ValueError as e:
print(e)
sys.exit(1)
print(f"{args.category} alarm {'enabled' if enabled else 'disabled'}")
def list_sounds():
"""Return sorted list of mp3 filenames available in sounds/."""
if not os.path.isdir(SOUNDS_DIR):
return []
return sorted(f for f in os.listdir(SOUNDS_DIR) if f.lower().endswith(".mp3"))
def set_day_sound(day, filename):
if day not in DAY_NAMES:
raise ValueError(f"day must be one of {DAY_NAMES}")
if filename not in list_sounds():
raise ValueError(f"unknown sound: {filename}")
config = load_config()
config["sounds"][day] = os.path.join(SOUNDS_DIR, filename)
save_config(config)
def cmd_sound(args):
if args.day not in DAY_NAMES:
print(f"day must be one of {DAY_NAMES}")
sys.exit(1)
config = load_config()
config["sounds"][args.day] = args.path
save_config(config)
print(f"{args.day} sound set to {args.path}")
def cmd_show(args):
config = load_config()
print("schedules:")
for cat in CATEGORIES:
s = config["schedules"][cat]
status = "ON" if s["enabled"] else "off"
print(f" {cat:8s} {s['time']} [{status}]")
print("sounds:")
for day in DAY_NAMES:
print(f" {day}: {config['sounds'][day]}")
today = datetime.now().date()
print(f"today ({today}) category: {category_for_date(today)}")
def _process_alive(pid):
try:
waited_pid, _ = os.waitpid(pid, os.WNOHANG)
except ChildProcessError:
try:
os.kill(pid, 0)
return True
except ProcessLookupError:
return False
return waited_pid == 0
def is_ringing():
if not os.path.exists(PID_FILE):
return False
with open(PID_FILE) as f:
pid = int(f.read().strip())
if _process_alive(pid):
return True
os.remove(PID_FILE)
return False
def get_snooze():
if not os.path.exists(SNOOZE_FILE):
return None
with open(SNOOZE_FILE) as f:
return json.load(f)
def do_stop():
acted = False
if os.path.exists(PID_FILE):
with open(PID_FILE) as f:
pid = int(f.read().strip())
try:
os.killpg(os.getpgid(pid), signal.SIGTERM)
except ProcessLookupError:
pass
os.remove(PID_FILE)
acted = True
if os.path.exists(SNOOZE_FILE):
os.remove(SNOOZE_FILE)
acted = True
return acted
def do_snooze(minutes=SNOOZE_MINUTES):
if not os.path.exists(PID_FILE):
return None
with open(PID_FILE) as f:
pid = int(f.read().strip())
try:
os.killpg(os.getpgid(pid), signal.SIGTERM)
except ProcessLookupError:
pass
os.remove(PID_FILE)
config = load_config()
day_name = DAY_NAMES[datetime.now().weekday()]
sound = config["sounds"][day_name]
fire_at = datetime.now() + timedelta(minutes=minutes)
with open(SNOOZE_FILE, "w") as f:
json.dump({"fire_at": fire_at.isoformat(), "sound": sound}, f)
return fire_at
def cmd_stop(args):
if do_stop():
print("alarm stopped")
else:
print("no alarm currently sounding")
def cmd_snooze(args):
fire_at = do_snooze()
if fire_at is None:
print("no alarm currently sounding")
sys.exit(1)
print(f"snoozed, will ring again at {fire_at.strftime('%H:%M:%S')}")
ALSA_DEVICE = "plughw:1,0" # USB speaker (card 1) — see `aplay -l`
def play_sound(sound_path):
proc = subprocess.Popen(
# --loop -1: repeat the track indefinitely until killed (stop/snooze/timeout).
["mpg123", "-o", "alsa", "-a", ALSA_DEVICE, "--loop", "-1", sound_path],
stdout=subprocess.DEVNULL,
preexec_fn=os.setsid,
)
with open(PID_FILE, "w") as f:
f.write(str(proc.pid))
return proc
def check_ring_timeout():
"""Auto-stop an alarm that's been looping unattended past RING_TIMEOUT_MINUTES."""
if not os.path.exists(PID_FILE):
return
started_at = datetime.fromtimestamp(os.path.getmtime(PID_FILE))
if datetime.now() - started_at > timedelta(minutes=RING_TIMEOUT_MINUTES):
do_stop()
def cmd_run(args):
print("alarm loop started, checking every 20s. Ctrl+C to quit.")
fired_this_minute = set()
while True:
try:
now = datetime.now()
current_time = now.strftime("%H:%M")
minute_key = now.strftime("%Y%m%d%H%M")
category = category_for_date(now.date())
day_name = DAY_NAMES[now.weekday()]
config = load_config()
schedule = config["schedules"][category]
if schedule["enabled"] and schedule["time"] == current_time:
key = (category, minute_key)
if key not in fired_this_minute:
fired_this_minute.add(key)
sound = config["sounds"][day_name]
print(f"[{now}] firing {category} alarm, playing {sound}")
play_sound(sound)
snooze = get_snooze()
if snooze and not is_ringing():
fire_at = datetime.fromisoformat(snooze["fire_at"])
if now >= fire_at:
print(f"[{now}] snooze expired, playing {snooze['sound']}")
play_sound(snooze["sound"])
os.remove(SNOOZE_FILE)
check_ring_timeout()
fired_this_minute = {k for k in fired_this_minute if k[1] == minute_key}
except Exception as e:
print(f"[{datetime.now()}] alarm loop error: {e!r}")
time.sleep(20)
def main():
parser = argparse.ArgumentParser(description="Raspberry Pi alarm clock")
sub = parser.add_subparsers(dest="command", required=True)
p_set = sub.add_parser("set", help="set alarm time for a category")
p_set.add_argument("category", choices=CATEGORIES)
p_set.add_argument("time", help="HH:MM 24h format")
p_set.set_defaults(func=cmd_set)
p_enable = sub.add_parser("enable", help="enable a category's alarm")
p_enable.add_argument("category", choices=CATEGORIES)
p_enable.set_defaults(func=lambda args: cmd_toggle(args, True))
p_disable = sub.add_parser("disable", help="disable a category's alarm")
p_disable.add_argument("category", choices=CATEGORIES)
p_disable.set_defaults(func=lambda args: cmd_toggle(args, False))
p_sound = sub.add_parser("sound", help="set the sound file for a day of week")
p_sound.add_argument("day", choices=DAY_NAMES)
p_sound.add_argument("path", help="path to mp3")
p_sound.set_defaults(func=cmd_sound)
p_show = sub.add_parser("show", help="show current config")
p_show.set_defaults(func=cmd_show)
p_stop = sub.add_parser("stop", help="stop a currently sounding alarm")
p_stop.set_defaults(func=cmd_stop)
p_snooze = sub.add_parser("snooze", help=f"snooze a sounding alarm for {SNOOZE_MINUTES} minutes")
p_snooze.set_defaults(func=cmd_snooze)
p_run = sub.add_parser("run", help="run the alarm checking loop (foreground)")
p_run.set_defaults(func=cmd_run)
args = parser.parse_args()
args.func(args)
if __name__ == "__main__":
main()
web.py
新規追加したweb.pyはスマホから操作できるようにするための部分を担っている。
コードとしては以下のようになっている。
#!/usr/bin/env python3
import json
import os
import threading
import time
from datetime import datetime
from flask import Flask, jsonify, request, send_from_directory
import alarm
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
STATIC_DIR = os.path.join(BASE_DIR, "static")
app = Flask(__name__)
@app.route("/")
def index():
return send_from_directory(STATIC_DIR, "index.html")
@app.route("/api/status")
def status():
config = alarm.load_config()
snooze = alarm.get_snooze()
today = datetime.now().date()
return jsonify(
{
"ringing": alarm.is_ringing(),
"snooze": snooze,
"schedules": config["schedules"],
"sounds": {day: os.path.basename(path) for day, path in config["sounds"].items()},
"today_category": alarm.category_for_date(today),
"now": datetime.now().isoformat(),
}
)
@app.route("/api/sounds")
def sounds():
config = alarm.load_config()
return jsonify(
{
"available": alarm.list_sounds(),
"sounds": {day: os.path.basename(path) for day, path in config["sounds"].items()},
}
)
@app.route("/api/sound", methods=["POST"])
def set_sound():
body = request.get_json(silent=True) or {}
try:
alarm.set_day_sound(body.get("day"), body.get("filename"))
except ValueError as e:
return jsonify({"error": str(e)}), 400
return jsonify({"ok": True})
@app.route("/api/stop", methods=["POST"])
def stop():
return jsonify({"stopped": alarm.do_stop()})
@app.route("/api/snooze", methods=["POST"])
def snooze():
fire_at = alarm.do_snooze()
if fire_at is None:
return jsonify({"error": "no alarm currently sounding"}), 409
return jsonify({"fire_at": fire_at.isoformat()})
@app.route("/api/schedule", methods=["POST"])
def schedule():
body = request.get_json(silent=True) or {}
try:
if "time" in body:
alarm.set_schedule_time(body.get("category"), body.get("time"))
if "enabled" in body:
alarm.set_schedule_enabled(body.get("category"), body.get("enabled"))
except ValueError as e:
return jsonify({"error": str(e)}), 400
return jsonify({"ok": True})
def run_alarm_loop():
fired_this_minute = set()
while True:
try:
now = datetime.now()
current_time = now.strftime("%H:%M")
minute_key = now.strftime("%Y%m%d%H%M")
category = alarm.category_for_date(now.date())
day_name = alarm.DAY_NAMES[now.weekday()]
config = alarm.load_config()
sched = config["schedules"][category]
if sched["enabled"] and sched["time"] == current_time:
key = (category, minute_key)
if key not in fired_this_minute:
fired_this_minute.add(key)
sound = config["sounds"][day_name]
print(f"[{now}] firing {category} alarm, playing {sound}")
alarm.play_sound(sound)
snoozed = alarm.get_snooze()
if snoozed and not alarm.is_ringing():
fire_at = datetime.fromisoformat(snoozed["fire_at"])
if now >= fire_at:
print(f"[{now}] snooze expired, playing {snoozed['sound']}")
alarm.play_sound(snoozed["sound"])
os.remove(alarm.SNOOZE_FILE)
alarm.check_ring_timeout()
fired_this_minute = {k for k in fired_this_minute if k[1] == minute_key}
except Exception as e:
print(f"[{datetime.now()}] alarm loop error: {e!r}")
time.sleep(20)
if __name__ == "__main__":
threading.Thread(target=run_alarm_loop, daemon=True).start()
app.run(host="0.0.0.0", port=8080)
アクセスして、最初に表示されるのが、index.htmlでコードとしては以下のようになっている。(画面の画像は後述)
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<title>目覚まし時計</title>
<style>
:root { color-scheme: dark; }
* { box-sizing: border-box; }
body {
margin: 0; min-height: 100vh; display: flex; flex-direction: column; align-items: center;
justify-content: center; gap: 20px; padding: 24px;
background: #14161c; color: #f0f0f0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
h1 { font-size: 1.1rem; font-weight: 500; color: #9aa0ab; margin: 0 0 4px; }
.card {
width: 100%; max-width: 380px; background: #1e2129; border-radius: 16px;
padding: 24px; display: flex; flex-direction: column; gap: 16px;
}
.hidden { display: none !important; }
input[type=tel], input[type=text] {
font-size: 1.3rem; padding: 14px; border-radius: 10px; border: 1px solid #3a3f4b;
background: #14161c; color: #f0f0f0; text-align: center; letter-spacing: 4px;
}
input[type=time] {
font-size: 1.1rem; padding: 10px; border-radius: 10px; border: 1px solid #3a3f4b;
background: #14161c; color: #f0f0f0;
}
button {
font-size: 1.1rem; padding: 16px; border-radius: 12px; border: none;
font-weight: 600; cursor: pointer;
}
#stopBtn { background: #e5484d; color: white; font-size: 1.4rem; padding: 28px; }
#snoozeBtn { background: #ffb224; color: #1a1300; font-size: 1.2rem; padding: 20px; }
.status-line { text-align: center; color: #9aa0ab; font-size: 0.95rem; }
.ringing { color: #ff6b6b; font-weight: 700; animation: pulse 1s infinite; }
@keyframes pulse { 50% { opacity: 0.4; } }
.sched-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.sched-row label { flex: 1; color: #c7cad1; }
.state { font-size: 0.8rem; font-weight: 600; margin-left: 6px; }
.state-on { color: #2f9e6b; }
.state-off { color: #6b7280; }
.sched-row input { width: 130px; flex: none; }
.sched-row .toggle { flex: none; width: auto; padding: 8px 14px; font-size: 0.85rem; background: #2a2f3a; color: #f0f0f0; }
.sched-row .toggle.on { background: #2f9e6b; color: white; }
.error { color: #ff6b6b; min-height: 1.2em; font-size: 0.9rem; text-align: center; }
.action-row { display: flex; flex-direction: column; gap: 12px; }
h2 { font-size: 0.95rem; font-weight: 500; color: #9aa0ab; margin: 4px 0 0; }
.sound-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.sound-row label { flex: 1; color: #c7cad1; }
.sound-row select {
flex: none; max-width: 190px; font-size: 0.95rem; padding: 8px; border-radius: 8px;
border: 1px solid #3a3f4b; background: #14161c; color: #f0f0f0;
}
</style>
</head>
<body>
<div id="mainCard" class="card">
<div id="statusLine" class="status-line">確認中...</div>
<div class="action-row">
<button id="stopBtn" class="hidden">止める</button>
<button id="snoozeBtn" class="hidden">スヌーズ (5分)</button>
</div>
<div id="schedules"></div>
<h2>曲設定</h2>
<div id="sounds"></div>
</div>
<script>
const statusLine = document.getElementById('statusLine');
const stopBtn = document.getElementById('stopBtn');
const snoozeBtn = document.getElementById('snoozeBtn');
const schedulesEl = document.getElementById('schedules');
const soundsEl = document.getElementById('sounds');
const CATEGORY_LABELS = { weekday: '平日', weekend: '週末', holiday: '祝日' };
const DAY_LABELS = { mon: '月', tue: '火', wed: '水', thu: '木', fri: '金', sat: '土', sun: '日' };
let availableSounds = [];
async function api(path, opts) {
return fetch(path, Object.assign({ headers: { 'Content-Type': 'application/json' } }, opts));
}
stopBtn.addEventListener('click', async () => {
await api('/api/stop', { method: 'POST' });
refreshStatus();
});
snoozeBtn.addEventListener('click', async () => {
await api('/api/snooze', { method: 'POST' });
refreshStatus();
});
function renderSchedules(schedules) {
schedulesEl.innerHTML = '';
for (const [cat, s] of Object.entries(schedules)) {
const row = document.createElement('div');
row.className = 'sched-row';
row.innerHTML = `
<label>${CATEGORY_LABELS[cat] || cat} <span class="state ${s.enabled ? 'state-on' : 'state-off'}">${s.enabled ? '● 有効' : '○ 無効'}</span></label>
<input type="time" value="${s.time}" data-cat="${cat}" class="time-input">
<button class="toggle ${s.enabled ? 'on' : ''}" data-cat="${cat}">${s.enabled ? 'ON' : 'OFF'}</button>
`;
schedulesEl.appendChild(row);
}
schedulesEl.querySelectorAll('.time-input').forEach(el => {
el.addEventListener('change', async () => {
await api('/api/schedule', { method: 'POST', body: JSON.stringify({ category: el.dataset.cat, time: el.value }) });
});
});
schedulesEl.querySelectorAll('.toggle').forEach(el => {
el.addEventListener('click', async () => {
const nowOn = el.classList.contains('on');
await api('/api/schedule', { method: 'POST', body: JSON.stringify({ category: el.dataset.cat, enabled: !nowOn }) });
refreshStatus();
});
});
}
function renderSounds(sounds) {
soundsEl.innerHTML = '';
for (const [day, current] of Object.entries(sounds)) {
const row = document.createElement('div');
row.className = 'sound-row';
const options = availableSounds.map(f =>
`<option value="${f}" ${f === current ? 'selected' : ''}>${f}</option>`
).join('');
row.innerHTML = `<label>${DAY_LABELS[day] || day}</label><select data-day="${day}">${options}</select>`;
soundsEl.appendChild(row);
}
soundsEl.querySelectorAll('select').forEach(el => {
el.addEventListener('change', async () => {
await api('/api/sound', { method: 'POST', body: JSON.stringify({ day: el.dataset.day, filename: el.value }) });
});
});
}
async function refreshSounds() {
try {
const res = await api('/api/sounds');
const data = await res.json();
availableSounds = data.available;
renderSounds(data.sounds);
} catch (e) { /* ignore */ }
}
async function refreshStatus() {
try {
const res = await api('/api/status');
const data = await res.json();
if (data.ringing) {
statusLine.innerHTML = '<span class="ringing">鳴っています</span>';
stopBtn.classList.remove('hidden');
snoozeBtn.classList.remove('hidden');
} else if (data.snooze) {
const t = new Date(data.snooze.fire_at).toLocaleTimeString('ja-JP', { hour: '2-digit', minute: '2-digit' });
statusLine.textContent = `スヌーズ中 (${t} に再度鳴動)`;
stopBtn.classList.remove('hidden');
snoozeBtn.classList.add('hidden');
} else {
statusLine.textContent = `待機中 (今日: ${CATEGORY_LABELS[data.today_category] || data.today_category})`;
stopBtn.classList.add('hidden');
snoozeBtn.classList.add('hidden');
}
const editingTime = document.activeElement && document.activeElement.type === 'time';
if (!editingTime) {
renderSchedules(data.schedules);
}
} catch (e) { /* ignore, retried on next poll */ }
}
refreshStatus();
refreshSounds();
setInterval(refreshStatus, 5000);
</script>
</body>
</html>動作確認
serviceファイルの再配置、リスタート、状態の確認を以下で実施。
pip install -r requirements.txt
sudo cp alarm-clock.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl restart alarm-clock
sudo systemctl status alarm-clockスマホのブラウザで http://<ラズパイzeroのIP>:8080 にアクセスすると以下のように画面を確認できる。これがindex.htlmになる。

また、アラームが鳴っているときにはそのアラームを止めるか、スヌーズするかを選択するボタンが表示される。

これを止めなければ、1時間は鳴り続ける。
最後に
これで朝は少しは寝過ごさずに起きられるようになったと思う。
