feat: add pluggable alarm styles and migrate wecker.py

Introduce a styles/ plugin package:
- styles/base.py: AlarmStyle ABC (injected set_led, update()->bool contract)
- styles/blink.py: BlinkStyle, the existing count-the-blinks puzzle moved
  out of wecker.py's AlarmClock
- styles/simple.py: SimpleStyle, press-once-to-stop (the new default style)
- styles/__init__.py: STYLES registry + get_style() validator + LEGACY_STYLE

wecker.py now resolves the style via the registry and owns only the shared
music/button/cleanup loop; the --style CLI arg defaults to blink so existing
cron entries keep their behaviour. Unknown styles raise before hardware init.
This commit is contained in:
2026-07-31 16:19:14 +02:00
parent 7ee28d72b6
commit b3f3db6f13
9 changed files with 395 additions and 212 deletions
+23
View File
@@ -0,0 +1,23 @@
from styles.base import AlarmStyle as AlarmStyle
from styles.blink import BlinkStyle
from styles.simple import SimpleStyle
# Registry of available alarm styles. Add a line here when you add a style.
STYLES = {
"blink": BlinkStyle,
"simple": SimpleStyle,
}
# Fallback style for legacy cron entries that predate --style, so an upgrade
# never silently changes an existing alarm's behaviour.
LEGACY_STYLE = "blink"
def get_style(name: str):
"""Return the style class for ``name`` or raise ValueError."""
try:
return STYLES[name]
except KeyError:
raise ValueError(
f"Unknown alarm style: {name!r}. Known: {sorted(STYLES)}"
)
+29
View File
@@ -0,0 +1,29 @@
from abc import ABC, abstractmethod
from typing import Callable
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).
Lifecycle: construct once with ``set_led``, then call ``update`` every
tick. Return ``False`` from ``update`` to stop the alarm.
"""
def __init__(self, set_led: Callable[[bool], None]):
self.set_led = set_led
@abstractmethod
def update(self, now: float, is_pressed: bool) -> bool:
"""Advance the style by one tick.
Args:
now: current time (``time.time()`` from the runner).
is_pressed: whether the button is currently held down.
Returns:
True to keep ringing, False to stop.
"""
+148
View File
@@ -0,0 +1,148 @@
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):
super().__init__(set_led)
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 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.
style = BlinkStyle(lambda on: None)
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")
+18
View File
@@ -0,0 +1,18 @@
from styles.base import AlarmStyle
class SimpleStyle(AlarmStyle):
"""Press the button once to stop the alarm."""
def update(self, now, is_pressed):
# LED solid on so the button is findable in the dark.
self.set_led(True)
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)
assert style.update(0.0, False) is True
assert style.update(0.1, True) is False
print("simple self-check ok")