Started working on the local ncurses UI. Also logging...

This commit is contained in:
Kalzu Rekku 2025-01-24 23:08:45 +02:00
parent 35d1e76ba0
commit 0eb5204833
6 changed files with 545 additions and 21 deletions

View File

@ -11,23 +11,15 @@ import re
from alarm_storage import AlarmStorage from alarm_storage import AlarmStorage
from data_classes import RepeatRule, Snooze, Metadata, Alarm from data_classes import RepeatRule, Snooze, Metadata, Alarm
from logging_config import setup_logging
# Set up logging configuration # Set up logging configuration
logging.basicConfig( logger = setup_logging()
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')
class AlertApi(BaseHTTPRequestHandler): class AlertApi(BaseHTTPRequestHandler):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
self.storage = AlarmStorage("data/alerts.json") self.storage = AlarmStorage("data/alerts.json")
self.logger = logging.getLogger('AlertApi') self.logger = logger
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
def _send_response(self, status_code: int, data: Any = None, error: str = None) -> None: def _send_response(self, status_code: int, data: Any = None, error: str = None) -> None:

102
alert_api/big_digits.py Normal file
View File

@ -0,0 +1,102 @@
# Big digit patterns (15x7 size)
BIG_DIGITS = {
'0': [
" █████████ ",
" ███████████ ",
"███ ████",
"███ ████",
"███ ████",
" ███████████ ",
" █████████ "
],
'1': [
" ███ ",
" █████ ",
" ███ ",
" ███ ",
" ███ ",
" ███ ",
" ███████ "
],
'2': [
" ██████████ ",
"████████████",
" ████",
" ██████████ ",
"████ ",
"████████████",
" ██████████ "
],
'3': [
" ██████████ ",
"████████████",
" ████",
" ██████ ",
" ████",
"████████████",
" ██████████ "
],
'4': [
"███ ████",
"███ ████",
"███ ████",
"████████████",
" ████",
" ████",
" ████"
],
'5': [
"████████████",
"████████████",
"████ ",
"████████████",
" ████",
"████████████",
"████████████"
],
'6': [
" ██████████ ",
"████████████",
"████ ",
"████████████",
"████ ████",
"████████████",
" ██████████ "
],
'7': [
"████████████",
"████████████",
" ████ ",
" ████ ",
" ████ ",
"████ ",
"████ "
],
'8': [
" ██████████ ",
"████████████",
"████ ████",
" ██████████ ",
"████ ████",
"████████████",
" ██████████ "
],
'9': [
" ██████████ ",
"████████████",
"████ ████",
"████████████",
" ████",
"████████████",
" ██████████ "
],
':': [
" ",
" ████ ",
" ████ ",
" ",
" ████ ",
" ████ ",
" "
]
}

View File

@ -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

View File

@ -12,17 +12,11 @@ from multiprocessing import Queue
from alarm_api import AlertApi, run as run_api from alarm_api import AlertApi, run as run_api
from alarm_storage import AlarmStorage from alarm_storage import AlarmStorage
from alarm_siren import AlarmSiren from alarm_siren import AlarmSiren
from ncurses_ui import UI
from logging_config import setup_logging
# Set up logging # Set up logging
logging.basicConfig( logger = setup_logging()
level=logging.DEBUG,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
handlers=[
logging.StreamHandler(),
logging.FileHandler('alarm_system.log')
]
)
logger = logging.getLogger('AlarmSystem')
class AlarmSystemManager: class AlarmSystemManager:
def __init__(self, def __init__(self,
@ -47,7 +41,6 @@ class AlarmSystemManager:
self.siren = AlarmSiren() self.siren = AlarmSiren()
self.storage = AlarmStorage(storage_path, siren=self.siren) self.storage = AlarmStorage(storage_path, siren=self.siren)
# API server setup # API server setup
self.api_port = api_port self.api_port = api_port
self.api_server = None self.api_server = None
@ -62,6 +55,9 @@ class AlarmSystemManager:
# Alarm synchronization # Alarm synchronization
self._sync_alarms() self._sync_alarms()
# UI..
self.ui = UI(self)
def _setup_signal_handlers(self): def _setup_signal_handlers(self):
"""Set up signal handlers for graceful shutdown""" """Set up signal handlers for graceful shutdown"""
signal.signal(signal.SIGINT, self._handle_shutdown) signal.signal(signal.SIGINT, self._handle_shutdown)
@ -125,6 +121,9 @@ class AlarmSystemManager:
# Start API server # Start API server
self._start_api_server() self._start_api_server()
# Start UI
ui_thread = self.ui.run()
# Log system startup # Log system startup
logger.info("Alarm System started successfully") logger.info("Alarm System started successfully")

230
alert_api/ncurses_ui.py Normal file
View File

@ -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)

View File

@ -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")