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.")