ポケモン目覚まし時計にスヌーズ機能を付ける

スポンサーリンク
Uncategorized

はじめに

ポケモン30周年だからという理由でポケモンの目覚まし時計を作った。

作ったときの記事は以下。

①

https://apao-m-appare99999.com/?p=18615

②

https://apao-m-appare99999.com/?p=18619

この目覚ましは時間になったら曲が流れるんだけど、流れ終わったらそれで終わってしまう。

つまり、その間に起きれなければ寝過ごしてしまう。

この問題を解消するべく、本日はスマホで停止や、スヌーズをすることができる画面を実装する。

これで、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時間は鳴り続ける。

最後に

これで朝は少しは寝過ごさずに起きられるようになったと思う。

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