Files
wecker/wecker.py
T

239 lines
7.0 KiB
Python
Raw Normal View History

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