Files
wecker/styles/blink.py
T
gurix 92eba762ce feat: styles own their ringing tone (simple beeps, blink plays music)
Move audio ownership out of the shared runner and into each style via an
injected Sound capability (play_music(path) / play_beep()). The runner now
only owns button sampling and cleanup; the style kicks off its own tone in
start() and drives the LED.

- simple: a repeating square-wave beep (ordinary alarm-clock tone), no music
  file required. The beep is synthesised in memory as signed-16-bit stereo
  PCM (stdlib array+math) and played via pygame.mixer.Sound — no shipped
  audio asset, no new dependency.
- blink: unchanged behaviour — music on an endless loop from --music-file /
  MUSIC_FILE / the default track, via pygame.mixer.music.
- AlarmStyle contract gains sound + music_file in __init__ and a start()
  lifecycle hook; AlarmSound Protocol documents the audio seam for future
  styles that handle their own tone.
- setup() no longer loads music (that is the style's job now).

README updated: per-style ringing tone, the --music-file note (blink only),
and the extended plugin contract.
2026-08-02 16:29:40 +02:00

162 lines
5.7 KiB
Python

import logging
import random
from styles.base import AlarmStyle
# State Machine states for the puzzle 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
class BlinkStyle(AlarmStyle):
"""The count-the-blinks puzzle.
Press once to start, watch the LED blink 1-7 times, then press the button
exactly that many times. A wrong count starts a fresh sequence.
"""
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
self.last_interaction_time = 0
self.button_was_pressed = False
self._blink_phase = 0
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:
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
self.set_led(True)
elif self.state == STATE_BLINKING:
# Non-blocking blink: toggle the LED at fixed intervals so the
# runner 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
self.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:
self.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
self.button_was_pressed = is_pressed
break
else:
self.set_led(self._blink_phase % 2 == 0)
elif self.state == STATE_WAIT_FOR_INPUT:
self.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}, "
f"Entered={self.user_presses}"
)
if self.user_presses == self.target_blinks:
logging.info("Puzzle solved correctly! Alarm clock is stopping.")
self.set_led(False)
return False # Signal the runner to stop.
else:
logging.info("Incorrect input! Waiting before retrying...")
self.state = STATE_WAIT_BEFORE_RETRY
self.last_interaction_time = now
self.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
if __name__ == "__main__":
# Self-check: a correct blink sequence stops the alarm.
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
import random as _random
_random.randint = lambda a, b: 3 # force 3 blinks
style.update(103.1, False)
assert style.state == STATE_BLINKING and style.target_blinks == 3
style.update(105.0, False)
assert style.state == STATE_WAIT_FOR_INPUT
for t in (105.5, 106.0, 106.5):
style.update(t, True)
style.update(t + 0.05, False)
assert style.user_presses == 3
style.update(109.7, False)
assert style.update(109.8, False) is False # solved -> stop
print("blink self-check ok")