Introduce a styles/ plugin package: - styles/base.py: AlarmStyle ABC (injected set_led, update()->bool contract) - styles/blink.py: BlinkStyle, the existing count-the-blinks puzzle moved out of wecker.py's AlarmClock - styles/simple.py: SimpleStyle, press-once-to-stop (the new default style) - styles/__init__.py: STYLES registry + get_style() validator + LEGACY_STYLE wecker.py now resolves the style via the registry and owns only the shared music/button/cleanup loop; the --style CLI arg defaults to blink so existing cron entries keep their behaviour. Unknown styles raise before hardware init.
167 lines
4.6 KiB
Python
167 lines
4.6 KiB
Python
import RPi.GPIO as GPIO
|
|
import time
|
|
import logging
|
|
import pygame
|
|
import sys
|
|
import os
|
|
import fcntl
|
|
|
|
# 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(music_file: str | None = None):
|
|
"""Initialize GPIO and audio hardware. Call once before running."""
|
|
if music_file is None:
|
|
music_file = os.environ.get("MUSIC_FILE", DEFAULT_MUSIC_FILE)
|
|
|
|
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()
|
|
|
|
try:
|
|
pygame.mixer.music.load(music_file)
|
|
pygame.mixer.music.set_volume(1.0)
|
|
except Exception as e:
|
|
logging.error(f"Error loading audio file: {e}")
|
|
# Don't exit in test mode
|
|
if "pytest" not in sys.modules:
|
|
sys.exit(1)
|
|
|
|
|
|
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 everything shared (music loop, button sampling, cleanup);
|
|
the style only decides when to stop and what the LED does.
|
|
"""
|
|
style = get_style(style_name)(set_led)
|
|
setup(music_file=music_file)
|
|
logging.info("Alarm clock started. Music is playing in an endless loop.")
|
|
if "pygame" in globals() and hasattr(pygame, "mixer") and pygame.mixer.get_init():
|
|
pygame.mixer.music.play(-1)
|
|
|
|
set_led(False) # LED off at start; the style drives it from here.
|
|
|
|
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="Path to the MP3 file to play (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.")
|