import RPi.GPIO as GPIO import time import logging import pygame import random import sys import os import fcntl from common import PID_FILE # Configure logging logging.basicConfig( level=logging.INFO, format="%(asctime)s - %(message)s", handlers=[logging.FileHandler("wecker.log"), 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 # GPIO Setup BUTTON_PIN = 17 LED_PIN = 27 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("Laid Back - Sunshine Reggae.mp3") 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) # State Machine states for the flow STATE_RINGING = 0 STATE_WAIT_BEFORE_BLINK = 1 STATE_BLINKING = 2 STATE_WAIT_FOR_INPUT = 3 STATE_EVALUATING = 4 STATE_WAIT_BEFORE_RETRY = 5 state = STATE_RINGING target_blinks = 0 user_presses = 0 last_interaction_time = 0 button_was_pressed = False 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 blink_led(times): """Blinks the LED a specific number of times (blocking)""" for _ in range(times): set_led(True) time.sleep(0.3) # LED on for 300ms set_led(False) time.sleep(0.3) # LED off for 300ms class AlarmClock: def __init__(self): self.state = STATE_RINGING self.target_blinks = 0 self.user_presses = 0 self.last_interaction_time = 0 self.button_was_pressed = False def update(self, now, is_pressed): button_just_pressed = False if is_pressed and not self.button_was_pressed: self.button_was_pressed = True button_just_pressed = True elif not is_pressed and self.button_was_pressed: self.button_was_pressed = False if self.state == STATE_RINGING: if button_just_pressed: logging.info( "Alarm button pressed! Puzzle started. Waiting 3 seconds..." ) self.state = STATE_WAIT_BEFORE_BLINK self.last_interaction_time = now elif self.state == STATE_WAIT_BEFORE_BLINK: if now - self.last_interaction_time >= 3.0: self.target_blinks = random.randint(1, 7) logging.info(f"Blinking {self.target_blinks} times...") self.state = STATE_BLINKING elif self.state == STATE_BLINKING: # Move the blink_led out of the update loop for testability, # or just call it directly. Here we call it. blink_led(self.target_blinks) logging.info("Blinking finished. Waiting for input...") self.state = STATE_WAIT_FOR_INPUT self.user_presses = 0 self.last_interaction_time = ( time.time() ) # Use time.time() to account for blocking blink_led if "GPIO" in globals() and hasattr(GPIO, "input"): self.button_was_pressed = GPIO.input(BUTTON_PIN) == GPIO.LOW elif self.state == STATE_WAIT_FOR_INPUT: set_led(is_pressed) if button_just_pressed: self.user_presses += 1 logging.info(f"Button pressed: {self.user_presses} times") if is_pressed: self.last_interaction_time = now if not is_pressed and (now - self.last_interaction_time >= 3.0): self.state = STATE_EVALUATING elif self.state == STATE_EVALUATING: logging.info( f"Evaluation: Target={self.target_blinks}, Entered={self.user_presses}" ) if self.user_presses == self.target_blinks: logging.info("Puzzle solved correctly! Alarm clock is stopping.") if ( "pygame" in globals() and hasattr(pygame, "mixer") and pygame.mixer.get_init() ): pygame.mixer.music.stop() set_led(False) return False # Indicate we should stop running else: logging.info("Incorrect input! Waiting 3 seconds before retrying...") self.state = STATE_WAIT_BEFORE_RETRY self.last_interaction_time = time.time() set_led(False) elif self.state == STATE_WAIT_BEFORE_RETRY: if now - self.last_interaction_time >= 5.0: self.target_blinks = random.randint(1, 7) logging.info(f"New attempt! Blinking {self.target_blinks} times...") self.state = STATE_BLINKING return True # Keep running def run_alarm(test_mode=False): 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 clock = AlarmClock() 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 = clock.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__": ensure_single_instance() try: run_alarm() finally: pygame.mixer.quit() GPIO.cleanup() remove_pid_file() logging.info("Program finished.")