2026-05-19 14:39:51 +02:00
|
|
|
import os
|
2026-08-02 16:29:40 +02:00
|
|
|
import sys
|
|
|
|
|
import time
|
2026-06-18 15:27:21 +02:00
|
|
|
import fcntl
|
2026-08-02 16:29:40 +02:00
|
|
|
import logging
|
|
|
|
|
|
|
|
|
|
import pygame
|
|
|
|
|
import RPi.GPIO as GPIO
|
2026-06-26 07:42:19 +02:00
|
|
|
|
|
|
|
|
# Use PulseAudio/PipeWire audio driver to avoid ALSA device-busy errors.
|
|
|
|
|
os.environ.setdefault("SDL_AUDIODRIVER", "pulse")
|
2026-06-18 16:41:30 +02:00
|
|
|
from logging.handlers import RotatingFileHandler
|
2026-04-30 13:32:32 +02:00
|
|
|
|
2026-05-19 15:15:05 +02:00
|
|
|
from common import PID_FILE
|
2026-07-31 16:19:14 +02:00
|
|
|
from styles import STYLES, LEGACY_STYLE, get_style
|
2026-05-19 15:15:05 +02:00
|
|
|
|
2026-06-18 16:41:30 +02:00
|
|
|
# Configure logging with rotation to avoid unbounded growth on the SD card.
|
2026-04-30 13:32:32 +02:00
|
|
|
logging.basicConfig(
|
|
|
|
|
level=logging.INFO,
|
2026-05-11 15:05:29 +02:00
|
|
|
format="%(asctime)s - %(message)s",
|
2026-06-18 16:41:30 +02:00
|
|
|
handlers=[
|
|
|
|
|
RotatingFileHandler("wecker.log", maxBytes=1_000_000, backupCount=3),
|
|
|
|
|
logging.StreamHandler(),
|
|
|
|
|
],
|
2026-04-30 13:32:32 +02:00
|
|
|
)
|
|
|
|
|
|
2026-05-19 14:39:51 +02:00
|
|
|
|
2026-06-18 15:27:21 +02:00
|
|
|
_pid_lock_fd = None
|
|
|
|
|
|
|
|
|
|
|
2026-05-19 14:39:51 +02:00
|
|
|
def ensure_single_instance():
|
2026-06-18 15:27:21 +02:00
|
|
|
"""Ensures that only one instance of the script is running.
|
2026-05-19 14:39:51 +02:00
|
|
|
|
2026-06-18 15:27:21 +02:00
|
|
|
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)
|
2026-05-19 14:39:51 +02:00
|
|
|
|
2026-06-18 15:27:21 +02:00
|
|
|
_pid_lock_fd.seek(0)
|
|
|
|
|
_pid_lock_fd.truncate()
|
|
|
|
|
_pid_lock_fd.write(str(os.getpid()))
|
|
|
|
|
_pid_lock_fd.flush()
|
2026-05-19 14:39:51 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def remove_pid_file():
|
2026-06-18 15:27:21 +02:00
|
|
|
"""Releases the PID file lock and removes the file."""
|
|
|
|
|
global _pid_lock_fd
|
|
|
|
|
if _pid_lock_fd is not None:
|
2026-05-19 14:39:51 +02:00
|
|
|
try:
|
2026-06-18 15:27:21 +02:00
|
|
|
_pid_lock_fd.close()
|
|
|
|
|
except OSError:
|
2026-05-19 14:39:51 +02:00
|
|
|
pass
|
2026-06-18 15:27:21 +02:00
|
|
|
_pid_lock_fd = None
|
|
|
|
|
try:
|
|
|
|
|
os.remove(PID_FILE)
|
|
|
|
|
except FileNotFoundError:
|
|
|
|
|
pass
|
2026-05-19 14:39:51 +02:00
|
|
|
|
|
|
|
|
|
2026-04-30 13:32:32 +02:00
|
|
|
BUTTON_PIN = 17
|
|
|
|
|
LED_PIN = 27
|
|
|
|
|
|
|
|
|
|
|
2026-06-18 17:01:26 +02:00
|
|
|
DEFAULT_MUSIC_FILE = "Laid Back - Sunshine Reggae.mp3"
|
|
|
|
|
|
|
|
|
|
|
2026-08-02 16:29:40 +02:00
|
|
|
def setup():
|
2026-08-02 21:50:00 +02:00
|
|
|
"""Initialize GPIO and the audio engine. Call once before running.
|
2026-06-18 17:01:26 +02:00
|
|
|
|
2026-08-02 21:50:00 +02:00
|
|
|
Each style owns its own ringing tone via pygame directly; this only brings
|
|
|
|
|
up the mixer and the GPIO pins.
|
2026-08-02 16:29:40 +02:00
|
|
|
"""
|
2026-06-18 16:58:57 +02:00
|
|
|
GPIO.setmode(GPIO.BCM)
|
|
|
|
|
GPIO.setup(LED_PIN, GPIO.OUT)
|
|
|
|
|
GPIO.setup(BUTTON_PIN, GPIO.IN, pull_up_down=GPIO.PUD_UP)
|
2026-04-30 13:32:32 +02:00
|
|
|
|
2026-06-18 16:58:57 +02:00
|
|
|
# Audio Setup (with optimized buffer)
|
|
|
|
|
pygame.mixer.pre_init(frequency=44100, size=-16, channels=2, buffer=4096)
|
|
|
|
|
pygame.mixer.init()
|
|
|
|
|
|
2026-08-02 16:29:40 +02:00
|
|
|
|
2026-04-30 13:32:32 +02:00
|
|
|
def set_led(on):
|
|
|
|
|
"""Turns the LED on or off (LOW = ON, HIGH = OFF)"""
|
2026-05-11 15:05:29 +02:00
|
|
|
if "GPIO" in globals() and hasattr(GPIO, "output"):
|
2026-05-09 22:31:28 +02:00
|
|
|
if on:
|
|
|
|
|
GPIO.output(LED_PIN, GPIO.LOW)
|
|
|
|
|
else:
|
|
|
|
|
GPIO.output(LED_PIN, GPIO.HIGH)
|
2026-04-30 13:32:32 +02:00
|
|
|
|
2026-05-11 15:05:29 +02:00
|
|
|
|
2026-07-31 16:19:14 +02:00
|
|
|
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.
|
2026-04-30 13:32:32 +02:00
|
|
|
|
2026-08-02 21:50:00 +02:00
|
|
|
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.
|
2026-07-31 16:19:14 +02:00
|
|
|
"""
|
2026-08-02 16:29:40 +02:00
|
|
|
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)
|
2026-05-09 22:31:28 +02:00
|
|
|
|
2026-08-02 16:29:40 +02:00
|
|
|
setup()
|
2026-08-02 22:09:46 +02:00
|
|
|
style = style_cls(set_led, music_file=music_file)
|
2026-08-02 16:29:40 +02:00
|
|
|
logging.info("Alarm clock started.")
|
2026-08-02 21:50:00 +02:00
|
|
|
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
|
2026-05-09 22:31:28 +02:00
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
while True:
|
|
|
|
|
now = time.time()
|
2026-05-11 15:05:29 +02:00
|
|
|
|
|
|
|
|
if "GPIO" in globals() and hasattr(GPIO, "input"):
|
|
|
|
|
is_pressed = GPIO.input(BUTTON_PIN) == GPIO.LOW
|
2026-05-09 22:31:28 +02:00
|
|
|
else:
|
|
|
|
|
is_pressed = False
|
2026-05-11 15:05:29 +02:00
|
|
|
|
2026-07-31 16:19:14 +02:00
|
|
|
keep_running = style.update(now, is_pressed)
|
2026-05-11 15:05:29 +02:00
|
|
|
|
2026-05-09 22:31:28 +02:00
|
|
|
if not keep_running or test_mode:
|
|
|
|
|
break
|
2026-05-11 15:05:29 +02:00
|
|
|
|
2026-05-09 22:31:28 +02:00
|
|
|
time.sleep(0.02)
|
2026-05-11 15:05:29 +02:00
|
|
|
|
2026-05-09 22:31:28 +02:00
|
|
|
except KeyboardInterrupt:
|
|
|
|
|
logging.info("Manually aborted (CTRL+C).")
|
|
|
|
|
|
2026-05-11 15:05:29 +02:00
|
|
|
|
2026-05-09 22:31:28 +02:00
|
|
|
if __name__ == "__main__":
|
2026-06-18 17:01:26 +02:00
|
|
|
import argparse
|
|
|
|
|
|
|
|
|
|
parser = argparse.ArgumentParser(description="Raspberry Pi alarm clock.")
|
|
|
|
|
parser.add_argument(
|
|
|
|
|
"--music-file",
|
|
|
|
|
default=os.environ.get("MUSIC_FILE", DEFAULT_MUSIC_FILE),
|
2026-08-02 16:29:40 +02:00
|
|
|
help="Music file for the blink style (default: $MUSIC_FILE or default track).",
|
2026-06-18 17:01:26 +02:00
|
|
|
)
|
2026-07-31 16:19:14 +02:00
|
|
|
parser.add_argument(
|
|
|
|
|
"--style",
|
|
|
|
|
default=LEGACY_STYLE,
|
|
|
|
|
choices=sorted(STYLES),
|
|
|
|
|
help="Alarm style to run (default: %(default)s).",
|
|
|
|
|
)
|
2026-06-18 17:01:26 +02:00
|
|
|
args = parser.parse_args()
|
|
|
|
|
|
2026-05-19 14:39:51 +02:00
|
|
|
ensure_single_instance()
|
2026-05-09 22:31:28 +02:00
|
|
|
try:
|
2026-07-31 16:19:14 +02:00
|
|
|
run_alarm(style_name=args.style, music_file=args.music_file)
|
2026-05-09 22:31:28 +02:00
|
|
|
finally:
|
|
|
|
|
pygame.mixer.quit()
|
|
|
|
|
GPIO.cleanup()
|
2026-05-19 14:39:51 +02:00
|
|
|
remove_pid_file()
|
2026-05-09 22:31:28 +02:00
|
|
|
logging.info("Program finished.")
|