diff --git a/alert_api/alarm_api.py b/alert_api/alarm_api.py index a3b1a86..bfd78f7 100644 --- a/alert_api/alarm_api.py +++ b/alert_api/alarm_api.py @@ -11,23 +11,15 @@ import re from alarm_storage import AlarmStorage from data_classes import RepeatRule, Snooze, Metadata, Alarm +from logging_config import setup_logging # Set up logging configuration -logging.basicConfig( - level=logging.DEBUG, # Set to DEBUG to show all log levels - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', - handlers=[ - logging.StreamHandler(), # Console handler - logging.FileHandler('alert_api.log') # File handler - ] -) - -logger = logging.getLogger('AlertApi') +logger = setup_logging() class AlertApi(BaseHTTPRequestHandler): def __init__(self, *args, **kwargs): self.storage = AlarmStorage("data/alerts.json") - self.logger = logging.getLogger('AlertApi') + self.logger = logger super().__init__(*args, **kwargs) def _send_response(self, status_code: int, data: Any = None, error: str = None) -> None: diff --git a/alert_api/big_digits.py b/alert_api/big_digits.py new file mode 100644 index 0000000..c86cf72 --- /dev/null +++ b/alert_api/big_digits.py @@ -0,0 +1,102 @@ +# Big digit patterns (15x7 size) +BIG_DIGITS = { + '0': [ + " █████████ ", + " ███████████ ", + "███ ████", + "███ ████", + "███ ████", + " ███████████ ", + " █████████ " + ], + '1': [ + " ███ ", + " █████ ", + " ███ ", + " ███ ", + " ███ ", + " ███ ", + " ███████ " + ], + '2': [ + " ██████████ ", + "████████████", + " ████", + " ██████████ ", + "████ ", + "████████████", + " ██████████ " + ], + '3': [ + " ██████████ ", + "████████████", + " ████", + " ██████ ", + " ████", + "████████████", + " ██████████ " + ], + '4': [ + "███ ████", + "███ ████", + "███ ████", + "████████████", + " ████", + " ████", + " ████" + ], + '5': [ + "████████████", + "████████████", + "████ ", + "████████████", + " ████", + "████████████", + "████████████" + ], + '6': [ + " ██████████ ", + "████████████", + "████ ", + "████████████", + "████ ████", + "████████████", + " ██████████ " + ], + '7': [ + "████████████", + "████████████", + " ████ ", + " ████ ", + " ████ ", + "████ ", + "████ " + ], + '8': [ + " ██████████ ", + "████████████", + "████ ████", + " ██████████ ", + "████ ████", + "████████████", + " ██████████ " + ], + '9': [ + " ██████████ ", + "████████████", + "████ ████", + "████████████", + " ████", + "████████████", + " ██████████ " + ], + ':': [ + " ", + " ████ ", + " ████ ", + " ", + " ████ ", + " ████ ", + " " + ] +} diff --git a/alert_api/logging_config.py b/alert_api/logging_config.py new file mode 100644 index 0000000..c07b1c7 --- /dev/null +++ b/alert_api/logging_config.py @@ -0,0 +1,17 @@ +import logging +import os + +def setup_logging(log_dir="logs", log_file="alarm_system.log", level=logging.DEBUG): + os.makedirs(log_dir, exist_ok=True) + log_path = os.path.join(log_dir, log_file) + + logging.basicConfig( + level=level, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', + handlers=[ + logging.StreamHandler(), + logging.FileHandler(log_path, mode='a', encoding='utf-8') + ] + ) + logger = logging.getLogger("AlarmSystem") + return logger diff --git a/alert_api/main.py b/alert_api/main.py index 6904ad0..fb618b2 100755 --- a/alert_api/main.py +++ b/alert_api/main.py @@ -12,17 +12,11 @@ from multiprocessing import Queue from alarm_api import AlertApi, run as run_api from alarm_storage import AlarmStorage from alarm_siren import AlarmSiren +from ncurses_ui import UI +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_system.log') - ] -) -logger = logging.getLogger('AlarmSystem') +logger = setup_logging() class AlarmSystemManager: def __init__(self, @@ -47,7 +41,6 @@ class AlarmSystemManager: self.siren = AlarmSiren() self.storage = AlarmStorage(storage_path, siren=self.siren) - # API server setup self.api_port = api_port self.api_server = None @@ -62,6 +55,9 @@ class AlarmSystemManager: # Alarm synchronization self._sync_alarms() + # UI.. + self.ui = UI(self) + def _setup_signal_handlers(self): """Set up signal handlers for graceful shutdown""" signal.signal(signal.SIGINT, self._handle_shutdown) @@ -125,6 +121,9 @@ class AlarmSystemManager: # Start API server self._start_api_server() + # Start UI + ui_thread = self.ui.run() + # Log system startup logger.info("Alarm System started successfully") diff --git a/alert_api/ncurses_ui.py b/alert_api/ncurses_ui.py new file mode 100644 index 0000000..b892a6e --- /dev/null +++ b/alert_api/ncurses_ui.py @@ -0,0 +1,230 @@ +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 + +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 + """ + 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.new_alarm_hour = datetime.now().hour + self.new_alarm_minute = datetime.now().minute + self.new_alarm_selected = 0 + 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 = [] + self.error_message = None + self.error_timestamp = None + + def run(self): + """ + Start the ncurses UI + """ + def ui_thread(): + try: + curses.wrapper(self._main_loop) + except Exception as e: + print(f"Error in UI thread: {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_add_alarm_input(self, key): + """ + Handle input for adding a new alarm + """ + if key == 27: # Escape + self.selected_menu = 0 + return + + if key == 10: # Enter + try: + # Prepare alarm data + 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 + } + + # Add repeat rule if applicable + if self.new_alarm_weekdays: + alarm_data["repeat_rule"] = { + "type": "weekly", + "days": self.new_alarm_weekdays + } + elif self.new_alarm_date: + alarm_data["repeat_rule"] = { + "type": "once", + "date": self.new_alarm_date.strftime("%Y-%m-%d") + } + + # Save new alarm + self.storage.save_new_alert(alarm_data) + + # Reset form + self.selected_menu = 0 + self.new_alarm_hour = datetime.now().hour + self.new_alarm_minute = datetime.now().minute + self.new_alarm_date = None + self.new_alarm_weekdays = [] + self.new_alarm_name = "Alarm" + self.new_alarm_enabled = True + except Exception as e: + # TODO: Show error on screen + print(f"Failed to save alarm: {e}") + return + + if key == curses.KEY_LEFT: + self.new_alarm_selected = (self.new_alarm_selected - 1) % 6 + elif key == curses.KEY_RIGHT: + self.new_alarm_selected = (self.new_alarm_selected + 1) % 6 + elif key == 32: # Space + if self.new_alarm_selected == 2: # Date + self.new_alarm_date = None + 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 self.new_alarm_selected == 5: # Enabled toggle + self.new_alarm_enabled = not self.new_alarm_enabled + + 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 self.new_alarm_selected == 4: # Name + self.new_alarm_name += " " + + 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) + elif self.new_alarm_selected == 4 and len(self.new_alarm_name) > 1: + self.new_alarm_name = self.new_alarm_name.rstrip()[:-1] + + def _handle_list_alarms_input(self, key): + """ + Handle input for the list alarms screen + """ + if key == 27: # Escape + self.selected_menu = 0 + elif key == ord('d'): + # Delete last alarm if exists + if self.alarms: + last_alarm = self.alarms[-1] + try: + self.storage.remove_saved_alert(last_alarm['id']) + except Exception as e: + print(f"Failed to delete alarm: {e}") + + def _show_error(self, message, duration=3): + """Display an error message for a specified duration""" + 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) + curses.curs_set(0) + + # Configure screen + stdscr.keypad(1) + stdscr.timeout(100) + + while not self.stop_event.is_set(): + # Clear the screen + stdscr.clear() + + # Draw appropriate screen based on selected menu + if self.selected_menu == 0: + _draw_main_clock(stdscr) + elif self.selected_menu == 1: + _draw_add_alarm(stdscr, { + 'new_alarm_selected': self.new_alarm_selected, + 'new_alarm_name': self.new_alarm_name, + '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) + }) + elif self.selected_menu == 2: + _draw_list_alarms(stdscr, { + 'alarms': self.alarms, + 'weekday_names': self.weekday_names + }) + + # Refresh the screen + stdscr.refresh() + + # Small sleep to reduce CPU usage + time.sleep(0.1) + + # Handle input + key = stdscr.getch() + if key != -1: + # Menu navigation and input handling + if key == ord('q') or key == 27: # 'q' or Escape + 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() + + # Context-specific input handling + if self.selected_menu == 1: + self._handle_add_alarm_input(key) + elif self.selected_menu == 2: + self._handle_list_alarms_input(key) diff --git a/alert_api/ncurses_ui_draw.py b/alert_api/ncurses_ui_draw.py new file mode 100644 index 0000000..8f07d66 --- /dev/null +++ b/alert_api/ncurses_ui_draw.py @@ -0,0 +1,184 @@ +from datetime import datetime +import curses +from big_digits import BIG_DIGITS + +def _draw_error(stdscr, error_message): + """Draw error message if present""" + if error_message: + height, width = stdscr.getmaxyx() + error_x = 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)) + stdscr.addstr(error_y, error_x, error_message) + stdscr.attroff(curses.color_pair(3)) + +def _draw_big_digit(stdscr, y, x, digit, big_digits): + """ + 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 + height, width = stdscr.getmaxyx() + + # 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 + + # Color for the 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) + 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_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 + + 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_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() + + form_y = height // 2 - 3 + stdscr.addstr(form_y, width // 2 - 10, "Add New Alarm") + + # Name input + 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']}") + 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}" + + # Highlight selected field + if context['new_alarm_selected'] == 0: + stdscr.attron(curses.color_pair(2)) + stdscr.addstr(form_y + 2, width // 2 - 2, hour_str) + if context['new_alarm_selected'] == 0: + stdscr.attroff(curses.color_pair(2)) + + stdscr.addstr(form_y + 2, width // 2, ":") + + if context['new_alarm_selected'] == 1: + stdscr.attron(curses.color_pair(2)) + stdscr.addstr(form_y + 2, width // 2 + 1, minute_str) + if context['new_alarm_selected'] == 1: + stdscr.attroff(curses.color_pair(2)) + + # Enabled/Disabled toggle + enabled_str = "Enabled" if context['new_alarm_enabled'] else "Disabled" + if context['new_alarm_selected'] == 5: + stdscr.attron(curses.color_pair(2)) + stdscr.addstr(form_y + 4, width // 2 - len(enabled_str)//2, enabled_str) + if context['new_alarm_selected'] == 5: + 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_selected'] == 2: + stdscr.attron(curses.color_pair(2)) + stdscr.addstr(form_y + 3, width // 2 - len(date_str) // 2, date_str) + if context['new_alarm_selected'] == 2: + stdscr.attroff(curses.color_pair(2)) + + # Weekday selection + weekday_str = "Repeat: " + " ".join( + context['weekday_names'][i] if i in context['new_alarm_weekdays'] else "___" + for i in range(7) + ) + 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) + if context['new_alarm_selected'] == 3: + stdscr.attroff(curses.color_pair(2)) + + # Instructions + stdscr.addstr(height - 2, 2, + "↑↓: Change ←→: 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 + """ + height, width = stdscr.getmaxyx() + + # Header + stdscr.addstr(2, width // 2 - 5, "Alarms") + + if not context['alarms']: + stdscr.addstr(4, width // 2 - 10, "No alarms set") + else: + for i, alarm in enumerate(context['alarms'][:height-6]): + # Format time and repeat information + time_str = alarm.get('time', 'Unknown') + + # Format repeat info + repeat_info = "" + repeat_rule = alarm.get('repeat_rule', {}) + 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)})" + 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}" + + # Truncate if too long + display_str = display_str[:width-4] + + stdscr.addstr(4 + i, 2, display_str) + + stdscr.addstr(height - 2, 2, "D: Delete Enter: Edit Esc: Back")