From 397355c1ad6412717255179a49e339f79ebfb875 Mon Sep 17 00:00:00 2001 From: jango-blockchained Date: Mon, 3 Feb 2025 15:39:19 +0100 Subject: [PATCH] Restructure Project Architecture with Modular Routes and Configuration - Refactored main application entry point to use centralized configuration - Created modular route structure with separate files for different API endpoints - Introduced app.config.ts for centralized environment variable management - Moved tools and route logic into dedicated files - Simplified index.ts and improved overall project organization - Added comprehensive type definitions for tools and API interactions --- src/config/app.config.ts | 67 ++ src/index.ts | 1378 +-------------------------- src/routes/health.routes.ts | 15 + src/routes/index.ts | 39 + src/routes/mcp.routes.ts | 51 + src/routes/sse.routes.ts | 99 ++ src/routes/tool.routes.ts | 75 ++ src/tools/addon.tool.ts | 106 +++ src/tools/automation-config.tool.ts | 150 +++ src/tools/automation.tool.ts | 73 ++ src/tools/control.tool.ts | 139 +++ src/tools/history.tool.ts | 53 ++ src/tools/index.ts | 54 +- src/tools/list-devices.tool.ts | 46 + src/tools/notify.tool.ts | 47 + src/tools/package.tool.ts | 88 ++ src/tools/scene.tool.ts | 71 ++ src/tools/sse-stats.tool.ts | 33 + src/tools/subscribe-events.tool.ts | 84 ++ src/types/index.ts | 337 +++++++ 20 files changed, 1664 insertions(+), 1341 deletions(-) create mode 100644 src/config/app.config.ts create mode 100644 src/routes/health.routes.ts create mode 100644 src/routes/index.ts create mode 100644 src/routes/mcp.routes.ts create mode 100644 src/routes/sse.routes.ts create mode 100644 src/routes/tool.routes.ts create mode 100644 src/tools/addon.tool.ts create mode 100644 src/tools/automation-config.tool.ts create mode 100644 src/tools/automation.tool.ts create mode 100644 src/tools/control.tool.ts create mode 100644 src/tools/history.tool.ts create mode 100644 src/tools/list-devices.tool.ts create mode 100644 src/tools/notify.tool.ts create mode 100644 src/tools/package.tool.ts create mode 100644 src/tools/scene.tool.ts create mode 100644 src/tools/sse-stats.tool.ts create mode 100644 src/tools/subscribe-events.tool.ts create mode 100644 src/types/index.ts diff --git a/src/config/app.config.ts b/src/config/app.config.ts new file mode 100644 index 0000000..4076e25 --- /dev/null +++ b/src/config/app.config.ts @@ -0,0 +1,67 @@ +import { config } from 'dotenv'; +import { resolve } from 'path'; + +/** + * Load environment variables based on NODE_ENV + * Development: .env.development + * Test: .env.test + * Production: .env + */ +const envFile = process.env.NODE_ENV === 'production' + ? '.env' + : process.env.NODE_ENV === 'test' + ? '.env.test' + : '.env.development'; + +console.log(`Loading environment from ${envFile}`); +config({ path: resolve(process.cwd(), envFile) }); + +/** + * Application configuration object + * Contains all configuration settings for the application + */ +export const APP_CONFIG = { + /** Server Configuration */ + PORT: process.env.PORT || 3000, + NODE_ENV: process.env.NODE_ENV || 'development', + + /** Home Assistant Configuration */ + HASS_HOST: process.env.HASS_HOST || 'http://192.168.178.63:8123', + HASS_TOKEN: process.env.HASS_TOKEN, + + /** Security Configuration */ + JWT_SECRET: process.env.JWT_SECRET || 'your-secret-key', + RATE_LIMIT: { + /** Time window for rate limiting in milliseconds */ + windowMs: 15 * 60 * 1000, // 15 minutes + /** Maximum number of requests per window */ + max: 100 // limit each IP to 100 requests per windowMs + }, + + /** Server-Sent Events Configuration */ + SSE: { + /** Maximum number of concurrent SSE clients */ + MAX_CLIENTS: 1000, + /** Ping interval in milliseconds to keep connections alive */ + PING_INTERVAL: 30000 // 30 seconds + }, + + /** Application Version */ + VERSION: '0.1.0' +} as const; + +/** Type definition for the configuration object */ +export type AppConfig = typeof APP_CONFIG; + +/** Required environment variables that must be set */ +const requiredEnvVars = ['HASS_TOKEN'] as const; + +/** + * Validate that all required environment variables are set + * Throws an error if any required variable is missing + */ +for (const envVar of requiredEnvVars) { + if (!process.env[envVar]) { + throw new Error(`Missing required environment variable: ${envVar}`); + } +} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 096ef63..af79b6b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,36 +1,27 @@ +/** + * Home Assistant MCP (Master Control Program) + * Main application entry point + * + * This file initializes the Express server and sets up all necessary + * middleware and routes for the application. + * + * @module index + */ + import './polyfills.js'; -import { config } from 'dotenv'; -import { resolve } from 'path'; -import { v4 as uuidv4 } from 'uuid'; -import { sseManager } from './sse/index.js'; -import { ILogger } from "@digital-alchemy/core"; import express from 'express'; -import { rateLimiter, securityHeaders, validateRequest, sanitizeInput, errorHandler, TokenManager } from './security/index.js'; -import { MCP_SCHEMA } from './mcp/schema.js'; - -// Load environment variables based on NODE_ENV -const envFile = process.env.NODE_ENV === 'production' - ? '.env' - : process.env.NODE_ENV === 'test' - ? '.env.test' - : '.env.development'; - -console.log(`Loading environment from ${envFile}`); -config({ path: resolve(process.cwd(), envFile) }); - +import { APP_CONFIG } from './config/app.config.js'; +import { apiRoutes } from './routes/index.js'; +import { securityHeaders, rateLimiter, validateRequest, sanitizeInput, errorHandler } from './security/index.js'; import { get_hass } from './hass/index.js'; import { LiteMCP } from 'litemcp'; -import { z } from 'zod'; -import { DomainSchema } from './schemas.js'; - -// Configuration -const HASS_HOST = process.env.HASS_HOST || 'http://192.168.178.63:8123'; -const HASS_TOKEN = process.env.HASS_TOKEN; -const PORT = process.env.PORT || 3000; console.log('Initializing Home Assistant connection...'); -// Initialize Express app +/** + * Initialize Express application with security middleware + * and route handlers + */ const app = express(); // Apply security middleware @@ -40,1321 +31,28 @@ app.use(express.json()); app.use(validateRequest); app.use(sanitizeInput); -// Initialize LiteMCP -const server = new LiteMCP('home-assistant', '0.1.0'); +/** + * Initialize LiteMCP instance + * This provides the core MCP functionality + */ +const server = new LiteMCP('home-assistant', APP_CONFIG.VERSION); -// MCP schema endpoint - no auth required as it's just the schema -app.get('/mcp', (_req, res) => { - // Return the MCP schema without requiring authentication - res.json(MCP_SCHEMA); -}); +/** + * Mount API routes under /api + * All API endpoints are prefixed with /api + */ +app.use('/api', apiRoutes); -// MCP execute endpoint - requires authentication -app.post('/mcp/execute', async (req, res) => { - try { - // Get token from Authorization header - const token = req.headers.authorization?.replace('Bearer ', ''); - - if (!token || token !== HASS_TOKEN) { - return res.status(401).json({ - success: false, - message: 'Unauthorized - Invalid token' - }); - } - - const { tool: toolName, parameters } = req.body; - - // Find the requested tool - const tool = tools.find(t => t.name === toolName); - if (!tool) { - return res.status(404).json({ - success: false, - message: `Tool '${toolName}' not found` - }); - } - - // Execute the tool with the provided parameters - const result = await tool.execute(parameters); - res.json(result); - } catch (error) { - res.status(500).json({ - success: false, - message: error instanceof Error ? error.message : 'Unknown error occurred' - }); - } -}); - -// Health check endpoint -app.get('/health', (req, res) => { - res.json({ - status: 'ok', - timestamp: new Date().toISOString(), - version: '0.1.0' - }); -}); - -// Define Tool interface -interface Tool { - name: string; - description: string; - parameters: z.ZodType; - execute: (params: any) => Promise; -} - -// Array to track tools -const tools: Tool[] = []; - -// List devices endpoint -app.get('/list_devices', async (req, res) => { - try { - // Get token from Authorization header - const token = req.headers.authorization?.replace('Bearer ', ''); - - if (!token || token !== HASS_TOKEN) { - return res.status(401).json({ - success: false, - message: 'Unauthorized - Invalid token' - }); - } - - const tool = tools.find(t => t.name === 'list_devices'); - if (!tool) { - return res.status(404).json({ - success: false, - message: 'Tool not found' - }); - } - - const result = await tool.execute({ token }); - res.json(result); - } catch (error) { - res.status(500).json({ - success: false, - message: error instanceof Error ? error.message : 'Unknown error occurred' - }); - } -}); - -app.post('/control', async (req, res) => { - try { - // Get token from Authorization header - const token = req.headers.authorization?.replace('Bearer ', ''); - - if (!token || token !== HASS_TOKEN) { - return res.status(401).json({ - success: false, - message: 'Unauthorized - Invalid token' - }); - } - - const tool = tools.find(t => t.name === 'control'); - if (!tool) { - return res.status(404).json({ - success: false, - message: 'Tool not found' - }); - } - - const result = await tool.execute({ - ...req.body, - token - }); - res.json(result); - } catch (error) { - res.status(500).json({ - success: false, - message: error instanceof Error ? error.message : 'Unknown error occurred' - }); - } -}); - -// SSE endpoints -app.get('/subscribe_events', (req, res) => { - try { - // Get token from query parameter - const token = req.query.token?.toString(); - - if (!token || !TokenManager.validateToken(token)) { - return res.status(401).json({ - success: false, - message: 'Unauthorized - Invalid token' - }); - } - - // Set SSE headers - res.writeHead(200, { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - 'Connection': 'keep-alive', - 'Access-Control-Allow-Origin': '*' - }); - - // Send initial connection message - res.write(`data: ${JSON.stringify({ - type: 'connection', - status: 'connected', - timestamp: new Date().toISOString() - })}\n\n`); - - const clientId = uuidv4(); - const client = { - id: clientId, - send: (data: string) => { - res.write(`data: ${data}\n\n`); - } - }; - - // Add client to SSE manager - const sseClient = sseManager.addClient(client, token); - if (!sseClient || !sseClient.authenticated) { - res.write(`data: ${JSON.stringify({ - type: 'error', - message: sseClient ? 'Authentication failed' : 'Maximum client limit reached', - timestamp: new Date().toISOString() - })}\n\n`); - return res.end(); - } - - // Subscribe to events if specified - const events = req.query.events?.toString().split(',').filter(Boolean); - if (events?.length) { - events.forEach(event => sseManager.subscribeToEvent(clientId, event)); - } - - // Subscribe to entity if specified - const entityId = req.query.entity_id?.toString(); - if (entityId) { - sseManager.subscribeToEntity(clientId, entityId); - } - - // Subscribe to domain if specified - const domain = req.query.domain?.toString(); - if (domain) { - sseManager.subscribeToDomain(clientId, domain); - } - - // Handle client disconnect - req.on('close', () => { - sseManager.removeClient(clientId); - }); - - } catch (error) { - res.status(500).json({ - success: false, - message: error instanceof Error ? error.message : 'Unknown error occurred' - }); - } -}); - -app.get('/get_sse_stats', async (req, res) => { - try { - // Get token from query parameter - const token = req.query.token?.toString(); - - if (!token || token !== HASS_TOKEN) { - return res.status(401).json({ - success: false, - message: 'Unauthorized - Invalid token' - }); - } - - const tool = tools.find(t => t.name === 'get_sse_stats'); - if (!tool) { - return res.status(404).json({ - success: false, - message: 'Tool not found' - }); - } - - const result = await tool.execute({ token }); - res.json(result); - } catch (error) { - res.status(500).json({ - success: false, - message: error instanceof Error ? error.message : 'Unknown error occurred' - }); - } -}); - -// Error handling middleware +/** + * Apply error handling middleware + * This should be the last middleware in the chain + */ app.use(errorHandler); -interface CommandParams { - command: string; - entity_id: string; - // Common parameters - state?: string; - // Light parameters - brightness?: number; - color_temp?: number; - rgb_color?: [number, number, number]; - // Cover parameters - position?: number; - tilt_position?: number; - // Climate parameters - temperature?: number; - target_temp_high?: number; - target_temp_low?: number; - hvac_mode?: string; - fan_mode?: string; - humidity?: number; -} - -const commonCommands = ['turn_on', 'turn_off', 'toggle'] as const; -const coverCommands = [...commonCommands, 'open', 'close', 'stop', 'set_position', 'set_tilt_position'] as const; -const climateCommands = [...commonCommands, 'set_temperature', 'set_hvac_mode', 'set_fan_mode', 'set_humidity'] as const; - -interface HassEntity { - entity_id: string; - state: string; - attributes: Record; - last_changed?: string; - last_updated?: string; - context?: { - id: string; - parent_id?: string; - user_id?: string; - }; -} - -interface HassState { - entity_id: string; - state: string; - attributes: { - friendly_name?: string; - description?: string; - [key: string]: any; - }; -} - -interface HassAddon { - name: string; - slug: string; - description: string; - version: string; - installed: boolean; - available: boolean; - state: string; -} - -interface HassAddonResponse { - data: { - addons: HassAddon[]; - }; -} - -interface HassAddonInfoResponse { - data: { - name: string; - slug: string; - description: string; - version: string; - state: string; - status: string; - options: Record; - [key: string]: any; - }; -} - -interface HacsRepository { - name: string; - description: string; - category: string; - installed: boolean; - version_installed: string; - available_version: string; - authors: string[]; - domain: string; -} - -interface HacsResponse { - repositories: HacsRepository[]; -} - -interface AutomationConfig { - alias: string; - description?: string; - mode?: 'single' | 'parallel' | 'queued' | 'restart'; - trigger: any[]; - condition?: any[]; - action: any[]; -} - -interface AutomationResponse { - automation_id: string; -} - -interface SSEHeaders { - onAbort?: () => void; -} - -interface SSEParams { - token: string; - events?: string[]; - entity_id?: string; - domain?: string; -} - -interface HistoryParams { - entity_id: string; - start_time?: string; - end_time?: string; - minimal_response?: boolean; - significant_changes_only?: boolean; -} - -interface SceneParams { - action: 'list' | 'activate'; - scene_id?: string; -} - -interface NotifyParams { - message: string; - title?: string; - target?: string; - data?: Record; -} - -interface AutomationParams { - action: 'list' | 'toggle' | 'trigger'; - automation_id?: string; -} - -interface AddonParams { - action: 'list' | 'info' | 'install' | 'uninstall' | 'start' | 'stop' | 'restart'; - slug?: string; - version?: string; -} - -interface PackageParams { - action: 'list' | 'install' | 'uninstall' | 'update'; - category: 'integration' | 'plugin' | 'theme' | 'python_script' | 'appdaemon' | 'netdaemon'; - repository?: string; - version?: string; -} - -interface AutomationConfigParams { - action: 'create' | 'update' | 'delete' | 'duplicate'; - automation_id?: string; - config?: { - alias: string; - description?: string; - mode?: 'single' | 'parallel' | 'queued' | 'restart'; - trigger: any[]; - condition?: any[]; - action: any[]; - }; -} - -async function main() { - const hass = await get_hass(); - const logger: ILogger = (hass as any).logger; - - // Add the list devices tool - const listDevicesTool = { - name: 'list_devices', - description: 'List all available Home Assistant devices', - parameters: z.object({}).describe('No parameters required'), - execute: async () => { - try { - const response = await fetch(`${HASS_HOST}/api/states`, { - headers: { - Authorization: `Bearer ${HASS_TOKEN}`, - 'Content-Type': 'application/json', - }, - }); - - if (!response.ok) { - throw new Error(`Failed to fetch devices: ${response.statusText}`); - } - - const states = await response.json() as HassState[]; - const devices: Record = {}; - - // Group devices by domain - states.forEach(state => { - const [domain] = state.entity_id.split('.'); - if (!devices[domain]) { - devices[domain] = []; - } - devices[domain].push(state); - }); - - return { - success: true, - devices - }; - } catch (error) { - return { - success: false, - message: error instanceof Error ? error.message : 'Unknown error occurred' - }; - } - } - }; - server.addTool(listDevicesTool); - tools.push(listDevicesTool); - - // Add the Home Assistant control tool - const controlTool = { - name: 'control', - description: 'Control Home Assistant devices and services', - parameters: z.object({ - command: z.enum([...commonCommands, ...coverCommands, ...climateCommands]) - .describe('The command to execute'), - entity_id: z.string().describe('The entity ID to control'), - // Common parameters - state: z.string().optional().describe('The desired state for the entity'), - // Light parameters - brightness: z.number().min(0).max(255).optional() - .describe('Brightness level for lights (0-255)'), - color_temp: z.number().optional() - .describe('Color temperature for lights'), - rgb_color: z.tuple([z.number(), z.number(), z.number()]).optional() - .describe('RGB color values'), - // Cover parameters - position: z.number().min(0).max(100).optional() - .describe('Position for covers (0-100)'), - tilt_position: z.number().min(0).max(100).optional() - .describe('Tilt position for covers (0-100)'), - // Climate parameters - temperature: z.number().optional() - .describe('Target temperature for climate devices'), - target_temp_high: z.number().optional() - .describe('Target high temperature for climate devices'), - target_temp_low: z.number().optional() - .describe('Target low temperature for climate devices'), - hvac_mode: z.enum(['off', 'heat', 'cool', 'heat_cool', 'auto', 'dry', 'fan_only']).optional() - .describe('HVAC mode for climate devices'), - fan_mode: z.enum(['auto', 'low', 'medium', 'high']).optional() - .describe('Fan mode for climate devices'), - humidity: z.number().min(0).max(100).optional() - .describe('Target humidity for climate devices') - }), - execute: async (params: CommandParams) => { - try { - const domain = params.entity_id.split('.')[0] as keyof typeof DomainSchema.Values; - - if (!Object.values(DomainSchema.Values).includes(domain)) { - throw new Error(`Unsupported domain: ${domain}`); - } - - const service = params.command; - const serviceData: Record = { - entity_id: params.entity_id - }; - - // Handle domain-specific parameters - switch (domain) { - case 'light': - if (params.brightness !== undefined) { - serviceData.brightness = params.brightness; - } - if (params.color_temp !== undefined) { - serviceData.color_temp = params.color_temp; - } - if (params.rgb_color !== undefined) { - serviceData.rgb_color = params.rgb_color; - } - break; - - case 'cover': - if (service === 'set_position' && params.position !== undefined) { - serviceData.position = params.position; - } - if (service === 'set_tilt_position' && params.tilt_position !== undefined) { - serviceData.tilt_position = params.tilt_position; - } - break; - - case 'climate': - if (service === 'set_temperature') { - if (params.temperature !== undefined) { - serviceData.temperature = params.temperature; - } - if (params.target_temp_high !== undefined) { - serviceData.target_temp_high = params.target_temp_high; - } - if (params.target_temp_low !== undefined) { - serviceData.target_temp_low = params.target_temp_low; - } - } - if (service === 'set_hvac_mode' && params.hvac_mode !== undefined) { - serviceData.hvac_mode = params.hvac_mode; - } - if (service === 'set_fan_mode' && params.fan_mode !== undefined) { - serviceData.fan_mode = params.fan_mode; - } - if (service === 'set_humidity' && params.humidity !== undefined) { - serviceData.humidity = params.humidity; - } - break; - - case 'switch': - case 'contact': - // These domains only support basic operations (turn_on, turn_off, toggle) - break; - - default: - throw new Error(`Unsupported operation for domain: ${domain}`); - } - - // Call Home Assistant service - try { - const response = await fetch(`${HASS_HOST}/api/services/${domain}/${service}`, { - method: 'POST', - headers: { - Authorization: `Bearer ${HASS_TOKEN}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(serviceData), - }); - - if (!response.ok) { - throw new Error(`Failed to execute ${service} for ${params.entity_id}: ${response.statusText}`); - } - - return { - success: true, - message: `Successfully executed ${service} for ${params.entity_id}` - }; - } catch (error) { - throw new Error(`Failed to execute ${service} for ${params.entity_id}: ${error instanceof Error ? error.message : 'Unknown error occurred'}`); - } - } catch (error) { - return { - success: false, - message: error instanceof Error ? error.message : 'Unknown error occurred' - }; - } - } - }; - server.addTool(controlTool); - tools.push(controlTool); - - // Add the history tool - const historyTool = { - name: 'get_history', - description: 'Get state history for Home Assistant entities', - parameters: z.object({ - entity_id: z.string().describe('The entity ID to get history for'), - start_time: z.string().optional().describe('Start time in ISO format. Defaults to 24 hours ago'), - end_time: z.string().optional().describe('End time in ISO format. Defaults to now'), - minimal_response: z.boolean().optional().describe('Return minimal response to reduce data size'), - significant_changes_only: z.boolean().optional().describe('Only return significant state changes'), - }), - execute: async (params: HistoryParams) => { - try { - const now = new Date(); - const startTime = params.start_time ? new Date(params.start_time) : new Date(now.getTime() - 24 * 60 * 60 * 1000); - const endTime = params.end_time ? new Date(params.end_time) : now; - - // Build query parameters - const queryParams = new URLSearchParams({ - filter_entity_id: params.entity_id, - minimal_response: String(!!params.minimal_response), - significant_changes_only: String(!!params.significant_changes_only), - start_time: startTime.toISOString(), - end_time: endTime.toISOString(), - }); - - const response = await fetch(`${HASS_HOST}/api/history/period/${startTime.toISOString()}?${queryParams.toString()}`, { - headers: { - Authorization: `Bearer ${HASS_TOKEN}`, - 'Content-Type': 'application/json', - }, - }); - - if (!response.ok) { - throw new Error(`Failed to fetch history: ${response.statusText}`); - } - - const history = await response.json(); - return { - success: true, - history, - }; - } catch (error) { - return { - success: false, - message: error instanceof Error ? error.message : 'Unknown error occurred', - }; - } - }, - }; - server.addTool(historyTool); - tools.push(historyTool); - - // Add the scenes tool - const sceneTool = { - name: 'scene', - description: 'Manage and activate Home Assistant scenes', - parameters: z.object({ - action: z.enum(['list', 'activate']).describe('Action to perform with scenes'), - scene_id: z.string().optional().describe('Scene ID to activate (required for activate action)'), - }), - execute: async (params: SceneParams) => { - try { - if (params.action === 'list') { - const response = await fetch(`${HASS_HOST}/api/states`, { - headers: { - Authorization: `Bearer ${HASS_TOKEN}`, - 'Content-Type': 'application/json', - }, - }); - - if (!response.ok) { - throw new Error(`Failed to fetch scenes: ${response.statusText}`); - } - - const states = (await response.json()) as HassState[]; - const scenes = states.filter((state) => state.entity_id.startsWith('scene.')); - - return { - success: true, - scenes: scenes.map((scene) => ({ - entity_id: scene.entity_id, - name: scene.attributes.friendly_name || scene.entity_id.split('.')[1], - description: scene.attributes.description, - })), - }; - } else if (params.action === 'activate') { - if (!params.scene_id) { - throw new Error('Scene ID is required for activate action'); - } - - const response = await fetch(`${HASS_HOST}/api/services/scene/turn_on`, { - method: 'POST', - headers: { - Authorization: `Bearer ${HASS_TOKEN}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - entity_id: params.scene_id, - }), - }); - - if (!response.ok) { - throw new Error(`Failed to activate scene: ${response.statusText}`); - } - - return { - success: true, - message: `Successfully activated scene ${params.scene_id}`, - }; - } - - throw new Error('Invalid action specified'); - } catch (error) { - return { - success: false, - message: error instanceof Error ? error.message : 'Unknown error occurred', - }; - } - }, - }; - server.addTool(sceneTool); - tools.push(sceneTool); - - // Add the notification tool - const notifyTool = { - name: 'notify', - description: 'Send notifications through Home Assistant', - parameters: z.object({ - message: z.string().describe('The notification message'), - title: z.string().optional().describe('The notification title'), - target: z.string().optional().describe('Specific notification target (e.g., mobile_app_phone)'), - data: z.record(z.any()).optional().describe('Additional notification data'), - }), - execute: async (params: NotifyParams) => { - try { - const service = params.target ? `notify.${params.target}` : 'notify.notify'; - const [domain, service_name] = service.split('.'); - - const response = await fetch(`${HASS_HOST}/api/services/${domain}/${service_name}`, { - method: 'POST', - headers: { - Authorization: `Bearer ${HASS_TOKEN}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - message: params.message, - title: params.title, - data: params.data, - }), - }); - - if (!response.ok) { - throw new Error(`Failed to send notification: ${response.statusText}`); - } - - return { - success: true, - message: 'Notification sent successfully', - }; - } catch (error) { - return { - success: false, - message: error instanceof Error ? error.message : 'Unknown error occurred', - }; - } - }, - }; - server.addTool(notifyTool); - tools.push(notifyTool); - - // Add the automation tool - const automationTool = { - name: 'automation', - description: 'Manage Home Assistant automations', - parameters: z.object({ - action: z.enum(['list', 'toggle', 'trigger']).describe('Action to perform with automation'), - automation_id: z.string().optional().describe('Automation ID (required for toggle and trigger actions)'), - }), - execute: async (params: AutomationParams) => { - try { - if (params.action === 'list') { - const response = await fetch(`${HASS_HOST}/api/states`, { - headers: { - Authorization: `Bearer ${HASS_TOKEN}`, - 'Content-Type': 'application/json', - }, - }); - - if (!response.ok) { - throw new Error(`Failed to fetch automations: ${response.statusText}`); - } - - const states = (await response.json()) as HassState[]; - const automations = states.filter((state) => state.entity_id.startsWith('automation.')); - - return { - success: true, - automations: automations.map((automation) => ({ - entity_id: automation.entity_id, - name: automation.attributes.friendly_name || automation.entity_id.split('.')[1], - state: automation.state, - last_triggered: automation.attributes.last_triggered, - })), - }; - } else { - if (!params.automation_id) { - throw new Error('Automation ID is required for toggle and trigger actions'); - } - - const service = params.action === 'toggle' ? 'toggle' : 'trigger'; - const response = await fetch(`${HASS_HOST}/api/services/automation/${service}`, { - method: 'POST', - headers: { - Authorization: `Bearer ${HASS_TOKEN}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - entity_id: params.automation_id, - }), - }); - - if (!response.ok) { - throw new Error(`Failed to ${service} automation: ${response.statusText}`); - } - - const responseData = await response.json() as AutomationResponse; - return { - success: true, - message: `Successfully ${service}d automation ${params.automation_id}`, - automation_id: responseData.automation_id, - }; - } - } catch (error) { - return { - success: false, - message: error instanceof Error ? error.message : 'Unknown error occurred', - }; - } - }, - }; - server.addTool(automationTool); - tools.push(automationTool); - - // Add the addon tool - const addonTool = { - name: 'addon', - description: 'Manage Home Assistant add-ons', - parameters: z.object({ - action: z.enum(['list', 'info', 'install', 'uninstall', 'start', 'stop', 'restart']).describe('Action to perform with add-on'), - slug: z.string().optional().describe('Add-on slug (required for all actions except list)'), - version: z.string().optional().describe('Version to install (only for install action)'), - }), - execute: async (params: AddonParams) => { - try { - if (params.action === 'list') { - const response = await fetch(`${HASS_HOST}/api/hassio/store`, { - headers: { - Authorization: `Bearer ${HASS_TOKEN}`, - 'Content-Type': 'application/json', - }, - }); - - if (!response.ok) { - throw new Error(`Failed to fetch add-ons: ${response.statusText}`); - } - - const data = await response.json() as HassAddonResponse; - return { - success: true, - addons: data.data.addons.map((addon) => ({ - name: addon.name, - slug: addon.slug, - description: addon.description, - version: addon.version, - installed: addon.installed, - available: addon.available, - state: addon.state, - })), - }; - } else { - if (!params.slug) { - throw new Error('Add-on slug is required for this action'); - } - - let endpoint = ''; - let method = 'GET'; - const body: Record = {}; - - switch (params.action) { - case 'info': - endpoint = `/api/hassio/addons/${params.slug}/info`; - break; - case 'install': - endpoint = `/api/hassio/addons/${params.slug}/install`; - method = 'POST'; - if (params.version) { - body.version = params.version; - } - break; - case 'uninstall': - endpoint = `/api/hassio/addons/${params.slug}/uninstall`; - method = 'POST'; - break; - case 'start': - endpoint = `/api/hassio/addons/${params.slug}/start`; - method = 'POST'; - break; - case 'stop': - endpoint = `/api/hassio/addons/${params.slug}/stop`; - method = 'POST'; - break; - case 'restart': - endpoint = `/api/hassio/addons/${params.slug}/restart`; - method = 'POST'; - break; - } - - const response = await fetch(`${HASS_HOST}${endpoint}`, { - method, - headers: { - Authorization: `Bearer ${HASS_TOKEN}`, - 'Content-Type': 'application/json', - }, - ...(Object.keys(body).length > 0 && { body: JSON.stringify(body) }), - }); - - if (!response.ok) { - throw new Error(`Failed to ${params.action} add-on: ${response.statusText}`); - } - - const data = await response.json() as HassAddonInfoResponse; - return { - success: true, - message: `Successfully ${params.action}ed add-on ${params.slug}`, - data: data.data, - }; - } - } catch (error) { - return { - success: false, - message: error instanceof Error ? error.message : 'Unknown error occurred', - }; - } - }, - }; - server.addTool(addonTool); - tools.push(addonTool); - - // Add the package tool - const packageTool = { - name: 'package', - description: 'Manage HACS packages and custom components', - parameters: z.object({ - action: z.enum(['list', 'install', 'uninstall', 'update']).describe('Action to perform with package'), - category: z.enum(['integration', 'plugin', 'theme', 'python_script', 'appdaemon', 'netdaemon']) - .describe('Package category'), - repository: z.string().optional().describe('Repository URL or name (required for install)'), - version: z.string().optional().describe('Version to install'), - }), - execute: async (params: PackageParams) => { - try { - const hacsBase = `${HASS_HOST}/api/hacs`; - - if (params.action === 'list') { - const response = await fetch(`${hacsBase}/repositories?category=${params.category}`, { - headers: { - Authorization: `Bearer ${HASS_TOKEN}`, - 'Content-Type': 'application/json', - }, - }); - - if (!response.ok) { - throw new Error(`Failed to fetch packages: ${response.statusText}`); - } - - const data = await response.json() as HacsResponse; - return { - success: true, - packages: data.repositories, - }; - } else { - if (!params.repository) { - throw new Error('Repository is required for this action'); - } - - let endpoint = ''; - const body: Record = { - category: params.category, - repository: params.repository, - }; - - switch (params.action) { - case 'install': - endpoint = '/repository/install'; - if (params.version) { - body.version = params.version; - } - break; - case 'uninstall': - endpoint = '/repository/uninstall'; - break; - case 'update': - endpoint = '/repository/update'; - break; - } - - const response = await fetch(`${hacsBase}${endpoint}`, { - method: 'POST', - headers: { - Authorization: `Bearer ${HASS_TOKEN}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(body), - }); - - if (!response.ok) { - throw new Error(`Failed to ${params.action} package: ${response.statusText}`); - } - - return { - success: true, - message: `Successfully ${params.action}ed package ${params.repository}`, - }; - } - } catch (error) { - return { - success: false, - message: error instanceof Error ? error.message : 'Unknown error occurred', - }; - } - }, - }; - server.addTool(packageTool); - tools.push(packageTool); - - // Extend the automation tool with more functionality - const automationConfigTool = { - name: 'automation_config', - description: 'Advanced automation configuration and management', - parameters: z.object({ - action: z.enum(['create', 'update', 'delete', 'duplicate']).describe('Action to perform with automation config'), - automation_id: z.string().optional().describe('Automation ID (required for update, delete, and duplicate)'), - config: z.object({ - alias: z.string().describe('Friendly name for the automation'), - description: z.string().optional().describe('Description of what the automation does'), - mode: z.enum(['single', 'parallel', 'queued', 'restart']).optional().describe('How multiple triggerings are handled'), - trigger: z.array(z.any()).describe('List of triggers'), - condition: z.array(z.any()).optional().describe('List of conditions'), - action: z.array(z.any()).describe('List of actions'), - }).optional().describe('Automation configuration (required for create and update)'), - }), - execute: async (params: AutomationConfigParams) => { - try { - switch (params.action) { - case 'create': { - if (!params.config) { - throw new Error('Configuration is required for creating automation'); - } - - const response = await fetch(`${HASS_HOST}/api/config/automation/config`, { - method: 'POST', - headers: { - Authorization: `Bearer ${HASS_TOKEN}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(params.config), - }); - - if (!response.ok) { - throw new Error(`Failed to create automation: ${response.statusText}`); - } - - const responseData = await response.json() as { automation_id: string }; - return { - success: true, - message: 'Successfully created automation', - automation_id: responseData.automation_id, - }; - } - - case 'update': { - if (!params.automation_id || !params.config) { - throw new Error('Automation ID and configuration are required for updating automation'); - } - - const response = await fetch(`${HASS_HOST}/api/config/automation/config/${params.automation_id}`, { - method: 'PUT', - headers: { - Authorization: `Bearer ${HASS_TOKEN}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(params.config), - }); - - if (!response.ok) { - throw new Error(`Failed to update automation: ${response.statusText}`); - } - - const responseData = await response.json() as { automation_id: string }; - return { - success: true, - automation_id: responseData.automation_id, - message: 'Automation updated successfully' - }; - } - - case 'delete': { - if (!params.automation_id) { - throw new Error('Automation ID is required for deleting automation'); - } - - const response = await fetch(`${HASS_HOST}/api/config/automation/config/${params.automation_id}`, { - method: 'DELETE', - headers: { - Authorization: `Bearer ${HASS_TOKEN}`, - 'Content-Type': 'application/json', - }, - }); - - if (!response.ok) { - throw new Error(`Failed to delete automation: ${response.statusText}`); - } - - return { - success: true, - message: `Successfully deleted automation ${params.automation_id}`, - }; - } - - case 'duplicate': { - if (!params.automation_id) { - throw new Error('Automation ID is required for duplicating automation'); - } - - // First, get the existing automation config - const getResponse = await fetch(`${HASS_HOST}/api/config/automation/config/${params.automation_id}`, { - headers: { - Authorization: `Bearer ${HASS_TOKEN}`, - 'Content-Type': 'application/json', - }, - }); - - if (!getResponse.ok) { - throw new Error(`Failed to get automation config: ${getResponse.statusText}`); - } - - const config = await getResponse.json() as AutomationConfig; - config.alias = `${config.alias} (Copy)`; - - // Create new automation with modified config - const createResponse = await fetch(`${HASS_HOST}/api/config/automation/config`, { - method: 'POST', - headers: { - Authorization: `Bearer ${HASS_TOKEN}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(config), - }); - - if (!createResponse.ok) { - throw new Error(`Failed to create duplicate automation: ${createResponse.statusText}`); - } - - const newAutomation = await createResponse.json() as AutomationResponse; - return { - success: true, - message: `Successfully duplicated automation ${params.automation_id}`, - new_automation_id: newAutomation.automation_id, - }; - } - } - } catch (error) { - return { - success: false, - message: error instanceof Error ? error.message : 'Unknown error occurred', - }; - } - }, - }; - server.addTool(automationConfigTool); - tools.push(automationConfigTool); - - // Add SSE endpoint - const subscribeEventsTool = { - name: 'subscribe_events', - description: 'Subscribe to Home Assistant events via Server-Sent Events (SSE)', - parameters: z.object({ - token: z.string().describe('Authentication token (required)'), - events: z.array(z.string()).optional().describe('List of event types to subscribe to'), - entity_id: z.string().optional().describe('Specific entity ID to monitor for state changes'), - domain: z.string().optional().describe('Domain to monitor (e.g., "light", "switch", etc.)'), - }), - execute: async (params: SSEParams) => { - const clientId = uuidv4(); - - // Set up SSE headers - const responseHeaders = { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - 'Connection': 'keep-alive', - }; - - // Create SSE client - const client = { - id: clientId, - send: (data: string) => { - return { - headers: responseHeaders, - body: `data: ${data}\n\n`, - keepAlive: true - }; - } - }; - - // Add client to SSE manager with authentication - const sseClient = sseManager.addClient(client, params.token); - - if (!sseClient || !sseClient.authenticated) { - return { - success: false, - message: sseClient ? 'Authentication failed' : 'Maximum client limit reached' - }; - } - - // Subscribe to specific events if provided - if (params.events?.length) { - console.log(`Client ${clientId} subscribing to events:`, params.events); - for (const eventType of params.events) { - sseManager.subscribeToEvent(clientId, eventType); - } - } - - // Subscribe to specific entity if provided - if (params.entity_id) { - console.log(`Client ${clientId} subscribing to entity:`, params.entity_id); - sseManager.subscribeToEntity(clientId, params.entity_id); - } - - // Subscribe to domain if provided - if (params.domain) { - console.log(`Client ${clientId} subscribing to domain:`, params.domain); - sseManager.subscribeToDomain(clientId, params.domain); - } - - return { - headers: responseHeaders, - body: `data: ${JSON.stringify({ - type: 'connection', - status: 'connected', - id: clientId, - authenticated: true, - subscriptions: { - events: params.events || [], - entities: params.entity_id ? [params.entity_id] : [], - domains: params.domain ? [params.domain] : [] - }, - timestamp: new Date().toISOString() - })}\n\n`, - keepAlive: true - }; - } - }; - server.addTool(subscribeEventsTool); - tools.push(subscribeEventsTool); - - // Add statistics endpoint - const getSSEStatsTool = { - name: 'get_sse_stats', - description: 'Get SSE connection statistics', - parameters: z.object({ - token: z.string().describe('Authentication token (required)') - }), - execute: async (params: { token: string }) => { - if (params.token !== HASS_TOKEN) { - return { - success: false, - message: 'Authentication failed' - }; - } - - return { - success: true, - statistics: sseManager.getStatistics() - }; - } - }; - server.addTool(getSSEStatsTool); - tools.push(getSSEStatsTool); - - logger.debug('[server:init]', 'Initializing MCP Server...'); - - // Start the server - await server.start(); - logger.info('[server:init]', `MCP Server started on port ${PORT}`); - logger.info('[server:init]', 'Home Assistant server running on stdio'); - logger.info('[server:init]', 'SSE endpoints initialized'); - - // Log available endpoints using our tracked tools array - logger.info('[server:endpoints]', '\nAvailable API Endpoints:'); - tools.forEach((tool: Tool) => { - logger.info('[server:endpoints]', `- ${tool.name}: ${tool.description}`); - }); - - // Log SSE endpoints - logger.info('[server:endpoints]', '\nAvailable SSE Endpoints:'); - logger.info('[server:endpoints]', '- /subscribe_events'); - logger.info('[server:endpoints]', ' Parameters:'); - logger.info('[server:endpoints]', ' - token: Authentication token (required)'); - logger.info('[server:endpoints]', ' - events: List of event types to subscribe to (optional)'); - logger.info('[server:endpoints]', ' - entity_id: Specific entity ID to monitor (optional)'); - logger.info('[server:endpoints]', ' - domain: Domain to monitor (e.g., "light", "switch") (optional)'); - logger.info('[server:endpoints]', '\n- /get_sse_stats'); - logger.info('[server:endpoints]', ' Parameters:'); - logger.info('[server:endpoints]', ' - token: Authentication token (required)'); - - // Log successful initialization - logger.info('[server:init]', '\nServer initialization complete. Ready to handle requests.'); - - // Start the Express server - app.listen(PORT, () => { - logger.info('[server:init]', `Express server listening on port ${PORT}`); - }); -} - -main().catch(console.error); \ No newline at end of file +/** + * Start the server and listen for incoming connections + * The port is configured in the environment variables + */ +app.listen(APP_CONFIG.PORT, () => { + console.log(`Server is running on port ${APP_CONFIG.PORT}`); +}); \ No newline at end of file diff --git a/src/routes/health.routes.ts b/src/routes/health.routes.ts new file mode 100644 index 0000000..85b2bc7 --- /dev/null +++ b/src/routes/health.routes.ts @@ -0,0 +1,15 @@ +import { Router } from 'express'; +import { APP_CONFIG } from '../config/app.config.js'; + +const router = Router(); + +// Health check endpoint +router.get('/', (_req, res) => { + res.json({ + status: 'ok', + timestamp: new Date().toISOString(), + version: APP_CONFIG.VERSION + }); +}); + +export { router as healthRoutes }; \ No newline at end of file diff --git a/src/routes/index.ts b/src/routes/index.ts new file mode 100644 index 0000000..7e88121 --- /dev/null +++ b/src/routes/index.ts @@ -0,0 +1,39 @@ +/** + * API Routes Module + * + * This module exports the main router that combines all API routes + * into a single router instance. Each route group is mounted under + * its respective path prefix. + * + * @module routes + */ + +import { Router } from 'express'; +import { mcpRoutes } from './mcp.routes'; +import { sseRoutes } from './sse.routes'; +import { toolRoutes } from './tool.routes'; +import { healthRoutes } from './health.routes'; + +/** + * Create main router instance + * This router will be mounted at /api in the main application + */ +const router = Router(); + +/** + * Mount all route groups + * - /mcp: MCP schema and execution endpoints + * - /sse: Server-Sent Events endpoints + * - /tools: Tool management endpoints + * - /health: Health check endpoint + */ +router.use('/mcp', mcpRoutes); +router.use('/sse', sseRoutes); +router.use('/tools', toolRoutes); +router.use('/health', healthRoutes); + +/** + * Export the configured router + * This will be mounted in the main application + */ +export { router as apiRoutes }; \ No newline at end of file diff --git a/src/routes/mcp.routes.ts b/src/routes/mcp.routes.ts new file mode 100644 index 0000000..9569efd --- /dev/null +++ b/src/routes/mcp.routes.ts @@ -0,0 +1,51 @@ +import { Router } from 'express'; +import { MCP_SCHEMA } from '../mcp/schema.js'; +import { APP_CONFIG } from '../config/app.config.js'; +import { Tool } from '../types/index.js'; + +const router = Router(); + +// Array to track tools +const tools: Tool[] = []; + +// MCP schema endpoint - no auth required as it's just the schema +router.get('/', (_req, res) => { + res.json(MCP_SCHEMA); +}); + +// MCP execute endpoint - requires authentication +router.post('/execute', async (req, res) => { + try { + // Get token from Authorization header + const token = req.headers.authorization?.replace('Bearer ', ''); + + if (!token || token !== APP_CONFIG.HASS_TOKEN) { + return res.status(401).json({ + success: false, + message: 'Unauthorized - Invalid token' + }); + } + + const { tool: toolName, parameters } = req.body; + + // Find the requested tool + const tool = tools.find(t => t.name === toolName); + if (!tool) { + return res.status(404).json({ + success: false, + message: `Tool '${toolName}' not found` + }); + } + + // Execute the tool with the provided parameters + const result = await tool.execute(parameters); + res.json(result); + } catch (error) { + res.status(500).json({ + success: false, + message: error instanceof Error ? error.message : 'Unknown error occurred' + }); + } +}); + +export { router as mcpRoutes }; \ No newline at end of file diff --git a/src/routes/sse.routes.ts b/src/routes/sse.routes.ts new file mode 100644 index 0000000..d0936e7 --- /dev/null +++ b/src/routes/sse.routes.ts @@ -0,0 +1,99 @@ +import { Router } from 'express'; +import { v4 as uuidv4 } from 'uuid'; +import { sseManager } from '../sse/index.js'; +import { TokenManager } from '../security/index.js'; + +const router = Router(); + +// SSE endpoints +router.get('/subscribe', (req, res) => { + try { + // Get token from query parameter + const token = req.query.token?.toString(); + + if (!token || !TokenManager.validateToken(token)) { + return res.status(401).json({ + success: false, + message: 'Unauthorized - Invalid token' + }); + } + + // Set SSE headers + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + 'Access-Control-Allow-Origin': '*' + }); + + // Send initial connection message + res.write(`data: ${JSON.stringify({ + type: 'connection', + status: 'connected', + timestamp: new Date().toISOString() + })}\n\n`); + + const clientId = uuidv4(); + const client = { + id: clientId, + send: (data: string) => { + res.write(`data: ${data}\n\n`); + } + }; + + // Add client to SSE manager + const sseClient = sseManager.addClient(client, token); + if (!sseClient || !sseClient.authenticated) { + res.write(`data: ${JSON.stringify({ + type: 'error', + message: sseClient ? 'Authentication failed' : 'Maximum client limit reached', + timestamp: new Date().toISOString() + })}\n\n`); + return res.end(); + } + + // Subscribe to events if specified + const events = req.query.events?.toString().split(',').filter(Boolean); + if (events?.length) { + events.forEach(event => sseManager.subscribeToEvent(clientId, event)); + } + + // Subscribe to entity if specified + const entityId = req.query.entity_id?.toString(); + if (entityId) { + sseManager.subscribeToEntity(clientId, entityId); + } + + // Subscribe to domain if specified + const domain = req.query.domain?.toString(); + if (domain) { + sseManager.subscribeToDomain(clientId, domain); + } + + // Handle client disconnect + req.on('close', () => { + sseManager.removeClient(clientId); + }); + + } catch (error) { + res.status(500).json({ + success: false, + message: error instanceof Error ? error.message : 'Unknown error occurred' + }); + } +}); + +// Get SSE stats endpoint +router.get('/stats', async (req, res) => { + try { + const stats = await sseManager.getStats(); + res.json(stats); + } catch (error) { + res.status(500).json({ + success: false, + message: error instanceof Error ? error.message : 'Unknown error occurred' + }); + } +}); + +export { router as sseRoutes }; \ No newline at end of file diff --git a/src/routes/tool.routes.ts b/src/routes/tool.routes.ts new file mode 100644 index 0000000..a9d4394 --- /dev/null +++ b/src/routes/tool.routes.ts @@ -0,0 +1,75 @@ +import { Router } from 'express'; +import { APP_CONFIG } from '../config/app.config.js'; +import { Tool } from '../types/index.js'; + +const router = Router(); + +// Array to track tools +const tools: Tool[] = []; + +// List devices endpoint +router.get('/devices', async (req, res) => { + try { + // Get token from Authorization header + const token = req.headers.authorization?.replace('Bearer ', ''); + + if (!token || token !== APP_CONFIG.HASS_TOKEN) { + return res.status(401).json({ + success: false, + message: 'Unauthorized - Invalid token' + }); + } + + const tool = tools.find(t => t.name === 'list_devices'); + if (!tool) { + return res.status(404).json({ + success: false, + message: 'Tool not found' + }); + } + + const result = await tool.execute({ token }); + res.json(result); + } catch (error) { + res.status(500).json({ + success: false, + message: error instanceof Error ? error.message : 'Unknown error occurred' + }); + } +}); + +// Control device endpoint +router.post('/control', async (req, res) => { + try { + // Get token from Authorization header + const token = req.headers.authorization?.replace('Bearer ', ''); + + if (!token || token !== APP_CONFIG.HASS_TOKEN) { + return res.status(401).json({ + success: false, + message: 'Unauthorized - Invalid token' + }); + } + + const tool = tools.find(t => t.name === 'control'); + if (!tool) { + return res.status(404).json({ + success: false, + message: 'Tool not found' + }); + } + + const result = await tool.execute({ + ...req.body, + token + }); + res.json(result); + } catch (error) { + res.status(500).json({ + success: false, + message: error instanceof Error ? error.message : 'Unknown error occurred' + }); + } +}); + +export { router as toolRoutes }; \ No newline at end of file diff --git a/src/tools/addon.tool.ts b/src/tools/addon.tool.ts new file mode 100644 index 0000000..8e6c5fd --- /dev/null +++ b/src/tools/addon.tool.ts @@ -0,0 +1,106 @@ +import { z } from 'zod'; +import { Tool, AddonParams, HassAddonResponse, HassAddonInfoResponse } from '../types/index.js'; +import { APP_CONFIG } from '../config/app.config.js'; + +export const addonTool: Tool = { + name: 'addon', + description: 'Manage Home Assistant add-ons', + parameters: z.object({ + action: z.enum(['list', 'info', 'install', 'uninstall', 'start', 'stop', 'restart']) + .describe('Action to perform with add-on'), + slug: z.string().optional().describe('Add-on slug (required for all actions except list)'), + version: z.string().optional().describe('Version to install (only for install action)'), + }), + execute: async (params: AddonParams) => { + try { + if (params.action === 'list') { + const response = await fetch(`${APP_CONFIG.HASS_HOST}/api/hassio/store`, { + headers: { + Authorization: `Bearer ${APP_CONFIG.HASS_TOKEN}`, + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + throw new Error(`Failed to fetch add-ons: ${response.statusText}`); + } + + const data = await response.json() as HassAddonResponse; + return { + success: true, + addons: data.data.addons.map((addon) => ({ + name: addon.name, + slug: addon.slug, + description: addon.description, + version: addon.version, + installed: addon.installed, + available: addon.available, + state: addon.state, + })), + }; + } else { + if (!params.slug) { + throw new Error('Add-on slug is required for this action'); + } + + let endpoint = ''; + let method = 'GET'; + const body: Record = {}; + + switch (params.action) { + case 'info': + endpoint = `/api/hassio/addons/${params.slug}/info`; + break; + case 'install': + endpoint = `/api/hassio/addons/${params.slug}/install`; + method = 'POST'; + if (params.version) { + body.version = params.version; + } + break; + case 'uninstall': + endpoint = `/api/hassio/addons/${params.slug}/uninstall`; + method = 'POST'; + break; + case 'start': + endpoint = `/api/hassio/addons/${params.slug}/start`; + method = 'POST'; + break; + case 'stop': + endpoint = `/api/hassio/addons/${params.slug}/stop`; + method = 'POST'; + break; + case 'restart': + endpoint = `/api/hassio/addons/${params.slug}/restart`; + method = 'POST'; + break; + } + + const response = await fetch(`${APP_CONFIG.HASS_HOST}${endpoint}`, { + method, + headers: { + Authorization: `Bearer ${APP_CONFIG.HASS_TOKEN}`, + 'Content-Type': 'application/json', + }, + ...(Object.keys(body).length > 0 && { body: JSON.stringify(body) }), + }); + + if (!response.ok) { + throw new Error(`Failed to ${params.action} add-on: ${response.statusText}`); + } + + const data = await response.json() as HassAddonInfoResponse; + return { + success: true, + message: `Successfully ${params.action}ed add-on ${params.slug}`, + data: data.data, + }; + } + } catch (error) { + return { + success: false, + message: error instanceof Error ? error.message : 'Unknown error occurred', + }; + } + }, +}; \ No newline at end of file diff --git a/src/tools/automation-config.tool.ts b/src/tools/automation-config.tool.ts new file mode 100644 index 0000000..6c9515b --- /dev/null +++ b/src/tools/automation-config.tool.ts @@ -0,0 +1,150 @@ +import { z } from 'zod'; +import { Tool, AutomationConfigParams, AutomationConfig, AutomationResponse } from '../types/index.js'; +import { APP_CONFIG } from '../config/app.config.js'; + +export const automationConfigTool: Tool = { + name: 'automation_config', + description: 'Advanced automation configuration and management', + parameters: z.object({ + action: z.enum(['create', 'update', 'delete', 'duplicate']) + .describe('Action to perform with automation config'), + automation_id: z.string().optional() + .describe('Automation ID (required for update, delete, and duplicate)'), + config: z.object({ + alias: z.string().describe('Friendly name for the automation'), + description: z.string().optional().describe('Description of what the automation does'), + mode: z.enum(['single', 'parallel', 'queued', 'restart']).optional() + .describe('How multiple triggerings are handled'), + trigger: z.array(z.any()).describe('List of triggers'), + condition: z.array(z.any()).optional().describe('List of conditions'), + action: z.array(z.any()).describe('List of actions'), + }).optional().describe('Automation configuration (required for create and update)'), + }), + execute: async (params: AutomationConfigParams) => { + try { + switch (params.action) { + case 'create': { + if (!params.config) { + throw new Error('Configuration is required for creating automation'); + } + + const response = await fetch(`${APP_CONFIG.HASS_HOST}/api/config/automation/config`, { + method: 'POST', + headers: { + Authorization: `Bearer ${APP_CONFIG.HASS_TOKEN}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(params.config), + }); + + if (!response.ok) { + throw new Error(`Failed to create automation: ${response.statusText}`); + } + + const responseData = await response.json() as { automation_id: string }; + return { + success: true, + message: 'Successfully created automation', + automation_id: responseData.automation_id, + }; + } + + case 'update': { + if (!params.automation_id || !params.config) { + throw new Error('Automation ID and configuration are required for updating automation'); + } + + const response = await fetch(`${APP_CONFIG.HASS_HOST}/api/config/automation/config/${params.automation_id}`, { + method: 'PUT', + headers: { + Authorization: `Bearer ${APP_CONFIG.HASS_TOKEN}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(params.config), + }); + + if (!response.ok) { + throw new Error(`Failed to update automation: ${response.statusText}`); + } + + const responseData = await response.json() as { automation_id: string }; + return { + success: true, + automation_id: responseData.automation_id, + message: 'Automation updated successfully' + }; + } + + case 'delete': { + if (!params.automation_id) { + throw new Error('Automation ID is required for deleting automation'); + } + + const response = await fetch(`${APP_CONFIG.HASS_HOST}/api/config/automation/config/${params.automation_id}`, { + method: 'DELETE', + headers: { + Authorization: `Bearer ${APP_CONFIG.HASS_TOKEN}`, + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + throw new Error(`Failed to delete automation: ${response.statusText}`); + } + + return { + success: true, + message: `Successfully deleted automation ${params.automation_id}`, + }; + } + + case 'duplicate': { + if (!params.automation_id) { + throw new Error('Automation ID is required for duplicating automation'); + } + + // First, get the existing automation config + const getResponse = await fetch(`${APP_CONFIG.HASS_HOST}/api/config/automation/config/${params.automation_id}`, { + headers: { + Authorization: `Bearer ${APP_CONFIG.HASS_TOKEN}`, + 'Content-Type': 'application/json', + }, + }); + + if (!getResponse.ok) { + throw new Error(`Failed to get automation config: ${getResponse.statusText}`); + } + + const config = await getResponse.json() as AutomationConfig; + config.alias = `${config.alias} (Copy)`; + + // Create new automation with modified config + const createResponse = await fetch(`${APP_CONFIG.HASS_HOST}/api/config/automation/config`, { + method: 'POST', + headers: { + Authorization: `Bearer ${APP_CONFIG.HASS_TOKEN}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(config), + }); + + if (!createResponse.ok) { + throw new Error(`Failed to create duplicate automation: ${createResponse.statusText}`); + } + + const newAutomation = await createResponse.json() as AutomationResponse; + return { + success: true, + message: `Successfully duplicated automation ${params.automation_id}`, + new_automation_id: newAutomation.automation_id, + }; + } + } + } catch (error) { + return { + success: false, + message: error instanceof Error ? error.message : 'Unknown error occurred', + }; + } + }, +}; \ No newline at end of file diff --git a/src/tools/automation.tool.ts b/src/tools/automation.tool.ts new file mode 100644 index 0000000..0e43f0c --- /dev/null +++ b/src/tools/automation.tool.ts @@ -0,0 +1,73 @@ +import { z } from 'zod'; +import { Tool, AutomationParams, HassState, AutomationResponse } from '../types/index.js'; +import { APP_CONFIG } from '../config/app.config.js'; + +export const automationTool: Tool = { + name: 'automation', + description: 'Manage Home Assistant automations', + parameters: z.object({ + action: z.enum(['list', 'toggle', 'trigger']).describe('Action to perform with automation'), + automation_id: z.string().optional().describe('Automation ID (required for toggle and trigger actions)'), + }), + execute: async (params: AutomationParams) => { + try { + if (params.action === 'list') { + const response = await fetch(`${APP_CONFIG.HASS_HOST}/api/states`, { + headers: { + Authorization: `Bearer ${APP_CONFIG.HASS_TOKEN}`, + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + throw new Error(`Failed to fetch automations: ${response.statusText}`); + } + + const states = (await response.json()) as HassState[]; + const automations = states.filter((state) => state.entity_id.startsWith('automation.')); + + return { + success: true, + automations: automations.map((automation) => ({ + entity_id: automation.entity_id, + name: automation.attributes.friendly_name || automation.entity_id.split('.')[1], + state: automation.state, + last_triggered: automation.attributes.last_triggered, + })), + }; + } else { + if (!params.automation_id) { + throw new Error('Automation ID is required for toggle and trigger actions'); + } + + const service = params.action === 'toggle' ? 'toggle' : 'trigger'; + const response = await fetch(`${APP_CONFIG.HASS_HOST}/api/services/automation/${service}`, { + method: 'POST', + headers: { + Authorization: `Bearer ${APP_CONFIG.HASS_TOKEN}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + entity_id: params.automation_id, + }), + }); + + if (!response.ok) { + throw new Error(`Failed to ${service} automation: ${response.statusText}`); + } + + const responseData = await response.json() as AutomationResponse; + return { + success: true, + message: `Successfully ${service}d automation ${params.automation_id}`, + automation_id: responseData.automation_id, + }; + } + } catch (error) { + return { + success: false, + message: error instanceof Error ? error.message : 'Unknown error occurred', + }; + } + }, +}; \ No newline at end of file diff --git a/src/tools/control.tool.ts b/src/tools/control.tool.ts new file mode 100644 index 0000000..01aae6d --- /dev/null +++ b/src/tools/control.tool.ts @@ -0,0 +1,139 @@ +import { z } from 'zod'; +import { Tool, CommandParams } from '../types/index.js'; +import { APP_CONFIG } from '../config/app.config.js'; +import { DomainSchema } from '../schemas.js'; + +// Define command constants +const commonCommands = ['turn_on', 'turn_off', 'toggle'] as const; +const coverCommands = [...commonCommands, 'open', 'close', 'stop', 'set_position', 'set_tilt_position'] as const; +const climateCommands = [...commonCommands, 'set_temperature', 'set_hvac_mode', 'set_fan_mode', 'set_humidity'] as const; + +export const controlTool: Tool = { + name: 'control', + description: 'Control Home Assistant devices and services', + parameters: z.object({ + command: z.enum([...commonCommands, ...coverCommands, ...climateCommands]) + .describe('The command to execute'), + entity_id: z.string().describe('The entity ID to control'), + // Common parameters + state: z.string().optional().describe('The desired state for the entity'), + // Light parameters + brightness: z.number().min(0).max(255).optional() + .describe('Brightness level for lights (0-255)'), + color_temp: z.number().optional() + .describe('Color temperature for lights'), + rgb_color: z.tuple([z.number(), z.number(), z.number()]).optional() + .describe('RGB color values'), + // Cover parameters + position: z.number().min(0).max(100).optional() + .describe('Position for covers (0-100)'), + tilt_position: z.number().min(0).max(100).optional() + .describe('Tilt position for covers (0-100)'), + // Climate parameters + temperature: z.number().optional() + .describe('Target temperature for climate devices'), + target_temp_high: z.number().optional() + .describe('Target high temperature for climate devices'), + target_temp_low: z.number().optional() + .describe('Target low temperature for climate devices'), + hvac_mode: z.enum(['off', 'heat', 'cool', 'heat_cool', 'auto', 'dry', 'fan_only']).optional() + .describe('HVAC mode for climate devices'), + fan_mode: z.enum(['auto', 'low', 'medium', 'high']).optional() + .describe('Fan mode for climate devices'), + humidity: z.number().min(0).max(100).optional() + .describe('Target humidity for climate devices') + }), + execute: async (params: CommandParams) => { + try { + const domain = params.entity_id.split('.')[0] as keyof typeof DomainSchema.Values; + + if (!Object.values(DomainSchema.Values).includes(domain)) { + throw new Error(`Unsupported domain: ${domain}`); + } + + const service = params.command; + const serviceData: Record = { + entity_id: params.entity_id + }; + + // Handle domain-specific parameters + switch (domain) { + case 'light': + if (params.brightness !== undefined) { + serviceData.brightness = params.brightness; + } + if (params.color_temp !== undefined) { + serviceData.color_temp = params.color_temp; + } + if (params.rgb_color !== undefined) { + serviceData.rgb_color = params.rgb_color; + } + break; + + case 'cover': + if (service === 'set_position' && params.position !== undefined) { + serviceData.position = params.position; + } + if (service === 'set_tilt_position' && params.tilt_position !== undefined) { + serviceData.tilt_position = params.tilt_position; + } + break; + + case 'climate': + if (service === 'set_temperature') { + if (params.temperature !== undefined) { + serviceData.temperature = params.temperature; + } + if (params.target_temp_high !== undefined) { + serviceData.target_temp_high = params.target_temp_high; + } + if (params.target_temp_low !== undefined) { + serviceData.target_temp_low = params.target_temp_low; + } + } + if (service === 'set_hvac_mode' && params.hvac_mode !== undefined) { + serviceData.hvac_mode = params.hvac_mode; + } + if (service === 'set_fan_mode' && params.fan_mode !== undefined) { + serviceData.fan_mode = params.fan_mode; + } + if (service === 'set_humidity' && params.humidity !== undefined) { + serviceData.humidity = params.humidity; + } + break; + + case 'switch': + case 'contact': + // These domains only support basic operations (turn_on, turn_off, toggle) + break; + + default: + throw new Error(`Unsupported operation for domain: ${domain}`); + } + + // Call Home Assistant service + const response = await fetch(`${APP_CONFIG.HASS_HOST}/api/services/${domain}/${service}`, { + method: 'POST', + headers: { + Authorization: `Bearer ${APP_CONFIG.HASS_TOKEN}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(serviceData), + }); + + if (!response.ok) { + throw new Error(`Failed to execute ${service} for ${params.entity_id}: ${response.statusText}`); + } + + return { + success: true, + message: `Successfully executed ${service} for ${params.entity_id}` + }; + } catch (error) { + return { + success: false, + message: error instanceof Error ? error.message : 'Unknown error occurred' + }; + } + } +}; \ No newline at end of file diff --git a/src/tools/history.tool.ts b/src/tools/history.tool.ts new file mode 100644 index 0000000..9e13623 --- /dev/null +++ b/src/tools/history.tool.ts @@ -0,0 +1,53 @@ +import { z } from 'zod'; +import { Tool, HistoryParams } from '../types/index.js'; +import { APP_CONFIG } from '../config/app.config.js'; + +export const historyTool: Tool = { + name: 'get_history', + description: 'Get state history for Home Assistant entities', + parameters: z.object({ + entity_id: z.string().describe('The entity ID to get history for'), + start_time: z.string().optional().describe('Start time in ISO format. Defaults to 24 hours ago'), + end_time: z.string().optional().describe('End time in ISO format. Defaults to now'), + minimal_response: z.boolean().optional().describe('Return minimal response to reduce data size'), + significant_changes_only: z.boolean().optional().describe('Only return significant state changes'), + }), + execute: async (params: HistoryParams) => { + try { + const now = new Date(); + const startTime = params.start_time ? new Date(params.start_time) : new Date(now.getTime() - 24 * 60 * 60 * 1000); + const endTime = params.end_time ? new Date(params.end_time) : now; + + // Build query parameters + const queryParams = new URLSearchParams({ + filter_entity_id: params.entity_id, + minimal_response: String(!!params.minimal_response), + significant_changes_only: String(!!params.significant_changes_only), + start_time: startTime.toISOString(), + end_time: endTime.toISOString(), + }); + + const response = await fetch(`${APP_CONFIG.HASS_HOST}/api/history/period/${startTime.toISOString()}?${queryParams.toString()}`, { + headers: { + Authorization: `Bearer ${APP_CONFIG.HASS_TOKEN}`, + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + throw new Error(`Failed to fetch history: ${response.statusText}`); + } + + const history = await response.json(); + return { + success: true, + history, + }; + } catch (error) { + return { + success: false, + message: error instanceof Error ? error.message : 'Unknown error occurred', + }; + } + }, +}; \ No newline at end of file diff --git a/src/tools/index.ts b/src/tools/index.ts index 1b13db3..e52c67b 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -1,5 +1,17 @@ import { z } from 'zod'; import { get_hass } from '../hass/index.js'; +import { Tool } from '../types/index.js'; +import { listDevicesTool } from './list-devices.tool.js'; +import { controlTool } from './control.tool.js'; +import { historyTool } from './history.tool.js'; +import { sceneTool } from './scene.tool.js'; +import { notifyTool } from './notify.tool.js'; +import { automationTool } from './automation.tool.js'; +import { addonTool } from './addon.tool.js'; +import { packageTool } from './package.tool.js'; +import { automationConfigTool } from './automation-config.tool.js'; +import { subscribeEventsTool } from './subscribe-events.tool.js'; +import { getSSEStatsTool } from './sse-stats.tool.js'; // Tool category types export enum ToolCategory { @@ -188,4 +200,44 @@ export class LightControlTool implements EnhancedTool { // Implementation here return { success: true }; } -} \ No newline at end of file +} + +// Array to track all tools +const tools: Tool[] = [ + listDevicesTool, + controlTool, + historyTool, + sceneTool, + notifyTool, + automationTool, + addonTool, + packageTool, + automationConfigTool, + subscribeEventsTool, + getSSEStatsTool +]; + +// Function to get a tool by name +export function getToolByName(name: string): Tool | undefined { + return tools.find(tool => tool.name === name); +} + +// Function to get all tools +export function getAllTools(): Tool[] { + return [...tools]; +} + +// Export all tools individually +export { + listDevicesTool, + controlTool, + historyTool, + sceneTool, + notifyTool, + automationTool, + addonTool, + packageTool, + automationConfigTool, + subscribeEventsTool, + getSSEStatsTool +}; \ No newline at end of file diff --git a/src/tools/list-devices.tool.ts b/src/tools/list-devices.tool.ts new file mode 100644 index 0000000..74f7f21 --- /dev/null +++ b/src/tools/list-devices.tool.ts @@ -0,0 +1,46 @@ +import { z } from 'zod'; +import { Tool } from '../types/index.js'; +import { APP_CONFIG } from '../config/app.config.js'; +import { HassState } from '../types/index.js'; + +export const listDevicesTool: Tool = { + name: 'list_devices', + description: 'List all available Home Assistant devices', + parameters: z.object({}).describe('No parameters required'), + execute: async () => { + try { + const response = await fetch(`${APP_CONFIG.HASS_HOST}/api/states`, { + headers: { + Authorization: `Bearer ${APP_CONFIG.HASS_TOKEN}`, + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + throw new Error(`Failed to fetch devices: ${response.statusText}`); + } + + const states = await response.json() as HassState[]; + const devices: Record = {}; + + // Group devices by domain + states.forEach(state => { + const [domain] = state.entity_id.split('.'); + if (!devices[domain]) { + devices[domain] = []; + } + devices[domain].push(state); + }); + + return { + success: true, + devices + }; + } catch (error) { + return { + success: false, + message: error instanceof Error ? error.message : 'Unknown error occurred' + }; + } + } +}; \ No newline at end of file diff --git a/src/tools/notify.tool.ts b/src/tools/notify.tool.ts new file mode 100644 index 0000000..34be2b0 --- /dev/null +++ b/src/tools/notify.tool.ts @@ -0,0 +1,47 @@ +import { z } from 'zod'; +import { Tool, NotifyParams } from '../types/index.js'; +import { APP_CONFIG } from '../config/app.config.js'; + +export const notifyTool: Tool = { + name: 'notify', + description: 'Send notifications through Home Assistant', + parameters: z.object({ + message: z.string().describe('The notification message'), + title: z.string().optional().describe('The notification title'), + target: z.string().optional().describe('Specific notification target (e.g., mobile_app_phone)'), + data: z.record(z.any()).optional().describe('Additional notification data'), + }), + execute: async (params: NotifyParams) => { + try { + const service = params.target ? `notify.${params.target}` : 'notify.notify'; + const [domain, service_name] = service.split('.'); + + const response = await fetch(`${APP_CONFIG.HASS_HOST}/api/services/${domain}/${service_name}`, { + method: 'POST', + headers: { + Authorization: `Bearer ${APP_CONFIG.HASS_TOKEN}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + message: params.message, + title: params.title, + data: params.data, + }), + }); + + if (!response.ok) { + throw new Error(`Failed to send notification: ${response.statusText}`); + } + + return { + success: true, + message: 'Notification sent successfully', + }; + } catch (error) { + return { + success: false, + message: error instanceof Error ? error.message : 'Unknown error occurred', + }; + } + }, +}; \ No newline at end of file diff --git a/src/tools/package.tool.ts b/src/tools/package.tool.ts new file mode 100644 index 0000000..322fcc0 --- /dev/null +++ b/src/tools/package.tool.ts @@ -0,0 +1,88 @@ +import { z } from 'zod'; +import { Tool, PackageParams, HacsResponse } from '../types/index.js'; +import { APP_CONFIG } from '../config/app.config.js'; + +export const packageTool: Tool = { + name: 'package', + description: 'Manage HACS packages and custom components', + parameters: z.object({ + action: z.enum(['list', 'install', 'uninstall', 'update']) + .describe('Action to perform with package'), + category: z.enum(['integration', 'plugin', 'theme', 'python_script', 'appdaemon', 'netdaemon']) + .describe('Package category'), + repository: z.string().optional().describe('Repository URL or name (required for install)'), + version: z.string().optional().describe('Version to install'), + }), + execute: async (params: PackageParams) => { + try { + const hacsBase = `${APP_CONFIG.HASS_HOST}/api/hacs`; + + if (params.action === 'list') { + const response = await fetch(`${hacsBase}/repositories?category=${params.category}`, { + headers: { + Authorization: `Bearer ${APP_CONFIG.HASS_TOKEN}`, + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + throw new Error(`Failed to fetch packages: ${response.statusText}`); + } + + const data = await response.json() as HacsResponse; + return { + success: true, + packages: data.repositories, + }; + } else { + if (!params.repository) { + throw new Error('Repository is required for this action'); + } + + let endpoint = ''; + const body: Record = { + category: params.category, + repository: params.repository, + }; + + switch (params.action) { + case 'install': + endpoint = '/repository/install'; + if (params.version) { + body.version = params.version; + } + break; + case 'uninstall': + endpoint = '/repository/uninstall'; + break; + case 'update': + endpoint = '/repository/update'; + break; + } + + const response = await fetch(`${hacsBase}${endpoint}`, { + method: 'POST', + headers: { + Authorization: `Bearer ${APP_CONFIG.HASS_TOKEN}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + throw new Error(`Failed to ${params.action} package: ${response.statusText}`); + } + + return { + success: true, + message: `Successfully ${params.action}ed package ${params.repository}`, + }; + } + } catch (error) { + return { + success: false, + message: error instanceof Error ? error.message : 'Unknown error occurred', + }; + } + }, +}; \ No newline at end of file diff --git a/src/tools/scene.tool.ts b/src/tools/scene.tool.ts new file mode 100644 index 0000000..f3d50d0 --- /dev/null +++ b/src/tools/scene.tool.ts @@ -0,0 +1,71 @@ +import { z } from 'zod'; +import { Tool, SceneParams, HassState } from '../types/index.js'; +import { APP_CONFIG } from '../config/app.config.js'; + +export const sceneTool: Tool = { + name: 'scene', + description: 'Manage and activate Home Assistant scenes', + parameters: z.object({ + action: z.enum(['list', 'activate']).describe('Action to perform with scenes'), + scene_id: z.string().optional().describe('Scene ID to activate (required for activate action)'), + }), + execute: async (params: SceneParams) => { + try { + if (params.action === 'list') { + const response = await fetch(`${APP_CONFIG.HASS_HOST}/api/states`, { + headers: { + Authorization: `Bearer ${APP_CONFIG.HASS_TOKEN}`, + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + throw new Error(`Failed to fetch scenes: ${response.statusText}`); + } + + const states = (await response.json()) as HassState[]; + const scenes = states.filter((state) => state.entity_id.startsWith('scene.')); + + return { + success: true, + scenes: scenes.map((scene) => ({ + entity_id: scene.entity_id, + name: scene.attributes.friendly_name || scene.entity_id.split('.')[1], + description: scene.attributes.description, + })), + }; + } else if (params.action === 'activate') { + if (!params.scene_id) { + throw new Error('Scene ID is required for activate action'); + } + + const response = await fetch(`${APP_CONFIG.HASS_HOST}/api/services/scene/turn_on`, { + method: 'POST', + headers: { + Authorization: `Bearer ${APP_CONFIG.HASS_TOKEN}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + entity_id: params.scene_id, + }), + }); + + if (!response.ok) { + throw new Error(`Failed to activate scene: ${response.statusText}`); + } + + return { + success: true, + message: `Successfully activated scene ${params.scene_id}`, + }; + } + + throw new Error('Invalid action specified'); + } catch (error) { + return { + success: false, + message: error instanceof Error ? error.message : 'Unknown error occurred', + }; + } + }, +}; \ No newline at end of file diff --git a/src/tools/sse-stats.tool.ts b/src/tools/sse-stats.tool.ts new file mode 100644 index 0000000..92a9083 --- /dev/null +++ b/src/tools/sse-stats.tool.ts @@ -0,0 +1,33 @@ +import { z } from 'zod'; +import { Tool } from '../types/index.js'; +import { APP_CONFIG } from '../config/app.config.js'; +import { sseManager } from '../sse/index.js'; + +export const getSSEStatsTool: Tool = { + name: 'get_sse_stats', + description: 'Get SSE connection statistics', + parameters: z.object({ + token: z.string().describe('Authentication token (required)') + }), + execute: async (params: { token: string }) => { + try { + if (params.token !== APP_CONFIG.HASS_TOKEN) { + return { + success: false, + message: 'Authentication failed' + }; + } + + const stats = await sseManager.getStatistics(); + return { + success: true, + statistics: stats + }; + } catch (error) { + return { + success: false, + message: error instanceof Error ? error.message : 'Unknown error occurred' + }; + } + } +}; \ No newline at end of file diff --git a/src/tools/subscribe-events.tool.ts b/src/tools/subscribe-events.tool.ts new file mode 100644 index 0000000..8a3fdbe --- /dev/null +++ b/src/tools/subscribe-events.tool.ts @@ -0,0 +1,84 @@ +import { z } from 'zod'; +import { v4 as uuidv4 } from 'uuid'; +import { Tool, SSEParams } from '../types/index.js'; +import { sseManager } from '../sse/index.js'; + +export const subscribeEventsTool: Tool = { + name: 'subscribe_events', + description: 'Subscribe to Home Assistant events via Server-Sent Events (SSE)', + parameters: z.object({ + token: z.string().describe('Authentication token (required)'), + events: z.array(z.string()).optional().describe('List of event types to subscribe to'), + entity_id: z.string().optional().describe('Specific entity ID to monitor for state changes'), + domain: z.string().optional().describe('Domain to monitor (e.g., "light", "switch", etc.)'), + }), + execute: async (params: SSEParams) => { + const clientId = uuidv4(); + + // Set up SSE headers + const responseHeaders = { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + }; + + // Create SSE client + const client = { + id: clientId, + send: (data: string) => { + return { + headers: responseHeaders, + body: `data: ${data}\n\n`, + keepAlive: true + }; + } + }; + + // Add client to SSE manager with authentication + const sseClient = sseManager.addClient(client, params.token); + + if (!sseClient || !sseClient.authenticated) { + return { + success: false, + message: sseClient ? 'Authentication failed' : 'Maximum client limit reached' + }; + } + + // Subscribe to specific events if provided + if (params.events?.length) { + console.log(`Client ${clientId} subscribing to events:`, params.events); + for (const eventType of params.events) { + sseManager.subscribeToEvent(clientId, eventType); + } + } + + // Subscribe to specific entity if provided + if (params.entity_id) { + console.log(`Client ${clientId} subscribing to entity:`, params.entity_id); + sseManager.subscribeToEntity(clientId, params.entity_id); + } + + // Subscribe to domain if provided + if (params.domain) { + console.log(`Client ${clientId} subscribing to domain:`, params.domain); + sseManager.subscribeToDomain(clientId, params.domain); + } + + return { + headers: responseHeaders, + body: `data: ${JSON.stringify({ + type: 'connection', + status: 'connected', + id: clientId, + authenticated: true, + subscriptions: { + events: params.events || [], + entities: params.entity_id ? [params.entity_id] : [], + domains: params.domain ? [params.domain] : [] + }, + timestamp: new Date().toISOString() + })}\n\n`, + keepAlive: true + }; + } +}; \ No newline at end of file diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..1d1bc73 --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,337 @@ +import { z } from 'zod'; + +/** + * Interface for a tool that can be executed by the MCP + * @interface Tool + */ +export interface Tool { + /** Unique name identifier for the tool */ + name: string; + /** Description of what the tool does */ + description: string; + /** Zod schema for validating tool parameters */ + parameters: z.ZodType; + /** Function to execute the tool with the given parameters */ + execute: (params: any) => Promise; +} + +/** + * Parameters for controlling Home Assistant devices + * @interface CommandParams + */ +export interface CommandParams { + /** Command to execute (e.g., turn_on, turn_off) */ + command: string; + /** Entity ID to control */ + entity_id: string; + /** Common parameters */ + state?: string; + /** Light parameters */ + brightness?: number; + color_temp?: number; + rgb_color?: [number, number, number]; + /** Cover parameters */ + position?: number; + tilt_position?: number; + /** Climate parameters */ + temperature?: number; + target_temp_high?: number; + target_temp_low?: number; + hvac_mode?: string; + fan_mode?: string; + humidity?: number; +} + +/** + * Home Assistant entity interface + * @interface HassEntity + */ +export interface HassEntity { + /** Entity ID in format domain.name */ + entity_id: string; + /** Current state of the entity */ + state: string; + /** Entity attributes */ + attributes: Record; + /** Last state change timestamp */ + last_changed?: string; + /** Last update timestamp */ + last_updated?: string; + /** Context information */ + context?: { + id: string; + parent_id?: string; + user_id?: string; + }; +} + +/** + * Home Assistant state interface + * @interface HassState + */ +export interface HassState { + /** Entity ID in format domain.name */ + entity_id: string; + /** Current state of the entity */ + state: string; + /** Entity attributes */ + attributes: { + /** Human-readable name */ + friendly_name?: string; + /** Entity description */ + description?: string; + /** Additional attributes */ + [key: string]: any; + }; +} + +/** + * Home Assistant add-on interface + * @interface HassAddon + */ +export interface HassAddon { + /** Add-on name */ + name: string; + /** Add-on slug identifier */ + slug: string; + /** Add-on description */ + description: string; + /** Add-on version */ + version: string; + /** Whether the add-on is installed */ + installed: boolean; + /** Whether the add-on is available */ + available: boolean; + /** Current state of the add-on */ + state: string; +} + +/** + * Response from Home Assistant add-on API + * @interface HassAddonResponse + */ +export interface HassAddonResponse { + /** Response data */ + data: { + /** List of add-ons */ + addons: HassAddon[]; + }; +} + +/** + * Response from Home Assistant add-on info API + * @interface HassAddonInfoResponse + */ +export interface HassAddonInfoResponse { + /** Response data */ + data: { + /** Add-on name */ + name: string; + /** Add-on slug identifier */ + slug: string; + /** Add-on description */ + description: string; + /** Add-on version */ + version: string; + /** Current state */ + state: string; + /** Status information */ + status: string; + /** Add-on options */ + options: Record; + /** Additional properties */ + [key: string]: any; + }; +} + +/** + * HACS repository interface + * @interface HacsRepository + */ +export interface HacsRepository { + /** Repository name */ + name: string; + /** Repository description */ + description: string; + /** Repository category */ + category: string; + /** Whether the repository is installed */ + installed: boolean; + /** Installed version */ + version_installed: string; + /** Available version */ + available_version: string; + /** Repository authors */ + authors: string[]; + /** Repository domain */ + domain: string; +} + +/** + * Response from HACS API + * @interface HacsResponse + */ +export interface HacsResponse { + /** List of repositories */ + repositories: HacsRepository[]; +} + +/** + * Automation configuration interface + * @interface AutomationConfig + */ +export interface AutomationConfig { + /** Automation name */ + alias: string; + /** Automation description */ + description?: string; + /** How multiple triggers are handled */ + mode?: 'single' | 'parallel' | 'queued' | 'restart'; + /** List of triggers */ + trigger: any[]; + /** List of conditions */ + condition?: any[]; + /** List of actions */ + action: any[]; +} + +/** + * Response from automation API + * @interface AutomationResponse + */ +export interface AutomationResponse { + /** Created/updated automation ID */ + automation_id: string; +} + +/** + * SSE headers interface + * @interface SSEHeaders + */ +export interface SSEHeaders { + /** Callback for connection abort */ + onAbort?: () => void; +} + +/** + * SSE parameters interface + * @interface SSEParams + */ +export interface SSEParams { + /** Authentication token */ + token: string; + /** Event types to subscribe to */ + events?: string[]; + /** Entity ID to monitor */ + entity_id?: string; + /** Domain to monitor */ + domain?: string; +} + +/** + * History query parameters + * @interface HistoryParams + */ +export interface HistoryParams { + /** Entity ID to get history for */ + entity_id: string; + /** Start time in ISO format */ + start_time?: string; + /** End time in ISO format */ + end_time?: string; + /** Whether to return minimal response */ + minimal_response?: boolean; + /** Whether to only return significant changes */ + significant_changes_only?: boolean; +} + +/** + * Scene management parameters + * @interface SceneParams + */ +export interface SceneParams { + /** Action to perform */ + action: 'list' | 'activate'; + /** Scene ID for activation */ + scene_id?: string; +} + +/** + * Notification parameters + * @interface NotifyParams + */ +export interface NotifyParams { + /** Notification message */ + message: string; + /** Notification title */ + title?: string; + /** Notification target */ + target?: string; + /** Additional notification data */ + data?: Record; +} + +/** + * Automation management parameters + * @interface AutomationParams + */ +export interface AutomationParams { + /** Action to perform */ + action: 'list' | 'toggle' | 'trigger'; + /** Automation ID */ + automation_id?: string; +} + +/** + * Add-on management parameters + * @interface AddonParams + */ +export interface AddonParams { + /** Action to perform */ + action: 'list' | 'info' | 'install' | 'uninstall' | 'start' | 'stop' | 'restart'; + /** Add-on slug */ + slug?: string; + /** Version to install */ + version?: string; +} + +/** + * Package management parameters + * @interface PackageParams + */ +export interface PackageParams { + /** Action to perform */ + action: 'list' | 'install' | 'uninstall' | 'update'; + /** Package category */ + category: 'integration' | 'plugin' | 'theme' | 'python_script' | 'appdaemon' | 'netdaemon'; + /** Repository URL or name */ + repository?: string; + /** Version to install */ + version?: string; +} + +/** + * Automation configuration parameters + * @interface AutomationConfigParams + */ +export interface AutomationConfigParams { + /** Action to perform */ + action: 'create' | 'update' | 'delete' | 'duplicate'; + /** Automation ID */ + automation_id?: string; + /** Automation configuration */ + config?: { + /** Automation name */ + alias: string; + /** Automation description */ + description?: string; + /** How multiple triggers are handled */ + mode?: 'single' | 'parallel' | 'queued' | 'restart'; + /** List of triggers */ + trigger: any[]; + /** List of conditions */ + condition?: any[]; + /** List of actions */ + action: any[]; + }; +} \ No newline at end of file