Files
wecker/wecker.py
T
gurix 3a7b6ac7a3 refactor: styles own their ringing tone directly (Pattern C)
Drop the injected AlarmSound Protocol and the wecker.Sound class. Each style
now owns its tone by using pygame directly; the runner owns only the audio
engine lifecycle (mixer init/quit), button sampling, and cleanup. This is the
seam for future styles that handle their own tone — a 'talk' style would just
import pygame and play speech, with no shared interface to extend.

- styles/base.py: AlarmStyle.__init__(set_led, music_file) + start() + update()
- styles/blink.py: start() loads+plays music via pygame.mixer.music
- styles/simple.py: owns its beep — synthesises a square-wave buffer in
  module (stdlib array+math) and plays it via pygame.mixer.Sound
- wecker.py: setup() brings up the mixer only; run_alarm constructs the style
  and guards start() with a clean log+exit on failure

Tests: a shared tests/conftest.py stubs RPi.GPIO/pygame in sys.modules before
any SUT import (order-independent, removes duplicated inline mocking); the
wecker mock_pygame fixture patches one fresh pygame mock into wecker + both
style modules so assertions see the same calls.

README: tone-ownership and plugin-contract updated.
2026-08-02 21:50:00 +02:00

168 lines
4.5 KiB
Python

import os
import sys
import time
import fcntl
import logging
import pygame
import RPi.GPIO as GPIO
# Use PulseAudio/PipeWire audio driver to avoid ALSA device-busy errors.
os.environ.setdefault("SDL_AUDIODRIVER", "pulse")
from logging.handlers import RotatingFileHandler
from common import PID_FILE
from styles import STYLES, LEGACY_STYLE, get_style
# Configure logging with rotation to avoid unbounded growth on the SD card.
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(message)s",
handlers=[
RotatingFileHandler("wecker.log", maxBytes=1_000_000, backupCount=3),
logging.StreamHandler(),
],
)
_pid_lock_fd = None
def ensure_single_instance():
"""Ensures that only one instance of the script is running.
Uses an advisory file lock on the PID file so the check-and-write
sequence is atomic and race-free.
"""
global _pid_lock_fd
_pid_lock_fd = open(PID_FILE, "a+")
try:
fcntl.flock(_pid_lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
except (BlockingIOError, OSError):
logging.error("Another instance of wecker is already running. Exiting.")
sys.exit(1)
_pid_lock_fd.seek(0)
_pid_lock_fd.truncate()
_pid_lock_fd.write(str(os.getpid()))
_pid_lock_fd.flush()
def remove_pid_file():
"""Releases the PID file lock and removes the file."""
global _pid_lock_fd
if _pid_lock_fd is not None:
try:
_pid_lock_fd.close()
except OSError:
pass
_pid_lock_fd = None
try:
os.remove(PID_FILE)
except FileNotFoundError:
pass
BUTTON_PIN = 17
LED_PIN = 27
DEFAULT_MUSIC_FILE = "Laid Back - Sunshine Reggae.mp3"
def setup():
"""Initialize GPIO and the audio engine. Call once before running.
Each style owns its own ringing tone via pygame directly; this only brings
up the mixer and the GPIO pins.
"""
GPIO.setmode(GPIO.BCM)
GPIO.setup(LED_PIN, GPIO.OUT)
GPIO.setup(BUTTON_PIN, GPIO.IN, pull_up_down=GPIO.PUD_UP)
# Audio Setup (with optimized buffer)
pygame.mixer.pre_init(frequency=44100, size=-16, channels=2, buffer=4096)
pygame.mixer.init()
def set_led(on):
"""Turns the LED on or off (LOW = ON, HIGH = OFF)"""
if "GPIO" in globals() and hasattr(GPIO, "output"):
if on:
GPIO.output(LED_PIN, GPIO.LOW)
else:
GPIO.output(LED_PIN, GPIO.HIGH)
def run_alarm(
style_name: str = LEGACY_STYLE,
test_mode: bool = False,
music_file: str | None = None,
):
"""Run the alarm clock with the given style until the style signals stop.
The runner owns only the audio engine lifecycle (mixer init/quit), button
sampling, and cleanup; each style owns its own ringing tone (via pygame
directly) and LED behaviour.
"""
style_cls = get_style(style_name) # validate before touching hardware
if music_file is None:
music_file = os.environ.get("MUSIC_FILE", DEFAULT_MUSIC_FILE)
setup()
style = style_cls(set_led, music_file)
logging.info("Alarm clock started.")
try:
style.start() # style kicks off its own tone + initial LED
except Exception:
logging.exception("Error starting alarm")
if "pytest" not in sys.modules:
sys.exit(1)
raise
try:
while True:
now = time.time()
if "GPIO" in globals() and hasattr(GPIO, "input"):
is_pressed = GPIO.input(BUTTON_PIN) == GPIO.LOW
else:
is_pressed = False
keep_running = style.update(now, is_pressed)
if not keep_running or test_mode:
break
time.sleep(0.02)
except KeyboardInterrupt:
logging.info("Manually aborted (CTRL+C).")
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description="Raspberry Pi alarm clock.")
parser.add_argument(
"--music-file",
default=os.environ.get("MUSIC_FILE", DEFAULT_MUSIC_FILE),
help="Music file for the blink style (default: $MUSIC_FILE or default track).",
)
parser.add_argument(
"--style",
default=LEGACY_STYLE,
choices=sorted(STYLES),
help="Alarm style to run (default: %(default)s).",
)
args = parser.parse_args()
ensure_single_instance()
try:
run_alarm(style_name=args.style, music_file=args.music_file)
finally:
pygame.mixer.quit()
GPIO.cleanup()
remove_pid_file()
logging.info("Program finished.")