Add comprehensive WebSocket, context, performance, and security modules
- Introduced WebSocket client for real-time Home Assistant event streaming - Created context management system for tracking resource relationships and state - Implemented performance monitoring and optimization utilities - Added security middleware with token validation, rate limiting, and input sanitization - Extended tool registry with enhanced tool registration and execution capabilities - Expanded test coverage for new modules and added comprehensive test scenarios - Improved type safety and added robust error handling across new modules
This commit is contained in:
226
src/context/index.ts
Normal file
226
src/context/index.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
// Resource types
|
||||
export enum ResourceType {
|
||||
DEVICE = 'device',
|
||||
AREA = 'area',
|
||||
USER = 'user',
|
||||
AUTOMATION = 'automation',
|
||||
SCENE = 'scene',
|
||||
SCRIPT = 'script',
|
||||
GROUP = 'group'
|
||||
}
|
||||
|
||||
// Resource state interface
|
||||
export interface ResourceState {
|
||||
id: string;
|
||||
type: ResourceType;
|
||||
state: any;
|
||||
attributes: Record<string, any>;
|
||||
lastUpdated: number;
|
||||
context?: Record<string, any>;
|
||||
}
|
||||
|
||||
// Resource relationship types
|
||||
export enum RelationType {
|
||||
CONTAINS = 'contains',
|
||||
CONTROLS = 'controls',
|
||||
TRIGGERS = 'triggers',
|
||||
DEPENDS_ON = 'depends_on',
|
||||
GROUPS = 'groups'
|
||||
}
|
||||
|
||||
// Resource relationship interface
|
||||
export interface ResourceRelationship {
|
||||
sourceId: string;
|
||||
targetId: string;
|
||||
type: RelationType;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
// Context manager class
|
||||
export class ContextManager extends EventEmitter {
|
||||
private resources: Map<string, ResourceState> = new Map();
|
||||
private relationships: ResourceRelationship[] = [];
|
||||
private stateHistory: Map<string, ResourceState[]> = new Map();
|
||||
private historyLimit = 100;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
// Resource management
|
||||
public addResource(resource: ResourceState): void {
|
||||
this.resources.set(resource.id, resource);
|
||||
this.emit('resource_added', resource);
|
||||
}
|
||||
|
||||
public updateResource(id: string, update: Partial<ResourceState>): void {
|
||||
const resource = this.resources.get(id);
|
||||
if (resource) {
|
||||
// Store current state in history
|
||||
this.addToHistory(resource);
|
||||
|
||||
// Update resource
|
||||
const updatedResource = {
|
||||
...resource,
|
||||
...update,
|
||||
lastUpdated: Date.now()
|
||||
};
|
||||
this.resources.set(id, updatedResource);
|
||||
this.emit('resource_updated', updatedResource);
|
||||
}
|
||||
}
|
||||
|
||||
public removeResource(id: string): void {
|
||||
const resource = this.resources.get(id);
|
||||
if (resource) {
|
||||
this.resources.delete(id);
|
||||
// Remove related relationships
|
||||
this.relationships = this.relationships.filter(
|
||||
rel => rel.sourceId !== id && rel.targetId !== id
|
||||
);
|
||||
this.emit('resource_removed', resource);
|
||||
}
|
||||
}
|
||||
|
||||
// Relationship management
|
||||
public addRelationship(relationship: ResourceRelationship): void {
|
||||
this.relationships.push(relationship);
|
||||
this.emit('relationship_added', relationship);
|
||||
}
|
||||
|
||||
public removeRelationship(sourceId: string, targetId: string, type: RelationType): void {
|
||||
const index = this.relationships.findIndex(
|
||||
rel => rel.sourceId === sourceId && rel.targetId === targetId && rel.type === type
|
||||
);
|
||||
if (index !== -1) {
|
||||
const removed = this.relationships.splice(index, 1)[0];
|
||||
this.emit('relationship_removed', removed);
|
||||
}
|
||||
}
|
||||
|
||||
// History management
|
||||
private addToHistory(state: ResourceState): void {
|
||||
const history = this.stateHistory.get(state.id) || [];
|
||||
history.push({ ...state });
|
||||
if (history.length > this.historyLimit) {
|
||||
history.shift();
|
||||
}
|
||||
this.stateHistory.set(state.id, history);
|
||||
}
|
||||
|
||||
public getHistory(id: string): ResourceState[] {
|
||||
return this.stateHistory.get(id) || [];
|
||||
}
|
||||
|
||||
// Context queries
|
||||
public getResource(id: string): ResourceState | undefined {
|
||||
return this.resources.get(id);
|
||||
}
|
||||
|
||||
public getResourcesByType(type: ResourceType): ResourceState[] {
|
||||
return Array.from(this.resources.values()).filter(
|
||||
resource => resource.type === type
|
||||
);
|
||||
}
|
||||
|
||||
public getRelatedResources(
|
||||
id: string,
|
||||
type?: RelationType,
|
||||
depth: number = 1
|
||||
): ResourceState[] {
|
||||
const related = new Set<ResourceState>();
|
||||
const visited = new Set<string>();
|
||||
|
||||
const traverse = (currentId: string, currentDepth: number) => {
|
||||
if (currentDepth > depth || visited.has(currentId)) return;
|
||||
visited.add(currentId);
|
||||
|
||||
this.relationships
|
||||
.filter(rel =>
|
||||
(rel.sourceId === currentId || rel.targetId === currentId) &&
|
||||
(!type || rel.type === type)
|
||||
)
|
||||
.forEach(rel => {
|
||||
const relatedId = rel.sourceId === currentId ? rel.targetId : rel.sourceId;
|
||||
const relatedResource = this.resources.get(relatedId);
|
||||
if (relatedResource) {
|
||||
related.add(relatedResource);
|
||||
traverse(relatedId, currentDepth + 1);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
traverse(id, 0);
|
||||
return Array.from(related);
|
||||
}
|
||||
|
||||
// Context analysis
|
||||
public analyzeResourceUsage(id: string): {
|
||||
dependencies: string[];
|
||||
dependents: string[];
|
||||
groups: string[];
|
||||
usage: {
|
||||
triggerCount: number;
|
||||
controlCount: number;
|
||||
groupCount: number;
|
||||
};
|
||||
} {
|
||||
const dependencies = this.relationships
|
||||
.filter(rel => rel.sourceId === id && rel.type === RelationType.DEPENDS_ON)
|
||||
.map(rel => rel.targetId);
|
||||
|
||||
const dependents = this.relationships
|
||||
.filter(rel => rel.targetId === id && rel.type === RelationType.DEPENDS_ON)
|
||||
.map(rel => rel.sourceId);
|
||||
|
||||
const groups = this.relationships
|
||||
.filter(rel => rel.targetId === id && rel.type === RelationType.GROUPS)
|
||||
.map(rel => rel.sourceId);
|
||||
|
||||
const usage = {
|
||||
triggerCount: this.relationships.filter(
|
||||
rel => rel.sourceId === id && rel.type === RelationType.TRIGGERS
|
||||
).length,
|
||||
controlCount: this.relationships.filter(
|
||||
rel => rel.sourceId === id && rel.type === RelationType.CONTROLS
|
||||
).length,
|
||||
groupCount: groups.length
|
||||
};
|
||||
|
||||
return { dependencies, dependents, groups, usage };
|
||||
}
|
||||
|
||||
// Event subscriptions
|
||||
public subscribeToResource(
|
||||
id: string,
|
||||
callback: (state: ResourceState) => void
|
||||
): () => void {
|
||||
const handler = (resource: ResourceState) => {
|
||||
if (resource.id === id) {
|
||||
callback(resource);
|
||||
}
|
||||
};
|
||||
|
||||
this.on('resource_updated', handler);
|
||||
return () => this.off('resource_updated', handler);
|
||||
}
|
||||
|
||||
public subscribeToType(
|
||||
type: ResourceType,
|
||||
callback: (state: ResourceState) => void
|
||||
): () => void {
|
||||
const handler = (resource: ResourceState) => {
|
||||
if (resource.type === type) {
|
||||
callback(resource);
|
||||
}
|
||||
};
|
||||
|
||||
this.on('resource_updated', handler);
|
||||
return () => this.off('resource_updated', handler);
|
||||
}
|
||||
}
|
||||
|
||||
// Export context manager instance
|
||||
export const contextManager = new ContextManager();
|
||||
631
src/index.ts
631
src/index.ts
@@ -46,6 +46,73 @@ interface HassEntity {
|
||||
};
|
||||
}
|
||||
|
||||
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<string, any>;
|
||||
[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;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const hass = await get_hass();
|
||||
|
||||
@@ -232,6 +299,570 @@ async function main() {
|
||||
}
|
||||
});
|
||||
|
||||
// Add the history tool
|
||||
server.addTool({
|
||||
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) => {
|
||||
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',
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Add the scenes tool
|
||||
server.addTool({
|
||||
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) => {
|
||||
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',
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Add the notification tool
|
||||
server.addTool({
|
||||
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) => {
|
||||
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',
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Add the automation tool
|
||||
server.addTool({
|
||||
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) => {
|
||||
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}`);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Successfully ${service}d automation ${params.automation_id}`,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : 'Unknown error occurred',
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Add the addon tool
|
||||
server.addTool({
|
||||
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) => {
|
||||
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<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(`${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',
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Add the package tool
|
||||
server.addTool({
|
||||
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) => {
|
||||
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<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 ${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',
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Extend the automation tool with more functionality
|
||||
server.addTool({
|
||||
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) => {
|
||||
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}`);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Successfully created automation',
|
||||
automation_id: (await response.json()).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}`);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Successfully updated automation ${params.automation_id}`,
|
||||
};
|
||||
}
|
||||
|
||||
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',
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Start the server
|
||||
await server.start();
|
||||
console.log('MCP Server started');
|
||||
|
||||
222
src/performance/index.ts
Normal file
222
src/performance/index.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
import { performance } from 'perf_hooks';
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
// Performance metrics types
|
||||
export interface Metric {
|
||||
name: string;
|
||||
value: number;
|
||||
timestamp: number;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface PerformanceThresholds {
|
||||
responseTime: number; // milliseconds
|
||||
memoryUsage: number; // bytes
|
||||
cpuUsage: number; // percentage
|
||||
}
|
||||
|
||||
// Performance monitoring class
|
||||
export class PerformanceMonitor extends EventEmitter {
|
||||
private metrics: Metric[] = [];
|
||||
private thresholds: PerformanceThresholds;
|
||||
private samplingInterval: number;
|
||||
private retentionPeriod: number;
|
||||
private intervalId?: NodeJS.Timeout;
|
||||
|
||||
constructor(
|
||||
thresholds: Partial<PerformanceThresholds> = {},
|
||||
samplingInterval = 5000, // 5 seconds
|
||||
retentionPeriod = 24 * 60 * 60 * 1000 // 24 hours
|
||||
) {
|
||||
super();
|
||||
this.thresholds = {
|
||||
responseTime: thresholds.responseTime || 1000, // 1 second
|
||||
memoryUsage: thresholds.memoryUsage || 1024 * 1024 * 1024, // 1 GB
|
||||
cpuUsage: thresholds.cpuUsage || 80 // 80%
|
||||
};
|
||||
this.samplingInterval = samplingInterval;
|
||||
this.retentionPeriod = retentionPeriod;
|
||||
}
|
||||
|
||||
// Start monitoring
|
||||
public start(): void {
|
||||
this.intervalId = setInterval(() => {
|
||||
this.collectMetrics();
|
||||
this.cleanOldMetrics();
|
||||
}, this.samplingInterval);
|
||||
}
|
||||
|
||||
// Stop monitoring
|
||||
public stop(): void {
|
||||
if (this.intervalId) {
|
||||
clearInterval(this.intervalId);
|
||||
}
|
||||
}
|
||||
|
||||
// Collect system metrics
|
||||
private collectMetrics(): void {
|
||||
const now = Date.now();
|
||||
const memoryUsage = process.memoryUsage();
|
||||
const cpuUsage = process.cpuUsage();
|
||||
|
||||
// Memory metrics
|
||||
this.addMetric({
|
||||
name: 'memory.heapUsed',
|
||||
value: memoryUsage.heapUsed,
|
||||
timestamp: now
|
||||
});
|
||||
|
||||
this.addMetric({
|
||||
name: 'memory.heapTotal',
|
||||
value: memoryUsage.heapTotal,
|
||||
timestamp: now
|
||||
});
|
||||
|
||||
this.addMetric({
|
||||
name: 'memory.rss',
|
||||
value: memoryUsage.rss,
|
||||
timestamp: now
|
||||
});
|
||||
|
||||
// CPU metrics
|
||||
this.addMetric({
|
||||
name: 'cpu.user',
|
||||
value: cpuUsage.user,
|
||||
timestamp: now
|
||||
});
|
||||
|
||||
this.addMetric({
|
||||
name: 'cpu.system',
|
||||
value: cpuUsage.system,
|
||||
timestamp: now
|
||||
});
|
||||
|
||||
// Check thresholds
|
||||
this.checkThresholds();
|
||||
}
|
||||
|
||||
// Add a metric
|
||||
public addMetric(metric: Metric): void {
|
||||
this.metrics.push(metric);
|
||||
this.emit('metric', metric);
|
||||
}
|
||||
|
||||
// Clean old metrics
|
||||
private cleanOldMetrics(): void {
|
||||
const cutoff = Date.now() - this.retentionPeriod;
|
||||
this.metrics = this.metrics.filter(metric => metric.timestamp > cutoff);
|
||||
}
|
||||
|
||||
// Check if metrics exceed thresholds
|
||||
private checkThresholds(): void {
|
||||
const memoryUsage = process.memoryUsage().heapUsed;
|
||||
if (memoryUsage > this.thresholds.memoryUsage) {
|
||||
this.emit('threshold_exceeded', {
|
||||
type: 'memory',
|
||||
value: memoryUsage,
|
||||
threshold: this.thresholds.memoryUsage
|
||||
});
|
||||
}
|
||||
|
||||
const cpuUsage = process.cpuUsage();
|
||||
const totalCPU = cpuUsage.user + cpuUsage.system;
|
||||
const cpuPercentage = (totalCPU / (process.uptime() * 1000000)) * 100;
|
||||
if (cpuPercentage > this.thresholds.cpuUsage) {
|
||||
this.emit('threshold_exceeded', {
|
||||
type: 'cpu',
|
||||
value: cpuPercentage,
|
||||
threshold: this.thresholds.cpuUsage
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Get metrics for a specific time range
|
||||
public getMetrics(
|
||||
startTime: number,
|
||||
endTime: number = Date.now(),
|
||||
metricName?: string
|
||||
): Metric[] {
|
||||
return this.metrics.filter(metric =>
|
||||
metric.timestamp >= startTime &&
|
||||
metric.timestamp <= endTime &&
|
||||
(!metricName || metric.name === metricName)
|
||||
);
|
||||
}
|
||||
|
||||
// Calculate average for a metric
|
||||
public calculateAverage(
|
||||
metricName: string,
|
||||
startTime: number,
|
||||
endTime: number = Date.now()
|
||||
): number {
|
||||
const metrics = this.getMetrics(startTime, endTime, metricName);
|
||||
if (metrics.length === 0) return 0;
|
||||
return metrics.reduce((sum, metric) => sum + metric.value, 0) / metrics.length;
|
||||
}
|
||||
}
|
||||
|
||||
// Performance optimization utilities
|
||||
export class PerformanceOptimizer {
|
||||
private static readonly GC_THRESHOLD = 0.9; // 90% heap usage
|
||||
|
||||
// Optimize memory usage
|
||||
public static async optimizeMemory(): Promise<void> {
|
||||
const memoryUsage = process.memoryUsage();
|
||||
const heapUsageRatio = memoryUsage.heapUsed / memoryUsage.heapTotal;
|
||||
|
||||
if (heapUsageRatio > this.GC_THRESHOLD) {
|
||||
if (global.gc) {
|
||||
global.gc();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Batch processing utility
|
||||
public static async processBatch<T, R>(
|
||||
items: T[],
|
||||
batchSize: number,
|
||||
processor: (batch: T[]) => Promise<R[]>
|
||||
): Promise<R[]> {
|
||||
const results: R[] = [];
|
||||
for (let i = 0; i < items.length; i += batchSize) {
|
||||
const batch = items.slice(i, i + batchSize);
|
||||
const batchResults = await processor(batch);
|
||||
results.push(...batchResults);
|
||||
await new Promise(resolve => setTimeout(resolve, 0)); // Yield to event loop
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
// Debounce utility
|
||||
public static debounce<T extends (...args: any[]) => any>(
|
||||
func: T,
|
||||
wait: number
|
||||
): (...args: Parameters<T>) => void {
|
||||
let timeout: NodeJS.Timeout;
|
||||
return (...args: Parameters<T>) => {
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(() => func(...args), wait);
|
||||
};
|
||||
}
|
||||
|
||||
// Throttle utility
|
||||
public static throttle<T extends (...args: any[]) => any>(
|
||||
func: T,
|
||||
limit: number
|
||||
): (...args: Parameters<T>) => void {
|
||||
let inThrottle = false;
|
||||
return (...args: Parameters<T>) => {
|
||||
if (!inThrottle) {
|
||||
func(...args);
|
||||
inThrottle = true;
|
||||
setTimeout(() => (inThrottle = false), limit);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Export performance monitoring instance
|
||||
export const performanceMonitor = new PerformanceMonitor();
|
||||
|
||||
// Start monitoring on module load
|
||||
performanceMonitor.start();
|
||||
180
src/security/index.ts
Normal file
180
src/security/index.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import crypto from 'crypto';
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import helmet from 'helmet';
|
||||
|
||||
// Security configuration
|
||||
const RATE_LIMIT_WINDOW = 15 * 60 * 1000; // 15 minutes
|
||||
const RATE_LIMIT_MAX = 100; // requests per window
|
||||
const TOKEN_EXPIRY = 24 * 60 * 60 * 1000; // 24 hours
|
||||
|
||||
// Rate limiting middleware
|
||||
export const rateLimiter = rateLimit({
|
||||
windowMs: RATE_LIMIT_WINDOW,
|
||||
max: RATE_LIMIT_MAX,
|
||||
message: 'Too many requests from this IP, please try again later'
|
||||
});
|
||||
|
||||
// Security headers middleware
|
||||
export const securityHeaders = helmet({
|
||||
contentSecurityPolicy: {
|
||||
directives: {
|
||||
defaultSrc: ["'self'"],
|
||||
scriptSrc: ["'self'", "'unsafe-inline'"],
|
||||
styleSrc: ["'self'", "'unsafe-inline'"],
|
||||
imgSrc: ["'self'", 'data:', 'https:'],
|
||||
connectSrc: ["'self'", process.env.HASS_HOST || ''],
|
||||
upgradeInsecureRequests: []
|
||||
}
|
||||
},
|
||||
crossOriginEmbedderPolicy: true,
|
||||
crossOriginOpenerPolicy: true,
|
||||
crossOriginResourcePolicy: { policy: 'same-site' },
|
||||
dnsPrefetchControl: true,
|
||||
frameguard: { action: 'deny' },
|
||||
hidePoweredBy: true,
|
||||
hsts: true,
|
||||
ieNoOpen: true,
|
||||
noSniff: true,
|
||||
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
|
||||
xssFilter: true
|
||||
});
|
||||
|
||||
// Token validation and encryption
|
||||
export class TokenManager {
|
||||
private static readonly algorithm = 'aes-256-gcm';
|
||||
private static readonly keyLength = 32;
|
||||
private static readonly ivLength = 16;
|
||||
private static readonly saltLength = 64;
|
||||
private static readonly tagLength = 16;
|
||||
private static readonly iterations = 100000;
|
||||
private static readonly digest = 'sha512';
|
||||
|
||||
private static deriveKey(password: string, salt: Buffer): Buffer {
|
||||
return crypto.pbkdf2Sync(
|
||||
password,
|
||||
salt,
|
||||
this.iterations,
|
||||
this.keyLength,
|
||||
this.digest
|
||||
);
|
||||
}
|
||||
|
||||
public static encryptToken(token: string, encryptionKey: string): string {
|
||||
const iv = crypto.randomBytes(this.ivLength);
|
||||
const salt = crypto.randomBytes(this.saltLength);
|
||||
const key = this.deriveKey(encryptionKey, salt);
|
||||
const cipher = crypto.createCipheriv(this.algorithm, key, iv, {
|
||||
authTagLength: this.tagLength
|
||||
});
|
||||
|
||||
const encrypted = Buffer.concat([
|
||||
cipher.update(token, 'utf8'),
|
||||
cipher.final()
|
||||
]);
|
||||
const tag = cipher.getAuthTag();
|
||||
|
||||
return Buffer.concat([salt, iv, tag, encrypted]).toString('base64');
|
||||
}
|
||||
|
||||
public static decryptToken(encryptedToken: string, encryptionKey: string): string {
|
||||
const buffer = Buffer.from(encryptedToken, 'base64');
|
||||
const salt = buffer.subarray(0, this.saltLength);
|
||||
const iv = buffer.subarray(this.saltLength, this.saltLength + this.ivLength);
|
||||
const tag = buffer.subarray(
|
||||
this.saltLength + this.ivLength,
|
||||
this.saltLength + this.ivLength + this.tagLength
|
||||
);
|
||||
const encrypted = buffer.subarray(this.saltLength + this.ivLength + this.tagLength);
|
||||
const key = this.deriveKey(encryptionKey, salt);
|
||||
|
||||
const decipher = crypto.createDecipheriv(this.algorithm, key, iv, {
|
||||
authTagLength: this.tagLength
|
||||
});
|
||||
decipher.setAuthTag(tag);
|
||||
|
||||
return decipher.update(encrypted) + decipher.final('utf8');
|
||||
}
|
||||
|
||||
public static validateToken(token: string): boolean {
|
||||
if (!token) return false;
|
||||
|
||||
try {
|
||||
// Check token format
|
||||
if (!/^[A-Za-z0-9-_=]+\.[A-Za-z0-9-_=]+\.?[A-Za-z0-9-_.+/=]*$/.test(token)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Decode token parts
|
||||
const [headerEncoded, payloadEncoded] = token.split('.');
|
||||
const header = JSON.parse(Buffer.from(headerEncoded, 'base64').toString());
|
||||
const payload = JSON.parse(Buffer.from(payloadEncoded, 'base64').toString());
|
||||
|
||||
// Check token expiry
|
||||
if (payload.exp && Date.now() >= payload.exp * 1000) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Additional checks can be added here
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Request validation middleware
|
||||
export function validateRequest(req: Request, res: Response, next: NextFunction) {
|
||||
// Validate content type
|
||||
if (req.method !== 'GET' && !req.is('application/json')) {
|
||||
return res.status(415).json({
|
||||
error: 'Unsupported Media Type - Content-Type must be application/json'
|
||||
});
|
||||
}
|
||||
|
||||
// Validate token
|
||||
const token = req.headers.authorization?.replace('Bearer ', '');
|
||||
if (!token || !TokenManager.validateToken(token)) {
|
||||
return res.status(401).json({
|
||||
error: 'Invalid or expired token'
|
||||
});
|
||||
}
|
||||
|
||||
// Validate request body
|
||||
if (req.method !== 'GET' && (!req.body || typeof req.body !== 'object')) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid request body'
|
||||
});
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
// Input sanitization middleware
|
||||
export function sanitizeInput(req: Request, res: Response, next: NextFunction) {
|
||||
if (req.body && typeof req.body === 'object') {
|
||||
const sanitized = JSON.parse(
|
||||
JSON.stringify(req.body).replace(/[<>]/g, '')
|
||||
);
|
||||
req.body = sanitized;
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
// Error handling middleware
|
||||
export function errorHandler(err: Error, req: Request, res: Response, next: NextFunction) {
|
||||
console.error(err.stack);
|
||||
res.status(500).json({
|
||||
error: 'Internal Server Error',
|
||||
message: process.env.NODE_ENV === 'development' ? err.message : undefined
|
||||
});
|
||||
}
|
||||
|
||||
// Export security middleware chain
|
||||
export const securityMiddleware = [
|
||||
rateLimiter,
|
||||
securityHeaders,
|
||||
validateRequest,
|
||||
sanitizeInput,
|
||||
errorHandler
|
||||
];
|
||||
181
src/tools/index.ts
Normal file
181
src/tools/index.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import { Tool } from 'litemcp';
|
||||
import { z } from 'zod';
|
||||
|
||||
// Tool category types
|
||||
export enum ToolCategory {
|
||||
DEVICE_CONTROL = 'device_control',
|
||||
SYSTEM_MANAGEMENT = 'system_management',
|
||||
AUTOMATION = 'automation',
|
||||
MONITORING = 'monitoring',
|
||||
SECURITY = 'security'
|
||||
}
|
||||
|
||||
// Tool priority levels
|
||||
export enum ToolPriority {
|
||||
HIGH = 'high',
|
||||
MEDIUM = 'medium',
|
||||
LOW = 'low'
|
||||
}
|
||||
|
||||
// Tool metadata interface
|
||||
export interface ToolMetadata {
|
||||
category: ToolCategory;
|
||||
priority: ToolPriority;
|
||||
requiresAuth: boolean;
|
||||
rateLimit?: {
|
||||
windowMs: number;
|
||||
max: number;
|
||||
};
|
||||
caching?: {
|
||||
enabled: boolean;
|
||||
ttl: number;
|
||||
};
|
||||
}
|
||||
|
||||
// Enhanced tool interface
|
||||
export interface EnhancedTool extends Tool {
|
||||
metadata: ToolMetadata;
|
||||
validate?: (params: any) => Promise<boolean>;
|
||||
preExecute?: (params: any) => Promise<void>;
|
||||
postExecute?: (result: any) => Promise<void>;
|
||||
}
|
||||
|
||||
// Tool registry for managing and organizing tools
|
||||
export class ToolRegistry {
|
||||
private tools: Map<string, EnhancedTool> = new Map();
|
||||
private categories: Map<ToolCategory, Set<string>> = new Map();
|
||||
private cache: Map<string, { data: any; timestamp: number }> = new Map();
|
||||
|
||||
constructor() {
|
||||
// Initialize categories
|
||||
Object.values(ToolCategory).forEach(category => {
|
||||
this.categories.set(category, new Set());
|
||||
});
|
||||
}
|
||||
|
||||
// Register a new tool
|
||||
public registerTool(tool: EnhancedTool): void {
|
||||
this.tools.set(tool.name, tool);
|
||||
this.categories.get(tool.metadata.category)?.add(tool.name);
|
||||
}
|
||||
|
||||
// Get tool by name
|
||||
public getTool(name: string): EnhancedTool | undefined {
|
||||
return this.tools.get(name);
|
||||
}
|
||||
|
||||
// Get all tools in a category
|
||||
public getToolsByCategory(category: ToolCategory): EnhancedTool[] {
|
||||
const toolNames = this.categories.get(category);
|
||||
if (!toolNames) return [];
|
||||
return Array.from(toolNames).map(name => this.tools.get(name)!);
|
||||
}
|
||||
|
||||
// Execute a tool with validation and hooks
|
||||
public async executeTool(name: string, params: any): Promise<any> {
|
||||
const tool = this.tools.get(name);
|
||||
if (!tool) {
|
||||
throw new Error(`Tool ${name} not found`);
|
||||
}
|
||||
|
||||
// Check cache if enabled
|
||||
if (tool.metadata.caching?.enabled) {
|
||||
const cacheKey = `${name}:${JSON.stringify(params)}`;
|
||||
const cached = this.cache.get(cacheKey);
|
||||
if (cached && Date.now() - cached.timestamp < tool.metadata.caching.ttl) {
|
||||
return cached.data;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate parameters
|
||||
if (tool.validate) {
|
||||
const isValid = await tool.validate(params);
|
||||
if (!isValid) {
|
||||
throw new Error('Invalid parameters');
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-execution hook
|
||||
if (tool.preExecute) {
|
||||
await tool.preExecute(params);
|
||||
}
|
||||
|
||||
// Execute tool
|
||||
const result = await tool.execute(params);
|
||||
|
||||
// Post-execution hook
|
||||
if (tool.postExecute) {
|
||||
await tool.postExecute(result);
|
||||
}
|
||||
|
||||
// Update cache if enabled
|
||||
if (tool.metadata.caching?.enabled) {
|
||||
const cacheKey = `${name}:${JSON.stringify(params)}`;
|
||||
this.cache.set(cacheKey, {
|
||||
data: result,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Clean up expired cache entries
|
||||
public cleanCache(): void {
|
||||
const now = Date.now();
|
||||
for (const [key, value] of this.cache.entries()) {
|
||||
const tool = this.tools.get(key.split(':')[0]);
|
||||
if (tool?.metadata.caching?.ttl && now - value.timestamp > tool.metadata.caching.ttl) {
|
||||
this.cache.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create and export the global tool registry
|
||||
export const toolRegistry = new ToolRegistry();
|
||||
|
||||
// Tool decorator for easy registration
|
||||
export function registerTool(metadata: ToolMetadata) {
|
||||
return function (target: any) {
|
||||
const tool: EnhancedTool = new target();
|
||||
tool.metadata = metadata;
|
||||
toolRegistry.registerTool(tool);
|
||||
};
|
||||
}
|
||||
|
||||
// Example usage:
|
||||
@registerTool({
|
||||
category: ToolCategory.DEVICE_CONTROL,
|
||||
priority: ToolPriority.HIGH,
|
||||
requiresAuth: true,
|
||||
caching: {
|
||||
enabled: true,
|
||||
ttl: 5000 // 5 seconds
|
||||
}
|
||||
})
|
||||
export class LightControlTool implements EnhancedTool {
|
||||
name = 'light_control';
|
||||
description = 'Control light devices';
|
||||
parameters = z.object({
|
||||
command: z.enum(['turn_on', 'turn_off', 'toggle']),
|
||||
entity_id: z.string(),
|
||||
brightness: z.number().min(0).max(255).optional(),
|
||||
color_temp: z.number().optional(),
|
||||
rgb_color: z.tuple([z.number(), z.number(), z.number()]).optional()
|
||||
});
|
||||
|
||||
async validate(params: any): Promise<boolean> {
|
||||
try {
|
||||
this.parameters.parse(params);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async execute(params: any): Promise<any> {
|
||||
// Implementation here
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
174
src/websocket/client.ts
Normal file
174
src/websocket/client.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import WebSocket from 'ws';
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
export class HassWebSocketClient extends EventEmitter {
|
||||
private ws: WebSocket | null = null;
|
||||
private messageId = 1;
|
||||
private authenticated = false;
|
||||
private reconnectAttempts = 0;
|
||||
private maxReconnectAttempts = 5;
|
||||
private reconnectDelay = 1000;
|
||||
private subscriptions = new Map<string, (data: any) => void>();
|
||||
|
||||
constructor(
|
||||
private url: string,
|
||||
private token: string,
|
||||
private options: {
|
||||
autoReconnect?: boolean;
|
||||
maxReconnectAttempts?: number;
|
||||
reconnectDelay?: number;
|
||||
} = {}
|
||||
) {
|
||||
super();
|
||||
this.maxReconnectAttempts = options.maxReconnectAttempts || 5;
|
||||
this.reconnectDelay = options.reconnectDelay || 1000;
|
||||
}
|
||||
|
||||
public async connect(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
this.ws = new WebSocket(this.url);
|
||||
|
||||
this.ws.on('open', () => {
|
||||
this.authenticate();
|
||||
});
|
||||
|
||||
this.ws.on('message', (data: string) => {
|
||||
const message = JSON.parse(data);
|
||||
this.handleMessage(message);
|
||||
});
|
||||
|
||||
this.ws.on('close', () => {
|
||||
this.handleDisconnect();
|
||||
});
|
||||
|
||||
this.ws.on('error', (error) => {
|
||||
this.emit('error', error);
|
||||
reject(error);
|
||||
});
|
||||
|
||||
this.once('auth_ok', () => {
|
||||
this.authenticated = true;
|
||||
this.reconnectAttempts = 0;
|
||||
resolve();
|
||||
});
|
||||
|
||||
this.once('auth_invalid', () => {
|
||||
reject(new Error('Authentication failed'));
|
||||
});
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private authenticate(): void {
|
||||
this.send({
|
||||
type: 'auth',
|
||||
access_token: this.token
|
||||
});
|
||||
}
|
||||
|
||||
private handleMessage(message: any): void {
|
||||
switch (message.type) {
|
||||
case 'auth_required':
|
||||
this.authenticate();
|
||||
break;
|
||||
case 'auth_ok':
|
||||
this.emit('auth_ok');
|
||||
break;
|
||||
case 'auth_invalid':
|
||||
this.emit('auth_invalid');
|
||||
break;
|
||||
case 'event':
|
||||
this.handleEvent(message);
|
||||
break;
|
||||
case 'result':
|
||||
this.emit(`result_${message.id}`, message);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private handleEvent(message: any): void {
|
||||
const subscription = this.subscriptions.get(message.event.event_type);
|
||||
if (subscription) {
|
||||
subscription(message.event.data);
|
||||
}
|
||||
this.emit('event', message.event);
|
||||
}
|
||||
|
||||
private handleDisconnect(): void {
|
||||
this.authenticated = false;
|
||||
this.emit('disconnected');
|
||||
|
||||
if (this.options.autoReconnect && this.reconnectAttempts < this.maxReconnectAttempts) {
|
||||
setTimeout(() => {
|
||||
this.reconnectAttempts++;
|
||||
this.connect().catch((error) => {
|
||||
this.emit('error', error);
|
||||
});
|
||||
}, this.reconnectDelay * Math.pow(2, this.reconnectAttempts));
|
||||
}
|
||||
}
|
||||
|
||||
public async subscribeEvents(eventType: string, callback: (data: any) => void): Promise<number> {
|
||||
if (!this.authenticated) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
|
||||
const id = this.messageId++;
|
||||
this.subscriptions.set(eventType, callback);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.send({
|
||||
id,
|
||||
type: 'subscribe_events',
|
||||
event_type: eventType
|
||||
});
|
||||
|
||||
this.once(`result_${id}`, (message) => {
|
||||
if (message.success) {
|
||||
resolve(id);
|
||||
} else {
|
||||
reject(new Error(message.error?.message || 'Subscription failed'));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public async unsubscribeEvents(subscription: number): Promise<void> {
|
||||
if (!this.authenticated) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
|
||||
const id = this.messageId++;
|
||||
return new Promise((resolve, reject) => {
|
||||
this.send({
|
||||
id,
|
||||
type: 'unsubscribe_events',
|
||||
subscription
|
||||
});
|
||||
|
||||
this.once(`result_${id}`, (message) => {
|
||||
if (message.success) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(message.error?.message || 'Unsubscribe failed'));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private send(message: any): void {
|
||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify(message));
|
||||
}
|
||||
}
|
||||
|
||||
public disconnect(): void {
|
||||
if (this.ws) {
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user