- Change default PORT in .env.example to 7123 and update CORS origins - Disable speech features in .env.example for a cleaner setup - Modify Dockerfile to streamline Python dependency installation and improve build performance - Add fix-env.js script to ensure NODE_ENV is set correctly before application starts - Update smithery.yaml to include new Home Assistant connection parameters - Introduce start.sh script to set NODE_ENV and start the application
1217 lines
45 KiB
TypeScript
1217 lines
45 KiB
TypeScript
import fetch from "node-fetch";
|
||
import OpenAI from "openai";
|
||
import { DOMParser, Element, Document } from '@xmldom/xmldom';
|
||
import dotenv from 'dotenv';
|
||
import readline from 'readline';
|
||
import chalk from 'chalk';
|
||
|
||
// Load environment variables
|
||
dotenv.config();
|
||
|
||
// Retrieve API keys from environment variables
|
||
const openaiApiKey = process.env.OPENAI_API_KEY;
|
||
const hassToken = process.env.HASS_TOKEN;
|
||
|
||
if (!openaiApiKey) {
|
||
console.error("Please set the OPENAI_API_KEY environment variable.");
|
||
process.exit(1);
|
||
}
|
||
|
||
if (!hassToken) {
|
||
console.error("Please set the HASS_TOKEN environment variable.");
|
||
process.exit(1);
|
||
}
|
||
|
||
// MCP Server configuration
|
||
const MCP_SERVER = 'http://localhost:3000';
|
||
|
||
interface McpTool {
|
||
name: string;
|
||
description: string;
|
||
parameters: {
|
||
properties: Record<string, any>;
|
||
required: string[];
|
||
};
|
||
}
|
||
|
||
interface ToolsResponse {
|
||
tools: McpTool[];
|
||
}
|
||
|
||
interface SystemAnalysis {
|
||
overview: {
|
||
state: string[];
|
||
health: string[];
|
||
configurations: string[];
|
||
integrations: string[];
|
||
issues: string[];
|
||
};
|
||
performance: {
|
||
resource_usage: string[];
|
||
response_times: string[];
|
||
optimization_areas: string[];
|
||
};
|
||
security: {
|
||
current_measures: string[];
|
||
vulnerabilities: string[];
|
||
recommendations: string[];
|
||
};
|
||
optimization: {
|
||
performance_suggestions: string[];
|
||
config_optimizations: string[];
|
||
integration_improvements: string[];
|
||
automation_opportunities: string[];
|
||
};
|
||
maintenance: {
|
||
required_updates: string[];
|
||
cleanup_tasks: string[];
|
||
regular_tasks: string[];
|
||
};
|
||
entity_usage: {
|
||
most_active: string[];
|
||
rarely_used: string[];
|
||
potential_duplicates: string[];
|
||
};
|
||
automation_analysis: {
|
||
inefficient_automations: string[];
|
||
potential_improvements: string[];
|
||
suggested_blueprints: string[];
|
||
condition_optimizations: string[];
|
||
};
|
||
energy_management?: {
|
||
high_consumption: string[];
|
||
monitoring_suggestions: string[];
|
||
tariff_optimizations: string[];
|
||
};
|
||
}
|
||
|
||
interface McpSchema {
|
||
tools: McpTool[];
|
||
prompts: any[];
|
||
resources: {
|
||
name: string;
|
||
url: string;
|
||
}[];
|
||
}
|
||
|
||
interface McpExecuteResponse {
|
||
success: boolean;
|
||
message?: string;
|
||
devices?: Record<string, any[]>;
|
||
}
|
||
|
||
interface ListDevicesResponse {
|
||
success: boolean;
|
||
message?: string;
|
||
devices?: Record<string, any[]>;
|
||
}
|
||
|
||
// Add model configuration interface
|
||
interface ModelConfig {
|
||
name: string;
|
||
maxTokens: number;
|
||
contextWindow: number;
|
||
}
|
||
|
||
// Update model listing to filter based on API key availability
|
||
const AVAILABLE_MODELS: ModelConfig[] = [
|
||
// OpenAI models always available
|
||
{ name: 'gpt-4', maxTokens: 8192, contextWindow: 8192 },
|
||
{ name: 'gpt-4-turbo-preview', maxTokens: 4096, contextWindow: 128000 },
|
||
{ name: 'gpt-3.5-turbo', maxTokens: 4096, contextWindow: 16385 },
|
||
{ name: 'gpt-3.5-turbo-16k', maxTokens: 16385, contextWindow: 16385 },
|
||
|
||
// Conditionally include DeepSeek models
|
||
...(process.env.DEEPSEEK_API_KEY ? [
|
||
{ name: 'deepseek-v3', maxTokens: 4096, contextWindow: 128000 },
|
||
{ name: 'deepseek-r1', maxTokens: 4096, contextWindow: 1000000 }
|
||
] : [])
|
||
];
|
||
|
||
// Add configuration interface
|
||
interface AppConfig {
|
||
mcpServer: string;
|
||
openaiModel: string;
|
||
maxRetries: number;
|
||
analysisTimeout: number;
|
||
selectedModel: ModelConfig;
|
||
}
|
||
|
||
// Add colored logging functions
|
||
const logger = {
|
||
info: (msg: string) => console.log(chalk.blue(`ℹ ${msg}`)),
|
||
success: (msg: string) => console.log(chalk.green(`✓ ${msg}`)),
|
||
warn: (msg: string) => console.log(chalk.yellow(`⚠ ${msg}`)),
|
||
error: (msg: string) => console.log(chalk.red(`✗ ${msg}`)),
|
||
debug: (msg: string) => process.env.DEBUG && console.log(chalk.gray(`› ${msg}`))
|
||
};
|
||
|
||
// Update default model selection in loadConfig
|
||
function loadConfig(): AppConfig {
|
||
// Always use gpt-4 for now
|
||
const defaultModel = AVAILABLE_MODELS.find(m => m.name === 'gpt-4') || AVAILABLE_MODELS[0];
|
||
|
||
return {
|
||
mcpServer: process.env.MCP_SERVER || 'http://localhost:3000',
|
||
openaiModel: defaultModel.name,
|
||
maxRetries: parseInt(process.env.MAX_RETRIES || '3'),
|
||
analysisTimeout: parseInt(process.env.ANALYSIS_TIMEOUT || '30000'),
|
||
selectedModel: defaultModel
|
||
};
|
||
}
|
||
|
||
function getOpenAIClient(): OpenAI {
|
||
const config = loadConfig();
|
||
|
||
return new OpenAI({
|
||
apiKey: config.selectedModel.name.startsWith('deepseek')
|
||
? process.env.DEEPSEEK_API_KEY
|
||
: openaiApiKey,
|
||
baseURL: config.selectedModel.name.startsWith('deepseek')
|
||
? 'https://api.deepseek.com/v1'
|
||
: 'https://api.openai.com/v1'
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Executes a tool on the MCP server
|
||
*/
|
||
async function executeMcpTool(toolName: string, parameters: Record<string, any> = {}): Promise<any> {
|
||
const config = loadConfig();
|
||
let attempt = 0;
|
||
|
||
while (attempt <= config.maxRetries) {
|
||
try {
|
||
const controller = new AbortController();
|
||
const timeoutId = setTimeout(() => controller.abort(), config.analysisTimeout);
|
||
|
||
// Update endpoint URL to use the correct API path
|
||
const endpoint = `${config.mcpServer}/api/mcp/execute`;
|
||
|
||
const response = await fetch(endpoint, {
|
||
method: "POST",
|
||
headers: {
|
||
'Authorization': `Bearer ${hassToken}`,
|
||
'Content-Type': "application/json",
|
||
'Accept': 'application/json'
|
||
},
|
||
body: JSON.stringify({ tool: toolName, parameters }),
|
||
signal: controller.signal
|
||
});
|
||
|
||
clearTimeout(timeoutId);
|
||
|
||
if (response.ok) {
|
||
const data = await response.json();
|
||
if (!isMcpExecuteResponse(data)) {
|
||
throw new Error('Invalid MCP response structure');
|
||
}
|
||
return data;
|
||
}
|
||
|
||
if (response.status === 429) {
|
||
const retryAfter = response.headers.get('Retry-After') || '1';
|
||
await new Promise(resolve => setTimeout(resolve, parseInt(retryAfter) * 1000));
|
||
continue;
|
||
}
|
||
|
||
if (response.status === 404) {
|
||
logger.error(`Endpoint not found: ${endpoint}`);
|
||
return { success: false, message: 'Endpoint not found' };
|
||
}
|
||
|
||
if (response.status >= 500) {
|
||
logger.warn(`Server error (${response.status}), retrying...`);
|
||
attempt++;
|
||
continue;
|
||
}
|
||
|
||
handleHttpError(response.status);
|
||
return { success: false, message: `HTTP error ${response.status}` };
|
||
|
||
} catch (error) {
|
||
if (error.name === 'AbortError') {
|
||
logger.warn(`Request timed out, retrying (${attempt + 1}/${config.maxRetries})...`);
|
||
attempt++;
|
||
continue;
|
||
}
|
||
logger.error(`Error executing tool ${toolName}: ${error.message}`);
|
||
return { success: false, message: error.message };
|
||
}
|
||
}
|
||
return { success: false, message: 'Max retries exceeded' };
|
||
}
|
||
|
||
// Add type guard for MCP responses
|
||
function isMcpExecuteResponse(obj: any): obj is McpExecuteResponse {
|
||
return typeof obj === 'object' &&
|
||
'success' in obj &&
|
||
(obj.success === true || typeof obj.message === 'string');
|
||
}
|
||
|
||
// Add mock data for testing
|
||
const MOCK_HA_INFO = {
|
||
devices: {
|
||
light: [
|
||
{ entity_id: 'light.living_room', state: 'on', attributes: { friendly_name: 'Living Room Light', brightness: 255 } },
|
||
{ entity_id: 'light.kitchen', state: 'off', attributes: { friendly_name: 'Kitchen Light', brightness: 0 } }
|
||
],
|
||
switch: [
|
||
{ entity_id: 'switch.tv', state: 'off', attributes: { friendly_name: 'TV Power' } }
|
||
],
|
||
sensor: [
|
||
{ entity_id: 'sensor.temperature', state: '21.5', attributes: { friendly_name: 'Living Room Temperature', unit_of_measurement: '°C' } },
|
||
{ entity_id: 'sensor.humidity', state: '45', attributes: { friendly_name: 'Living Room Humidity', unit_of_measurement: '%' } }
|
||
],
|
||
climate: [
|
||
{ entity_id: 'climate.thermostat', state: 'heat', attributes: { friendly_name: 'Main Thermostat', current_temperature: 20, target_temp_high: 24 } }
|
||
]
|
||
}
|
||
};
|
||
|
||
interface HassState {
|
||
entity_id: string;
|
||
state: string;
|
||
attributes: Record<string, any>;
|
||
last_changed: string;
|
||
last_updated: string;
|
||
}
|
||
|
||
interface ServiceInfo {
|
||
name: string;
|
||
description: string;
|
||
fields: Record<string, any>;
|
||
}
|
||
|
||
interface ServiceDomain {
|
||
domain: string;
|
||
services: Record<string, ServiceInfo>;
|
||
}
|
||
|
||
/**
|
||
* Collects comprehensive information about the Home Assistant instance using MCP tools
|
||
*/
|
||
async function collectHomeAssistantInfo(): Promise<any> {
|
||
const info: Record<string, any> = {};
|
||
const hassHost = process.env.HASS_HOST;
|
||
|
||
try {
|
||
// Check if we're in test mode
|
||
if (process.env.HA_TEST_MODE === '1') {
|
||
logger.info("Running in test mode with mock data");
|
||
return MOCK_HA_INFO;
|
||
}
|
||
|
||
// Get states from Home Assistant directly
|
||
const statesResponse = await fetch(`${hassHost}/api/states`, {
|
||
headers: {
|
||
'Authorization': `Bearer ${hassToken}`,
|
||
'Content-Type': 'application/json'
|
||
}
|
||
});
|
||
|
||
if (!statesResponse.ok) {
|
||
throw new Error(`Failed to fetch states: ${statesResponse.status}`);
|
||
}
|
||
|
||
const states = await statesResponse.json() as HassState[];
|
||
|
||
// Group devices by domain
|
||
const devices: Record<string, HassState[]> = {};
|
||
for (const state of states) {
|
||
const [domain] = state.entity_id.split('.');
|
||
if (!devices[domain]) {
|
||
devices[domain] = [];
|
||
}
|
||
devices[domain].push(state);
|
||
}
|
||
|
||
info.devices = devices;
|
||
info.device_summary = {
|
||
total_devices: states.length,
|
||
device_types: Object.keys(devices),
|
||
by_domain: Object.fromEntries(
|
||
Object.entries(devices).map(([domain, items]) => [domain, items.length])
|
||
)
|
||
};
|
||
|
||
const deviceCount = states.length;
|
||
const domainCount = Object.keys(devices).length;
|
||
|
||
if (deviceCount > 0) {
|
||
logger.success(`Found ${deviceCount} devices across ${domainCount} domains`);
|
||
} else {
|
||
logger.warn('No devices found in Home Assistant');
|
||
}
|
||
|
||
return info;
|
||
} catch (error) {
|
||
logger.error(`Error fetching devices: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||
if (process.env.HA_TEST_MODE !== '1') {
|
||
logger.warn(`Failed to connect to Home Assistant. Run with HA_TEST_MODE=1 to use test data.`);
|
||
return {
|
||
devices: {},
|
||
device_summary: {
|
||
total_devices: 0,
|
||
device_types: [],
|
||
by_domain: {}
|
||
}
|
||
};
|
||
}
|
||
return MOCK_HA_INFO;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Formats the analysis into a nice looking console output
|
||
*/
|
||
function formatAnalysis(analysis: SystemAnalysis): string {
|
||
const formatSection = (items: string[]): string =>
|
||
items.map(item => ` • ${item}`).join('\n');
|
||
|
||
return `
|
||
=== System Overview ===
|
||
Current State: ${analysis.overview.state.join(', ')}
|
||
Health: ${analysis.overview.health.join(', ')}
|
||
|
||
Notable Configurations:
|
||
${formatSection(analysis.overview.configurations)}
|
||
|
||
Active Integrations:
|
||
${formatSection(analysis.overview.integrations)}
|
||
|
||
Identified Issues:
|
||
${formatSection(analysis.overview.issues)}
|
||
|
||
=== Performance Analysis ===
|
||
Resource Usage:
|
||
${formatSection(analysis.performance.resource_usage)}
|
||
|
||
Response Times:
|
||
${formatSection(analysis.performance.response_times)}
|
||
|
||
Areas Needing Optimization:
|
||
${formatSection(analysis.performance.optimization_areas)}
|
||
|
||
=== Security Assessment ===
|
||
Current Security Measures:
|
||
${formatSection(analysis.security.current_measures)}
|
||
|
||
Potential Vulnerabilities:
|
||
${formatSection(analysis.security.vulnerabilities)}
|
||
|
||
Security Recommendations:
|
||
${formatSection(analysis.security.recommendations)}
|
||
|
||
=== Optimization Recommendations ===
|
||
Performance Improvements:
|
||
${formatSection(analysis.optimization.performance_suggestions)}
|
||
|
||
Configuration Optimizations:
|
||
${formatSection(analysis.optimization.config_optimizations)}
|
||
|
||
Integration Improvements:
|
||
${formatSection(analysis.optimization.integration_improvements)}
|
||
|
||
Automation Opportunities:
|
||
${formatSection(analysis.optimization.automation_opportunities)}
|
||
|
||
=== Maintenance Tasks ===
|
||
Required Updates:
|
||
${formatSection(analysis.maintenance.required_updates)}
|
||
|
||
Cleanup Tasks:
|
||
${formatSection(analysis.maintenance.cleanup_tasks)}
|
||
|
||
Regular Maintenance:
|
||
${formatSection(analysis.maintenance.regular_tasks)}
|
||
`;
|
||
}
|
||
|
||
// Update compression function with filtering
|
||
function compressHaInfo(haInfo: any, focus?: string): string {
|
||
return JSON.stringify(haInfo, (key: string, value: any) => {
|
||
// Filter based on device type if focus exists
|
||
if (focus && key === 'devices') {
|
||
const focusedTypes = getRelevantDeviceTypes(focus);
|
||
return Object.fromEntries(
|
||
Object.entries(value).filter(([domain]) =>
|
||
focusedTypes.includes(domain)
|
||
)
|
||
);
|
||
}
|
||
|
||
// Existing compression logic
|
||
if (key === 'attributes') {
|
||
return Object.keys(value).length > 0 ? value : undefined;
|
||
}
|
||
return value;
|
||
}, 2); // Added space parameter of 2 for better readability
|
||
}
|
||
|
||
// Add device type mapping
|
||
function getRelevantDeviceTypes(prompt: string): string[] {
|
||
const TYPE_MAP: Record<string, string[]> = {
|
||
light: ['light', 'switch', 'group'],
|
||
temperature: ['climate', 'sensor'],
|
||
security: ['binary_sensor', 'alarm_control_panel']
|
||
};
|
||
|
||
return Object.entries(TYPE_MAP)
|
||
.filter(([keyword]) => prompt.toLowerCase().includes(keyword))
|
||
.flatMap(([, types]) => types);
|
||
}
|
||
|
||
/**
|
||
* Generates analysis and recommendations using the OpenAI API based on the Home Assistant data
|
||
*/
|
||
async function generateAnalysis(haInfo: any): Promise<SystemAnalysis> {
|
||
const config = loadConfig();
|
||
|
||
// If in test mode, return mock analysis
|
||
if (process.env.HA_TEST_MODE === '1') {
|
||
logger.info("Generating mock analysis...");
|
||
return {
|
||
overview: {
|
||
state: ["System running normally", "4 device types detected"],
|
||
health: ["All systems operational", "No critical issues found"],
|
||
configurations: ["Basic configuration detected", "Default settings in use"],
|
||
integrations: ["Light", "Switch", "Sensor", "Climate"],
|
||
issues: ["No major issues detected"]
|
||
},
|
||
performance: {
|
||
resource_usage: ["Normal CPU usage", "Memory usage within limits"],
|
||
response_times: ["Average response time: 0.5s"],
|
||
optimization_areas: ["Consider grouping lights by room"]
|
||
},
|
||
security: {
|
||
current_measures: ["Basic security measures in place"],
|
||
vulnerabilities: ["No critical vulnerabilities detected"],
|
||
recommendations: ["Enable 2FA if not already enabled"]
|
||
},
|
||
optimization: {
|
||
performance_suggestions: ["Group frequently used devices"],
|
||
config_optimizations: ["Consider creating room-based views"],
|
||
integration_improvements: ["Add friendly names to all entities"],
|
||
automation_opportunities: ["Create morning/evening routines"]
|
||
},
|
||
maintenance: {
|
||
required_updates: ["No critical updates pending"],
|
||
cleanup_tasks: ["Remove unused entities"],
|
||
regular_tasks: ["Check sensor battery levels"]
|
||
},
|
||
entity_usage: {
|
||
most_active: ["light.living_room", "sensor.temperature"],
|
||
rarely_used: ["switch.tv"],
|
||
potential_duplicates: []
|
||
},
|
||
automation_analysis: {
|
||
inefficient_automations: [],
|
||
potential_improvements: ["Add time-based light controls"],
|
||
suggested_blueprints: ["Motion-activated lighting"],
|
||
condition_optimizations: []
|
||
},
|
||
energy_management: {
|
||
high_consumption: ["No high consumption devices detected"],
|
||
monitoring_suggestions: ["Add power monitoring to main appliances"],
|
||
tariff_optimizations: ["Consider time-of-use automation"]
|
||
}
|
||
};
|
||
}
|
||
|
||
// Original analysis code for non-test mode
|
||
const openai = getOpenAIClient();
|
||
|
||
const systemSummary = {
|
||
total_devices: haInfo.device_summary?.total_devices || 0,
|
||
device_types: haInfo.device_summary?.device_types || [],
|
||
device_summary: haInfo.device_summary?.by_domain || {}
|
||
};
|
||
|
||
const prompt = `Analyze this Home Assistant system and provide insights in XML format:
|
||
${JSON.stringify(systemSummary, null, 2)}
|
||
|
||
Focus on:
|
||
1. System health and state
|
||
2. Performance optimization
|
||
3. Security considerations
|
||
4. Maintenance needs
|
||
5. Integration improvements
|
||
6. Automation opportunities
|
||
|
||
Generate your response in this EXACT format:
|
||
<analysis>
|
||
<overview>
|
||
<state>Current system state summary</state>
|
||
<health>System health assessment</health>
|
||
<configurations>Key configuration insights</configurations>
|
||
<integrations>Integration status</integrations>
|
||
<issues>Identified issues</issues>
|
||
</overview>
|
||
<performance>
|
||
<resource_usage>Resource usage insights</resource_usage>
|
||
<response_times>Response time analysis</response_times>
|
||
<optimization_areas>Areas needing optimization</optimization_areas>
|
||
</performance>
|
||
<security>
|
||
<current_measures>Current security measures</current_measures>
|
||
<vulnerabilities>Potential vulnerabilities</vulnerabilities>
|
||
<recommendations>Security recommendations</recommendations>
|
||
</security>
|
||
<optimization>
|
||
<performance_suggestions>Performance improvement suggestions</performance_suggestions>
|
||
<config_optimizations>Configuration optimization ideas</config_optimizations>
|
||
<integration_improvements>Integration improvement suggestions</integration_improvements>
|
||
<automation_opportunities>Automation opportunities</automation_opportunities>
|
||
</optimization>
|
||
<maintenance>
|
||
<required_updates>Required updates</required_updates>
|
||
<cleanup_tasks>Cleanup tasks</cleanup_tasks>
|
||
<regular_tasks>Regular maintenance tasks</regular_tasks>
|
||
</maintenance>
|
||
<entity_usage>
|
||
<most_active>Most active entities</most_active>
|
||
<rarely_used>Rarely used entities</rarely_used>
|
||
<potential_duplicates>Potential duplicate entities</potential_duplicates>
|
||
</entity_usage>
|
||
<automation_analysis>
|
||
<inefficient_automations>Inefficient automations</inefficient_automations>
|
||
<potential_improvements>Potential improvements</potential_improvements>
|
||
<suggested_blueprints>Suggested blueprints</suggested_blueprints>
|
||
<condition_optimizations>Condition optimizations</condition_optimizations>
|
||
</automation_analysis>
|
||
<energy_management>
|
||
<high_consumption>High consumption devices</high_consumption>
|
||
<monitoring_suggestions>Monitoring suggestions</monitoring_suggestions>
|
||
<tariff_optimizations>Tariff optimizations</tariff_optimizations>
|
||
</energy_management>
|
||
</analysis>`;
|
||
|
||
try {
|
||
const completion = await openai.chat.completions.create({
|
||
model: config.selectedModel.name,
|
||
messages: [
|
||
{
|
||
role: "system",
|
||
content: "You are a Home Assistant expert. Analyze the system data and provide detailed insights in the specified XML format. Be specific and actionable in your recommendations."
|
||
},
|
||
{ role: "user", content: prompt }
|
||
],
|
||
temperature: 0.7,
|
||
max_tokens: Math.min(config.selectedModel.maxTokens, 4000)
|
||
});
|
||
|
||
const result = completion.choices[0].message?.content || "";
|
||
|
||
// Clean the response and parse XML
|
||
const cleanedResult = result.replace(/```xml/g, '').replace(/```/g, '').trim();
|
||
const parser = new DOMParser();
|
||
|
||
try {
|
||
const xmlDoc = parser.parseFromString(cleanedResult, "text/xml");
|
||
|
||
if (xmlDoc.getElementsByTagName('analysis').length === 0) {
|
||
throw new Error('Invalid XML response structure');
|
||
}
|
||
|
||
// Helper function to get text content safely
|
||
const getTextContent = (path: string): string[] => {
|
||
const elements = xmlDoc.getElementsByTagName(path);
|
||
return Array.from(elements).map(el => el.textContent || '').filter(Boolean);
|
||
};
|
||
|
||
// Map XML response to SystemAnalysis structure
|
||
const analysis: SystemAnalysis = {
|
||
overview: {
|
||
state: getTextContent('state'),
|
||
health: getTextContent('health'),
|
||
configurations: getTextContent('configurations'),
|
||
integrations: getTextContent('integrations'),
|
||
issues: getTextContent('issues'),
|
||
},
|
||
performance: {
|
||
resource_usage: getTextContent('resource_usage'),
|
||
response_times: getTextContent('response_times'),
|
||
optimization_areas: getTextContent('optimization_areas'),
|
||
},
|
||
security: {
|
||
current_measures: getTextContent('current_measures'),
|
||
vulnerabilities: getTextContent('vulnerabilities'),
|
||
recommendations: getTextContent('recommendations'),
|
||
},
|
||
optimization: {
|
||
performance_suggestions: getTextContent('performance_suggestions'),
|
||
config_optimizations: getTextContent('config_optimizations'),
|
||
integration_improvements: getTextContent('integration_improvements'),
|
||
automation_opportunities: getTextContent('automation_opportunities'),
|
||
},
|
||
maintenance: {
|
||
required_updates: getTextContent('required_updates'),
|
||
cleanup_tasks: getTextContent('cleanup_tasks'),
|
||
regular_tasks: getTextContent('regular_tasks'),
|
||
},
|
||
entity_usage: {
|
||
most_active: getTextContent('most_active'),
|
||
rarely_used: getTextContent('rarely_used'),
|
||
potential_duplicates: getTextContent('potential_duplicates'),
|
||
},
|
||
automation_analysis: {
|
||
inefficient_automations: getTextContent('inefficient_automations'),
|
||
potential_improvements: getTextContent('potential_improvements'),
|
||
suggested_blueprints: getTextContent('suggested_blueprints'),
|
||
condition_optimizations: getTextContent('condition_optimizations'),
|
||
},
|
||
energy_management: {
|
||
high_consumption: getTextContent('high_consumption'),
|
||
monitoring_suggestions: getTextContent('monitoring_suggestions'),
|
||
tariff_optimizations: getTextContent('tariff_optimizations'),
|
||
}
|
||
};
|
||
|
||
return analysis;
|
||
} catch (parseError) {
|
||
throw new Error(`Failed to parse analysis response: ${parseError.message}`);
|
||
}
|
||
} catch (error) {
|
||
console.error("Error during OpenAI API call:", error);
|
||
throw new Error("Failed to generate analysis");
|
||
}
|
||
}
|
||
|
||
interface AutomationConfig {
|
||
id?: string;
|
||
alias?: string;
|
||
description?: string;
|
||
trigger?: Array<{
|
||
platform: string;
|
||
[key: string]: any;
|
||
}>;
|
||
condition?: Array<{
|
||
condition: string;
|
||
[key: string]: any;
|
||
}>;
|
||
action?: Array<{
|
||
service?: string;
|
||
[key: string]: any;
|
||
}>;
|
||
mode?: string;
|
||
}
|
||
|
||
async function handleAutomationOptimization(haInfo: any): Promise<void> {
|
||
try {
|
||
const hassHost = process.env.HASS_HOST;
|
||
|
||
// Get automations directly from Home Assistant
|
||
const automationsResponse = await fetch(`${hassHost}/api/states`, {
|
||
headers: {
|
||
'Authorization': `Bearer ${hassToken}`,
|
||
'Content-Type': 'application/json'
|
||
}
|
||
});
|
||
|
||
if (!automationsResponse.ok) {
|
||
throw new Error(`Failed to fetch automations: ${automationsResponse.status}`);
|
||
}
|
||
|
||
const states = await automationsResponse.json() as HassState[];
|
||
const automations = states.filter(state => state.entity_id.startsWith('automation.'));
|
||
|
||
// Get services to understand what actions are available
|
||
const servicesResponse = await fetch(`${hassHost}/api/services`, {
|
||
headers: {
|
||
'Authorization': `Bearer ${hassToken}`,
|
||
'Content-Type': 'application/json'
|
||
}
|
||
});
|
||
|
||
let availableServices: Record<string, any> = {};
|
||
if (servicesResponse.ok) {
|
||
const services = await servicesResponse.json() as ServiceDomain[];
|
||
availableServices = services.reduce((acc: Record<string, any>, service: ServiceDomain) => {
|
||
if (service.domain && service.services) {
|
||
acc[service.domain] = service.services;
|
||
}
|
||
return acc;
|
||
}, {});
|
||
logger.debug(`Retrieved services from ${Object.keys(availableServices).length} domains`);
|
||
}
|
||
|
||
// Enrich automation data with service information
|
||
const enrichedAutomations = automations.map(automation => {
|
||
const actions = automation.attributes?.action || [];
|
||
const enrichedActions = actions.map((action: any) => {
|
||
if (action.service) {
|
||
const [domain, service] = action.service.split('.');
|
||
const serviceInfo = availableServices[domain]?.[service];
|
||
return {
|
||
...action,
|
||
service_info: serviceInfo
|
||
};
|
||
}
|
||
return action;
|
||
});
|
||
|
||
return {
|
||
...automation,
|
||
config: {
|
||
id: automation.entity_id.split('.')[1],
|
||
alias: automation.attributes?.friendly_name,
|
||
trigger: automation.attributes?.trigger || [],
|
||
condition: automation.attributes?.condition || [],
|
||
action: enrichedActions,
|
||
mode: automation.attributes?.mode || 'single'
|
||
}
|
||
};
|
||
});
|
||
|
||
if (automations.length === 0) {
|
||
console.log(chalk.bold.underline("\nAutomation Optimization Report"));
|
||
console.log(chalk.yellow("No automations found in the system. Consider creating some automations to improve your Home Assistant experience."));
|
||
return;
|
||
}
|
||
|
||
logger.info(`Analyzing ${automations.length} automations...`);
|
||
const optimizationXml = await analyzeAutomations(enrichedAutomations);
|
||
|
||
const parser = new DOMParser();
|
||
const xmlDoc = parser.parseFromString(optimizationXml, "text/xml");
|
||
|
||
const formatSection = (title: string, items: string[]) => {
|
||
if (!items || items.length === 0) return '';
|
||
return `${chalk.bold(title)}:\n${items.map(i => ` • ${i}`).join('\n')}`;
|
||
};
|
||
|
||
console.log(chalk.bold.underline("\nAutomation Optimization Report"));
|
||
|
||
const findings = getItems(xmlDoc, "analysis > findings > item");
|
||
const recommendations = getItems(xmlDoc, "analysis > recommendations > item");
|
||
const blueprints = getItems(xmlDoc, "analysis > blueprints > item");
|
||
|
||
if (!findings.length && !recommendations.length && !blueprints.length) {
|
||
console.log(chalk.green("✓ Your automations appear to be well-configured! No immediate optimizations needed."));
|
||
console.log("\nSuggestions for future improvements:");
|
||
console.log(" • Consider adding error handling to critical automations");
|
||
console.log(" • Review automation triggers periodically for efficiency");
|
||
console.log(" • Monitor automation performance over time");
|
||
return;
|
||
}
|
||
|
||
const findingsSection = formatSection("Key Findings", findings);
|
||
const recommendationsSection = formatSection("Recommendations", recommendations);
|
||
const blueprintsSection = formatSection("Suggested Blueprints", blueprints);
|
||
|
||
if (findingsSection) console.log(findingsSection);
|
||
if (recommendationsSection) console.log("\n" + recommendationsSection);
|
||
if (blueprintsSection) console.log("\n" + blueprintsSection);
|
||
|
||
} catch (error) {
|
||
logger.error(`Automation optimization failed: ${error.message}`);
|
||
console.log(chalk.yellow("\nSuggested actions:"));
|
||
console.log(" • Check if your Home Assistant instance is running");
|
||
console.log(" • Verify your authentication token");
|
||
console.log(" • Try running the analysis again in a few minutes");
|
||
}
|
||
}
|
||
|
||
async function analyzeAutomations(automations: any[]): Promise<string> {
|
||
const openai = getOpenAIClient();
|
||
const config = loadConfig();
|
||
|
||
// Create a more detailed summary of automations
|
||
const automationSummary = {
|
||
total: automations.length,
|
||
active: automations.filter(a => a.state === 'on').length,
|
||
by_type: automations.reduce((acc: Record<string, number>, auto) => {
|
||
const type = auto.attributes?.mode || 'single';
|
||
acc[type] = (acc[type] || 0) + 1;
|
||
return acc;
|
||
}, {}),
|
||
recently_triggered: automations.filter(a => {
|
||
const lastTriggered = a.attributes?.last_triggered;
|
||
if (!lastTriggered) return false;
|
||
const lastTriggerDate = new Date(lastTriggered);
|
||
const oneDayAgo = new Date();
|
||
oneDayAgo.setDate(oneDayAgo.getDate() - 1);
|
||
return lastTriggerDate > oneDayAgo;
|
||
}).length,
|
||
trigger_types: automations.reduce((acc: Record<string, number>, auto) => {
|
||
const triggers = auto.config?.trigger || [];
|
||
triggers.forEach((trigger: any) => {
|
||
const type = trigger.platform || 'unknown';
|
||
acc[type] = (acc[type] || 0) + 1;
|
||
});
|
||
return acc;
|
||
}, {}),
|
||
action_types: automations.reduce((acc: Record<string, number>, auto) => {
|
||
const actions = auto.config?.action || [];
|
||
actions.forEach((action: any) => {
|
||
const type = action.service?.split('.')[0] || 'unknown';
|
||
acc[type] = (acc[type] || 0) + 1;
|
||
});
|
||
return acc;
|
||
}, {}),
|
||
service_domains: Array.from(new Set(automations.flatMap(auto =>
|
||
(auto.config?.action || [])
|
||
.map((action: any) => action.service?.split('.')[0])
|
||
.filter(Boolean)
|
||
))).sort(),
|
||
names: automations.map(a => a.attributes?.friendly_name || a.entity_id.split('.')[1]).slice(0, 10)
|
||
};
|
||
|
||
const prompt = `Analyze these Home Assistant automations and provide optimization suggestions in XML format:
|
||
${JSON.stringify(automationSummary, null, 2)}
|
||
|
||
Key metrics:
|
||
- Total automations: ${automationSummary.total}
|
||
- Active automations: ${automationSummary.active}
|
||
- Recently triggered: ${automationSummary.recently_triggered}
|
||
- Automation modes: ${JSON.stringify(automationSummary.by_type)}
|
||
- Trigger types: ${JSON.stringify(automationSummary.trigger_types)}
|
||
- Action types: ${JSON.stringify(automationSummary.action_types)}
|
||
- Service domains used: ${automationSummary.service_domains.join(', ')}
|
||
|
||
Generate your response in this EXACT format:
|
||
<analysis>
|
||
<findings>
|
||
<item>Finding 1</item>
|
||
<item>Finding 2</item>
|
||
</findings>
|
||
<recommendations>
|
||
<item>Recommendation 1</item>
|
||
<item>Recommendation 2</item>
|
||
</recommendations>
|
||
<blueprints>
|
||
<item>Blueprint suggestion 1</item>
|
||
<item>Blueprint suggestion 2</item>
|
||
</blueprints>
|
||
</analysis>
|
||
|
||
Focus on:
|
||
1. Identifying patterns and potential improvements based on trigger and action types
|
||
2. Suggesting energy-saving optimizations based on the services being used
|
||
3. Recommending error handling improvements
|
||
4. Suggesting relevant blueprints for common automation patterns
|
||
5. Analyzing the distribution of automation types and suggesting optimizations`;
|
||
|
||
try {
|
||
const completion = await openai.chat.completions.create({
|
||
model: config.selectedModel.name,
|
||
messages: [
|
||
{
|
||
role: "system",
|
||
content: "You are a Home Assistant automation expert. Analyze the provided automation summary and respond with specific, actionable suggestions in the required XML format."
|
||
},
|
||
{ role: "user", content: prompt }
|
||
],
|
||
temperature: 0.2,
|
||
max_tokens: Math.min(config.selectedModel.maxTokens, 2048)
|
||
});
|
||
|
||
const response = completion.choices[0].message?.content || "";
|
||
|
||
// Ensure the response is valid XML
|
||
if (!response.trim().startsWith('<analysis>')) {
|
||
return `<?xml version="1.0"?>
|
||
<analysis>
|
||
<findings>
|
||
<item>No issues found in automation analysis</item>
|
||
</findings>
|
||
<recommendations>
|
||
<item>Current automation setup appears optimal</item>
|
||
</recommendations>
|
||
<blueprints>
|
||
<item>No additional blueprints needed at this time</item>
|
||
</blueprints>
|
||
</analysis>`;
|
||
}
|
||
|
||
return response;
|
||
} catch (error) {
|
||
logger.error(`Automation analysis failed: ${error.message}`);
|
||
return `<?xml version="1.0"?>
|
||
<analysis>
|
||
<findings>
|
||
<item>Error during analysis: ${error.message}</item>
|
||
</findings>
|
||
<recommendations>
|
||
<item>Please try again later</item>
|
||
<item>Check your Home Assistant connection</item>
|
||
<item>Verify your authentication token</item>
|
||
</recommendations>
|
||
<blueprints>
|
||
<item>No blueprint suggestions available</item>
|
||
</blueprints>
|
||
</analysis>`;
|
||
}
|
||
}
|
||
|
||
// Add new handleCustomPrompt function
|
||
async function handleCustomPrompt(haInfo: any, customPrompt: string): Promise<void> {
|
||
try {
|
||
// Add device metadata
|
||
const deviceTypes = haInfo.devices ? Object.keys(haInfo.devices) : [];
|
||
const deviceStates = haInfo.devices ? Object.entries(haInfo.devices).reduce((acc: Record<string, number>, [domain, devices]) => {
|
||
acc[domain] = (devices as any[]).length;
|
||
return acc;
|
||
}, {}) : {};
|
||
const totalDevices = deviceTypes.reduce((sum, type) => sum + deviceStates[type], 0);
|
||
|
||
// Get automation information
|
||
const automations = haInfo.devices?.automation || [];
|
||
const automationDetails = automations.map((auto: any) => ({
|
||
name: auto.attributes?.friendly_name || auto.entity_id.split('.')[1],
|
||
state: auto.state,
|
||
last_triggered: auto.attributes?.last_triggered,
|
||
mode: auto.attributes?.mode,
|
||
triggers: auto.attributes?.trigger?.map((t: any) => ({
|
||
platform: t.platform,
|
||
...t
|
||
})) || [],
|
||
conditions: auto.attributes?.condition?.map((c: any) => ({
|
||
condition: c.condition,
|
||
...c
|
||
})) || [],
|
||
actions: auto.attributes?.action?.map((a: any) => ({
|
||
service: a.service,
|
||
...a
|
||
})) || []
|
||
}));
|
||
|
||
const automationSummary = {
|
||
total: automations.length,
|
||
active: automations.filter((a: any) => a.state === 'on').length,
|
||
trigger_types: automations.reduce((acc: Record<string, number>, auto: any) => {
|
||
const triggers = auto.attributes?.trigger || [];
|
||
triggers.forEach((trigger: any) => {
|
||
const type = trigger.platform || 'unknown';
|
||
acc[type] = (acc[type] || 0) + 1;
|
||
});
|
||
return acc;
|
||
}, {}),
|
||
action_types: automations.reduce((acc: Record<string, number>, auto: any) => {
|
||
const actions = auto.attributes?.action || [];
|
||
actions.forEach((action: any) => {
|
||
const type = action.service?.split('.')[0] || 'unknown';
|
||
acc[type] = (acc[type] || 0) + 1;
|
||
});
|
||
return acc;
|
||
}, {}),
|
||
service_domains: Array.from(new Set(automations.flatMap((auto: any) =>
|
||
(auto.attributes?.action || [])
|
||
.map((action: any) => action.service?.split('.')[0])
|
||
.filter(Boolean)
|
||
))).sort()
|
||
};
|
||
|
||
// Create a summary of the devices
|
||
const deviceSummary = Object.entries(deviceStates)
|
||
.map(([domain, count]) => `${domain}: ${count}`)
|
||
.join(', ');
|
||
|
||
if (process.env.HA_TEST_MODE === '1') {
|
||
console.log("\nTest Mode Analysis Results:\n");
|
||
console.log("Based on your Home Assistant setup with:");
|
||
console.log(`- ${totalDevices} total devices`);
|
||
console.log(`- Device types: ${deviceTypes.join(', ')}`);
|
||
console.log("\nAnalysis for prompt: " + customPrompt);
|
||
console.log("1. Current State:");
|
||
console.log(" - All devices are functioning normally");
|
||
console.log(" - System is responsive and stable");
|
||
console.log("\n2. Recommendations:");
|
||
console.log(" - Consider grouping devices by room");
|
||
console.log(" - Add automation for frequently used devices");
|
||
console.log(" - Monitor power usage of main appliances");
|
||
console.log("\n3. Optimization Opportunities:");
|
||
console.log(" - Create scenes for different times of day");
|
||
console.log(" - Set up presence detection for automatic control");
|
||
return;
|
||
}
|
||
|
||
const openai = getOpenAIClient();
|
||
const config = loadConfig();
|
||
|
||
const completion = await openai.chat.completions.create({
|
||
model: config.selectedModel.name,
|
||
messages: [
|
||
{
|
||
role: "system",
|
||
content: `You are a Home Assistant expert. Analyze the following Home Assistant information and respond to the user's prompt.
|
||
Current system has ${totalDevices} devices across ${deviceTypes.length} types.
|
||
Device distribution: ${deviceSummary}
|
||
|
||
Automation Summary:
|
||
- Total automations: ${automationSummary.total}
|
||
- Active automations: ${automationSummary.active}
|
||
- Trigger types: ${JSON.stringify(automationSummary.trigger_types)}
|
||
- Action types: ${JSON.stringify(automationSummary.action_types)}
|
||
- Service domains used: ${automationSummary.service_domains.join(', ')}
|
||
|
||
Detailed Automation List:
|
||
${JSON.stringify(automationDetails, null, 2)}`
|
||
},
|
||
{ role: "user", content: customPrompt },
|
||
],
|
||
max_tokens: Math.min(config.selectedModel.maxTokens, 2048), // Limit token usage
|
||
temperature: 0.3,
|
||
});
|
||
|
||
console.log("\nAnalysis Results:\n");
|
||
console.log(completion.choices[0].message?.content || "No response generated");
|
||
|
||
} catch (error) {
|
||
console.error("Error processing custom prompt:", error);
|
||
|
||
if (process.env.HA_TEST_MODE === '1') {
|
||
console.log("\nTest Mode Fallback Analysis:\n");
|
||
console.log("1. System Overview:");
|
||
console.log(" - Basic configuration detected");
|
||
console.log(" - All core services operational");
|
||
console.log("\n2. Suggestions:");
|
||
console.log(" - Review device naming conventions");
|
||
console.log(" - Consider adding automation blueprints");
|
||
return;
|
||
}
|
||
|
||
// Retry with simplified prompt if there's an error
|
||
try {
|
||
const retryPrompt = "Please provide a simpler analysis of the Home Assistant system.";
|
||
const openai = getOpenAIClient();
|
||
const config = loadConfig();
|
||
|
||
const retryCompletion = await openai.chat.completions.create({
|
||
model: config.selectedModel.name,
|
||
messages: [
|
||
{
|
||
role: "system",
|
||
content: "You are a Home Assistant expert. Provide a simple analysis of the system."
|
||
},
|
||
{ role: "user", content: retryPrompt },
|
||
],
|
||
max_tokens: Math.min(config.selectedModel.maxTokens, 2048), // Limit token usage
|
||
temperature: 0.3,
|
||
});
|
||
|
||
console.log("\nAnalysis Results:\n");
|
||
console.log(retryCompletion.choices[0].message?.content || "No response generated");
|
||
} catch (retryError) {
|
||
console.error("Error during retry:", retryError);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Enhanced main function with progress indicators
|
||
async function main() {
|
||
let config = loadConfig();
|
||
|
||
logger.info(`Starting analysis with ${config.selectedModel.name} model...`);
|
||
|
||
try {
|
||
logger.info("Collecting Home Assistant information...");
|
||
const haInfo = await collectHomeAssistantInfo();
|
||
|
||
if (!Object.keys(haInfo).length) {
|
||
logger.error("Failed to collect Home Assistant information");
|
||
return;
|
||
}
|
||
|
||
logger.success(`Collected data from ${Object.keys(haInfo.devices).length} device types`);
|
||
|
||
// Get mode from command line argument or default to 1
|
||
const mode = process.argv[2] || "1";
|
||
|
||
console.log("\nAvailable modes:");
|
||
console.log("1. Standard Analysis");
|
||
console.log("2. Custom Prompt");
|
||
console.log("3. Automation Optimization");
|
||
console.log(`Selected mode: ${mode}\n`);
|
||
|
||
if (mode === "2") {
|
||
// For custom prompt mode, get the prompt from remaining arguments
|
||
const customPrompt = process.argv.slice(3).join(" ") || "Analyze my Home Assistant setup";
|
||
console.log(`Custom prompt: ${customPrompt}\n`);
|
||
await handleCustomPrompt(haInfo, customPrompt);
|
||
} else if (mode === "3") {
|
||
await handleAutomationOptimization(haInfo);
|
||
} else {
|
||
logger.info("Generating standard analysis...");
|
||
const analysis = await generateAnalysis(haInfo);
|
||
const formattedAnalysis = formatAnalysis(analysis);
|
||
console.log("\n" + chalk.bold.underline("Home Assistant Analysis") + "\n");
|
||
console.log(formattedAnalysis);
|
||
}
|
||
|
||
} catch (error) {
|
||
logger.error(`Critical failure: ${error.message}`);
|
||
process.exit(1);
|
||
}
|
||
}
|
||
|
||
// Add HTTP error handler
|
||
function handleHttpError(status: number): void {
|
||
const errors: Record<number, string> = {
|
||
400: 'Invalid request parameters',
|
||
401: 'Authentication failed - check HASS_TOKEN',
|
||
403: 'Insufficient permissions',
|
||
404: 'Endpoint not found',
|
||
429: 'Too many requests'
|
||
};
|
||
|
||
logger.error(errors[status] || `HTTP error ${status}`);
|
||
}
|
||
|
||
// Add helper function for XML parsing
|
||
function getItems(xmlDoc: Document, path: string): string[] {
|
||
return Array.from(xmlDoc.getElementsByTagName('item'))
|
||
.filter(item => {
|
||
let parent = item.parentNode;
|
||
const pathParts = path.split('>').reverse();
|
||
for (const part of pathParts) {
|
||
if (!parent || parent.nodeName !== part.trim()) return false;
|
||
parent = parent.parentNode;
|
||
}
|
||
return true;
|
||
})
|
||
.map(item => (item as Element).textContent || "");
|
||
}
|
||
|
||
// Replace the Express server initialization at the bottom with Bun's server
|
||
if (process.env.PROCESSOR_TYPE === 'openai') {
|
||
// Initialize Bun server for OpenAI
|
||
const server = Bun.serve({
|
||
port: process.env.PORT || 3000,
|
||
async fetch(req) {
|
||
const url = new URL(req.url);
|
||
|
||
// Handle chat endpoint
|
||
if (url.pathname === '/chat' && req.method === 'POST') {
|
||
try {
|
||
const body = await req.json();
|
||
// Handle chat logic here
|
||
return new Response(JSON.stringify({ success: true }), {
|
||
headers: { 'Content-Type': 'application/json' }
|
||
});
|
||
} catch (error) {
|
||
return new Response(JSON.stringify({
|
||
success: false,
|
||
error: error.message
|
||
}), {
|
||
status: 400,
|
||
headers: { 'Content-Type': 'application/json' }
|
||
});
|
||
}
|
||
}
|
||
|
||
// Handle 404 for unknown routes
|
||
return new Response('Not Found', { status: 404 });
|
||
},
|
||
});
|
||
|
||
console.log(`[OpenAI Server] Running on port ${server.port}`);
|
||
} else {
|
||
console.log('[Claude Mode] Using stdio communication');
|
||
}
|
||
|
||
main().catch((error) => {
|
||
console.error("Unexpected error:", error);
|
||
});
|