diff --git a/README.md b/README.md index 92960bc..e7dd31a 100644 --- a/README.md +++ b/README.md @@ -33,13 +33,13 @@ Please refer to the [arcade-button-wiring.md](arcade-button-wiring.md) file for # Use the default music file or the MUSIC_FILE environment variable python3 wecker.py - # Or pass a custom music file directly + # Or pass a custom music file (used by the blink style; ignored by simple) python3 wecker.py --music-file /path/to/your/alarm.mp3 # Choose an alarm style (default: blink; see Alarm Styles below) python3 wecker.py --style simple ``` - The music file is resolved in this order: `--music-file` argument, `MUSIC_FILE` environment variable, default `Laid Back - Sunshine Reggae.mp3`. The `--style` argument selects the alarm behaviour and defaults to `blink` (so existing cron entries keep working unchanged). + `--style` selects the alarm behaviour and defaults to `blink` (so existing cron entries keep working unchanged). Each style owns its own ringing tone: `blink` plays a music file on loop (resolved from `--music-file`, then the `MUSIC_FILE` env var, then the default `Laid Back - Sunshine Reggae.mp3`); `simple` ignores the music file and beeps. ## Automating and Managing Alarms (GraphQL API) @@ -145,12 +145,14 @@ Returns `true` if the alarm was stopped, `false` if it wasn't ringing. Each alarm has a **style** that decides how you turn it off. Set it per alarm via the `style` field of `setAlarm` (or the `style` argument of `startRinging`). Available styles: -| Style | Behaviour | -|---|---| -| `simple` | Press the button once to stop. The LED stays solid on so the button is findable in the dark. **Default for new alarms.** | -| `blink` | The memory-and-attention puzzle described in [How the Puzzle Works](#how-the-puzzle-works). | +| Style | Ringing tone | How to stop | +|---|---|---| +| `simple` | A repeating, ordinary-alarm-clock **beep** (no music file needed). | Press the button once. The LED stays solid on so the button is findable in the dark. **Default for new alarms.** | +| `blink` | A music file on an endless loop (from `--music-file` / `MUSIC_FILE` / the default track). | The memory-and-attention puzzle described in [How the Puzzle Works](#how-the-puzzle-works). | -Adding a style is a plugin-style drop-in: add a class under `styles/` implementing the `AlarmStyle` contract (`__init__(set_led)` + `update(now, is_pressed) -> bool`) and register one line in `styles/__init__.py`. +**The style owns its ringing tone.** The runner hands each style an audio capability (`play_music(path)` and `play_beep()`); the style decides which to use and when. This is the seam for future styles that handle their own tone. + +Adding a style is a plugin-style drop-in: add a class under `styles/` implementing the `AlarmStyle` contract (`__init__(set_led, sound, music_file)` + `start()` + `update(now, is_pressed) -> bool`) and register one line in `styles/__init__.py`. **Backward compatibility:** existing cron entries created before this feature have no `--style` flag and keep running the `blink` puzzle, so an upgrade never silently changes an alarm. To switch an existing alarm to `simple`, re-save it with `setAlarm(id: ..., cronExpression: ..., style: "simple")`. diff --git a/styles/base.py b/styles/base.py index 0d1ce80..920f7b8 100644 --- a/styles/base.py +++ b/styles/base.py @@ -1,20 +1,48 @@ from abc import ABC, abstractmethod -from typing import Callable +from typing import Callable, Protocol + + +class AlarmSound(Protocol): + """Audio capability injected into a style so it can own its ringing tone. + + Styles stay free of pygame/GPIO imports (the API process imports them only + for name validation); the runner supplies a concrete implementation. + """ + + def play_music(self, path: str) -> None: + """Load and play a music file on an endless loop.""" + ... + + def play_beep(self) -> None: + """Play one short beep tone.""" + ... class AlarmStyle(ABC): """A pluggable alarm behaviour. - A style decides *when* the alarm stops and what the LED does while it - rings. It never touches hardware directly: ``set_led`` is injected by the - runner, so styles stay import-safe in the API process (no GPIO/pygame). + A style owns *its own ringing tone* (via the injected ``sound``) and + decides when the alarm stops and what the LED does. It never touches + hardware directly: ``set_led`` and ``sound`` are injected by the runner, so + styles stay import-safe in the API process (no GPIO/pygame). - Lifecycle: construct once with ``set_led``, then call ``update`` every - tick. Return ``False`` from ``update`` to stop the alarm. + Lifecycle: construct once with ``set_led``/``sound``/``music_file``; call + ``start()`` to begin ringing (kick off the tone and initial LED); then call + ``update`` every tick. Return ``False`` from ``update`` to stop the alarm. """ - def __init__(self, set_led: Callable[[bool], None]): + def __init__( + self, + set_led: Callable[[bool], None], + sound: AlarmSound, + music_file: str | None = None, + ): self.set_led = set_led + self.sound = sound + self.music_file = music_file + + def start(self) -> None: + """Begin ringing. Override to start the style's tone / initial LED.""" @abstractmethod def update(self, now: float, is_pressed: bool) -> bool: diff --git a/styles/blink.py b/styles/blink.py index 7d6d046..be507ee 100644 --- a/styles/blink.py +++ b/styles/blink.py @@ -21,8 +21,8 @@ class BlinkStyle(AlarmStyle): exactly that many times. A wrong count starts a fresh sequence. """ - def __init__(self, set_led): - super().__init__(set_led) + def __init__(self, set_led, sound, music_file=None): + super().__init__(set_led, sound, music_file) self.state = STATE_RINGING self.target_blinks = 0 self.user_presses = 0 @@ -32,6 +32,12 @@ class BlinkStyle(AlarmStyle): self._blink_phases = 0 self._blink_next_toggle: float | None = None + def start(self): + # The puzzle rings with music on an endless loop; the LED stays off + # until the button is pressed to start the blink sequence. + self.sound.play_music(self.music_file) + self.set_led(False) + def update(self, now, is_pressed): button_just_pressed = False if is_pressed and not self.button_was_pressed: @@ -124,7 +130,14 @@ class BlinkStyle(AlarmStyle): if __name__ == "__main__": # Self-check: a correct blink sequence stops the alarm. - style = BlinkStyle(lambda on: None) + class _StubSound: + def play_music(self, path): + pass + + def play_beep(self): + pass + + style = BlinkStyle(lambda on: None, _StubSound(), "x.mp3") assert style.update(100.0, True) is True assert style.state == STATE_WAIT_BEFORE_BLINK diff --git a/styles/simple.py b/styles/simple.py index d8bdd47..0abc2c2 100644 --- a/styles/simple.py +++ b/styles/simple.py @@ -1,18 +1,52 @@ from styles.base import AlarmStyle +# Time between beep retriggers; a short beep followed by this gap gives the +# classic alarm-clock "beep ... beep ... beep". +BEEP_INTERVAL = 0.5 + class SimpleStyle(AlarmStyle): - """Press the button once to stop the alarm.""" + """Press the button once to stop. Rings with a repeating beep. - def update(self, now, is_pressed): + This style plays no music file; it owns its tone via ``sound.play_beep``. + """ + + def __init__(self, set_led, sound, music_file=None): + super().__init__(set_led, sound, music_file) + self._next_beep = 0.0 + + def start(self): # LED solid on so the button is findable in the dark. self.set_led(True) + + def update(self, now, is_pressed): + self.set_led(True) + if now >= self._next_beep: + self.sound.play_beep() + self._next_beep = now + BEEP_INTERVAL return not is_pressed # first press -> False -> stop if __name__ == "__main__": - # Self-check: not pressed keeps ringing; a press stops it. - style = SimpleStyle(lambda on: None) + # Self-check: not pressed keeps ringing and beeps; a press stops it. + class _StubSound: + def __init__(self): + self.beeps = 0 + + def play_music(self, path): + raise AssertionError("simple style must never play music") + + def play_beep(self): + self.beeps += 1 + + sound = _StubSound() + style = SimpleStyle(lambda on: None, sound) + style.start() assert style.update(0.0, False) is True - assert style.update(0.1, True) is False + assert sound.beeps == 1 # first tick beeps + assert style.update(0.4, False) is True # before interval -> no beep + assert sound.beeps == 1 + assert style.update(0.5, False) is True # at interval -> beep + assert sound.beeps == 2 + assert style.update(0.6, True) is False # press -> stop print("simple self-check ok") diff --git a/tests/test_styles_blink.py b/tests/test_styles_blink.py index 0f304a4..a861723 100644 --- a/tests/test_styles_blink.py +++ b/tests/test_styles_blink.py @@ -1,3 +1,5 @@ +from unittest.mock import MagicMock + from styles.blink import ( STATE_BLINKING, STATE_EVALUATING, @@ -8,23 +10,31 @@ from styles.blink import ( ) -def _led(): - """Return (calls list, set_led callable recording each call).""" +def _make_blink(music_file="music.mp3"): + """Return (style, sound_mock) for a BlinkStyle ready to start.""" + sound = MagicMock() + style = BlinkStyle(lambda on: None, sound, music_file) + return style, sound + + +def test_start_plays_music_and_led_off(): calls = [] - return calls, lambda on: calls.append(on) + sound = MagicMock() + style = BlinkStyle(lambda on: calls.append(on), sound, "track.mp3") + style.start() + sound.play_music.assert_called_once_with("track.mp3") + assert calls == [False] # LED off while music plays until a press starts the puzzle + assert style.state == 0 # STATE_RINGING until a press def test_press_starts_puzzle(): - _, set_led = _led() - style = BlinkStyle(set_led) + style, _ = _make_blink() assert style.update(100.0, True) is True assert style.state == STATE_WAIT_BEFORE_BLINK def test_blink_correct_sequence_stops(monkeypatch): - _, set_led = _led() - style = BlinkStyle(set_led) - + style, _ = _make_blink() style.update(100.0, True) # press -> start puzzle assert style.state == STATE_WAIT_BEFORE_BLINK @@ -48,8 +58,7 @@ def test_blink_correct_sequence_stops(monkeypatch): def test_blink_incorrect_retries(): - _, set_led = _led() - style = BlinkStyle(set_led) + style, _ = _make_blink() style.state = STATE_WAIT_FOR_INPUT style.target_blinks = 3 @@ -65,8 +74,9 @@ def test_blink_incorrect_retries(): def test_blinking_non_blocking(): - calls, set_led = _led() - style = BlinkStyle(set_led) + calls = [] + style, _ = _make_blink() + style.set_led = calls.append style.state = STATE_BLINKING style.target_blinks = 2 @@ -84,8 +94,7 @@ def test_blinking_non_blocking(): def test_blinking_advances_time(): - _, set_led = _led() - style = BlinkStyle(set_led) + style, _ = _make_blink() style.state = STATE_BLINKING style.target_blinks = 4 diff --git a/tests/test_styles_simple.py b/tests/test_styles_simple.py index 74f9bc1..3bf1d8e 100644 --- a/tests/test_styles_simple.py +++ b/tests/test_styles_simple.py @@ -1,23 +1,49 @@ +from unittest.mock import MagicMock + from styles.simple import SimpleStyle +def _make_style(): + sound = MagicMock() + style = SimpleStyle(lambda on: None, sound) + return style, sound + + def test_simple_keeps_ringing_when_not_pressed(): - calls = [] - style = SimpleStyle(lambda on: calls.append(on)) + style, _ = _make_style() assert style.update(0.0, False) is True - assert calls[-1] is True # LED solid on while ringing def test_simple_stops_on_first_press(): - calls = [] - style = SimpleStyle(lambda on: calls.append(on)) + style, _ = _make_style() assert style.update(0.0, True) is False # press -> stop -def test_simple_led_off_on_release_after_press(): - # After a press the style stops; verify it never turns the LED off itself - # (the runner's cleanup handles that). It only ever asserts LED on. +def test_simple_led_solid_on(): calls = [] - style = SimpleStyle(lambda on: calls.append(on)) - style.update(0.0, False) + sound = MagicMock() + style = SimpleStyle(lambda on: calls.append(on), sound) + style.start() assert calls == [True] + style.update(0.0, False) + assert calls[-1] is True # stays on every tick + + +def test_simple_beeps_on_interval(): + style, sound = _make_style() + style.start() + style.update(100.0, False) # first tick -> beep + assert sound.play_beep.called + sound.play_beep.reset_mock() + style.update(100.4, False) # before interval -> no beep + assert not sound.play_beep.called + style.update(100.5, False) # at interval -> beep + assert sound.play_beep.called + + +def test_simple_never_plays_music(): + style, sound = _make_style() + style.start() + style.update(0.0, False) + style.update(0.0, True) # press -> stop + assert not sound.play_music.called diff --git a/tests/test_wecker.py b/tests/test_wecker.py index 851b16d..9fa16b5 100644 --- a/tests/test_wecker.py +++ b/tests/test_wecker.py @@ -32,11 +32,14 @@ def test_set_led(mock_gpio): mock_gpio.output.assert_called_with(wecker.LED_PIN, mock_gpio.HIGH) -@patch("wecker.time.time") -def test_run_alarm_plays_music(mock_time, mock_gpio, mock_pygame): - mock_pygame.mixer.get_init.return_value = True - wecker.run_alarm(test_mode=True) - assert mock_pygame.mixer.music.play.called +def test_setup_inits_mixer_and_gpio_not_music(mock_gpio, mock_pygame): + wecker.setup() + assert mock_pygame.mixer.pre_init.called + assert mock_pygame.mixer.init.called + assert mock_gpio.setmode.called + assert mock_gpio.setup.called + # setup() no longer loads music — that is now the style's job. + assert not mock_pygame.mixer.music.load.called def test_run_alarm_unknown_style_raises(mock_gpio, mock_pygame): @@ -47,8 +50,7 @@ def test_run_alarm_unknown_style_raises(mock_gpio, mock_pygame): def test_run_alarm_uses_selected_style(mock_gpio, mock_pygame): - """run_alarm looks up the style by name and drives the returned style.""" - mock_pygame.mixer.get_init.return_value = True + """run_alarm looks up the style, injects a Sound, and calls start().""" fake = MagicMock() fake.update.return_value = True # keep ringing fake_style_cls = MagicMock(return_value=fake) @@ -57,17 +59,68 @@ def test_run_alarm_uses_selected_style(mock_gpio, mock_pygame): wecker.run_alarm(style_name="whatever", test_mode=True) get_style.assert_called_once_with("whatever") - fake_style_cls.assert_called_once_with(wecker.set_led) + args = fake_style_cls.call_args.args + assert args[0] is wecker.set_led + assert isinstance(args[1], wecker.Sound) + fake.start.assert_called_once() fake.update.assert_called() -def test_setup_uses_env_music_file(mock_gpio, mock_pygame, monkeypatch): - monkeypatch.setenv("MUSIC_FILE", "/custom/track.mp3") - wecker.setup() - mock_pygame.mixer.music.load.assert_called_with("/custom/track.mp3") +def test_run_alarm_default_music_file(mock_gpio, mock_pygame, monkeypatch): + monkeypatch.delenv("MUSIC_FILE", raising=False) + fake = MagicMock() + fake.update.return_value = True + fake_style_cls = MagicMock(return_value=fake) + with patch("wecker.get_style", return_value=fake_style_cls): + wecker.run_alarm(test_mode=True) + assert fake_style_cls.call_args.args[2] == wecker.DEFAULT_MUSIC_FILE -def test_setup_music_file_argument_overrides_env(mock_gpio, mock_pygame, monkeypatch): +def test_run_alarm_env_music_file(mock_gpio, mock_pygame, monkeypatch): monkeypatch.setenv("MUSIC_FILE", "/env/track.mp3") - wecker.setup(music_file="/arg/track.mp3") - mock_pygame.mixer.music.load.assert_called_with("/arg/track.mp3") + fake = MagicMock() + fake.update.return_value = True + fake_style_cls = MagicMock(return_value=fake) + with patch("wecker.get_style", return_value=fake_style_cls): + wecker.run_alarm(test_mode=True) + assert fake_style_cls.call_args.args[2] == "/env/track.mp3" + + +def test_run_alarm_music_file_arg_overrides_env(mock_gpio, mock_pygame, monkeypatch): + monkeypatch.setenv("MUSIC_FILE", "/env/track.mp3") + fake = MagicMock() + fake.update.return_value = True + fake_style_cls = MagicMock(return_value=fake) + with patch("wecker.get_style", return_value=fake_style_cls): + wecker.run_alarm(music_file="/arg/track.mp3", test_mode=True) + assert fake_style_cls.call_args.args[2] == "/arg/track.mp3" + + +@patch("wecker.time.time") +def test_run_alarm_blink_plays_music(mock_time, mock_gpio, mock_pygame): + """The default (blink) style plays music on an endless loop.""" + wecker.run_alarm(test_mode=True) + assert mock_pygame.mixer.music.play.called + assert mock_pygame.mixer.music.play.call_args.args[0] == -1 # endless loop + + +def test_run_alarm_simple_beeps_and_plays_no_music(mock_gpio, mock_pygame): + """The simple style beeps and never touches the music stream.""" + wecker.run_alarm(style_name="simple", test_mode=True) + assert mock_pygame.mixer.Sound.return_value.play.called # beep played + assert not mock_pygame.mixer.music.play.called # no music + + +def test_sound_play_music_loads_and_loops(mock_pygame): + s = wecker.Sound() + s.play_music("/track.mp3") + mock_pygame.mixer.music.load.assert_called_with("/track.mp3") + mock_pygame.mixer.music.set_volume.assert_called_with(1.0) + mock_pygame.mixer.music.play.assert_called_with(-1) + + +def test_sound_play_beep_plays_buffer(mock_pygame): + s = wecker.Sound() + s.play_beep() + mock_pygame.mixer.Sound.assert_called_once_with(wecker._BEEP_BUFFER) + mock_pygame.mixer.Sound.return_value.play.assert_called_once() diff --git a/wecker.py b/wecker.py index 71632c4..f141788 100644 --- a/wecker.py +++ b/wecker.py @@ -1,10 +1,13 @@ -import RPi.GPIO as GPIO -import time -import logging -import pygame -import sys +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") @@ -69,11 +72,12 @@ 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) +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) @@ -82,14 +86,47 @@ def setup(music_file: str | None = None): 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) + +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): @@ -108,16 +145,18 @@ def run_alarm( ): """Run the alarm clock with the given style until the style signals stop. - The runner owns everything shared (music loop, button sampling, cleanup); - the style only decides when to stop and what the LED does. + 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 = get_style(style_name)(set_led) - 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) + 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) - set_led(False) # LED off at start; the style drives it from here. + 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: @@ -146,7 +185,7 @@ if __name__ == "__main__": 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).", + help="Music file for the blink style (default: $MUSIC_FILE or default track).", ) parser.add_argument( "--style",