from time import time from datetime import datetime, timedelta from zoneinfo import ZoneInfo from typing import Callable from grove.gpio import GPIO from grove.display.jhd1802 import JHD1802 from .config import CITY, SHELLY_DEVICE_ID from .shelly import set_shelly_plug_state from .wakeup_action import WakeUpAction class AlarmClock: """Main alarm clock controller with LCD display and GPIO controls.""" wakeup_actions: list[WakeUpAction] = [] led_gpio: GPIO button_gpio: GPIO relay_gpio: GPIO last_known_button_state: bool = False lcd: JHD1802 last_button_press_time: float = 0.0 last_button_state_change_time: float = 0.0 debounce_delay: float = 0.05 # 50ms debounce delay long_press_threshold: float = 1.0 # 1 second for long press button_press_start_time: float = 0.0 config_mode: str = "normal" # modes: normal, set_hour, set_minute alarm_time: datetime = datetime.now(ZoneInfo("Europe/Helsinki")).replace( hour=9, minute=59, second=0, microsecond=0 ) actions_reset_for_today: bool = False alarm_active: bool = False last_lcd_text: str = "" def __init__(self, pins: dict = {}): self.led_gpio = GPIO(pins.get("led", 5), GPIO.OUT) self.button_gpio = GPIO(pins.get("button", 6), GPIO.IN) self.relay_gpio = GPIO(pins.get("relay", 16), GPIO.OUT) self.lcd = JHD1802() self.wakeup_actions = [] self.add_wakeup_action(0, lambda: self.trigger_alarm()) # Activate alarm state self.set_lcd_text("AlarmClock Init") def add_wakeup_action( self, offset_seconds: int, action: Callable[[], None], dismiss_action: Callable[[], None] | None = None ): """Add a wakeup action to be triggered offset_seconds before alarm time.""" self.wakeup_actions.append(WakeUpAction(offset_seconds, action, dismiss_action)) def reset_wakeup_actions(self): """Reset all wakeup actions to untriggered state.""" for action in self.wakeup_actions: action.triggered = False self.actions_reset_for_today = True print("Wakeup actions reset") def trigger_alarm(self): """Activate the alarm state.""" if self.alarm_active: return self.alarm_active = True print("Alarm is now active!") def dismiss_alarm(self): """Dismiss the active alarm and call dismiss actions.""" if not self.alarm_active: return self.alarm_active = False for action in self.wakeup_actions: if action.dismiss_action: action.dismiss_action() print("Alarm dismissed") def check_wakeup_actions(self): """Check and trigger any wakeup actions that are due.""" if not self.wakeup_actions: return # If not in normal mode, skip wakeup actions if self.config_mode != "normal": return now = datetime.now(ZoneInfo(CITY.timezone)) today_alarm = now.replace( hour=self.alarm_time.hour, minute=self.alarm_time.minute, second=0, microsecond=0 ) # Find the earliest action (largest offset) max_offset = max(action.offset_seconds for action in self.wakeup_actions) first_trigger_time = today_alarm - timedelta(seconds=max_offset) reset_time = first_trigger_time - timedelta(seconds=10) alarm_window_end = today_alarm + timedelta(minutes=1) # Reset actions 10 seconds before the first one should trigger if now >= reset_time and now < first_trigger_time and not self.actions_reset_for_today: self.reset_wakeup_actions() # After alarm window ends, allow reset to happen again tomorrow if now >= alarm_window_end: self.actions_reset_for_today = False for wakeup_action in self.wakeup_actions: if wakeup_action.triggered: continue trigger_time = today_alarm - timedelta(seconds=wakeup_action.offset_seconds) if now >= trigger_time and now < today_alarm + timedelta(minutes=1): print(f"Triggering wakeup action (offset: {wakeup_action.offset_seconds}s)") wakeup_action.action() wakeup_action.triggered = True def start(self): """Start the alarm clock main loop.""" print("AlarmClock started") while True: self.loop() def set_relay_state(self, state: bool): """Set the relay GPIO state.""" self.relay_gpio.write(1 if state else 0) def loop(self): """Main loop iteration - handles display and button input.""" current_button_state = not self.button_gpio.read() current_time = time() if self.config_mode == "normal": now = datetime.now(ZoneInfo(CITY.timezone)) current_hour = now.hour current_minute = now.minute current_second = now.second if self.alarm_active: self.set_lcd_text("!! ALARM !!\nPress to dismiss") else: self.set_lcd_text( f"Time {current_hour:02d}:{current_minute:02d}:{current_second:02d}\n" f"Alarm {self.alarm_time.hour:02d}:{self.alarm_time.minute:02d}" ) # Check and trigger wakeup actions self.check_wakeup_actions() # Only process button state change if debounce delay has passed if current_button_state != self.last_known_button_state: if (current_time - self.last_button_state_change_time) >= self.debounce_delay: self.last_known_button_state = current_button_state self.last_button_state_change_time = current_time self.on_button_press("button", current_button_state) # Check for long press while button is held if current_button_state and self.button_press_start_time > 0: press_duration = current_time - self.button_press_start_time if press_duration >= self.long_press_threshold: self.on_long_press() self.button_press_start_time = 0.0 # Reset to prevent repeated triggers def on_button_press(self, button: str, state: bool): """Handle button press/release events.""" print(f"Button '{button}' pressed state: {state}") current_time = time() if button == "button": if state: # Button pressed down self.button_press_start_time = current_time else: # Button released if self.button_press_start_time > 0: press_duration = current_time - self.button_press_start_time self.button_press_start_time = 0.0 # Only handle as short press if it wasn't a long press if press_duration < self.long_press_threshold: self.on_short_press() def on_short_press(self): """Handle short button press based on current mode.""" if self.config_mode == "normal": if self.alarm_active: # Dismiss active alarm self.dismiss_alarm() else: # Normal mode: toggle relay self.set_relay_state(not self.relay_gpio.read()) set_shelly_plug_state(SHELLY_DEVICE_ID, self.relay_gpio.read() == 0) elif self.config_mode == "set_hour": # Increment hour self.alarm_time = self.alarm_time.replace(hour=(self.alarm_time.hour + 1) % 24) self.set_lcd_text(f"Set Hour: {self.alarm_time.hour:02d}") elif self.config_mode == "set_minute": # Increment minute self.alarm_time = self.alarm_time.replace(minute=(self.alarm_time.minute + 1) % 60) self.set_lcd_text(f"Set Min: {self.alarm_time.minute:02d}") self.reset_wakeup_actions() # Reset wakeup actions to apply new alarm time immediately def on_long_press(self): """Handle long button press - cycle through configuration modes.""" if self.config_mode == "normal": self.config_mode = "set_hour" self.set_lcd_text(f"Set Hour: {self.alarm_time.hour:02d}") print("Entering hour setting mode") elif self.config_mode == "set_hour": self.config_mode = "set_minute" self.set_lcd_text(f"Set Min: {self.alarm_time.minute:02d}") print("Switching to minute setting mode") elif self.config_mode == "set_minute": self.config_mode = "normal" self.set_lcd_text(f"Saved: {self.alarm_time.hour:02d}:{self.alarm_time.minute:02d}") print(f"Alarm time saved: {self.alarm_time.hour:02d}:{self.alarm_time.minute:02d}") def set_lcd_text(self, text: str): """Update the LCD display with new text.""" if text == self.last_lcd_text: return self.last_lcd_text = text self.lcd.clear() rows = text.split("\n") for i, row in enumerate(rows): if i == 0: self.lcd.setCursor(0, 0) self.lcd.write(f"{(' ' + row):<17}") elif i == 1: self.lcd.setCursor(1, 0) self.lcd.write(row)