diff --git a/alert_clients/__init__.py b/alert_clients/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/alert_clients/__pycache__/__init__.cpython-312.pyc b/alert_clients/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..ac403a5 Binary files /dev/null and b/alert_clients/__pycache__/__init__.cpython-312.pyc differ diff --git a/alert_clients/alarm_api_client/__init__.py b/alert_clients/alarm_api_client/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/alert_clients/alarm_api_client.py b/alert_clients/alarm_api_client/alarm_api_client.py similarity index 100% rename from alert_clients/alarm_api_client.py rename to alert_clients/alarm_api_client/alarm_api_client.py diff --git a/alert_clients/alarm_client_cli.py b/alert_clients/cli_client/cli_client.py similarity index 98% rename from alert_clients/alarm_client_cli.py rename to alert_clients/cli_client/cli_client.py index b550bd6..aaf843a 100755 --- a/alert_clients/alarm_client_cli.py +++ b/alert_clients/cli_client/cli_client.py @@ -4,8 +4,7 @@ import argparse import json import os from pathlib import Path -from alarm_api_client import AlarmApiClient - +from alert_clients.alarm_api_client.alarm_api_client import AlarmApiClient CONFIG_FILE = Path.home() / ".alarms.json" diff --git a/alert_clients/ncurses_client/__init__.py b/alert_clients/ncurses_client/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/alert_clients/ncurses_client/__pycache__/__init__.cpython-312.pyc b/alert_clients/ncurses_client/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..be2ecf1 Binary files /dev/null and b/alert_clients/ncurses_client/__pycache__/__init__.cpython-312.pyc differ diff --git a/alert_clients/ncurses_client/__pycache__/main.cpython-312.pyc b/alert_clients/ncurses_client/__pycache__/main.cpython-312.pyc new file mode 100644 index 0000000..af87dfb Binary files /dev/null and b/alert_clients/ncurses_client/__pycache__/main.cpython-312.pyc differ diff --git a/alert_clients/ncurses_client/__pycache__/ncurses_handlers.cpython-312.pyc b/alert_clients/ncurses_client/__pycache__/ncurses_handlers.cpython-312.pyc new file mode 100644 index 0000000..106931d Binary files /dev/null and b/alert_clients/ncurses_client/__pycache__/ncurses_handlers.cpython-312.pyc differ diff --git a/alert_clients/ncurses_client/alarm-logger.py b/alert_clients/ncurses_client/alarm-logger.py new file mode 100644 index 0000000..f4b95ce --- /dev/null +++ b/alert_clients/ncurses_client/alarm-logger.py @@ -0,0 +1,78 @@ +import logging +import os +from logging.handlers import RotatingFileHandler +from datetime import datetime + +class AlarmLogger: + def __init__(self, log_dir='logs', log_level=logging.INFO): + """ + Initialize a comprehensive logging system for the alarm application. + + Args: + log_dir (str): Directory to store log files + log_level (int): Logging level (default: logging.INFO) + """ + # Ensure log directory exists + os.makedirs(log_dir, exist_ok=True) + + # Create a unique log filename with timestamp + log_filename = os.path.join( + log_dir, + f"alarm_app_{datetime.now().strftime('%Y%m%d_%H%M%S')}.log" + ) + + # Configure the root logger + logging.basicConfig( + level=log_level, + format='%(asctime)s | %(levelname)8s | %(module)15s:%(lineno)4d | %(message)s', + datefmt='%Y-%m-%d %H:%M:%S' + ) + + # Create rotating file handler + file_handler = RotatingFileHandler( + log_filename, + maxBytes=10*1024*1024, # 10 MB + backupCount=5 + ) + file_handler.setFormatter(logging.Formatter( + '%(asctime)s | %(levelname)8s | %(module)15s:%(lineno)4d | %(message)s', + datefmt='%Y-%m-%d %H:%M:%S' + )) + + # Create console handler + console_handler = logging.StreamHandler() + console_handler.setFormatter(logging.Formatter( + '%(levelname)8s | %(module)15s:%(lineno)4d | %(message)s' + )) + + # Get the root logger and add handlers + root_logger = logging.getLogger() + root_logger.addHandler(file_handler) + root_logger.addHandler(console_handler) + + self.logger = logging.getLogger(__name__) + + def log_alarm_created(self, alarm_details): + """Log alarm creation details.""" + self.logger.info(f"Alarm Created: {alarm_details}") + + def log_alarm_triggered(self, alarm_id): + """Log when an alarm is triggered.""" + self.logger.warning(f"Alarm Triggered: ID {alarm_id}") + + def log_alarm_snoozed(self, alarm_id, snooze_duration): + """Log alarm snooze details.""" + self.logger.info(f"Alarm Snoozed: ID {alarm_id}, Duration: {snooze_duration} minutes") + + def log_system_error(self, error_message, exc_info=False): + """Log system errors with optional exception details.""" + self.logger.error(error_message, exc_info=exc_info) + + def log_api_interaction(self, method, endpoint, status): + """Log API interactions.""" + self.logger.info(f"API {method.upper()}: {endpoint} - Status: {status}") + +# Example usage in other modules +# logger = AlarmLogger() +# logger.log_alarm_created({"time": "07:00", "repeat": "daily"}) +# logger.log_system_error("Failed to connect to API", exc_info=True) diff --git a/alert_clients/ncurses_client/alert_logic.py b/alert_clients/ncurses_client/alert_logic.py new file mode 100644 index 0000000..eb64276 --- /dev/null +++ b/alert_clients/ncurses_client/alert_logic.py @@ -0,0 +1,88 @@ +from datetime import datetime, timedelta +from alarm_logger import AlarmLogger + +class AlarmLogic: + def __init__(self, api_client): + self.logger = AlarmLogger() + self.api_client = api_client + self.alarms = [] + self.current_alarm = None + self.snooze_minutes = 5 + self.snooze_until = None + + def refresh_alarms(self): + """Fetches alarms from the API and updates the local list.""" + try: + self.alarms = self.api_client.get_alarms() + except Exception as e: + raise RuntimeError(f"Failed to fetch alarms: {str(e)}") + + def create_alarm(self, hour, minute, date=None, weekdays=None): + """Creates a new alarm.""" + alarm_data = { + "name": f"Alarm {hour:02d}:{minute:02d}", + "time": f"{hour:02d}:{minute:02d}:00", + "enabled": True, + "repeat_rule": {"type": "once"}, + "snooze": { + "enabled": True, + "duration": self.snooze_minutes, + "max_count": 3 + } + } + + if weekdays: + alarm_data["repeat_rule"] = { + "type": "weekly", + "days": weekdays + } + elif date: + alarm_data["repeat_rule"] = { + "type": "once", + "date": date.strftime("%Y-%m-%d") + } + + try: + self.api_client.create_alarm(alarm_data) + self.refresh_alarms() + self.logger.log_alarm_created(alarm_data) + except Exception as e: + self.logger.log_system_error(f"Alarm creation failed: {e}", exc_info=True) + raise RuntimeError(f"Failed to create alarm: {str(e)}") + + def stop_alarm(self): + """Stops the current alarm.""" + if self.current_alarm: + try: + alarm_data = self.current_alarm.copy() + alarm_data["enabled"] = False + self.api_client.update_alarm(self.current_alarm["id"], alarm_data) + self.current_alarm = None + self.snooze_until = None + except Exception as e: + self.logger.log_system_error(f"Failed to stop alarm: {e}", exc_info=True) + raise RuntimeError(f"Failed to stop alarm: {str(e)}") + + def snooze_alarm(self): + """Snoozes the current alarm.""" + if self.current_alarm: + self.snooze_until = datetime.now() + timedelta(minutes=self.snooze_minutes) + self.stop_alarm() + + def check_alarms(self): + """Checks if any alarm should trigger based on the current time.""" + current_time = datetime.now() + + if self.snooze_until and current_time >= self.snooze_until: + self.current_alarm = {"time": self.snooze_until.strftime("%H:%M:%S")} + self.snooze_until = None + + if not self.current_alarm: + self.refresh_alarms() + for alarm in self.alarms: + alarm_time = datetime.strptime(alarm["time"], "%H:%M:%S").time() + if (alarm_time.hour == current_time.hour and + alarm_time.minute == current_time.minute and + alarm["enabled"]): + self.current_alarm = alarm + break diff --git a/alert_clients/ncurses_client/main.py b/alert_clients/ncurses_client/main.py new file mode 100755 index 0000000..d97b417 --- /dev/null +++ b/alert_clients/ncurses_client/main.py @@ -0,0 +1,18 @@ +#!/usr/bin/python3 + +import curses +from ncurses_handlers import AlarmClock +from alarm_api_client.alarm_api_client import AlarmApiClient + +def main(stdscr): + """Main entry point for the ncurses alarm clock client.""" + try: + alarm_clock = AlarmClock(stdscr) + alarm_clock.run() + except Exception as e: + # Fallback error handling + curses.endwin() + print(f"An error occurred: {e}") + +if __name__ == "__main__": + curses.wrapper(main) diff --git a/alert_clients/ncurses_client/ncurses_handlers.py b/alert_clients/ncurses_client/ncurses_handlers.py new file mode 100644 index 0000000..a922a06 --- /dev/null +++ b/alert_clients/ncurses_client/ncurses_handlers.py @@ -0,0 +1,212 @@ +import curses +from datetime import datetime, date, timedelta +import time +from threading import Thread +import sys +import os +from pathlib import Path + +from alert_clients.alarm_api_client.alarm_api_client import AlarmApiClient +#from alarm_api_client.alarm_api_client import AlarmApiClient +from ncurses_ui import NcursesUI +from alert_logic import AlarmLogic +from ncurses_threads import NcursesThreads +from alarm_logger import AlarmLogger + +class AlarmClock: + def __init__(self, stdscr): + # Initialize logging first + self.logger = AlarmLogger() + + try: + self.stdscr = stdscr + self.api_client = AlarmApiClient("http://localhost:8000") + self.alarm_logic = AlarmLogic(self.api_client) + self.ncurses_ui = NcursesUI(stdscr) + self.ncurses_threads = NcursesThreads(self.alarm_logic) + + self.alarms = [] + self.running = True + self.selected_menu = 0 + self.new_alarm_hour = 0 + self.new_alarm_minute = 0 + self.new_alarm_selected = 0 + self.new_alarm_date = None + self.new_alarm_weekdays = [] + self.weekday_names = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] + self.current_alarm = None + self.current_alarm_process = False + self.snooze_minutes = 5 + self.snooze_until = None + + # Initialize curses + self.stdscr.keypad(1) + self.stdscr.timeout(100) + + self.logger.log_system_error("Alarm Clock Initialized Successfully") + except Exception as e: + self.logger.log_system_error(f"Initialization Error: {str(e)}", exc_info=True) + raise + + def show_error(self, message): + self.logger.log_system_error(message) + self.ncurses_ui.show_error(message) + + def handle_add_alarm_input(self, key): + try: + if key == 27: # Escape + self.selected_menu = 0 + self.logger.log_system_error("Add Alarm menu cancelled") + return + + if key == 10: # Enter + try: + alarm_details = { + "hour": self.new_alarm_hour, + "minute": self.new_alarm_minute, + "date": self.new_alarm_date, + "weekdays": self.new_alarm_weekdays if self.new_alarm_weekdays else None + } + self.alarm_logic.create_alarm( + self.new_alarm_hour, + self.new_alarm_minute, + self.new_alarm_date, + self.new_alarm_weekdays if self.new_alarm_weekdays else None + ) + self.logger.log_alarm_created(alarm_details) + self.alarms = self.api_client.get_alarms() + self.selected_menu = 0 + except Exception as e: + self.show_error(f"Failed to create alarm: {str(e)}") + return + + if key == curses.KEY_LEFT: + self.new_alarm_selected = (self.new_alarm_selected - 1) % 4 + self.logger.log_system_error(f"Selected field changed to {self.new_alarm_selected}") + elif key == curses.KEY_RIGHT: + self.new_alarm_selected = (self.new_alarm_selected + 1) % 4 + self.logger.log_system_error(f"Selected field changed to {self.new_alarm_selected}") + elif key == 32: # Space + if self.new_alarm_selected == 2: # Date + self.new_alarm_date = None + self.logger.log_system_error(f"Selected field changed to DATE") + elif self.new_alarm_selected == 3: # Weekdays + 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 key == 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 key == 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) + + except Exception as e: + self.logger.log_system_error(f"Error in add alarm input: {str(e)}", exc_info=True) + + def delete_selected_alarm(self): + try: + if self.alarms: + alarm = self.alarms[-1] # Get the last alarm + if self.api_client.delete_alarm(alarm["id"]): + self.logger.log_system_error(f"Deleted Alarm ID: {alarm['id']}") + self.alarm_logic.refresh_alarms() + else: + self.show_error("Failed to delete alarm") + except Exception as e: + self.logger.log_system_error(f"Delete alarm error: {str(e)}", exc_info=True) + self.show_error(f"Delete error: {str(e)}") + + def handle_list_alarms_input(self, key): + try: + if key == 27: # Escape + self.selected_menu = 0 + self.logger.log_system_error("List Alarms menu cancelled") + elif key == ord('d'): + self.logger.log_system_error("Attempting to delete last alarm") + self.delete_selected_alarm() + except Exception as e: + self.logger.log_system_error(f"Error in list alarms input: {str(e)}", exc_info=True) + + def draw_main_clock(self): + self.ncurses_ui.draw_main_clock() + + def draw_add_alarm(self): + self.ncurses_ui.draw_add_alarm() + + def draw_list_alarms(self): + self.ncurses_ui.draw_list_alarms() + + def run(self): + try: + # Start alarm checking thread + self.ncurses_threads.start_threads() + self.logger.log_system_error("Alarm checking threads started") + + while self.running: + try: + # Clear and redraw the screen + self.stdscr.erase() + + if self.selected_menu == 0: + self.draw_main_clock() + elif self.selected_menu == 1: + self.draw_add_alarm() + elif self.selected_menu == 2: + self.draw_list_alarms() + + self.stdscr.refresh() + + # Handle input with timeout + key = self.stdscr.getch() + if key != -1: # Key was pressed + if self.selected_menu == 0: + if key == ord('q'): + self.logger.log_system_error("Application quit requested") + self.alarm_logic.stop_alarm() + break + elif key == ord('a'): + self.selected_menu = 1 + self.logger.log_system_error("Switched to Add Alarm menu") + elif key == ord('l'): + self.selected_menu = 2 + self.logger.log_system_error("Switched to List Alarms menu") + elif key == ord('s'): + self.logger.log_system_error("Alarm stopped") + self.alarm_logic.stop_alarm() + elif key == ord('z'): + self.logger.log_alarm_snoozed( + "current_alarm", + self.snooze_minutes + ) + self.alarm_logic.snooze_alarm() + elif self.selected_menu == 1: + self.handle_add_alarm_input(key) + elif self.selected_menu == 2: + self.handle_list_alarms_input(key) + + except curses.error as e: + self.logger.log_system_error(f"Curses error: {str(e)}", exc_info=True) + self.running = False + raise Exception(f"Curses error: {str(e)}") + + except Exception as e: + self.logger.log_system_error(f"Unhandled error in main run loop: {str(e)}", exc_info=True) + raise diff --git a/alert_clients/ncurses_client/ncurses_threads.py b/alert_clients/ncurses_client/ncurses_threads.py new file mode 100644 index 0000000..1c231d1 --- /dev/null +++ b/alert_clients/ncurses_client/ncurses_threads.py @@ -0,0 +1,30 @@ +import time +from threading import Thread + +class NcursesThreads: + def __init__(self, alarm_logic): + self.alarm_logic = alarm_logic + self.running = True + + def alarm_check_thread(self): + """Thread for continuously checking alarms.""" + while self.running: + try: + self.alarm_logic.check_alarms() + time.sleep(1) + except Exception as e: + # Handle any errors during alarm checking + print(f"Alarm check error: {str(e)}") + time.sleep(5) + + def start_threads(self): + """Starts the necessary threads for ncurses client.""" + self.alarm_thread = Thread(target=self.alarm_check_thread) + self.alarm_thread.daemon = True + self.alarm_thread.start() + + def stop_threads(self): + """Stops all running threads.""" + self.running = False + if self.alarm_thread.is_alive(): + self.alarm_thread.join() diff --git a/alert_clients/ncurses_client/ncurses_ui.py b/alert_clients/ncurses_client/ncurses_ui.py new file mode 100644 index 0000000..ccf7c81 --- /dev/null +++ b/alert_clients/ncurses_client/ncurses_ui.py @@ -0,0 +1,166 @@ +import curses +import os +from datetime import datetime +from big_digits import BIG_DIGITS + +class NcursesUI: + def __init__(self, stdscr): + # Initialize curses + 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) + curses.curs_set(0) + self.stdscr.keypad(1) + self.stdscr.timeout(100) + + def show_error(self, message): + height, width = self.stdscr.getmaxyx() + self.stdscr.attron(curses.color_pair(3)) + self.stdscr.addstr(height-1, 0, f"ERROR: {message}"[:width-1]) + self.stdscr.attroff(curses.color_pair(3)) + self.stdscr.refresh() + time.sleep(2) + + def draw_big_digit(self, y, x, digit): + patterns = BIG_DIGITS[digit] + for i, line in enumerate(patterns): + self.stdscr.addstr(y + i, x, line) + + def draw_big_time(self, current_time): + height, width = self.stdscr.getmaxyx() + time_str = current_time.strftime("%H:%M:%S") + + # Calculate starting position to center the big clock + digit_width = 14 # Width of each digit pattern including spacing + 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 + + self.stdscr.attron(curses.color_pair(1)) + for i, digit in enumerate(time_str): + self.draw_big_digit(start_y, start_x + i * digit_width, digit) + self.stdscr.attroff(curses.color_pair(1)) + + def draw_main_clock(self): + current_time = datetime.now() + time_str = current_time.strftime("%H:%M:%S") + date_str = current_time.strftime("%Y-%m-%d") + + # Get terminal dimensions + height, width = self.stdscr.getmaxyx() + + # Draw big time + self.draw_big_time(current_time) + + # Draw date + date_x = width // 2 - len(date_str) // 2 + date_y = height // 2 + 4 # Below the big clock + + self.stdscr.attron(curses.color_pair(2)) + self.stdscr.addstr(date_y, date_x, date_str) + self.stdscr.attroff(curses.color_pair(2)) + + # Draw menu options + menu_str = "A: Add Alarm L: List Alarms S: Stop Z: Snooze Q: Quit" + menu_x = width // 2 - len(menu_str) // 2 + self.stdscr.addstr(height - 2, menu_x, menu_str) + + # Show alarm/snooze status + if self.current_alarm_process: + status_str = "⏰ ALARM ACTIVE - Press 'S' to stop or 'Z' to snooze" + status_x = width // 2 - len(status_str) // 2 + self.stdscr.attron(curses.color_pair(3)) + self.stdscr.addstr(height - 4, status_x, status_str) + self.stdscr.attroff(curses.color_pair(3)) + elif self.snooze_until: + snooze_str = f"💤 Snoozed until {self.snooze_until.strftime('%H:%M')}" + snooze_x = width // 2 - len(snooze_str) // 2 + self.stdscr.attron(curses.color_pair(2)) + self.stdscr.addstr(height - 4, snooze_x, snooze_str) + self.stdscr.attroff(curses.color_pair(2)) + + def draw_add_alarm(self): + height, width = self.stdscr.getmaxyx() + + form_y = height // 2 - 3 + self.stdscr.addstr(form_y, width // 2 - 10, "New Alarm") + + # Time selection + hour_str = f"{self.new_alarm_hour:02d}" + minute_str = f"{self.new_alarm_minute:02d}" + + # Highlight selected field + if self.new_alarm_selected == 0: + self.stdscr.attron(curses.color_pair(2)) + self.stdscr.addstr(form_y + 1, width // 2 - 2, hour_str) + if self.new_alarm_selected == 0: + self.stdscr.attroff(curses.color_pair(2)) + + self.stdscr.addstr(form_y + 1, width // 2, ":") + + if self.new_alarm_selected == 1: + self.stdscr.attron(curses.color_pair(2)) + self.stdscr.addstr(form_y + 1, width // 2 + 1, minute_str) + if self.new_alarm_selected == 1: + self.stdscr.attroff(curses.color_pair(2)) + + # Date selection + date_str = "No specific date" if not self.new_alarm_date else self.new_alarm_date.strftime("%Y-%m-%d") + if self.new_alarm_selected == 2: + self.stdscr.attron(curses.color_pair(2)) + self.stdscr.addstr(form_y + 3, width // 2 - len(date_str) // 2, date_str) + if self.new_alarm_selected == 2: + self.stdscr.attroff(curses.color_pair(2)) + + # Weekday selection + weekday_str = "Repeat: " + " ".join( + self.weekday_names[i] if i in self.new_alarm_weekdays else "___" + for i in range(7) + ) + if self.new_alarm_selected == 3: + self.stdscr.attron(curses.color_pair(2)) + self.stdscr.addstr(form_y + 4, width // 2 - len(weekday_str) // 2, weekday_str) + if self.new_alarm_selected == 3: + self.stdscr.attroff(curses.color_pair(2)) + + # Instructions + if self.new_alarm_selected < 2: + self.stdscr.addstr(height - 2, 2, "↑↓: Change value ←→: Switch field Enter: Save Esc: Cancel") + elif self.new_alarm_selected == 2: + self.stdscr.addstr(height - 2, 2, "↑↓: Change date Space: Clear date Enter: Save Esc: Cancel") + else: # weekday selection + self.stdscr.addstr(height - 2, 2, "←→: Select day Space: Toggle Enter: Save Esc: Cancel") + + def draw_list_alarms(self): + height, width = self.stdscr.getmaxyx() + + self.stdscr.addstr(2, width // 2 - 5, "Alarms") + + try: + self.alarms = self.api_client.get_alarms() + for i, alarm in enumerate(self.alarms): + # Parse time from the time string + time_parts = alarm['time'].split(':') + time_str = f"{time_parts[0]}:{time_parts[1]}" + + # Add repeat rule information + if 'repeat_rule' in alarm: + if alarm['repeat_rule']['type'] == 'once' and 'date' in alarm['repeat_rule']: + time_str += f" on {alarm['repeat_rule']['date']}" + elif alarm['repeat_rule']['type'] == 'weekly' and 'days' in alarm['repeat_rule']: + weekdays = [self.weekday_names[d] for d in alarm['repeat_rule']['days']] + time_str += f" every {', '.join(weekdays)}" + + status = "✓" if alarm['enabled'] else "✗" + display_str = f"{status} {time_str}" + + self.stdscr.addstr(4 + i, width // 2 - len(display_str) // 2, display_str) + + if not self.alarms: + self.stdscr.addstr(4, width // 2 - 7, "No alarms set") + + except Exception as e: + self.show_error(f"Failed to display alarms: {str(e)}") + + self.stdscr.addstr(height - 2, 2, "D: Delete alarm Esc: Back")