2025-01-24 19:28:14 +02:00
|
|
|
import os
|
|
|
|
import time
|
|
|
|
import threading
|
|
|
|
import subprocess
|
|
|
|
import queue
|
|
|
|
import logging
|
|
|
|
from datetime import datetime, timedelta
|
|
|
|
from typing import Optional, Dict, Any
|
|
|
|
|
2025-01-25 23:24:27 +02:00
|
|
|
from logging_config import setup_logging
|
|
|
|
|
2025-01-24 19:28:14 +02:00
|
|
|
# Set up logging
|
2025-01-25 23:24:27 +02:00
|
|
|
logger = setup_logging()
|
2025-01-24 19:28:14 +02:00
|
|
|
|
|
|
|
class AlarmSiren:
|
|
|
|
def __init__(self):
|
|
|
|
# Communication queues
|
|
|
|
self.alarm_queue = queue.Queue()
|
|
|
|
self.control_queue = queue.Queue()
|
2025-01-25 23:24:27 +02:00
|
|
|
|
2025-01-24 19:28:14 +02:00
|
|
|
# Tracking active alarms
|
|
|
|
self.active_alarms: Dict[int, Dict[str, Any]] = {}
|
2025-01-25 23:24:27 +02:00
|
|
|
|
2025-01-24 19:28:14 +02:00
|
|
|
# Playback thread
|
|
|
|
self.playback_thread = threading.Thread(target=self._playback_worker, daemon=True)
|
|
|
|
self.playback_thread.start()
|
|
|
|
|
|
|
|
def schedule_alarm(self, alarm_config: Dict[str, Any]):
|
|
|
|
"""Schedule an alarm based on its configuration"""
|
|
|
|
logger.info(f"Scheduling alarm: {alarm_config}")
|
|
|
|
self.alarm_queue.put(alarm_config)
|
|
|
|
|
|
|
|
def _calculate_next_alarm_time(self, alarm_config: Dict[str, Any]) -> Optional[datetime]:
|
|
|
|
"""Calculate the next alarm trigger time based on repeat rule"""
|
|
|
|
now = datetime.now()
|
|
|
|
current_time = now.time()
|
2025-01-25 23:24:27 +02:00
|
|
|
|
2025-01-24 19:28:14 +02:00
|
|
|
# Parse alarm time
|
|
|
|
alarm_time = datetime.strptime(alarm_config['time'], "%H:%M:%S").time()
|
2025-01-25 23:24:27 +02:00
|
|
|
|
2025-01-24 19:28:14 +02:00
|
|
|
# Determine the next trigger
|
2025-01-25 23:24:27 +02:00
|
|
|
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':
|
2025-01-24 19:28:14 +02:00
|
|
|
# For one-time alarm, check the specific date
|
|
|
|
try:
|
2025-01-25 23:24:27 +02:00
|
|
|
# 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:
|
2025-01-24 19:28:14 +02:00
|
|
|
logger.error("Invalid one-time alarm configuration")
|
|
|
|
return None
|
2025-01-25 23:24:27 +02:00
|
|
|
|
|
|
|
elif repeat_type == 'daily':
|
2025-01-24 19:28:14 +02:00
|
|
|
# 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)
|
2025-01-25 23:24:27 +02:00
|
|
|
|
|
|
|
elif repeat_type == 'weekly':
|
2025-01-24 19:28:14 +02:00
|
|
|
# Weekly alarm - check configured days
|
|
|
|
today = now.strftime("%A").lower()
|
2025-01-25 23:24:27 +02:00
|
|
|
configured_days = [day.lower() for day in repeat_days]
|
|
|
|
|
2025-01-24 19:28:14 +02:00
|
|
|
if today in configured_days and current_time < alarm_time:
|
|
|
|
return datetime.combine(now.date(), alarm_time)
|
2025-01-25 23:24:27 +02:00
|
|
|
|
2025-01-24 19:28:14 +02:00
|
|
|
# Find next configured day
|
|
|
|
days_order = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday']
|
|
|
|
current_index = days_order.index(today)
|
2025-01-25 23:24:27 +02:00
|
|
|
|
2025-01-24 19:28:14 +02:00
|
|
|
for offset in range(1, 8):
|
|
|
|
next_day_index = (current_index + offset) % 7
|
|
|
|
next_day = days_order[next_day_index]
|
2025-01-25 23:24:27 +02:00
|
|
|
|
2025-01-24 19:28:14 +02:00
|
|
|
if next_day in configured_days:
|
|
|
|
next_date = now.date() + timedelta(days=offset)
|
|
|
|
return datetime.combine(next_date, alarm_time)
|
2025-01-25 23:24:27 +02:00
|
|
|
|
2025-01-24 19:28:14 +02:00
|
|
|
return None
|
|
|
|
|
|
|
|
def _play_audio(self, file_path: str, volume: int = 100):
|
2025-01-26 22:02:23 +02:00
|
|
|
"""Play audio file using mpg123 in the background."""
|
2025-01-24 19:28:14 +02:00
|
|
|
try:
|
|
|
|
if not os.path.exists(file_path):
|
|
|
|
logger.error(f"Audio file not found: {file_path}")
|
2025-01-26 22:02:23 +02:00
|
|
|
return None
|
2025-01-24 19:28:14 +02:00
|
|
|
|
|
|
|
# Construct mpg123 command with volume control
|
|
|
|
volume_adjust = f"-g {volume}"
|
|
|
|
cmd = ["mpg123", volume_adjust, file_path]
|
2025-01-25 23:24:27 +02:00
|
|
|
|
2025-01-24 19:28:14 +02:00
|
|
|
logger.info(f"Playing alarm: {file_path}")
|
2025-01-25 23:24:27 +02:00
|
|
|
|
2025-01-26 22:02:23 +02:00
|
|
|
# Run mpg123 in the background, suppressing stdout/stderr
|
|
|
|
process = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
2025-01-24 19:28:14 +02:00
|
|
|
return process
|
|
|
|
except Exception as e:
|
|
|
|
logger.error(f"Error playing audio: {e}")
|
2025-01-26 22:02:23 +02:00
|
|
|
return None
|
2025-01-24 19:28:14 +02:00
|
|
|
|
|
|
|
def _playback_worker(self):
|
|
|
|
"""Background thread for managing alarm playback"""
|
|
|
|
while True:
|
|
|
|
try:
|
|
|
|
# Check for new alarms to schedule
|
|
|
|
try:
|
|
|
|
new_alarm = self.alarm_queue.get(timeout=1)
|
|
|
|
alarm_time = self._calculate_next_alarm_time(new_alarm)
|
|
|
|
if alarm_time:
|
2025-01-26 22:02:23 +02:00
|
|
|
alarm_id = new_alarm.get('id', id(new_alarm))
|
|
|
|
self.active_alarms[alarm_id] = {
|
2025-01-24 19:28:14 +02:00
|
|
|
'config': new_alarm,
|
|
|
|
'trigger_time': alarm_time,
|
2025-01-26 22:02:23 +02:00
|
|
|
'snooze_count': 0,
|
|
|
|
'process': None
|
2025-01-24 19:28:14 +02:00
|
|
|
}
|
|
|
|
except queue.Empty:
|
|
|
|
pass
|
|
|
|
|
|
|
|
# Check for control signals (snooze/dismiss)
|
|
|
|
try:
|
|
|
|
control_msg = self.control_queue.get(timeout=0.1)
|
2025-01-26 22:02:23 +02:00
|
|
|
alarm_id = control_msg.get('alarm_id')
|
|
|
|
if control_msg['type'] == 'snooze':
|
|
|
|
self.snooze_alarm(alarm_id)
|
|
|
|
elif control_msg['type'] == 'dismiss':
|
|
|
|
self.dismiss_alarm(alarm_id)
|
2025-01-24 19:28:14 +02:00
|
|
|
except queue.Empty:
|
|
|
|
pass
|
|
|
|
|
|
|
|
# Check for alarms to trigger
|
|
|
|
now = datetime.now()
|
|
|
|
for alarm_id, alarm_info in list(self.active_alarms.items()):
|
2025-01-27 11:26:45 +02:00
|
|
|
# Check if alarm is more than 1 hour late
|
|
|
|
if now > alarm_info['trigger_time'] + timedelta(hours=1):
|
|
|
|
logger.warning(f"Alarm {alarm_id} is over 1 hour late. Disabling.")
|
|
|
|
del self.active_alarms[alarm_id]
|
|
|
|
continue
|
2025-01-24 19:28:14 +02:00
|
|
|
if now >= alarm_info['trigger_time']:
|
2025-01-26 22:02:23 +02:00
|
|
|
# Trigger alarm if not already active
|
|
|
|
if alarm_info['process'] is None:
|
|
|
|
alarm_info['process'] = self._play_audio(
|
|
|
|
alarm_info['config']['file_to_play'],
|
|
|
|
alarm_info['config'].get('metadata', {}).get('volume', 100)
|
|
|
|
)
|
|
|
|
logger.info(f"Alarm {alarm_id} triggered at {now}.")
|
|
|
|
|
|
|
|
# Notify UI about the triggered alarm
|
|
|
|
self.control_queue.put({
|
|
|
|
'type': 'trigger',
|
|
|
|
'alarm_id': alarm_id,
|
|
|
|
'info': alarm_info
|
|
|
|
})
|
|
|
|
|
|
|
|
# Handle alarms that have been snoozed or dismissed
|
|
|
|
if alarm_info['process'] and alarm_info['process'].poll() is not None:
|
|
|
|
# Process has finished naturally
|
2025-01-24 19:28:14 +02:00
|
|
|
next_trigger = self._calculate_next_alarm_time(alarm_info['config'])
|
|
|
|
if next_trigger:
|
|
|
|
alarm_info['trigger_time'] = next_trigger
|
2025-01-26 22:02:23 +02:00
|
|
|
alarm_info['process'] = None
|
2025-01-24 19:28:14 +02:00
|
|
|
else:
|
2025-01-26 22:02:23 +02:00
|
|
|
logger.info(f"Removing non-repeating alarm {alarm_id}.")
|
2025-01-24 19:28:14 +02:00
|
|
|
del self.active_alarms[alarm_id]
|
|
|
|
|
2025-01-27 11:26:45 +02:00
|
|
|
# Actively clean up zombie processes
|
|
|
|
for alarm_id, alarm_info in list(self.active_alarms.items()):
|
|
|
|
process = alarm_info.get('process')
|
|
|
|
if process and process.poll() is not None:
|
|
|
|
# Remove terminated processes
|
|
|
|
alarm_info['process'] = None
|
|
|
|
|
|
|
|
time.sleep(0.5) # Prevent tight loop
|
2025-01-24 19:28:14 +02:00
|
|
|
except Exception as e:
|
|
|
|
logger.error(f"Error in playback worker: {e}")
|
|
|
|
time.sleep(1)
|
|
|
|
|
|
|
|
def snooze_alarm(self, alarm_id: int):
|
|
|
|
"""Snooze a specific alarm"""
|
2025-01-25 23:24:27 +02:00
|
|
|
if alarm_id not in self.active_alarms:
|
|
|
|
logger.warning(f"Cannot snooze alarm {alarm_id} - not found in active alarms")
|
|
|
|
return False
|
|
|
|
|
|
|
|
alarm_info = self.active_alarms[alarm_id]
|
|
|
|
alarm_config = alarm_info['config']
|
|
|
|
|
|
|
|
# Default snooze configuration if not provided
|
|
|
|
snooze_config = alarm_config.get('snooze', {
|
2025-01-26 22:02:23 +02:00
|
|
|
'enabled': True,
|
2025-01-25 23:24:27 +02:00
|
|
|
'duration': 5, # Default 5 minutes
|
|
|
|
'max_count': 3 # Default max 3 snoozes
|
|
|
|
})
|
|
|
|
|
2025-01-26 22:02:23 +02:00
|
|
|
if not snooze_config.get('enabled', True):
|
2025-01-25 23:24:27 +02:00
|
|
|
logger.warning(f"Snooze not enabled for alarm {alarm_id}")
|
|
|
|
return False
|
|
|
|
|
|
|
|
# Check snooze count
|
2025-01-26 22:02:23 +02:00
|
|
|
if alarm_info.get('snooze_count', 0) >= snooze_config['max_count']:
|
2025-01-25 23:24:27 +02:00
|
|
|
logger.warning(f"Maximum snooze count reached for alarm {alarm_id}")
|
|
|
|
return False
|
|
|
|
|
2025-01-26 22:02:23 +02:00
|
|
|
# Increment snooze count and set next trigger time
|
|
|
|
alarm_info['snooze_count'] = alarm_info.get('snooze_count', 0) + 1
|
|
|
|
snooze_duration = snooze_config.get('duration', 5)
|
2025-01-25 23:24:27 +02:00
|
|
|
alarm_info['trigger_time'] = datetime.now() + timedelta(minutes=snooze_duration)
|
|
|
|
|
2025-01-26 22:02:23 +02:00
|
|
|
# Stop any active playback
|
|
|
|
process = alarm_info.get('process')
|
|
|
|
if process:
|
|
|
|
process.terminate()
|
|
|
|
process.wait()
|
|
|
|
alarm_info['process'] = None
|
|
|
|
|
|
|
|
logger.info(f"Snoozed alarm {alarm_id} for {snooze_duration} minutes.")
|
2025-01-25 23:24:27 +02:00
|
|
|
return True
|
2025-01-26 22:02:23 +02:00
|
|
|
|
|
|
|
def dismiss_alarm(self, alarm_id: int):
|
|
|
|
"""Dismiss a specific alarm."""
|
|
|
|
if alarm_id not in self.active_alarms:
|
|
|
|
logger.warning(f"Cannot dismiss alarm {alarm_id} - not found in active alarms")
|
|
|
|
return False
|
|
|
|
|
2025-01-27 11:26:45 +02:00
|
|
|
# Stop playback and terminate any running process
|
2025-01-26 22:02:23 +02:00
|
|
|
alarm_info = self.active_alarms.pop(alarm_id, None)
|
2025-01-27 11:26:45 +02:00
|
|
|
if alarm_info and alarm_info.get('process'):
|
|
|
|
try:
|
|
|
|
alarm_info['process'].terminate()
|
|
|
|
alarm_info['process'].wait(timeout=2)
|
|
|
|
except Exception as e:
|
|
|
|
logger.error(f"Error terminating alarm process: {e}")
|
|
|
|
# Force kill if terminate fails
|
|
|
|
try:
|
|
|
|
alarm_info['process'].kill()
|
|
|
|
except Exception:
|
|
|
|
pass
|
2025-01-26 22:02:23 +02:00
|
|
|
|
|
|
|
logger.info(f"Dismissed alarm {alarm_id}.")
|
|
|
|
return True
|