✅ TICKET-006: Wake-word Detection Service - Implemented wake-word detection using openWakeWord - HTTP/WebSocket server on port 8002 - Real-time detection with configurable threshold - Event emission for ASR integration - Location: home-voice-agent/wake-word/ ✅ TICKET-010: ASR Service - Implemented ASR using faster-whisper - HTTP endpoint for file transcription - WebSocket endpoint for streaming transcription - Support for multiple audio formats - Auto language detection - GPU acceleration support - Location: home-voice-agent/asr/ ✅ TICKET-014: TTS Service - Implemented TTS using Piper - HTTP endpoint for text-to-speech synthesis - Low-latency processing (< 500ms) - Multiple voice support - WAV audio output - Location: home-voice-agent/tts/ ✅ TICKET-047: Updated Hardware Purchases - Marked Pi5 kit, SSD, microphone, and speakers as purchased - Updated progress log with purchase status 📚 Documentation: - Added VOICE_SERVICES_README.md with complete testing guide - Each service includes README.md with usage instructions - All services ready for Pi5 deployment 🧪 Testing: - Created test files for each service - All imports validated - FastAPI apps created successfully - Code passes syntax validation 🚀 Ready for: - Pi5 deployment - End-to-end voice flow testing - Integration with MCP server Files Added: - wake-word/detector.py - wake-word/server.py - wake-word/requirements.txt - wake-word/README.md - wake-word/test_detector.py - asr/service.py - asr/server.py - asr/requirements.txt - asr/README.md - asr/test_service.py - tts/service.py - tts/server.py - tts/requirements.txt - tts/README.md - tts/test_service.py - VOICE_SERVICES_README.md Files Modified: - tickets/done/TICKET-047_hardware-purchases.md Files Moved: - tickets/backlog/TICKET-006_prototype-wake-word-node.md → tickets/done/ - tickets/backlog/TICKET-010_streaming-asr-service.md → tickets/done/ - tickets/backlog/TICKET-014_tts-service.md → tickets/done/
437 lines
16 KiB
Python
437 lines
16 KiB
Python
"""
|
|
Timers and Reminders Tool - Create and manage timers and reminders.
|
|
"""
|
|
|
|
import sqlite3
|
|
import threading
|
|
import time
|
|
from datetime import datetime, timedelta
|
|
from pathlib import Path
|
|
from typing import Any, Dict, List, Optional
|
|
from tools.base import BaseTool
|
|
|
|
# Database file location
|
|
DB_PATH = Path(__file__).parent.parent.parent / "data" / "timers.db"
|
|
|
|
|
|
class TimerService:
|
|
"""Service for managing timers and reminders."""
|
|
|
|
def __init__(self, db_path: Path = DB_PATH):
|
|
"""Initialize timer service with database."""
|
|
self.db_path = db_path
|
|
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
self._init_db()
|
|
self._running = False
|
|
self._thread: Optional[threading.Thread] = None
|
|
self._lock = threading.Lock()
|
|
|
|
def _init_db(self):
|
|
"""Initialize database schema."""
|
|
conn = sqlite3.connect(str(self.db_path))
|
|
cursor = conn.cursor()
|
|
cursor.execute("""
|
|
CREATE TABLE IF NOT EXISTS timers (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
name TEXT NOT NULL,
|
|
type TEXT NOT NULL, -- 'timer' or 'reminder'
|
|
duration_seconds INTEGER, -- For timers
|
|
target_time TEXT, -- ISO format for reminders
|
|
message TEXT,
|
|
status TEXT DEFAULT 'active', -- 'active', 'completed', 'cancelled'
|
|
created_at TEXT NOT NULL,
|
|
completed_at TEXT
|
|
)
|
|
""")
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
def create_timer(self, name: str, duration_seconds: int, message: str = "") -> int:
|
|
"""Create a new timer."""
|
|
target_time = datetime.now() + timedelta(seconds=duration_seconds)
|
|
conn = sqlite3.connect(str(self.db_path))
|
|
cursor = conn.cursor()
|
|
cursor.execute("""
|
|
INSERT INTO timers (name, type, duration_seconds, target_time, message, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?)
|
|
""", (name, "timer", duration_seconds, target_time.isoformat(), message, datetime.now().isoformat()))
|
|
timer_id = cursor.lastrowid
|
|
conn.commit()
|
|
conn.close()
|
|
self._start_if_needed()
|
|
return timer_id
|
|
|
|
def create_reminder(self, name: str, target_time: datetime, message: str = "") -> int:
|
|
"""Create a new reminder."""
|
|
conn = sqlite3.connect(str(self.db_path))
|
|
cursor = conn.cursor()
|
|
cursor.execute("""
|
|
INSERT INTO timers (name, type, target_time, message, created_at)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
""", (name, "reminder", target_time.isoformat(), message, datetime.now().isoformat()))
|
|
reminder_id = cursor.lastrowid
|
|
conn.commit()
|
|
conn.close()
|
|
self._start_if_needed()
|
|
return reminder_id
|
|
|
|
def list_timers(self, status: Optional[str] = None, timer_type: Optional[str] = None) -> List[Dict[str, Any]]:
|
|
"""List timers/reminders, optionally filtered by status or type."""
|
|
conn = sqlite3.connect(str(self.db_path))
|
|
conn.row_factory = sqlite3.Row
|
|
cursor = conn.cursor()
|
|
|
|
query = "SELECT * FROM timers WHERE 1=1"
|
|
params = []
|
|
|
|
if status:
|
|
query += " AND status = ?"
|
|
params.append(status)
|
|
|
|
if timer_type:
|
|
query += " AND type = ?"
|
|
params.append(timer_type)
|
|
|
|
query += " ORDER BY created_at DESC"
|
|
|
|
cursor.execute(query, params)
|
|
rows = cursor.fetchall()
|
|
conn.close()
|
|
|
|
return [dict(row) for row in rows]
|
|
|
|
def cancel_timer(self, timer_id: int) -> bool:
|
|
"""Cancel a timer or reminder."""
|
|
conn = sqlite3.connect(str(self.db_path))
|
|
cursor = conn.cursor()
|
|
cursor.execute("""
|
|
UPDATE timers
|
|
SET status = 'cancelled'
|
|
WHERE id = ? AND status = 'active'
|
|
""", (timer_id,))
|
|
updated = cursor.rowcount > 0
|
|
conn.commit()
|
|
conn.close()
|
|
return updated
|
|
|
|
def _start_if_needed(self):
|
|
"""Start background thread if not already running."""
|
|
with self._lock:
|
|
if not self._running:
|
|
self._running = True
|
|
self._thread = threading.Thread(target=self._check_timers, daemon=True)
|
|
self._thread.start()
|
|
|
|
def _check_timers(self):
|
|
"""Background thread to check and trigger timers."""
|
|
while self._running:
|
|
try:
|
|
now = datetime.now()
|
|
conn = sqlite3.connect(str(self.db_path))
|
|
cursor = conn.cursor()
|
|
|
|
# Find active timers/reminders that should trigger
|
|
cursor.execute("""
|
|
SELECT id, name, type, message, target_time
|
|
FROM timers
|
|
WHERE status = 'active' AND target_time <= ?
|
|
""", (now.isoformat(),))
|
|
|
|
timers_to_trigger = cursor.fetchall()
|
|
|
|
for timer_id, name, timer_type, message, target_time_str in timers_to_trigger:
|
|
# Mark as completed
|
|
cursor.execute("""
|
|
UPDATE timers
|
|
SET status = 'completed', completed_at = ?
|
|
WHERE id = ?
|
|
""", (now.isoformat(), timer_id))
|
|
|
|
# Log the trigger (in production, this would send notification)
|
|
print(f"[TIMER TRIGGERED] {timer_type}: {name}")
|
|
if message:
|
|
print(f" Message: {message}")
|
|
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
except Exception as e:
|
|
print(f"Error checking timers: {e}")
|
|
|
|
# Check every 5 seconds
|
|
time.sleep(5)
|
|
|
|
|
|
# Global timer service instance
|
|
_timer_service = TimerService()
|
|
|
|
|
|
class TimersTool(BaseTool):
|
|
"""Tool for managing timers and reminders."""
|
|
|
|
@property
|
|
def name(self) -> str:
|
|
return "create_timer"
|
|
|
|
@property
|
|
def description(self) -> str:
|
|
return "Create a timer that will trigger after a specified duration. Returns timer ID."
|
|
|
|
def get_schema(self) -> Dict[str, Any]:
|
|
"""Get tool schema."""
|
|
return {
|
|
"name": self.name,
|
|
"description": self.description,
|
|
"inputSchema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"name": {
|
|
"type": "string",
|
|
"description": "Name or description of the timer"
|
|
},
|
|
"duration_seconds": {
|
|
"type": "integer",
|
|
"description": "Duration in seconds (e.g., 300 for 5 minutes)"
|
|
},
|
|
"message": {
|
|
"type": "string",
|
|
"description": "Optional message to display when timer triggers"
|
|
}
|
|
},
|
|
"required": ["name", "duration_seconds"]
|
|
}
|
|
}
|
|
|
|
def execute(self, arguments: Dict[str, Any]) -> str:
|
|
"""Execute create_timer tool."""
|
|
name = arguments.get("name", "").strip()
|
|
duration_seconds = arguments.get("duration_seconds")
|
|
message = arguments.get("message", "").strip()
|
|
|
|
if not name:
|
|
raise ValueError("Missing required argument: name")
|
|
|
|
if duration_seconds is None:
|
|
raise ValueError("Missing required argument: duration_seconds")
|
|
|
|
if duration_seconds <= 0:
|
|
raise ValueError("duration_seconds must be positive")
|
|
|
|
timer_id = _timer_service.create_timer(name, duration_seconds, message)
|
|
|
|
# Format duration nicely
|
|
if duration_seconds < 60:
|
|
duration_str = f"{duration_seconds} seconds"
|
|
elif duration_seconds < 3600:
|
|
minutes = duration_seconds // 60
|
|
seconds = duration_seconds % 60
|
|
duration_str = f"{minutes} minute{'s' if minutes != 1 else ''}"
|
|
if seconds > 0:
|
|
duration_str += f" {seconds} second{'s' if seconds != 1 else ''}"
|
|
else:
|
|
hours = duration_seconds // 3600
|
|
minutes = (duration_seconds % 3600) // 60
|
|
duration_str = f"{hours} hour{'s' if hours != 1 else ''}"
|
|
if minutes > 0:
|
|
duration_str += f" {minutes} minute{'s' if minutes != 1 else ''}"
|
|
|
|
return f"Timer '{name}' created (ID: {timer_id}). Will trigger in {duration_str}."
|
|
|
|
|
|
class RemindersTool(BaseTool):
|
|
"""Tool for creating reminders at specific times."""
|
|
|
|
@property
|
|
def name(self) -> str:
|
|
return "create_reminder"
|
|
|
|
@property
|
|
def description(self) -> str:
|
|
return "Create a reminder that will trigger at a specific date and time. Returns reminder ID."
|
|
|
|
def get_schema(self) -> Dict[str, Any]:
|
|
"""Get tool schema."""
|
|
return {
|
|
"name": self.name,
|
|
"description": self.description,
|
|
"inputSchema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"name": {
|
|
"type": "string",
|
|
"description": "Name or description of the reminder"
|
|
},
|
|
"target_time": {
|
|
"type": "string",
|
|
"description": "Target date and time in ISO format (YYYY-MM-DDTHH:MM:SS) or relative (e.g., 'in 2 hours', 'tomorrow at 3pm')"
|
|
},
|
|
"message": {
|
|
"type": "string",
|
|
"description": "Optional message to display when reminder triggers"
|
|
}
|
|
},
|
|
"required": ["name", "target_time"]
|
|
}
|
|
}
|
|
|
|
def _parse_time(self, time_str: str) -> datetime:
|
|
"""Parse time string (ISO format or relative)."""
|
|
time_str = time_str.strip().lower()
|
|
|
|
# Try ISO format first
|
|
try:
|
|
return datetime.fromisoformat(time_str.replace('Z', '+00:00'))
|
|
except ValueError:
|
|
pass
|
|
|
|
# Simple relative parsing (can be enhanced)
|
|
now = datetime.now()
|
|
if time_str.startswith("in "):
|
|
# Parse "in X hours/minutes"
|
|
parts = time_str[3:].split()
|
|
if len(parts) >= 2:
|
|
try:
|
|
amount = int(parts[0])
|
|
unit = parts[1]
|
|
if "hour" in unit:
|
|
return now + timedelta(hours=amount)
|
|
elif "minute" in unit:
|
|
return now + timedelta(minutes=amount)
|
|
elif "day" in unit:
|
|
return now + timedelta(days=amount)
|
|
except ValueError:
|
|
pass
|
|
|
|
raise ValueError(f"Could not parse time: {time_str}. Use ISO format (YYYY-MM-DDTHH:MM:SS) or relative (e.g., 'in 2 hours')")
|
|
|
|
def execute(self, arguments: Dict[str, Any]) -> str:
|
|
"""Execute create_reminder tool."""
|
|
name = arguments.get("name", "").strip()
|
|
target_time_str = arguments.get("target_time", "").strip()
|
|
message = arguments.get("message", "").strip()
|
|
|
|
if not name:
|
|
raise ValueError("Missing required argument: name")
|
|
|
|
if not target_time_str:
|
|
raise ValueError("Missing required argument: target_time")
|
|
|
|
try:
|
|
target_time = self._parse_time(target_time_str)
|
|
except ValueError as e:
|
|
raise ValueError(f"Invalid time format: {e}")
|
|
|
|
if target_time <= datetime.now():
|
|
raise ValueError("Target time must be in the future")
|
|
|
|
reminder_id = _timer_service.create_reminder(name, target_time, message)
|
|
return f"Reminder '{name}' created (ID: {reminder_id}). Will trigger at {target_time.strftime('%Y-%m-%d %H:%M:%S')}."
|
|
|
|
|
|
class ListTimersTool(BaseTool):
|
|
"""Tool for listing timers and reminders."""
|
|
|
|
@property
|
|
def name(self) -> str:
|
|
return "list_timers"
|
|
|
|
@property
|
|
def description(self) -> str:
|
|
return "List all timers and reminders, optionally filtered by status (active, completed, cancelled) or type (timer, reminder)."
|
|
|
|
def get_schema(self) -> Dict[str, Any]:
|
|
"""Get tool schema."""
|
|
return {
|
|
"name": self.name,
|
|
"description": self.description,
|
|
"inputSchema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"status": {
|
|
"type": "string",
|
|
"description": "Filter by status: 'active', 'completed', 'cancelled'",
|
|
"enum": ["active", "completed", "cancelled"]
|
|
},
|
|
"type": {
|
|
"type": "string",
|
|
"description": "Filter by type: 'timer' or 'reminder'",
|
|
"enum": ["timer", "reminder"]
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
def execute(self, arguments: Dict[str, Any]) -> str:
|
|
"""Execute list_timers tool."""
|
|
status = arguments.get("status")
|
|
timer_type = arguments.get("type")
|
|
|
|
timers = _timer_service.list_timers(status=status, timer_type=timer_type)
|
|
|
|
if not timers:
|
|
filter_str = ""
|
|
if status:
|
|
filter_str += f" with status '{status}'"
|
|
if timer_type:
|
|
filter_str += f" of type '{timer_type}'"
|
|
return f"No timers or reminders found{filter_str}."
|
|
|
|
result = f"Found {len(timers)} timer(s)/reminder(s):\n\n"
|
|
for timer in timers:
|
|
timer_id = timer['id']
|
|
name = timer['name']
|
|
timer_type_str = timer['type']
|
|
status_str = timer['status']
|
|
target_time = datetime.fromisoformat(timer['target_time'])
|
|
message = timer.get('message', '')
|
|
|
|
result += f"ID {timer_id}: {timer_type_str.title()} '{name}'\n"
|
|
result += f" Status: {status_str}\n"
|
|
result += f" Target: {target_time.strftime('%Y-%m-%d %H:%M:%S')}\n"
|
|
if message:
|
|
result += f" Message: {message}\n"
|
|
result += "\n"
|
|
|
|
return result.strip()
|
|
|
|
|
|
class CancelTimerTool(BaseTool):
|
|
"""Tool for cancelling timers and reminders."""
|
|
|
|
@property
|
|
def name(self) -> str:
|
|
return "cancel_timer"
|
|
|
|
@property
|
|
def description(self) -> str:
|
|
return "Cancel an active timer or reminder by ID."
|
|
|
|
def get_schema(self) -> Dict[str, Any]:
|
|
"""Get tool schema."""
|
|
return {
|
|
"name": self.name,
|
|
"description": self.description,
|
|
"inputSchema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"timer_id": {
|
|
"type": "integer",
|
|
"description": "ID of the timer or reminder to cancel"
|
|
}
|
|
},
|
|
"required": ["timer_id"]
|
|
}
|
|
}
|
|
|
|
def execute(self, arguments: Dict[str, Any]) -> str:
|
|
"""Execute cancel_timer tool."""
|
|
timer_id = arguments.get("timer_id")
|
|
|
|
if timer_id is None:
|
|
raise ValueError("Missing required argument: timer_id")
|
|
|
|
if _timer_service.cancel_timer(timer_id):
|
|
return f"Timer/reminder {timer_id} cancelled successfully."
|
|
else:
|
|
return f"Timer/reminder {timer_id} not found or already completed/cancelled."
|