import array import math 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 audio hardware. Call once before running. Audio playback itself is owned by each style via the injected ``Sound`` capability, so this only brings up the mixer and 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 _make_beep_buffer( freq: int = 1000, duration: float = 0.15, rate: int = 44100 ) -> bytes: """Synthesize a short square-wave beep as signed-16-bit stereo PCM. Matches the mixer format from ``pre_init`` (rate, -16, 2 channels) so it feeds straight into ``pygame.mixer.Sound``. No audio file, no extra dep. """ n = int(rate * duration) buf = array.array("h") for i in range(n): v = 32767 if math.sin(2 * math.pi * freq * (i / rate)) >= 0 else -32767 buf.append(v) buf.append(v) # duplicate to stereo return buf.tobytes() _BEEP_BUFFER = _make_beep_buffer() class Sound: """Audio capability injected into styles so each can own its ringing tone.""" def __init__(self): self._beep = pygame.mixer.Sound(_BEEP_BUFFER) def play_music(self, path: str): """Load and play a music file on an endless loop.""" try: pygame.mixer.music.load(path) pygame.mixer.music.set_volume(1.0) pygame.mixer.music.play(-1) except Exception as e: logging.error(f"Error loading audio file: {e}") if "pytest" not in sys.modules: sys.exit(1) def play_beep(self): """Play one short beep tone.""" self._beep.play() 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 shared button sampling and cleanup; each style owns its own ringing tone (via the injected ``Sound``) 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() sound = Sound() style = style_cls(set_led, sound, music_file) logging.info("Alarm clock started.") style.start() # style kicks off its own tone + initial LED 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.")