Files
wecker/wecker.py
T

267 lines
8.4 KiB
Python

import RPi.GPIO as GPIO
import time
import logging
import pygame
import random
import sys
import os
import fcntl
from logging.handlers import RotatingFileHandler
from common import PID_FILE
# 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)
# 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
BLINK_INTERVAL = 0.3
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)
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
self._blink_phase: int = 0
self._blink_phases: int = 0
self._blink_next_toggle: float | None = None
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
self._blink_phase = 0
self._blink_phases = self.target_blinks * 2
self._blink_next_toggle = now + BLINK_INTERVAL
set_led(True)
elif self.state == STATE_BLINKING:
# Non-blocking blink: toggle the LED at fixed intervals so the main
# loop keeps sampling the button and can be interrupted.
if self._blink_next_toggle is None:
# Safety for direct state assignment (e.g. tests).
self._blink_phase = 0
self._blink_phases = self.target_blinks * 2
self._blink_next_toggle = now + BLINK_INTERVAL
set_led(True)
while now >= self._blink_next_toggle:
self._blink_phase += 1
self._blink_next_toggle += BLINK_INTERVAL
if self._blink_phase >= self._blink_phases:
set_led(False)
self._blink_phase = 0
self._blink_phases = 0
self._blink_next_toggle = None
logging.info("Blinking finished. Waiting for input...")
self.state = STATE_WAIT_FOR_INPUT
self.user_presses = 0
self.last_interaction_time = now
if "GPIO" in globals() and hasattr(GPIO, "input"):
self.button_was_pressed = GPIO.input(BUTTON_PIN) == GPIO.LOW
break
else:
set_led(self._blink_phase % 2 == 0)
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, music_file: str | None = None):
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
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__":
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).",
)
args = parser.parse_args()
ensure_single_instance()
try:
run_alarm(music_file=args.music_file)
finally:
pygame.mixer.quit()
GPIO.cleanup()
remove_pid_file()
logging.info("Program finished.")