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:
+148
@@ -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")
|
||||
Reference in New Issue
Block a user