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
This commit is contained in:
67
src/config/app.config.ts
Normal file
67
src/config/app.config.ts
Normal file
@@ -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}`);
|
||||
}
|
||||
}
|
||||
1378
src/index.ts
1378
src/index.ts
File diff suppressed because it is too large
Load Diff
15
src/routes/health.routes.ts
Normal file
15
src/routes/health.routes.ts
Normal file
@@ -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 };
|
||||
39
src/routes/index.ts
Normal file
39
src/routes/index.ts
Normal file
@@ -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 };
|
||||
51
src/routes/mcp.routes.ts
Normal file
51
src/routes/mcp.routes.ts
Normal file
@@ -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 };
|
||||
99
src/routes/sse.routes.ts
Normal file
99
src/routes/sse.routes.ts
Normal file
@@ -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 };
|
||||
75
src/routes/tool.routes.ts
Normal file
75
src/routes/tool.routes.ts
Normal file
@@ -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 };
|
||||
106
src/tools/addon.tool.ts
Normal file
106
src/tools/addon.tool.ts
Normal file
@@ -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<string, any> = {};
|
||||
|
||||
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',
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
150
src/tools/automation-config.tool.ts
Normal file
150
src/tools/automation-config.tool.ts
Normal file
@@ -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',
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
73
src/tools/automation.tool.ts
Normal file
73
src/tools/automation.tool.ts
Normal file
@@ -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',
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
139
src/tools/control.tool.ts
Normal file
139
src/tools/control.tool.ts
Normal file
@@ -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<string, any> = {
|
||||
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'
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
53
src/tools/history.tool.ts
Normal file
53
src/tools/history.tool.ts
Normal file
@@ -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',
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
};
|
||||
46
src/tools/list-devices.tool.ts
Normal file
46
src/tools/list-devices.tool.ts
Normal file
@@ -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<string, HassState[]> = {};
|
||||
|
||||
// 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'
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
47
src/tools/notify.tool.ts
Normal file
47
src/tools/notify.tool.ts
Normal file
@@ -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',
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
88
src/tools/package.tool.ts
Normal file
88
src/tools/package.tool.ts
Normal file
@@ -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<string, any> = {
|
||||
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',
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
71
src/tools/scene.tool.ts
Normal file
71
src/tools/scene.tool.ts
Normal file
@@ -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',
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
33
src/tools/sse-stats.tool.ts
Normal file
33
src/tools/sse-stats.tool.ts
Normal file
@@ -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'
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
84
src/tools/subscribe-events.tool.ts
Normal file
84
src/tools/subscribe-events.tool.ts
Normal file
@@ -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
|
||||
};
|
||||
}
|
||||
};
|
||||
337
src/types/index.ts
Normal file
337
src/types/index.ts
Normal file
@@ -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<any>;
|
||||
/** Function to execute the tool with the given parameters */
|
||||
execute: (params: any) => Promise<any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string, any>;
|
||||
/** 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<string, any>;
|
||||
/** 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<string, any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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[];
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user