Add core API routes, Home Assistant interfaces, and device management tools
- Implemented comprehensive API routes for MCP schema, device execution, and control - Created Home Assistant entity and event interfaces with detailed type definitions - Added tools for listing and controlling Home Assistant devices - Introduced SSE (Server-Sent Events) subscription and management endpoints - Implemented flexible device control with support for multiple domains and parameters
This commit is contained in:
185
src/api/routes.ts
Normal file
185
src/api/routes.ts
Normal file
@@ -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;
|
||||||
83
src/interfaces/hass.ts
Normal file
83
src/interfaces/hass.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
/// <reference lib="dom" />
|
||||||
|
|
||||||
|
// Home Assistant entity types
|
||||||
|
export interface HassEntity {
|
||||||
|
entity_id: string;
|
||||||
|
state: string;
|
||||||
|
attributes: Record<string, any>;
|
||||||
|
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<number>;
|
||||||
|
unsubscribeEvents: (subscription: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HassStates {
|
||||||
|
get: () => Promise<HassEntity[]>;
|
||||||
|
subscribe: (callback: (states: HassEntity[]) => void) => Promise<number>;
|
||||||
|
unsubscribe: (subscription: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HassServices {
|
||||||
|
get: () => Promise<Record<string, Record<string, HassService>>>;
|
||||||
|
call: (domain: string, service: string, serviceData?: Record<string, any>) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HassConnection {
|
||||||
|
socket: WebSocket;
|
||||||
|
subscribeEvents: (callback: (event: HassEvent) => void, eventType?: string) => Promise<number>;
|
||||||
|
unsubscribeEvents: (subscription: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HassService {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
target?: {
|
||||||
|
entity?: {
|
||||||
|
domain: string[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
fields: Record<string, {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
required?: boolean;
|
||||||
|
example?: any;
|
||||||
|
selector?: any;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HassEvent {
|
||||||
|
event_type: string;
|
||||||
|
data: Record<string, any>;
|
||||||
|
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';
|
||||||
101
src/services/tools.ts
Normal file
101
src/services/tools.ts
Normal file
@@ -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<string, any> = {
|
||||||
|
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}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
Reference in New Issue
Block a user