diff --git a/src/api/routes.ts b/src/api/routes.ts new file mode 100644 index 0000000..412811f --- /dev/null +++ b/src/api/routes.ts @@ -0,0 +1,185 @@ +import { Router } from 'express'; +import { MCP_SCHEMA } from '../mcp/schema.js'; +import { middleware } from '../middleware/index.js'; +import { sseManager } from '../sse/index.js'; +import { v4 as uuidv4 } from 'uuid'; +import { TokenManager } from '../security/index.js'; +import { tools } from '../services/tools.js'; +import { Tool } from '../interfaces/index.js'; + +const router = Router(); + +// MCP schema endpoint - no auth required as it's just the schema +router.get('/mcp', (_req, res) => { + res.json(MCP_SCHEMA); +}); + +// MCP execute endpoint - requires authentication +router.post('/mcp/execute', middleware.authenticate, async (req, res) => { + try { + const { tool: toolName, parameters } = req.body; + + // Find the requested tool + const tool = tools.find((t: Tool) => 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 +router.get('/health', (_req, res) => { + res.json({ + status: 'ok', + timestamp: new Date().toISOString(), + version: '0.1.0' + }); +}); + +// List devices endpoint +router.get('/list_devices', middleware.authenticate, async (req, res) => { + try { + const tool = tools.find((t: Tool) => t.name === 'list_devices'); + if (!tool) { + return res.status(404).json({ + success: false, + message: 'Tool not found' + }); + } + + const result = await tool.execute({ token: req.headers.authorization?.replace('Bearer ', '') }); + res.json(result); + } catch (error) { + res.status(500).json({ + success: false, + message: error instanceof Error ? error.message : 'Unknown error occurred' + }); + } +}); + +// Device control endpoint +router.post('/control', middleware.authenticate, async (req, res) => { + try { + const tool = tools.find((t: Tool) => t.name === 'control'); + if (!tool) { + return res.status(404).json({ + success: false, + message: 'Tool not found' + }); + } + + const result = await tool.execute({ + ...req.body, + token: req.headers.authorization?.replace('Bearer ', '') + }); + res.json(result); + } catch (error) { + res.status(500).json({ + success: false, + message: error instanceof Error ? error.message : 'Unknown error occurred' + }); + } +}); + +// SSE endpoints +router.get('/subscribe_events', middleware.wsRateLimiter, (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' + }); + } +}); + +// SSE stats endpoint +router.get('/get_sse_stats', middleware.authenticate, (_req, res) => { + const stats = { + clients: sseManager.getClientCount(), + events: sseManager.getEventSubscriptions(), + entities: sseManager.getEntitySubscriptions(), + domains: sseManager.getDomainSubscriptions() + }; + res.json(stats); +}); + +export default router; \ No newline at end of file diff --git a/src/interfaces/hass.ts b/src/interfaces/hass.ts new file mode 100644 index 0000000..382ec02 --- /dev/null +++ b/src/interfaces/hass.ts @@ -0,0 +1,83 @@ +/// + +// Home Assistant entity types +export interface HassEntity { + entity_id: string; + state: string; + attributes: Record; + last_changed?: string; + last_updated?: string; + context?: { + id: string; + parent_id?: string; + user_id?: string; + }; +} + +export interface HassState { + entity_id: string; + state: string; + attributes: { + friendly_name?: string; + description?: string; + [key: string]: any; + }; +} + +// Home Assistant instance types +export interface HassInstance { + states: HassStates; + services: HassServices; + connection: HassConnection; + subscribeEvents: (callback: (event: HassEvent) => void, eventType?: string) => Promise; + unsubscribeEvents: (subscription: number) => void; +} + +export interface HassStates { + get: () => Promise; + subscribe: (callback: (states: HassEntity[]) => void) => Promise; + unsubscribe: (subscription: number) => void; +} + +export interface HassServices { + get: () => Promise>>; + call: (domain: string, service: string, serviceData?: Record) => Promise; +} + +export interface HassConnection { + socket: WebSocket; + subscribeEvents: (callback: (event: HassEvent) => void, eventType?: string) => Promise; + unsubscribeEvents: (subscription: number) => void; +} + +export interface HassService { + name: string; + description: string; + target?: { + entity?: { + domain: string[]; + }; + }; + fields: Record; +} + +export interface HassEvent { + event_type: string; + data: Record; + origin: string; + time_fired: string; + context: { + id: string; + parent_id?: string; + user_id?: string; + }; +} + +// Re-export entity types from index.ts +export { HassEntity, HassState } from './index.js'; \ No newline at end of file diff --git a/src/services/tools.ts b/src/services/tools.ts new file mode 100644 index 0000000..953fe93 --- /dev/null +++ b/src/services/tools.ts @@ -0,0 +1,101 @@ +import { z } from 'zod'; +import { Tool } from '../interfaces/index.js'; +import { get_hass } from '../hass/index.js'; +import { DomainSchema } from '../schemas.js'; +import { HassEntity, HassState } from '../interfaces/index.js'; + +// Define tools array +export const tools: Tool[] = [ + { + name: 'list_devices', + description: 'List all devices connected to Home Assistant', + parameters: z.object({ + domain: DomainSchema.optional(), + area: z.string().optional(), + floor: z.string().optional() + }), + execute: async (params) => { + const hass = await get_hass(); + const states = await hass.states.get(); + + // Filter by domain if specified + let filteredStates = states; + if (params.domain) { + filteredStates = states.filter((state: HassEntity) => state.entity_id.startsWith(`${params.domain}.`)); + } + + // Filter by area if specified + if (params.area) { + filteredStates = filteredStates.filter((state: HassEntity) => + state.attributes.area_id === params.area || + state.attributes.area === params.area + ); + } + + // Filter by floor if specified + if (params.floor) { + filteredStates = filteredStates.filter((state: HassEntity) => + state.attributes.floor === params.floor + ); + } + + return { + success: true, + devices: filteredStates.map((state: HassEntity) => ({ + entity_id: state.entity_id, + state: state.state, + attributes: state.attributes + })) + }; + } + }, + { + name: 'control', + description: 'Control a Home Assistant device', + parameters: z.object({ + command: z.string(), + entity_id: z.string(), + state: z.string().optional(), + brightness: z.number().min(0).max(255).optional(), + color_temp: z.number().optional(), + rgb_color: z.tuple([z.number(), z.number(), z.number()]).optional(), + position: z.number().min(0).max(100).optional(), + tilt_position: z.number().min(0).max(100).optional(), + temperature: z.number().optional(), + target_temp_high: z.number().optional(), + target_temp_low: z.number().optional(), + hvac_mode: z.string().optional(), + fan_mode: z.string().optional(), + humidity: z.number().min(0).max(100).optional() + }), + execute: async (params) => { + const hass = await get_hass(); + const domain = params.entity_id.split('.')[0]; + + const serviceData: Record = { + entity_id: params.entity_id + }; + + // Add optional parameters if they exist + if (params.state) serviceData.state = params.state; + if (params.brightness) serviceData.brightness = params.brightness; + if (params.color_temp) serviceData.color_temp = params.color_temp; + if (params.rgb_color) serviceData.rgb_color = params.rgb_color; + if (params.position) serviceData.position = params.position; + if (params.tilt_position) serviceData.tilt_position = params.tilt_position; + if (params.temperature) serviceData.temperature = params.temperature; + if (params.target_temp_high) serviceData.target_temp_high = params.target_temp_high; + if (params.target_temp_low) serviceData.target_temp_low = params.target_temp_low; + if (params.hvac_mode) serviceData.hvac_mode = params.hvac_mode; + if (params.fan_mode) serviceData.fan_mode = params.fan_mode; + if (params.humidity) serviceData.humidity = params.humidity; + + await hass.services.call(domain, params.command, serviceData); + + return { + success: true, + message: `Command '${params.command}' executed on ${params.entity_id}` + }; + } + } +]; \ No newline at end of file