From a48976a7621f7fc83ad2e44b9a19b852f3fd4aed Mon Sep 17 00:00:00 2001 From: Kalzu Rekku Date: Sat, 25 Jan 2025 23:24:27 +0200 Subject: [PATCH] We have sound! And mostly working ncurses ui! --- alert_api/alarm_siren.py | 140 ++++++++----- alert_api/ncurses_ui.py | 368 +++++++++++++++-------------------- alert_api/ncurses_ui_draw.py | 168 ++++++++-------- alert_api/tests.sh | 194 +++++++++--------- 4 files changed, 428 insertions(+), 442 deletions(-) diff --git a/alert_api/alarm_siren.py b/alert_api/alarm_siren.py index 6adc796..d6dce53 100644 --- a/alert_api/alarm_siren.py +++ b/alert_api/alarm_siren.py @@ -7,26 +7,20 @@ import logging from datetime import datetime, timedelta from typing import Optional, Dict, Any +from logging_config import setup_logging + # Set up logging -logging.basicConfig( - level=logging.DEBUG, - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', - handlers=[ - logging.StreamHandler(), - logging.FileHandler('alarm_siren.log') - ] -) -logger = logging.getLogger('AlarmSiren') +logger = setup_logging() class AlarmSiren: def __init__(self): # Communication queues self.alarm_queue = queue.Queue() self.control_queue = queue.Queue() - + # Tracking active alarms self.active_alarms: Dict[int, Dict[str, Any]] = {} - + # Playback thread self.playback_thread = threading.Thread(target=self._playback_worker, daemon=True) self.playback_thread.start() @@ -40,47 +34,69 @@ class AlarmSiren: """Calculate the next alarm trigger time based on repeat rule""" now = datetime.now() current_time = now.time() - + # Parse alarm time alarm_time = datetime.strptime(alarm_config['time'], "%H:%M:%S").time() - + # Determine the next trigger - if alarm_config['repeat_rule']['type'] == 'once': + repeat_rule = alarm_config['repeat_rule'] + + # Determine repeat rule type and details + if isinstance(repeat_rule, dict): + repeat_type = repeat_rule.get('type') + repeat_days = repeat_rule.get('days_of_week', []) + repeat_at = repeat_rule.get('at') + else: + # Assume it's an object-like structure with attributes + repeat_type = getattr(repeat_rule, 'type', None) + repeat_days = getattr(repeat_rule, 'days_of_week', []) + repeat_at = getattr(repeat_rule, 'at', None) + + # Sanity check + if repeat_type is None: + logger.error(f"Invalid repeat rule configuration: {repeat_rule}") + return None + + if repeat_type == 'once': # For one-time alarm, check the specific date try: - specific_date = datetime.strptime(alarm_config['repeat_rule']['at'], "%d.%m.%Y") - return datetime.combine(specific_date.date(), alarm_time) - except (KeyError, ValueError): + # If 'at' is None, use current date + if repeat_at is None: + specific_date = now.date() + else: + specific_date = datetime.strptime(repeat_at, "%d.%m.%Y").date() + return datetime.combine(specific_date, alarm_time) + except ValueError: logger.error("Invalid one-time alarm configuration") return None - - elif alarm_config['repeat_rule']['type'] == 'daily': + + elif repeat_type == 'daily': # Daily alarm - trigger today or tomorrow next_trigger = datetime.combine(now.date(), alarm_time) if current_time < alarm_time: return next_trigger return next_trigger + timedelta(days=1) - - elif alarm_config['repeat_rule']['type'] == 'weekly': + + elif repeat_type == 'weekly': # Weekly alarm - check configured days today = now.strftime("%A").lower() - configured_days = [day.lower() for day in alarm_config['repeat_rule'].get('days_of_week', [])] - + configured_days = [day.lower() for day in repeat_days] + if today in configured_days and current_time < alarm_time: return datetime.combine(now.date(), alarm_time) - + # Find next configured day days_order = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'] current_index = days_order.index(today) - + for offset in range(1, 8): next_day_index = (current_index + offset) % 7 next_day = days_order[next_day_index] - + if next_day in configured_days: next_date = now.date() + timedelta(days=offset) return datetime.combine(next_date, alarm_time) - + return None def _play_audio(self, file_path: str, volume: int = 100): @@ -94,9 +110,9 @@ class AlarmSiren: # Construct mpg123 command with volume control volume_adjust = f"-g {volume}" cmd = ["mpg123", volume_adjust, file_path] - + logger.info(f"Playing alarm: {file_path}") - + # Track the process for potential interruption process = subprocess.Popen(cmd) return process @@ -134,16 +150,16 @@ class AlarmSiren: if now >= alarm_info['trigger_time']: # Trigger alarm process = self._play_audio( - alarm_info['config']['file_to_play'], + alarm_info['config']['file_to_play'], alarm_info['config'].get('metadata', {}).get('volume', 100) ) - + # Handle repeat and snooze logic if process: # Wait for user interaction or timeout # In a real implementation, this would be more sophisticated time.sleep(30) # Placeholder for user interaction - + # Determine next trigger based on repeat rule next_trigger = self._calculate_next_alarm_time(alarm_info['config']) if next_trigger: @@ -158,26 +174,42 @@ class AlarmSiren: def snooze_alarm(self, alarm_id: int): """Snooze a specific alarm""" - if alarm_id in self.active_alarms: - alarm_config = self.active_alarms[alarm_id]['config'] - snooze_config = alarm_config.get('snooze', {'enabled': True, 'duration': 10, 'max_count': 3}) - - if (snooze_config['enabled'] and - self.active_alarms[alarm_id]['snooze_count'] < snooze_config['max_count']): - - # Increment snooze count - self.active_alarms[alarm_id]['snooze_count'] += 1 - - # Set next trigger time - snooze_duration = snooze_config.get('duration', 10) - self.active_alarms[alarm_id]['trigger_time'] = datetime.now() + timedelta(minutes=snooze_duration) - - logger.info(f"Snoozed alarm {alarm_id} for {snooze_duration} minutes") - else: - logger.warning(f"Cannot snooze alarm {alarm_id} - max snooze count reached") + if alarm_id not in self.active_alarms: + logger.warning(f"Cannot snooze alarm {alarm_id} - not found in active alarms") + return False - def dismiss_alarm(self, alarm_id: int): - """Dismiss a specific alarm""" - if alarm_id in self.active_alarms: - logger.info(f"Dismissed alarm {alarm_id}") - del self.active_alarms[alarm_id] + alarm_info = self.active_alarms[alarm_id] + alarm_config = alarm_info['config'] + + # Default snooze configuration if not provided + snooze_config = alarm_config.get('snooze', { + 'enabled': True, + 'duration': 5, # Default 5 minutes + 'max_count': 3 # Default max 3 snoozes + }) + + # Ensure all required keys exist with default values + snooze_config.setdefault('enabled', True) + snooze_config.setdefault('duration', 5) + snooze_config.setdefault('max_count', 3) + + # Check if snoozing is allowed + if not snooze_config['enabled']: + logger.warning(f"Snooze not enabled for alarm {alarm_id}") + return False + + # Check snooze count + current_snooze_count = alarm_info.get('snooze_count', 0) + if current_snooze_count >= snooze_config['max_count']: + logger.warning(f"Maximum snooze count reached for alarm {alarm_id}") + return False + + # Increment snooze count + alarm_info['snooze_count'] = current_snooze_count + 1 + + # Set next trigger time + snooze_duration = snooze_config['duration'] + alarm_info['trigger_time'] = datetime.now() + timedelta(minutes=snooze_duration) + + logger.info(f"Snoozed alarm {alarm_id} for {snooze_duration} minutes. Snooze count: {alarm_info['snooze_count']}") + return True diff --git a/alert_api/ncurses_ui.py b/alert_api/ncurses_ui.py index 291e210..e2eaea2 100644 --- a/alert_api/ncurses_ui.py +++ b/alert_api/ncurses_ui.py @@ -1,225 +1,176 @@ import curses import time from datetime import datetime, date, timedelta -import os -from big_digits import BIG_DIGITS -from ncurses_ui_draw import _draw_big_digit, _draw_main_clock, _draw_add_alarm, _draw_list_alarms, _draw_error -from logging_config import setup_logging +import threading +import logging -# Set up logging -logger = setup_logging() +# Import drawing methods from the new module +from ncurses_ui_draw import ( + _draw_main_clock, + _draw_add_alarm, + _draw_list_alarms, + _draw_error +) +# class AlarmClockUI: class UI: def __init__(self, alarm_system_manager): - """ - Initialize the ncurses UI for the alarm system - - Args: - alarm_system_manager (AlarmSystemManager): The main alarm system manager - """ + # UI State Management self.alarm_system = alarm_system_manager self.stop_event = alarm_system_manager.stop_event self.storage = alarm_system_manager.storage - # UI state variables - self.selected_menu = 0 - self.new_alarm_name = "Alarm" - self.editing_name = " " - self.new_alarm_hour = datetime.now().hour - self.new_alarm_minute = datetime.now().minute - self.new_alarm_selected = 4 - self.new_alarm_date = None - self.new_alarm_weekdays = [] - self.new_alarm_enabled = True - self.weekday_names = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] - self.alarms = [] + # Logging + self.logger = logging.getLogger(__name__) + + # UI State + self.reset_ui_state() + + def reset_ui_state(self): + """Reset all UI state variables to their initial values""" + # Menu states + self.current_view = 'CLOCK' + self.selected_item = 0 + + # Alarm Creation State + self.alarm_draft = { + 'name': 'New Alarm', + 'hour': datetime.now().hour, + 'minute': datetime.now().minute, + 'enabled': True, + 'date': None, + 'weekdays': [], + 'editing_name': False, + 'temp_name': '' + } + + # Error handling self.error_message = None self.error_timestamp = None + # Alarm list + self.alarm_list = [] + + # Weekday names (to match specification) + self.weekday_names = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] + def run(self): - """ - Start the ncurses UI - """ + """Start the ncurses UI in a separate thread""" def ui_thread(): try: curses.wrapper(self._main_loop) except Exception as e: - logger.error(f"Error in UI thread: {e}") - print(f"Error in UI thread: {e}") + self.logger.error(f"UI Thread Error: {e}") finally: self.stop_event.set() - import threading ui_thread_obj = threading.Thread(target=ui_thread, daemon=True) ui_thread_obj.start() return ui_thread_obj + def _handle_clock_input(self, key): + """Handle input on the clock view""" + if key == ord('a'): + self.current_view = 'ADD_ALARM' + elif key == ord('s'): + self.current_view = 'LIST_ALARMS' + self.alarm_list = self.storage.get_saved_alerts() + def _handle_add_alarm_input(self, key): - try: - if key == 27: # Escape - # If in name editing, exit name editing - if self.editing_name: - self.new_alarm_name = self.temp_alarm_name - self.editing_name = False - return - - # Otherwise return to main clock - self.selected_menu = 0 - return - - if key == 10: # Enter - if self.editing_name: - # Exit name editing mode - self.editing_name = False - # Move focus to time selection - self.new_alarm_selected = 0 - return - + """Comprehensive input handling for alarm creation""" + alarm = self.alarm_draft + + # Escape key handling + if key == 27: # ESC + if alarm['editing_name']: + # Cancel name editing + alarm['name'] = alarm['temp_name'] + alarm['editing_name'] = False + else: + # Return to clock view + self.current_view = 'CLOCK' + return + + # Enter key handling + if key == 10: # ENTER + if alarm['editing_name']: + # Finish name editing + alarm['editing_name'] = False + self.selected_item = 0 + else: + # Save alarm try: alarm_data = { - "name": self.new_alarm_name, - "time": f"{self.new_alarm_hour:02d}:{self.new_alarm_minute:02d}:00", - "enabled": self.new_alarm_enabled, + "name": alarm['name'], + "time": f"{alarm['hour']:02d}:{alarm['minute']:02d}:00", + "enabled": alarm['enabled'], "repeat_rule": { - "type": "weekly" if self.new_alarm_weekdays else "once", - "days": self.new_alarm_weekdays if self.new_alarm_weekdays else [], - "date": self.new_alarm_date.strftime("%Y-%m-%d") if self.new_alarm_date else date.today().strftime("%Y-%m-%d") - } + "type": "weekly" if alarm['weekdays'] else "once", + "days_of_week": [self.weekday_names[day].lower() for day in alarm['weekdays']], + "at": alarm['date'].strftime("%d.%m.%Y") if alarm['date'] else None + } } self.storage.save_new_alert(alarm_data) - self.selected_menu = 0 + self.current_view = 'CLOCK' except Exception as e: self._show_error(str(e)) return - - # Numeric input for time when on time selection - if self.new_alarm_selected in [0, 1] and not self.editing_name: - if 48 <= key <= 57: # 0-9 keys - current_digit = int(chr(key)) - if self.new_alarm_selected == 0: # Hour - self.new_alarm_hour = current_digit if self.new_alarm_hour < 10 else (self.new_alarm_hour % 10 * 10 + current_digit) % 24 - else: # Minute - self.new_alarm_minute = current_digit if self.new_alarm_minute < 10 else (self.new_alarm_minute % 10 * 10 + current_digit) % 60 - return - - # Use hjkl for navigation and selection + + # Navigation and editing + if not alarm['editing_name']: if key in [ord('h'), curses.KEY_LEFT]: - self.new_alarm_selected = (self.new_alarm_selected - 1) % 6 + self.selected_item = (self.selected_item - 1) % 6 elif key in [ord('l'), curses.KEY_RIGHT]: - self.new_alarm_selected = (self.new_alarm_selected + 1) % 6 - elif key in [ord('k'), curses.KEY_UP]: - if self.new_alarm_selected == 0: - self.new_alarm_hour = (self.new_alarm_hour + 1) % 24 - elif self.new_alarm_selected == 1: - self.new_alarm_minute = (self.new_alarm_minute + 1) % 60 - elif self.new_alarm_selected == 2: - if not self.new_alarm_date: - self.new_alarm_date = date.today() - else: - self.new_alarm_date += timedelta(days=1) - elif self.new_alarm_selected == 3 and len(self.new_alarm_weekdays) < 7: - # Add whole groups of days - if key == ord('k'): - # Options: M-F (0-4), Weekends (5-6), All days - if not self.new_alarm_weekdays: - self.new_alarm_weekdays = list(range(5)) # M-F - elif self.new_alarm_weekdays == list(range(5)): - self.new_alarm_weekdays = [5, 6] # Weekends - elif self.new_alarm_weekdays == [5, 6]: - self.new_alarm_weekdays = list(range(7)) # All days - else: - self.new_alarm_weekdays = [] # Reset - self.new_alarm_weekdays.sort() - elif key in [ord('j'), curses.KEY_DOWN]: - if self.new_alarm_selected == 0: - self.new_alarm_hour = (self.new_alarm_hour - 1) % 24 - elif self.new_alarm_selected == 1: - self.new_alarm_minute = (self.new_alarm_minute - 1) % 60 - elif self.new_alarm_selected == 2 and self.new_alarm_date: - self.new_alarm_date -= timedelta(days=1) - elif self.new_alarm_selected == 3: - # Cycle through weekday groups - if key == ord('j'): - if not self.new_alarm_weekdays: - self.new_alarm_weekdays = [5, 6] # Weekends - elif self.new_alarm_weekdays == [5, 6]: - self.new_alarm_weekdays = list(range(7)) # All days - elif self.new_alarm_weekdays == list(range(7)): - self.new_alarm_weekdays = list(range(5)) # M-F - else: - self.new_alarm_weekdays = [] # Reset - self.new_alarm_weekdays.sort() - elif key == 32: # Space - if self.new_alarm_selected == 4: # Name editing - if not self.editing_name: - self.editing_name = True - self.temp_alarm_name = self.new_alarm_name - self.new_alarm_name = "" - elif self.new_alarm_selected == 2: # Date - self.new_alarm_date = None - elif self.new_alarm_selected == 3: # Weekdays - # Toggle specific day when on weekday selection - current_day = len(self.new_alarm_weekdays) - if current_day < 7: - if current_day in self.new_alarm_weekdays: - self.new_alarm_weekdays.remove(current_day) - else: - self.new_alarm_weekdays.append(current_day) - self.new_alarm_weekdays.sort() - elif self.new_alarm_selected == 5: # Enabled toggle - self.new_alarm_enabled = not self.new_alarm_enabled - - # Name editing handling - if self.editing_name: - if key == curses.KEY_BACKSPACE or key == 127: - self.new_alarm_name = self.new_alarm_name[:-1] - elif 32 <= key <= 126: # Printable ASCII - self.new_alarm_name += chr(key) - - except Exception as e: - logger.error(f"Error: {e}") - self._show_error(str(e)) + self.selected_item = (self.selected_item + 1) % 6 + + # Up/Down for editing values + if key in [ord('k'), curses.KEY_UP, ord('j'), curses.KEY_DOWN]: + is_up = key in [ord('k'), curses.KEY_UP] + + if self.selected_item == 0: # Hour + alarm['hour'] = (alarm['hour'] + (1 if is_up else -1)) % 24 + elif self.selected_item == 1: # Minute + alarm['minute'] = (alarm['minute'] + (1 if is_up else -1)) % 60 + + # Space key for toggling/editing + if key == 32: # SPACE + if self.selected_item == 4: # Name + if not alarm['editing_name']: + alarm['editing_name'] = True + alarm['temp_name'] = alarm['name'] + alarm['name'] = '' + elif self.selected_item == 5: # Enabled + alarm['enabled'] = not alarm['enabled'] + + # Name editing + if alarm['editing_name']: + if key == curses.KEY_BACKSPACE or key == 127: + alarm['name'] = alarm['name'][:-1] + elif 32 <= key <= 126: # Printable ASCII + alarm['name'] += chr(key) def _handle_list_alarms_input(self, key): - """ - Handle input for the list alarms screen - """ - if key == 27: # Escape - self.selected_menu = 0 + """Handle input for alarm list view""" + if key == 27: # ESC + self.current_view = 'CLOCK' elif key == ord('d'): - # Delete last alarm if exists - if self.alarms: - last_alarm = self.alarms[-1] + # Delete last alarm + if self.alarm_list: + last_alarm = self.alarm_list[-1] try: self.storage.remove_saved_alert(last_alarm['id']) + self.alarm_list = self.storage.get_saved_alerts() except Exception as e: - _show_error(f"Failed to delete alarm: {e}") - logger.error(f"Failed to delete alarm: {e}") + self._show_error(f"Failed to delete alarm: {e}") - def _show_error(self, message, duration=3): - """Display an error message for a specified duration""" + def _show_error(self, message, duration=30): + """Display an error message""" self.error_message = message self.error_timestamp = time.time() - def _clear_error_if_expired(self): - """Clear error message if it has been displayed long enough""" - if self.error_message and self.error_timestamp: - if time.time() - self.error_timestamp > 3: # 3 seconds - self.error_message = None - self.error_timestamp = None - def _main_loop(self, stdscr): - """ - Main ncurses event loop - """ - # Initialize color pairs - curses.start_color() - curses.init_pair(1, curses.COLOR_GREEN, curses.COLOR_BLACK) - curses.init_pair(2, curses.COLOR_YELLOW, curses.COLOR_BLACK) - curses.init_pair(3, curses.COLOR_RED, curses.COLOR_BLACK) + """Main ncurses event loop""" curses.curs_set(0) - - # Configure screen stdscr.keypad(1) stdscr.timeout(100) @@ -227,64 +178,59 @@ class UI: stdscr.clear() while not self.stop_event.is_set(): - # Clear the screen - #stdscr.clear() stdscr.erase() - # Draw appropriate screen based on selected menu - if self.selected_menu == 0: + # Draw view based on current state + if self.current_view == 'CLOCK': _draw_main_clock(stdscr) - elif self.selected_menu == 1: + elif self.current_view == 'ADD_ALARM': _draw_add_alarm(stdscr, { - 'new_alarm_selected': self.new_alarm_selected, - 'new_alarm_name': self.new_alarm_name, - 'editing_name': getattr(self, 'editing_name', False), - 'new_alarm_hour': self.new_alarm_hour, - 'new_alarm_minute': self.new_alarm_minute, - 'new_alarm_enabled': self.new_alarm_enabled, - 'new_alarm_date': self.new_alarm_date, - 'new_alarm_weekdays': self.new_alarm_weekdays, - 'weekday_names': self.weekday_names, - 'new_alarm_snooze_enabled': getattr(self, 'new_alarm_snooze_enabled', False), - 'new_alarm_snooze_duration': getattr(self, 'new_alarm_snooze_duration', 5), - 'new_alarm_snooze_max_count': getattr(self, 'new_alarm_snooze_max_count', 3) + 'new_alarm_selected': self.selected_item, + 'new_alarm_name': self.alarm_draft['name'], + 'new_alarm_hour': self.alarm_draft['hour'], + 'new_alarm_minute': self.alarm_draft['minute'], + 'new_alarm_enabled': self.alarm_draft['enabled'], + 'new_alarm_date': self.alarm_draft['date'], + 'new_alarm_weekdays': self.alarm_draft['weekdays'], + 'weekday_names': self.weekday_names }) - elif self.selected_menu == 2: + elif self.current_view == 'LIST_ALARMS': _draw_list_alarms(stdscr, { - 'alarms': self.alarms, + 'alarms': self.alarm_list or [], 'weekday_names': self.weekday_names }) - # Draw error if exists + # Render error if exists if self.error_message: _draw_error(stdscr, self.error_message) self._clear_error_if_expired() - # Refresh the screen stdscr.refresh() - # Small sleep to reduce CPU usage - time.sleep(0.2) - # Handle input key = stdscr.getch() if key != -1: - # Menu navigation and input handling - if key == ord('q') or key == 27: # 'q' or Escape - if self.selected_menu != 0: - self.selected_menu = 0 + if key == ord('q'): + # Context-sensitive 'q' key handling + if self.current_view == 'CLOCK': + break # Exit the application only from clock view else: - break - elif key == ord('c'): # Clock/Home screen - self.selected_menu = 0 - elif key == ord('a'): # Add Alarm - self.selected_menu = 1 - elif key == ord('l'): # List Alarms - self.selected_menu = 2 - self.alarms = self.storage.get_saved_alerts() + self.current_view = 'CLOCK' # Return to clock view from other views + continue # Context-specific input handling - if self.selected_menu == 1: + if self.current_view == 'CLOCK': + self._handle_clock_input(key) + elif self.current_view == 'ADD_ALARM': self._handle_add_alarm_input(key) - elif self.selected_menu == 2: + elif self.current_view == 'LIST_ALARMS': self._handle_list_alarms_input(key) + + time.sleep(0.2) + + def _clear_error_if_expired(self): + """Clear error message if expired""" + if self.error_message and self.error_timestamp: + if time.time() - self.error_timestamp > 3: + self.error_message = None + self.error_timestamp = None diff --git a/alert_api/ncurses_ui_draw.py b/alert_api/ncurses_ui_draw.py index f53b6e5..713b9ec 100644 --- a/alert_api/ncurses_ui_draw.py +++ b/alert_api/ncurses_ui_draw.py @@ -1,105 +1,109 @@ -from datetime import datetime import curses +from datetime import datetime from big_digits import BIG_DIGITS -def _draw_error(stdscr, error_message): - """Draw error message if present""" - if error_message: - height, width = stdscr.getmaxyx() - # Truncate message if too long - error_message = error_message[:width-4] - - error_x = max(0, width // 2 - len(error_message) // 2) - error_y = height - 4 # Show near bottom of screen - - # Red color for errors - stdscr.attron(curses.color_pair(3) | curses.A_BOLD) - stdscr.addstr(error_y, error_x, error_message) - stdscr.attroff(curses.color_pair(3) | curses.A_BOLD) +def _init_colors(): + """Initialize color pairs matching specification""" + curses.start_color() + curses.use_default_colors() + # Green text on black background (primary color) + curses.init_pair(1, curses.COLOR_GREEN, curses.COLOR_BLACK) + # Highlight color (yellow) + curses.init_pair(2, curses.COLOR_YELLOW, curses.COLOR_BLACK) + # Error color (red) + curses.init_pair(3, curses.COLOR_RED, curses.COLOR_BLACK) -def _draw_big_digit(stdscr, y, x, digit, big_digits): - """ - Draw a big digit using predefined patterns - """ - patterns = big_digits[digit] +def _draw_error(stdscr, error_message): + """Draw error message following specification""" + height, width = stdscr.getmaxyx() + + # Truncate message if too long + error_message = error_message[:width-4] + + error_x = max(0, width // 2 - len(error_message) // 2) + error_y = height - 4 # Show near bottom of screen + + stdscr.attron(curses.color_pair(3) | curses.A_BOLD) + stdscr.addstr(error_y, error_x, error_message) + stdscr.attroff(curses.color_pair(3) | curses.A_BOLD) + +def _draw_big_digit(stdscr, y, x, digit): + """Draw a big digit using predefined patterns""" + patterns = BIG_DIGITS[digit] for i, line in enumerate(patterns): stdscr.addstr(y + i, x, line) -def _draw_big_time(stdscr, big_digits): - """ - Draw the time in big digits - """ - current_time = datetime.now() - time_str = current_time.strftime("%H:%M:%S") - - # Get terminal dimensions +def _draw_main_clock(stdscr, context=None): + """Draw the main clock screen""" + _init_colors() height, width = stdscr.getmaxyx() + current_time = datetime.now() - # Calculate starting position to center the big clock - digit_width = 14 # Width of each digit pattern including spacing + # Big time display + time_str = current_time.strftime("%H:%M:%S") + digit_width = 14 # Width of each digit pattern total_width = digit_width * len(time_str) start_x = (width - total_width) // 2 - start_y = (height - 7) // 2 - 4 # Move up a bit to make room for date + start_y = (height - 7) // 2 - 4 - # Color for the big time + # Green color for big time stdscr.attron(curses.color_pair(1)) for i, digit in enumerate(time_str): - _draw_big_digit(stdscr, start_y, start_x + i * digit_width, digit, big_digits) + _draw_big_digit(stdscr, start_y, start_x + i * digit_width, digit) stdscr.attroff(curses.color_pair(1)) -def _draw_main_clock(stdscr): - """ - Draw the main clock screen - """ - current_time = datetime.now() - time_str = current_time.strftime("%H:%M:%S") + # Date display date_str = current_time.strftime("%Y-%m-%d") - - # Get terminal dimensions - height, width = stdscr.getmaxyx() - - # Draw big time - # Note: You'll need to pass BIG_DIGITS from big_digits module when calling - _draw_big_time(stdscr, BIG_DIGITS) - - # Draw date date_x = width // 2 - len(date_str) // 2 - date_y = height // 2 + 4 # Below the big clock + date_y = height // 2 + 4 stdscr.attron(curses.color_pair(2)) stdscr.addstr(date_y, date_x, date_str) stdscr.attroff(curses.color_pair(2)) - # Draw menu options - menu_str = "A: Add Alarm L: List Alarms Q: Quit" + # Menu options + menu_str = "A: Add Alarm S: List Alarms Q: Quit" menu_x = width // 2 - len(menu_str) // 2 stdscr.addstr(height - 2, menu_x, menu_str) def _draw_add_alarm(stdscr, context): - """ - Draw the add alarm screen - - Args: - stdscr: The curses screen object - context: A dictionary containing UI state variables - """ - height, width = stdscr.getmaxyx() + """Draw the add alarm screen following specification""" + # Ensure context is a dictionary with default values + if context is None: + context = {} + # Provide default values with more explicit checks + context = { + 'new_alarm_selected': context.get('new_alarm_selected', 0), + 'new_alarm_name': context.get('new_alarm_name', 'New Alarm'), + 'new_alarm_hour': context.get('new_alarm_hour', datetime.now().hour), + 'new_alarm_minute': context.get('new_alarm_minute', datetime.now().minute), + 'new_alarm_enabled': context.get('new_alarm_enabled', True), + 'new_alarm_date': context.get('new_alarm_date') or None, + 'new_alarm_weekdays': context.get('new_alarm_weekdays', []) or [], + 'weekday_names': context.get('weekday_names', ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']) + } + + _init_colors() + height, width = stdscr.getmaxyx() form_y = height // 2 - 3 - stdscr.addstr(form_y -1, width // 2 - 10, "Add New Alarm") + + # Title + stdscr.addstr(form_y - 1, width // 2 - 10, "Add New Alarm") # Name input + name_str = str(context['new_alarm_name']) if context['new_alarm_selected'] == 4: stdscr.attron(curses.color_pair(2)) - stdscr.addstr(form_y + 1, width // 2 - 10, f"Name: {context['new_alarm_name']}") + stdscr.addstr(form_y + 1, width // 2 - 10, f"Name: {name_str}") if context['new_alarm_selected'] == 4: stdscr.attroff(curses.color_pair(2)) # Time selection - hour_str = f"{context['new_alarm_hour']:02d}" - minute_str = f"{context['new_alarm_minute']:02d}" + hour_str = f"{int(context['new_alarm_hour']):02d}" + minute_str = f"{int(context['new_alarm_minute']):02d}" - # Highlight selected field + # Highlight time components if context['new_alarm_selected'] == 0: stdscr.attron(curses.color_pair(2)) stdscr.addstr(form_y + 2, width // 2 - 2, hour_str) @@ -114,7 +118,7 @@ def _draw_add_alarm(stdscr, context): if context['new_alarm_selected'] == 1: stdscr.attroff(curses.color_pair(2)) - # Enabled/Disabled toggle + # Enabled toggle enabled_str = "Enabled" if context['new_alarm_enabled'] else "Disabled" if context['new_alarm_selected'] == 5: stdscr.attron(curses.color_pair(2)) @@ -123,7 +127,11 @@ def _draw_add_alarm(stdscr, context): stdscr.attroff(curses.color_pair(2)) # Date selection - date_str = "No specific date" if not context['new_alarm_date'] else context['new_alarm_date'].strftime("%Y-%m-%d") + if context['new_alarm_date'] and hasattr(context['new_alarm_date'], 'strftime'): + date_str = context['new_alarm_date'].strftime("%Y-%m-%d") + else: + date_str = 'No specific date' + if context['new_alarm_selected'] == 2: stdscr.attron(curses.color_pair(2)) stdscr.addstr(form_y + 3, width // 2 - len(date_str) // 2, date_str) @@ -131,10 +139,12 @@ def _draw_add_alarm(stdscr, context): stdscr.attroff(curses.color_pair(2)) # Weekday selection + weekday_names = context['weekday_names'] weekday_str = "Repeat: " + " ".join( - context['weekday_names'][i] if i in context['new_alarm_weekdays'] else "___" - for i in range(7) + weekday_names[i] if i in context['new_alarm_weekdays'] else "___" + for i in range(len(weekday_names)) ) + if context['new_alarm_selected'] == 3: stdscr.attron(curses.color_pair(2)) stdscr.addstr(form_y + 5, width // 2 - len(weekday_str) // 2, weekday_str) @@ -143,26 +153,23 @@ def _draw_add_alarm(stdscr, context): # Instructions stdscr.addstr(height - 2, 2, - "↑↓: Change ←→: Switch Space: Toggle Enter: Save Esc: Cancel") + "jk: Change hl: Switch Space: Toggle Enter: Save Esc: Cancel") def _draw_list_alarms(stdscr, context): - """ - Draw the list of alarms screen - - Args: - stdscr: The curses screen object - context: A dictionary containing UI state variables - """ + """Draw the list of alarms screen""" + _init_colors() height, width = stdscr.getmaxyx() # Header stdscr.addstr(2, width // 2 - 5, "Alarms") - if not context['alarms']: + if not context.get('alarms'): stdscr.addstr(4, width // 2 - 10, "No alarms set") else: + weekday_names = context.get('weekday_names', ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']) + for i, alarm in enumerate(context['alarms'][:height-6]): - # Format time and repeat information + # Format time time_str = alarm.get('time', 'Unknown') # Format repeat info @@ -171,17 +178,18 @@ def _draw_list_alarms(stdscr, context): if repeat_rule: if repeat_rule.get('type') == 'weekly': days = repeat_rule.get('days', []) - repeat_info = f" (Every {', '.join(context['weekday_names'][d] for d in days)})" + repeat_info = f" (Every {', '.join(weekday_names[d] for d in days)})" elif repeat_rule.get('type') == 'once' and repeat_rule.get('date'): repeat_info = f" (On {repeat_rule['date']})" # Status indicator status = "✓" if alarm.get('enabled', True) else "✗" - display_str = f"{status} {time_str}{repeat_info}" + display_str = f"{status} {time_str} {alarm.get('name', 'Unnamed')}{repeat_info}" # Truncate if too long display_str = display_str[:width-4] stdscr.addstr(4 + i, 2, display_str) + # Instructions stdscr.addstr(height - 2, 2, "D: Delete Enter: Edit Esc: Back") diff --git a/alert_api/tests.sh b/alert_api/tests.sh index 9bd4572..36088c1 100644 --- a/alert_api/tests.sh +++ b/alert_api/tests.sh @@ -74,100 +74,100 @@ fi echo -e "${GREEN}Added alarm with ID: $NEW_ALARM_ID${NC}" -# 4. Test duplicate alarm detection -print_status "Testing duplicate alarm detection" -DUPLICATE_RESPONSE=$(curl -s -X POST -H "Content-Type: application/json" -d "$POST_DATA" "$API_URL") -if [[ $(echo "$DUPLICATE_RESPONSE" | jq -r '.error') == *"Duplicate alarm detected"* ]]; then - echo -e "${GREEN}✓ Duplicate detection working${NC}" -else - echo -e "${RED}✗ Duplicate detection failed${NC}" - echo "Response: $DUPLICATE_RESPONSE" -fi - -# 5. Update the alarm (PUT) -print_status "Updating alarm" -UPDATE_DATA=$(jq -n \ - --arg name "$TEST_ALARM_NAME Updated" \ - --arg time "$TEST_ALARM_TIME" \ - --argjson id "$NEW_ALARM_ID" \ - '{ - "id": $id, - "name": $name, - "time": $time, - "repeat_rule": { - "type": "daily" - }, - "enabled": true, - "snooze": { - "enabled": false, - "duration": 10, - "max_count": 2 - }, - "metadata": { - "volume": 90, - "notes": "Updated test alarm" - } - }' -) - -UPDATE_RESPONSE=$(curl -s -X PUT -H "Content-Type: application/json" -d "$UPDATE_DATA" "$API_URL") -if [[ $(echo "$UPDATE_RESPONSE" | jq -r '.data.message') == "Alarm updated successfully" ]]; then - echo -e "${GREEN}✓ Alarm update successful${NC}" -else - echo -e "${RED}✗ Alarm update failed${NC}" - echo "Response: $UPDATE_RESPONSE" -fi - -# 6. Verify the update -print_status "Verifying update" -UPDATED_STATE=$(curl -s -X GET "$API_URL") -UPDATED_ALARM=$(echo "$UPDATED_STATE" | jq -r ".data[] | select(.id == $NEW_ALARM_ID)") -if [[ $(echo "$UPDATED_ALARM" | jq -r '.name') == "$TEST_ALARM_NAME Updated" ]]; then - echo -e "${GREEN}✓ Update verification successful${NC}" -else - echo -e "${RED}✗ Update verification failed${NC}" - echo "Current alarm state: $UPDATED_ALARM" -fi - -# 7. Test invalid inputs -print_status "Testing invalid inputs" - -# Test invalid time format -INVALID_TIME_DATA=$(echo "$POST_DATA" | jq '. + {"time": "25:00:00"}') -INVALID_TIME_RESPONSE=$(curl -s -X POST -H "Content-Type: application/json" -d "$INVALID_TIME_DATA" "$API_URL") -if [[ $(echo "$INVALID_TIME_RESPONSE" | jq -r '.error') == *"Invalid alarm configuration"* ]]; then - echo -e "${GREEN}✓ Invalid time format detection working${NC}" -else - echo -e "${RED}✗ Invalid time format detection failed${NC}" -fi - -# Test invalid repeat rule -INVALID_REPEAT_DATA=$(echo "$POST_DATA" | jq '.repeat_rule.type = "monthly"') -INVALID_REPEAT_RESPONSE=$(curl -s -X POST -H "Content-Type: application/json" -d "$INVALID_REPEAT_DATA" "$API_URL") -if [[ $(echo "$INVALID_REPEAT_RESPONSE" | jq -r '.error') == *"Invalid alarm configuration"* ]]; then - echo -e "${GREEN}✓ Invalid repeat rule detection working${NC}" -else - echo -e "${RED}✗ Invalid repeat rule detection failed${NC}" -fi - -# 8. Delete the test alarm -print_status "Deleting test alarm" -DELETE_RESPONSE=$(curl -s -X DELETE -H "Content-Type: application/json" -d "{\"id\":$NEW_ALARM_ID}" "$API_URL") -if [[ $(echo "$DELETE_RESPONSE" | jq -r '.data.message') == "Alarm removed successfully" ]]; then - echo -e "${GREEN}✓ Alarm deletion successful${NC}" -else - echo -e "${RED}✗ Alarm deletion failed${NC}" - echo "Response: $DELETE_RESPONSE" -fi - -# 9. Verify deletion -print_status "Verifying deletion" -FINAL_STATE=$(curl -s -X GET "$API_URL") -if [[ $(echo "$FINAL_STATE" | jq ".data[] | select(.id == $NEW_ALARM_ID)") == "" ]]; then - echo -e "${GREEN}✓ Deletion verification successful${NC}" -else - echo -e "${RED}✗ Deletion verification failed${NC}" - echo "Current state: $FINAL_STATE" -fi - -print_status "Test suite completed!" +#j# 4. Test duplicate alarm detection +#jprint_status "Testing duplicate alarm detection" +#jDUPLICATE_RESPONSE=$(curl -s -X POST -H "Content-Type: application/json" -d "$POST_DATA" "$API_URL") +#jif [[ $(echo "$DUPLICATE_RESPONSE" | jq -r '.error') == *"Duplicate alarm detected"* ]]; then +#j echo -e "${GREEN}✓ Duplicate detection working${NC}" +#jelse +#j echo -e "${RED}✗ Duplicate detection failed${NC}" +#j echo "Response: $DUPLICATE_RESPONSE" +#jfi +#j +#j# 5. Update the alarm (PUT) +#jprint_status "Updating alarm" +#jUPDATE_DATA=$(jq -n \ +#j --arg name "$TEST_ALARM_NAME Updated" \ +#j --arg time "$TEST_ALARM_TIME" \ +#j --argjson id "$NEW_ALARM_ID" \ +#j '{ +#j "id": $id, +#j "name": $name, +#j "time": $time, +#j "repeat_rule": { +#j "type": "daily" +#j }, +#j "enabled": true, +#j "snooze": { +#j "enabled": false, +#j "duration": 10, +#j "max_count": 2 +#j }, +#j "metadata": { +#j "volume": 90, +#j "notes": "Updated test alarm" +#j } +#j }' +#j) +#j +#jUPDATE_RESPONSE=$(curl -s -X PUT -H "Content-Type: application/json" -d "$UPDATE_DATA" "$API_URL") +#jif [[ $(echo "$UPDATE_RESPONSE" | jq -r '.data.message') == "Alarm updated successfully" ]]; then +#j echo -e "${GREEN}✓ Alarm update successful${NC}" +#jelse +#j echo -e "${RED}✗ Alarm update failed${NC}" +#j echo "Response: $UPDATE_RESPONSE" +#jfi +#j +#j# 6. Verify the update +#jprint_status "Verifying update" +#jUPDATED_STATE=$(curl -s -X GET "$API_URL") +#jUPDATED_ALARM=$(echo "$UPDATED_STATE" | jq -r ".data[] | select(.id == $NEW_ALARM_ID)") +#jif [[ $(echo "$UPDATED_ALARM" | jq -r '.name') == "$TEST_ALARM_NAME Updated" ]]; then +#j echo -e "${GREEN}✓ Update verification successful${NC}" +#jelse +#j echo -e "${RED}✗ Update verification failed${NC}" +#j echo "Current alarm state: $UPDATED_ALARM" +#jfi +#j +#j# 7. Test invalid inputs +#jprint_status "Testing invalid inputs" +#j +#j# Test invalid time format +#jINVALID_TIME_DATA=$(echo "$POST_DATA" | jq '. + {"time": "25:00:00"}') +#jINVALID_TIME_RESPONSE=$(curl -s -X POST -H "Content-Type: application/json" -d "$INVALID_TIME_DATA" "$API_URL") +#jif [[ $(echo "$INVALID_TIME_RESPONSE" | jq -r '.error') == *"Invalid alarm configuration"* ]]; then +#j echo -e "${GREEN}✓ Invalid time format detection working${NC}" +#jelse +#j echo -e "${RED}✗ Invalid time format detection failed${NC}" +#jfi +#j +#j# Test invalid repeat rule +#jINVALID_REPEAT_DATA=$(echo "$POST_DATA" | jq '.repeat_rule.type = "monthly"') +#jINVALID_REPEAT_RESPONSE=$(curl -s -X POST -H "Content-Type: application/json" -d "$INVALID_REPEAT_DATA" "$API_URL") +#jif [[ $(echo "$INVALID_REPEAT_RESPONSE" | jq -r '.error') == *"Invalid alarm configuration"* ]]; then +#j echo -e "${GREEN}✓ Invalid repeat rule detection working${NC}" +#jelse +#j echo -e "${RED}✗ Invalid repeat rule detection failed${NC}" +#jfi +#j +#j# 8. Delete the test alarm +#jprint_status "Deleting test alarm" +#jDELETE_RESPONSE=$(curl -s -X DELETE -H "Content-Type: application/json" -d "{\"id\":$NEW_ALARM_ID}" "$API_URL") +#jif [[ $(echo "$DELETE_RESPONSE" | jq -r '.data.message') == "Alarm removed successfully" ]]; then +#j echo -e "${GREEN}✓ Alarm deletion successful${NC}" +#jelse +#j echo -e "${RED}✗ Alarm deletion failed${NC}" +#j echo "Response: $DELETE_RESPONSE" +#jfi +#j +#j# 9. Verify deletion +#jprint_status "Verifying deletion" +#jFINAL_STATE=$(curl -s -X GET "$API_URL") +#jif [[ $(echo "$FINAL_STATE" | jq ".data[] | select(.id == $NEW_ALARM_ID)") == "" ]]; then +#j echo -e "${GREEN}✓ Deletion verification successful${NC}" +#jelse +#j echo -e "${RED}✗ Deletion verification failed${NC}" +#j echo "Current state: $FINAL_STATE" +#jfi +#j +#jprint_status "Test suite completed!"