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:
jango-blockchained
2025-01-30 09:18:17 +01:00
parent d7e5fcf764
commit 110f2a308c
8 changed files with 2428 additions and 129 deletions

226
src/context/index.ts Normal file
View 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();

View File

@@ -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
View 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
View 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
View 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
View 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;
}
}
}