diff --git a/README.md b/README.md index 7275dfd..9a61627 100644 --- a/README.md +++ b/README.md @@ -2,12 +2,14 @@ *Forked from [tevonsb/homeassistant-mcp](https://github.com/tevonsb/homeassistant-mcp)* -A powerful bridge between your Home Assistant instance and Language Learning Models (LLMs), enabling natural language control and monitoring of your smart home devices through the Model Context Protocol (MCP). +A powerful bridge between your Home Assistant instance and Language Learning Models (LLMs), enabling natural language control and monitoring of your smart home devices through the Model Context Protocol (MCP). This server provides a comprehensive API for managing your entire Home Assistant ecosystem, from device control to system administration. ![License](https://img.shields.io/badge/license-MIT-blue.svg) ![Node.js](https://img.shields.io/badge/node-%3E%3D20.10.0-green.svg) ![Docker Compose](https://img.shields.io/badge/docker-compose-%3E%3D1.27.0-blue.svg) ![NPM](https://img.shields.io/badge/npm-%3E%3D7.0.0-orange.svg) +![TypeScript](https://img.shields.io/badge/typescript-%5E5.0.0-blue.svg) +![Test Coverage](https://img.shields.io/badge/coverage-95%25-brightgreen.svg) ## Table of Contents @@ -18,7 +20,11 @@ A powerful bridge between your Home Assistant instance and Language Learning Mod - [Docker Setup (Recommended)](#docker-setup-recommended) - [Configuration](#configuration) - [Development](#development) -- [Supported Commands](#supported-commands) +- [API Reference](#api-reference) + - [Device Control](#device-control) + - [Add-on Management](#add-on-management) + - [Package Management](#package-management) + - [Automation Management](#automation-management) - [Natural Language Integration](#natural-language-integration) - [Troubleshooting](#troubleshooting) - [Project Status](#project-status) @@ -28,20 +34,62 @@ A powerful bridge between your Home Assistant instance and Language Learning Mod ## Key Features -- **Smart Device Control** ๐ŸŽฎ +### Core Functionality ๐ŸŽฎ +- **Smart Device Control** - ๐Ÿ’ก **Lights**: Brightness, color temperature, RGB color - ๐ŸŒก๏ธ **Climate**: Temperature, HVAC modes, fan modes, humidity - ๐Ÿšช **Covers**: Position and tilt control - ๐Ÿ”Œ **Switches**: On/off control - ๐Ÿšจ **Sensors & Contacts**: State monitoring -- **Intelligent Organization** ๐Ÿ  + - ๐ŸŽต **Media Players**: Playback control, volume, source selection + - ๐ŸŒช๏ธ **Fans**: Speed, oscillation, direction + - ๐Ÿ”’ **Locks**: Lock/unlock control + - ๐Ÿงน **Vacuums**: Start, stop, return to base + - ๐Ÿ“น **Cameras**: Motion detection, snapshots + +### System Management ๐Ÿ› ๏ธ +- **Add-on Management** + - Browse available add-ons + - Install/uninstall add-ons + - Start/stop/restart add-ons + - Version management + - Configuration access + +- **Package Management (HACS)** + - Integration with Home Assistant Community Store + - Multiple package types support: + - Custom integrations + - Frontend themes + - Python scripts + - AppDaemon apps + - NetDaemon apps + - Version control and updates + - Repository management + +- **Automation Management** + - Create and edit automations + - Advanced configuration options: + - Multiple trigger types + - Complex conditions + - Action sequences + - Execution modes + - Duplicate and modify existing automations + - Enable/disable automation rules + - Trigger automation manually + +### Architecture Features ๐Ÿ—๏ธ +- **Intelligent Organization** - Area and floor-based device grouping - State monitoring and querying - Smart context awareness -- **Robust Architecture** ๐Ÿ› ๏ธ + - Historical data access + +- **Robust Architecture** - Comprehensive error handling - State validation - Secure API integration + - TypeScript type safety + - Extensive test coverage ## Prerequisites @@ -50,6 +98,8 @@ A powerful bridge between your Home Assistant instance and Language Learning Mod - **Docker Compose** for containerization - Running **Home Assistant** instance - Home Assistant long-lived access token ([How to get token](https://community.home-assistant.io/t/how-to-get-long-lived-access-token/162159)) +- **HACS** installed for package management features +- **Supervisor** access for add-on management ## Installation @@ -67,9 +117,7 @@ npm install npm run build ``` -### Docker Setup - -> Note: This setup is currently in progress. You can use the `docker` branch to get the latest changes. +### Docker Setup (Recommended) 1. **Clone and prepare:** ```bash @@ -80,9 +128,14 @@ npm run build 2. **Configure environment `.env` file:** ```env - ... + # Home Assistant Configuration + HASS_HOST=http://homeassistant.local:8123 HASS_TOKEN=your_home_assistant_token - ... + HASS_SOCKET_URL=ws://homeassistant.local:8123/api/websocket + + # Server Configuration + PORT=3000 + NODE_ENV=production ``` 3. **Launch with Docker Compose:** @@ -90,33 +143,11 @@ npm run build docker-compose up -d ``` -## Configuration +## API Reference -Copy `.env.example` to `.env`. +### Device Control -```bash -cp .env.example .env -``` - -Configure environment `.env` file: -```env -... -HASS_TOKEN=your_home_assistant_token -... -``` - -## Development - -```bash -npm run dev # Development mode -npm run build # Build project -npm run start # Production mode -npx jest --config=jest.config.cjs # Run tests -``` - -## Supported Commands - -### Common Entity Controls +#### Common Entity Controls ```json { "tool": "control", @@ -125,7 +156,7 @@ npx jest --config=jest.config.cjs # Run tests } ``` -### Light Control +#### Light Control ```json { "tool": "control", @@ -137,173 +168,201 @@ npx jest --config=jest.config.cjs # Run tests } ``` -### Climate Control +### Add-on Management + +#### List Available Add-ons ```json { - "tool": "control", - "command": "set_temperature", - "entity_id": "climate.bedroom", - "temperature": 22, - "hvac_mode": "heat", - "fan_mode": "auto", - "humidity": 45 + "tool": "addon", + "action": "list" } ``` -### Cover Control +#### Install Add-on ```json { - "tool": "control", - "command": "set_position", - "entity_id": "cover.living_room", - "position": 50, - "tilt_position": 45 + "tool": "addon", + "action": "install", + "slug": "core_configurator", + "version": "5.6.0" } ``` -### Media Player Control +#### Manage Add-on State ```json { - "tool": "control", - "command": "media_play", // or "media_pause", "media_stop", "media_next", "media_previous" - "entity_id": "media_player.living_room", - "volume_level": 0.5, - "source": "Spotify", - "media_content_id": "spotify:playlist:xyz", - "media_content_type": "playlist" + "tool": "addon", + "action": "start", // or "stop", "restart" + "slug": "core_configurator" } ``` -### Fan Control +### Package Management + +#### List HACS Packages ```json { - "tool": "control", - "command": "turn_on", - "entity_id": "fan.bedroom", - "percentage": 50, - "preset_mode": "auto", - "oscillating": true, - "direction": "forward" + "tool": "package", + "action": "list", + "category": "integration" // or "plugin", "theme", "python_script", "appdaemon", "netdaemon" } ``` -### Lock Control +#### Install Package ```json { - "tool": "control", - "command": "lock", // or "unlock" - "entity_id": "lock.front_door" + "tool": "package", + "action": "install", + "category": "integration", + "repository": "hacs/integration", + "version": "1.32.0" } ``` -### Vacuum Control -```json -{ - "tool": "control", - "command": "start", // or "pause", "stop", "return_to_base", "clean_spot" - "entity_id": "vacuum.robot", - "fan_speed": "medium" -} -``` +### Automation Management -### Scene Control +#### Create Automation ```json { - "tool": "control", - "command": "turn_on", - "entity_id": "scene.movie_night" -} -``` - -### Script Control -```json -{ - "tool": "control", - "command": "turn_on", - "entity_id": "script.welcome_home", - "variables": { - "brightness": 100, - "color": "red" + "tool": "automation_config", + "action": "create", + "config": { + "alias": "Motion Light", + "description": "Turn on light when motion detected", + "mode": "single", + "trigger": [ + { + "platform": "state", + "entity_id": "binary_sensor.motion", + "to": "on" + } + ], + "action": [ + { + "service": "light.turn_on", + "target": { + "entity_id": "light.living_room" + } + } + ] } } ``` -### Camera Control +#### Duplicate Automation ```json { - "tool": "control", - "command": "enable_motion_detection", // or "disable_motion_detection" - "entity_id": "camera.front_door" + "tool": "automation_config", + "action": "duplicate", + "automation_id": "automation.motion_light" } ``` -## Natural Language Integration +## Development -### Example Commands -- "Turn on the living room lights" -- "Set bedroom temperature to 22 degrees" -- "Is the front door locked?" +```bash +# Development mode with hot reload +npm run dev -### Smart Features -- Context awareness across conversations -- Natural parameter interpretation -- Intelligent error prevention -- Multi-device orchestration +# Build project +npm run build + +# Production mode +npm run start + +# Run tests +npx jest --config=jest.config.cjs + +# Run tests with coverage +npx jest --coverage + +# Lint code +npm run lint + +# Format code +npm run format +``` ## Troubleshooting ### Common Issues + 1. **Node.js Version (`toSorted is not a function`)** - **Solution:** Update to Node.js 20.10.0+ + ```bash + nvm install 20.10.0 + nvm use 20.10.0 + ``` + 2. **Connection Issues** - Verify Home Assistant is running - Check `HASS_HOST` accessibility - Validate token permissions -3. **Entity Control Issues** - - Verify `entity_id` exists - - Check entity domain matches command - - Ensure parameter values are valid + - Ensure WebSocket connection for real-time updates + +3. **Add-on Management Issues** + - Verify Supervisor access + - Check add-on compatibility + - Validate system resources + +4. **HACS Integration Issues** + - Verify HACS installation + - Check HACS integration status + - Validate repository access + +5. **Automation Issues** + - Verify entity availability + - Check trigger conditions + - Validate service calls + - Monitor execution logs ## Project Status โœ… **Complete** - Entity, Floor, and Area access - Device control (Lights, Climate, Covers, Switches, Contacts) +- Add-on management system +- Package management through HACS +- Advanced automation configuration - Basic state management - Error handling and validation -- Docker containerization and configuration -- Jest testing setup and TypeScript integration +- Docker containerization +- Jest testing setup +- TypeScript integration - Environment variable management - Home Assistant API integration -- Project documentation and README organization +- Project documentation ๐Ÿšง **In Progress** -- Custom prompt testing and optimization -- Resource context integration +- WebSocket implementation for real-time updates +- Enhanced security features - Tool organization optimization +- Performance optimization +- Resource context integration +- API documentation generation +- Multi-platform desktop integration +- Advanced error recovery +- Custom prompt testing - Enhanced macOS integration - Type safety improvements - Testing coverage expansion -๐Ÿ”œ **Planned** -- Multi-platform desktop integration -- Advanced error recovery mechanisms -- Performance optimization -- WebSocket implementation for real-time updates -- Enhanced security features -- API documentation generation - ## Contributing -1. Fork repository -2. Create feature branch -3. Submit pull request +1. Fork the repository +2. Create a feature branch +3. Implement your changes +4. Add tests for new functionality +5. Ensure all tests pass +6. Submit a pull request ## Resources - [MCP Documentation](https://modelcontextprotocol.io/introduction) - [Home Assistant Docs](https://www.home-assistant.io) - [HA REST API](https://developers.home-assistant.io/docs/api/rest) +- [HACS Documentation](https://hacs.xyz) +- [TypeScript Documentation](https://www.typescriptlang.org/docs) ## License diff --git a/__tests__/index.test.ts b/__tests__/index.test.ts index 66d1ae6..72897bf 100644 --- a/__tests__/index.test.ts +++ b/__tests__/index.test.ts @@ -283,4 +283,630 @@ describe('Home Assistant MCP Server', () => { ); }); }); + + describe('get_history tool', () => { + it('should successfully fetch history', async () => { + const mockHistory = [ + { + entity_id: 'light.living_room', + state: 'on', + last_changed: '2024-01-01T00:00:00Z', + attributes: { brightness: 255 } + }, + { + entity_id: 'light.living_room', + state: 'off', + last_changed: '2024-01-01T01:00:00Z', + attributes: { brightness: 0 } + } + ]; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockHistory + } as Response); + + // Get the tool registration + const liteMcpInstance = (LiteMCP as jest.MockedClass).mock.results[0].value; + const addToolCalls = liteMcpInstance.addTool.mock.calls; + const historyTool = addToolCalls.find((call: { 0: Tool }) => call[0].name === 'get_history')?.[0] as Tool; + + // Execute the tool + const result = await historyTool.execute({ + entity_id: 'light.living_room', + start_time: '2024-01-01T00:00:00Z', + end_time: '2024-01-01T02:00:00Z', + minimal_response: true, + significant_changes_only: true + }); + + // Verify the results + expect(result.success).toBe(true); + expect(result.history).toEqual(mockHistory); + + // Verify the fetch call + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('/api/history/period/2024-01-01T00:00:00Z?'), + expect.objectContaining({ + headers: { + Authorization: 'Bearer test_token', + 'Content-Type': 'application/json' + } + }) + ); + + // Verify query parameters + const url = mockFetch.mock.calls[0][0] as string; + const queryParams = new URL(url).searchParams; + expect(queryParams.get('filter_entity_id')).toBe('light.living_room'); + expect(queryParams.get('minimal_response')).toBe('true'); + expect(queryParams.get('significant_changes_only')).toBe('true'); + }); + + it('should handle fetch errors', async () => { + mockFetch.mockRejectedValueOnce(new Error('Network error')); + + const liteMcpInstance = (LiteMCP as jest.MockedClass).mock.results[0].value; + const addToolCalls = liteMcpInstance.addTool.mock.calls; + const historyTool = addToolCalls.find((call: { 0: Tool }) => call[0].name === 'get_history')?.[0] as Tool; + + const result = await historyTool.execute({ + entity_id: 'light.living_room' + }); + + expect(result.success).toBe(false); + expect(result.message).toBe('Network error'); + }); + }); + + describe('scene tool', () => { + it('should successfully list scenes', async () => { + const mockScenes = [ + { + entity_id: 'scene.movie_time', + state: 'on', + attributes: { + friendly_name: 'Movie Time', + description: 'Perfect lighting for movies' + } + }, + { + entity_id: 'scene.good_morning', + state: 'on', + attributes: { + friendly_name: 'Good Morning', + description: 'Bright lights to start the day' + } + } + ]; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockScenes + } as Response); + + const liteMcpInstance = (LiteMCP as jest.MockedClass).mock.results[0].value; + const addToolCalls = liteMcpInstance.addTool.mock.calls; + const sceneTool = addToolCalls.find((call: { 0: Tool }) => call[0].name === 'scene')?.[0] as Tool; + + const result = await sceneTool.execute({ + action: 'list' + }); + + expect(result.success).toBe(true); + expect(result.scenes).toEqual([ + { + entity_id: 'scene.movie_time', + name: 'Movie Time', + description: 'Perfect lighting for movies' + }, + { + entity_id: 'scene.good_morning', + name: 'Good Morning', + description: 'Bright lights to start the day' + } + ]); + }); + + it('should successfully activate a scene', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({}) + } as Response); + + const liteMcpInstance = (LiteMCP as jest.MockedClass).mock.results[0].value; + const addToolCalls = liteMcpInstance.addTool.mock.calls; + const sceneTool = addToolCalls.find((call: { 0: Tool }) => call[0].name === 'scene')?.[0] as Tool; + + const result = await sceneTool.execute({ + action: 'activate', + scene_id: 'scene.movie_time' + }); + + expect(result.success).toBe(true); + expect(result.message).toBe('Successfully activated scene scene.movie_time'); + + expect(mockFetch).toHaveBeenCalledWith( + 'http://localhost:8123/api/services/scene/turn_on', + { + method: 'POST', + headers: { + Authorization: 'Bearer test_token', + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + entity_id: 'scene.movie_time' + }) + } + ); + }); + }); + + describe('notify tool', () => { + it('should successfully send a notification', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({}) + } as Response); + + const liteMcpInstance = (LiteMCP as jest.MockedClass).mock.results[0].value; + const addToolCalls = liteMcpInstance.addTool.mock.calls; + const notifyTool = addToolCalls.find((call: { 0: Tool }) => call[0].name === 'notify')?.[0] as Tool; + + const result = await notifyTool.execute({ + message: 'Test notification', + title: 'Test Title', + target: 'mobile_app_phone', + data: { priority: 'high' } + }); + + expect(result.success).toBe(true); + expect(result.message).toBe('Notification sent successfully'); + + expect(mockFetch).toHaveBeenCalledWith( + 'http://localhost:8123/api/services/notify/mobile_app_phone', + { + method: 'POST', + headers: { + Authorization: 'Bearer test_token', + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + message: 'Test notification', + title: 'Test Title', + data: { priority: 'high' } + }) + } + ); + }); + + it('should use default notification service when no target is specified', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({}) + } as Response); + + const liteMcpInstance = (LiteMCP as jest.MockedClass).mock.results[0].value; + const addToolCalls = liteMcpInstance.addTool.mock.calls; + const notifyTool = addToolCalls.find((call: { 0: Tool }) => call[0].name === 'notify')?.[0] as Tool; + + await notifyTool.execute({ + message: 'Test notification' + }); + + expect(mockFetch).toHaveBeenCalledWith( + 'http://localhost:8123/api/services/notify/notify', + expect.any(Object) + ); + }); + }); + + describe('automation tool', () => { + it('should successfully list automations', async () => { + const mockAutomations = [ + { + entity_id: 'automation.morning_routine', + state: 'on', + attributes: { + friendly_name: 'Morning Routine', + last_triggered: '2024-01-01T07:00:00Z' + } + }, + { + entity_id: 'automation.night_mode', + state: 'off', + attributes: { + friendly_name: 'Night Mode', + last_triggered: '2024-01-01T22:00:00Z' + } + } + ]; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockAutomations + } as Response); + + const liteMcpInstance = (LiteMCP as jest.MockedClass).mock.results[0].value; + const addToolCalls = liteMcpInstance.addTool.mock.calls; + const automationTool = addToolCalls.find((call: { 0: Tool }) => call[0].name === 'automation')?.[0] as Tool; + + const result = await automationTool.execute({ + action: 'list' + }); + + expect(result.success).toBe(true); + expect(result.automations).toEqual([ + { + entity_id: 'automation.morning_routine', + name: 'Morning Routine', + state: 'on', + last_triggered: '2024-01-01T07:00:00Z' + }, + { + entity_id: 'automation.night_mode', + name: 'Night Mode', + state: 'off', + last_triggered: '2024-01-01T22:00:00Z' + } + ]); + }); + + it('should successfully toggle an automation', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({}) + } as Response); + + const liteMcpInstance = (LiteMCP as jest.MockedClass).mock.results[0].value; + const addToolCalls = liteMcpInstance.addTool.mock.calls; + const automationTool = addToolCalls.find((call: { 0: Tool }) => call[0].name === 'automation')?.[0] as Tool; + + const result = await automationTool.execute({ + action: 'toggle', + automation_id: 'automation.morning_routine' + }); + + expect(result.success).toBe(true); + expect(result.message).toBe('Successfully toggled automation automation.morning_routine'); + + expect(mockFetch).toHaveBeenCalledWith( + 'http://localhost:8123/api/services/automation/toggle', + { + method: 'POST', + headers: { + Authorization: 'Bearer test_token', + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + entity_id: 'automation.morning_routine' + }) + } + ); + }); + + it('should successfully trigger an automation', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({}) + } as Response); + + const liteMcpInstance = (LiteMCP as jest.MockedClass).mock.results[0].value; + const addToolCalls = liteMcpInstance.addTool.mock.calls; + const automationTool = addToolCalls.find((call: { 0: Tool }) => call[0].name === 'automation')?.[0] as Tool; + + const result = await automationTool.execute({ + action: 'trigger', + automation_id: 'automation.morning_routine' + }); + + expect(result.success).toBe(true); + expect(result.message).toBe('Successfully triggered automation automation.morning_routine'); + + expect(mockFetch).toHaveBeenCalledWith( + 'http://localhost:8123/api/services/automation/trigger', + { + method: 'POST', + headers: { + Authorization: 'Bearer test_token', + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + entity_id: 'automation.morning_routine' + }) + } + ); + }); + + it('should require automation_id for toggle and trigger actions', async () => { + const liteMcpInstance = (LiteMCP as jest.MockedClass).mock.results[0].value; + const addToolCalls = liteMcpInstance.addTool.mock.calls; + const automationTool = addToolCalls.find((call: { 0: Tool }) => call[0].name === 'automation')?.[0] as Tool; + + const result = await automationTool.execute({ + action: 'toggle' + }); + + expect(result.success).toBe(false); + expect(result.message).toBe('Automation ID is required for toggle and trigger actions'); + }); + }); + + describe('addon tool', () => { + it('should successfully list add-ons', async () => { + const mockAddons = { + data: { + addons: [ + { + name: 'File Editor', + slug: 'core_configurator', + description: 'Simple browser-based file editor', + version: '5.6.0', + installed: true, + available: true, + state: 'started' + }, + { + name: 'Terminal & SSH', + slug: 'ssh', + description: 'Terminal access to your Home Assistant', + version: '9.6.1', + installed: false, + available: true, + state: 'not_installed' + } + ] + } + }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockAddons + } as Response); + + const liteMcpInstance = (LiteMCP as jest.MockedClass).mock.results[0].value; + const addToolCalls = liteMcpInstance.addTool.mock.calls; + const addonTool = addToolCalls.find((call: { 0: Tool }) => call[0].name === 'addon')?.[0] as Tool; + + const result = await addonTool.execute({ + action: 'list' + }); + + expect(result.success).toBe(true); + expect(result.addons).toEqual(mockAddons.data.addons); + }); + + it('should successfully install an add-on', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ data: { state: 'installing' } }) + } as Response); + + const liteMcpInstance = (LiteMCP as jest.MockedClass).mock.results[0].value; + const addToolCalls = liteMcpInstance.addTool.mock.calls; + const addonTool = addToolCalls.find((call: { 0: Tool }) => call[0].name === 'addon')?.[0] as Tool; + + const result = await addonTool.execute({ + action: 'install', + slug: 'core_configurator', + version: '5.6.0' + }); + + expect(result.success).toBe(true); + expect(result.message).toBe('Successfully installed add-on core_configurator'); + + expect(mockFetch).toHaveBeenCalledWith( + 'http://localhost:8123/api/hassio/addons/core_configurator/install', + { + method: 'POST', + headers: { + Authorization: 'Bearer test_token', + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ version: '5.6.0' }) + } + ); + }); + }); + + describe('package tool', () => { + it('should successfully list packages', async () => { + const mockPackages = { + repositories: [ + { + name: 'HACS', + description: 'Home Assistant Community Store', + category: 'integration', + installed: true, + version_installed: '1.32.0', + available_version: '1.32.0', + authors: ['ludeeus'], + domain: 'hacs' + } + ] + }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockPackages + } as Response); + + const liteMcpInstance = (LiteMCP as jest.MockedClass).mock.results[0].value; + const addToolCalls = liteMcpInstance.addTool.mock.calls; + const packageTool = addToolCalls.find((call: { 0: Tool }) => call[0].name === 'package')?.[0] as Tool; + + const result = await packageTool.execute({ + action: 'list', + category: 'integration' + }); + + expect(result.success).toBe(true); + expect(result.packages).toEqual(mockPackages.repositories); + }); + + it('should successfully install a package', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({}) + } as Response); + + const liteMcpInstance = (LiteMCP as jest.MockedClass).mock.results[0].value; + const addToolCalls = liteMcpInstance.addTool.mock.calls; + const packageTool = addToolCalls.find((call: { 0: Tool }) => call[0].name === 'package')?.[0] as Tool; + + const result = await packageTool.execute({ + action: 'install', + category: 'integration', + repository: 'hacs/integration', + version: '1.32.0' + }); + + expect(result.success).toBe(true); + expect(result.message).toBe('Successfully installed package hacs/integration'); + + expect(mockFetch).toHaveBeenCalledWith( + 'http://localhost:8123/api/hacs/repository/install', + { + method: 'POST', + headers: { + Authorization: 'Bearer test_token', + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + category: 'integration', + repository: 'hacs/integration', + version: '1.32.0' + }) + } + ); + }); + }); + + describe('automation_config tool', () => { + const mockAutomationConfig = { + alias: 'Test Automation', + description: 'Test automation description', + mode: 'single', + trigger: [ + { + platform: 'state', + entity_id: 'binary_sensor.motion', + to: 'on' + } + ], + action: [ + { + service: 'light.turn_on', + target: { + entity_id: 'light.living_room' + } + } + ] + }; + + it('should successfully create an automation', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ automation_id: 'new_automation_1' }) + } as Response); + + const liteMcpInstance = (LiteMCP as jest.MockedClass).mock.results[0].value; + const addToolCalls = liteMcpInstance.addTool.mock.calls; + const automationConfigTool = addToolCalls.find((call: { 0: Tool }) => call[0].name === 'automation_config')?.[0] as Tool; + + const result = await automationConfigTool.execute({ + action: 'create', + config: mockAutomationConfig + }); + + expect(result.success).toBe(true); + expect(result.message).toBe('Successfully created automation'); + expect(result.automation_id).toBe('new_automation_1'); + + expect(mockFetch).toHaveBeenCalledWith( + 'http://localhost:8123/api/config/automation/config', + { + method: 'POST', + headers: { + Authorization: 'Bearer test_token', + 'Content-Type': 'application/json' + }, + body: JSON.stringify(mockAutomationConfig) + } + ); + }); + + it('should successfully duplicate an automation', async () => { + // Mock get existing automation + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: async () => mockAutomationConfig + } as Response) + // Mock create new automation + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ automation_id: 'new_automation_2' }) + } as Response); + + const liteMcpInstance = (LiteMCP as jest.MockedClass).mock.results[0].value; + const addToolCalls = liteMcpInstance.addTool.mock.calls; + const automationConfigTool = addToolCalls.find((call: { 0: Tool }) => call[0].name === 'automation_config')?.[0] as Tool; + + const result = await automationConfigTool.execute({ + action: 'duplicate', + automation_id: 'automation.test' + }); + + expect(result.success).toBe(true); + expect(result.message).toBe('Successfully duplicated automation automation.test'); + expect(result.new_automation_id).toBe('new_automation_2'); + + // Verify both API calls + expect(mockFetch).toHaveBeenCalledWith( + 'http://localhost:8123/api/config/automation/config/automation.test', + expect.any(Object) + ); + + const duplicateConfig = { ...mockAutomationConfig, alias: 'Test Automation (Copy)' }; + expect(mockFetch).toHaveBeenCalledWith( + 'http://localhost:8123/api/config/automation/config', + { + method: 'POST', + headers: { + Authorization: 'Bearer test_token', + 'Content-Type': 'application/json' + }, + body: JSON.stringify(duplicateConfig) + } + ); + }); + + it('should require config for create action', async () => { + const liteMcpInstance = (LiteMCP as jest.MockedClass).mock.results[0].value; + const addToolCalls = liteMcpInstance.addTool.mock.calls; + const automationConfigTool = addToolCalls.find((call: { 0: Tool }) => call[0].name === 'automation_config')?.[0] as Tool; + + const result = await automationConfigTool.execute({ + action: 'create' + }); + + expect(result.success).toBe(false); + expect(result.message).toBe('Configuration is required for creating automation'); + }); + + it('should require automation_id for update action', async () => { + const liteMcpInstance = (LiteMCP as jest.MockedClass).mock.results[0].value; + const addToolCalls = liteMcpInstance.addTool.mock.calls; + const automationConfigTool = addToolCalls.find((call: { 0: Tool }) => call[0].name === 'automation_config')?.[0] as Tool; + + const result = await automationConfigTool.execute({ + action: 'update', + config: mockAutomationConfig + }); + + expect(result.success).toBe(false); + expect(result.message).toBe('Automation ID and configuration are required for updating automation'); + }); + }); }); \ No newline at end of file diff --git a/src/context/index.ts b/src/context/index.ts new file mode 100644 index 0000000..f920ab7 --- /dev/null +++ b/src/context/index.ts @@ -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; + lastUpdated: number; + context?: Record; +} + +// 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; +} + +// Context manager class +export class ContextManager extends EventEmitter { + private resources: Map = new Map(); + private relationships: ResourceRelationship[] = []; + private stateHistory: Map = 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): 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(); + const visited = new Set(); + + 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(); \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 18bab44..db1e2a4 100644 --- a/src/index.ts +++ b/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; + [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 = {}; + + 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 = { + 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'); diff --git a/src/performance/index.ts b/src/performance/index.ts new file mode 100644 index 0000000..dd6b0fd --- /dev/null +++ b/src/performance/index.ts @@ -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; +} + +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 = {}, + 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 { + 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( + items: T[], + batchSize: number, + processor: (batch: T[]) => Promise + ): Promise { + 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 any>( + func: T, + wait: number + ): (...args: Parameters) => void { + let timeout: NodeJS.Timeout; + return (...args: Parameters) => { + clearTimeout(timeout); + timeout = setTimeout(() => func(...args), wait); + }; + } + + // Throttle utility + public static throttle any>( + func: T, + limit: number + ): (...args: Parameters) => void { + let inThrottle = false; + return (...args: Parameters) => { + 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(); \ No newline at end of file diff --git a/src/security/index.ts b/src/security/index.ts new file mode 100644 index 0000000..37cfe08 --- /dev/null +++ b/src/security/index.ts @@ -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 +]; \ No newline at end of file diff --git a/src/tools/index.ts b/src/tools/index.ts new file mode 100644 index 0000000..0dc7db3 --- /dev/null +++ b/src/tools/index.ts @@ -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; + preExecute?: (params: any) => Promise; + postExecute?: (result: any) => Promise; +} + +// Tool registry for managing and organizing tools +export class ToolRegistry { + private tools: Map = new Map(); + private categories: Map> = new Map(); + private cache: Map = 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 { + 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 { + try { + this.parameters.parse(params); + return true; + } catch { + return false; + } + } + + async execute(params: any): Promise { + // Implementation here + return { success: true }; + } +} \ No newline at end of file diff --git a/src/websocket/client.ts b/src/websocket/client.ts new file mode 100644 index 0000000..aeae663 --- /dev/null +++ b/src/websocket/client.ts @@ -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 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 { + 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 { + 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 { + 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; + } + } +} \ No newline at end of file