diff --git a/.env.test.example b/.env.test.example new file mode 100644 index 0000000..5d6c1aa --- /dev/null +++ b/.env.test.example @@ -0,0 +1,11 @@ +# Test Environment Configuration + +# Home Assistant Test Instance +TEST_HASS_HOST=http://localhost:8123 +TEST_HASS_TOKEN=test_token +TEST_HASS_SOCKET_URL=ws://localhost:8123/api/websocket + +# Test Server Configuration +TEST_PORT=3001 +NODE_ENV=test +DEBUG=false \ No newline at end of file diff --git a/README.md b/README.md index 9a61627..b0078c3 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,11 @@ npm run build # Server Configuration PORT=3000 NODE_ENV=production + DEBUG=false + + # Test Configuration + TEST_HASS_HOST=http://localhost:8123 + TEST_HASS_TOKEN=test_token ``` 3. **Launch with Docker Compose:** @@ -143,6 +148,32 @@ npm run build docker-compose up -d ``` +## Configuration + +### Environment Variables + +```env +# Home Assistant Configuration +HASS_HOST=http://homeassistant.local:8123 # Your Home Assistant instance URL +HASS_TOKEN=your_home_assistant_token # Long-lived access token +HASS_SOCKET_URL=ws://homeassistant.local:8123/api/websocket # WebSocket URL + +# Server Configuration +PORT=3000 # Server port (default: 3000) +NODE_ENV=production # Environment (production/development) +DEBUG=false # Enable debug mode + +# Test Configuration +TEST_HASS_HOST=http://localhost:8123 # Test instance URL +TEST_HASS_TOKEN=test_token # Test token +``` + +### Configuration Files + +1. **Development**: Copy `.env.example` to `.env.development` +2. **Production**: Copy `.env.example` to `.env.production` +3. **Testing**: Copy `.env.example` to `.env.test` + ## API Reference ### Device Control @@ -258,6 +289,268 @@ npm run build } ``` +### Core Functions + +#### State Management +```http +GET /api/state +POST /api/state +``` + +Manages the current state of the system. + +**Example Request:** +```json +POST /api/state +{ + "context": "living_room", + "state": { + "lights": "on", + "temperature": 22 + } +} +``` + +#### Context Updates +```http +POST /api/context +``` + +Updates the current context with new information. + +**Example Request:** +```json +POST /api/context +{ + "user": "john", + "location": "kitchen", + "time": "morning", + "activity": "cooking" +} +``` + +### Action Endpoints + +#### Execute Action +```http +POST /api/action +``` + +Executes a specified action with given parameters. + +**Example Request:** +```json +POST /api/action +{ + "action": "turn_on_lights", + "parameters": { + "room": "living_room", + "brightness": 80 + } +} +``` + +#### Batch Actions +```http +POST /api/actions/batch +``` + +Executes multiple actions in sequence. + +**Example Request:** +```json +POST /api/actions/batch +{ + "actions": [ + { + "action": "turn_on_lights", + "parameters": { + "room": "living_room" + } + }, + { + "action": "set_temperature", + "parameters": { + "temperature": 22 + } + } + ] +} +``` + +### Query Functions + +#### Get Available Actions +```http +GET /api/actions +``` + +Returns a list of all available actions. + +**Example Response:** +```json +{ + "actions": [ + { + "name": "turn_on_lights", + "parameters": ["room", "brightness"], + "description": "Turns on lights in specified room" + }, + { + "name": "set_temperature", + "parameters": ["temperature"], + "description": "Sets temperature in current context" + } + ] +} +``` + +#### Context Query +```http +GET /api/context?type=current +``` + +Retrieves context information. + +**Example Response:** +```json +{ + "current_context": { + "user": "john", + "location": "kitchen", + "time": "morning", + "activity": "cooking" + } +} +``` + +### WebSocket Events + +The server supports real-time updates via WebSocket connections. + +```javascript +// Client-side connection example +const ws = new WebSocket('ws://localhost:3000/ws'); + +ws.onmessage = (event) => { + const data = JSON.parse(event.data); + console.log('Received update:', data); +}; +``` + +#### Supported Events + +- `state_change`: Emitted when system state changes +- `context_update`: Emitted when context is updated +- `action_executed`: Emitted when an action is completed +- `error`: Emitted when an error occurs + +**Example Event Data:** +```json +{ + "event": "state_change", + "data": { + "previous_state": { + "lights": "off" + }, + "current_state": { + "lights": "on" + }, + "timestamp": "2024-03-20T10:30:00Z" + } +} +``` + +### Error Handling + +All endpoints return standard HTTP status codes: + +- 200: Success +- 400: Bad Request +- 401: Unauthorized +- 403: Forbidden +- 404: Not Found +- 500: Internal Server Error + +**Error Response Format:** +```json +{ + "error": { + "code": "INVALID_PARAMETERS", + "message": "Missing required parameter: room", + "details": { + "missing_fields": ["room"] + } + } +} +``` + +### Rate Limiting + +The API implements rate limiting to prevent abuse: + +- 100 requests per minute per IP for regular endpoints +- 1000 requests per minute per IP for WebSocket connections + +When rate limit is exceeded, the server returns: + +```json +{ + "error": { + "code": "RATE_LIMIT_EXCEEDED", + "message": "Too many requests", + "reset_time": "2024-03-20T10:31:00Z" + } +} +``` + +### Example Usage + +#### Using curl +```bash +# Get current state +curl -X GET \ + http://localhost:3000/api/state \ + -H 'Authorization: ApiKey your_api_key_here' + +# Execute action +curl -X POST \ + http://localhost:3000/api/action \ + -H 'Authorization: ApiKey your_api_key_here' \ + -H 'Content-Type: application/json' \ + -d '{ + "action": "turn_on_lights", + "parameters": { + "room": "living_room", + "brightness": 80 + } + }' +``` + +#### Using JavaScript +```javascript +// Execute action +async function executeAction() { + const response = await fetch('http://localhost:3000/api/action', { + method: 'POST', + headers: { + 'Authorization': 'ApiKey your_api_key_here', + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + action: 'turn_on_lights', + parameters: { + room: 'living_room', + brightness: 80 + } + }) + }); + + const data = await response.json(); + console.log('Action result:', data); +} +``` + ## Development ```bash diff --git a/__tests__/index.test.ts b/__tests__/index.test.ts index dfb2b65..ef548bc 100644 --- a/__tests__/index.test.ts +++ b/__tests__/index.test.ts @@ -4,9 +4,15 @@ import { LiteMCP } from 'litemcp'; import { get_hass } from '../src/hass/index.js'; import type { WebSocket } from 'ws'; -// Mock environment variables -process.env.HASS_HOST = 'http://localhost:8123'; -process.env.HASS_TOKEN = 'test_token'; +// Load test environment variables with defaults +const TEST_HASS_HOST = process.env.TEST_HASS_HOST || 'http://localhost:8123'; +const TEST_HASS_TOKEN = process.env.TEST_HASS_TOKEN || 'test_token'; +const TEST_HASS_SOCKET_URL = process.env.TEST_HASS_SOCKET_URL || 'ws://localhost:8123/api/websocket'; + +// Set environment variables for testing +process.env.HASS_HOST = TEST_HASS_HOST; +process.env.HASS_TOKEN = TEST_HASS_TOKEN; +process.env.HASS_SOCKET_URL = TEST_HASS_SOCKET_URL; // Mock fetch const mockFetchResponse = { @@ -230,11 +236,11 @@ describe('Home Assistant MCP Server', () => { // Verify the fetch call expect(mockFetch).toHaveBeenCalledWith( - 'http://localhost:8123/api/services/light/turn_on', + `${TEST_HASS_HOST}/api/services/light/turn_on`, { method: 'POST', headers: { - Authorization: 'Bearer test_token', + Authorization: `Bearer ${TEST_HASS_TOKEN}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -312,11 +318,11 @@ describe('Home Assistant MCP Server', () => { // Verify the fetch call expect(mockFetch).toHaveBeenCalledWith( - 'http://localhost:8123/api/services/climate/set_temperature', + `${TEST_HASS_HOST}/api/services/climate/set_temperature`, { method: 'POST', headers: { - Authorization: 'Bearer test_token', + Authorization: `Bearer ${TEST_HASS_TOKEN}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -354,11 +360,11 @@ describe('Home Assistant MCP Server', () => { // Verify the fetch call expect(mockFetch).toHaveBeenCalledWith( - 'http://localhost:8123/api/services/cover/set_position', + `${TEST_HASS_HOST}/api/services/cover/set_position`, { method: 'POST', headers: { - Authorization: 'Bearer test_token', + Authorization: `Bearer ${TEST_HASS_TOKEN}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -415,7 +421,7 @@ describe('Home Assistant MCP Server', () => { expect.stringContaining('/api/history/period/2024-01-01T00:00:00Z?'), expect.objectContaining({ headers: { - Authorization: 'Bearer test_token', + Authorization: `Bearer ${TEST_HASS_TOKEN}`, 'Content-Type': 'application/json' } }) @@ -513,11 +519,11 @@ describe('Home Assistant MCP Server', () => { expect(result.message).toBe('Successfully activated scene scene.movie_time'); expect(mockFetch).toHaveBeenCalledWith( - 'http://localhost:8123/api/services/scene/turn_on', + `${TEST_HASS_HOST}/api/services/scene/turn_on`, { method: 'POST', headers: { - Authorization: 'Bearer test_token', + Authorization: `Bearer ${TEST_HASS_TOKEN}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -550,11 +556,11 @@ describe('Home Assistant MCP Server', () => { expect(result.message).toBe('Notification sent successfully'); expect(mockFetch).toHaveBeenCalledWith( - 'http://localhost:8123/api/services/notify/mobile_app_phone', + `${TEST_HASS_HOST}/api/services/notify/mobile_app_phone`, { method: 'POST', headers: { - Authorization: 'Bearer test_token', + Authorization: `Bearer ${TEST_HASS_TOKEN}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -581,7 +587,7 @@ describe('Home Assistant MCP Server', () => { }); expect(mockFetch).toHaveBeenCalledWith( - 'http://localhost:8123/api/services/notify/notify', + `${TEST_HASS_HOST}/api/services/notify/notify`, expect.any(Object) ); }); @@ -657,11 +663,11 @@ describe('Home Assistant MCP Server', () => { expect(result.message).toBe('Successfully toggled automation automation.morning_routine'); expect(mockFetch).toHaveBeenCalledWith( - 'http://localhost:8123/api/services/automation/toggle', + `${TEST_HASS_HOST}/api/services/automation/toggle`, { method: 'POST', headers: { - Authorization: 'Bearer test_token', + Authorization: `Bearer ${TEST_HASS_TOKEN}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -690,11 +696,11 @@ describe('Home Assistant MCP Server', () => { expect(result.message).toBe('Successfully triggered automation automation.morning_routine'); expect(mockFetch).toHaveBeenCalledWith( - 'http://localhost:8123/api/services/automation/trigger', + `${TEST_HASS_HOST}/api/services/automation/trigger`, { method: 'POST', headers: { - Authorization: 'Bearer test_token', + Authorization: `Bearer ${TEST_HASS_TOKEN}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -782,11 +788,11 @@ describe('Home Assistant MCP Server', () => { expect(result.message).toBe('Successfully installed add-on core_configurator'); expect(mockFetch).toHaveBeenCalledWith( - 'http://localhost:8123/api/hassio/addons/core_configurator/install', + `${TEST_HASS_HOST}/api/hassio/addons/core_configurator/install`, { method: 'POST', headers: { - Authorization: 'Bearer test_token', + Authorization: `Bearer ${TEST_HASS_TOKEN}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ version: '5.6.0' }) @@ -851,11 +857,11 @@ describe('Home Assistant MCP Server', () => { expect(result.message).toBe('Successfully installed package hacs/integration'); expect(mockFetch).toHaveBeenCalledWith( - 'http://localhost:8123/api/hacs/repository/install', + `${TEST_HASS_HOST}/api/hacs/repository/install`, { method: 'POST', headers: { - Authorization: 'Bearer test_token', + Authorization: `Bearer ${TEST_HASS_TOKEN}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -910,11 +916,11 @@ describe('Home Assistant MCP Server', () => { expect(result.automation_id).toBe('new_automation_1'); expect(mockFetch).toHaveBeenCalledWith( - 'http://localhost:8123/api/config/automation/config', + `${TEST_HASS_HOST}/api/config/automation/config`, { method: 'POST', headers: { - Authorization: 'Bearer test_token', + Authorization: `Bearer ${TEST_HASS_TOKEN}`, 'Content-Type': 'application/json' }, body: JSON.stringify(mockAutomationConfig) @@ -950,17 +956,17 @@ describe('Home Assistant MCP Server', () => { // Verify both API calls expect(mockFetch).toHaveBeenCalledWith( - 'http://localhost:8123/api/config/automation/config/automation.test', + `${TEST_HASS_HOST}/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', + `${TEST_HASS_HOST}/api/config/automation/config`, { method: 'POST', headers: { - Authorization: 'Bearer test_token', + Authorization: `Bearer ${TEST_HASS_TOKEN}`, 'Content-Type': 'application/json' }, body: JSON.stringify(duplicateConfig) diff --git a/jest.config.cjs b/jest.config.cjs index 14dd960..83ef389 100644 --- a/jest.config.cjs +++ b/jest.config.cjs @@ -1,9 +1,8 @@ /** @type {import('ts-jest').JestConfigWithTsJest} */ module.exports = { - preset: 'ts-jest/presets/default-esm', + preset: 'ts-jest', testEnvironment: 'node', - moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], - extensionsToTreatAsEsm: ['.ts', '.tsx'], + setupFiles: ['./jest.setup.ts'], moduleNameMapper: { '^(\\.{1,2}/.*)\\.js$': '$1', '#(.*)': '/node_modules/$1', @@ -13,20 +12,15 @@ module.exports = { '#supports-color': '/node_modules/supports-color/index.js' }, transform: { - '^.+\\.(ts|tsx|js|jsx)$': [ - 'ts-jest', - { - useESM: true, - tsconfig: 'tsconfig.json' - }, - ], + '^.+\\.tsx?$': ['ts-jest', { + useESM: true, + }], }, transformIgnorePatterns: [ 'node_modules/(?!(@digital-alchemy|chalk|#ansi-styles|#supports-color)/)' ], resolver: '/jest-resolver.cjs', testMatch: ['**/__tests__/**/*.test.ts'], - setupFilesAfterEnv: ['/jest.setup.cjs'], globals: { 'ts-jest': { useESM: true, @@ -36,9 +30,9 @@ module.exports = { coverageDirectory: 'coverage', coverageReporters: ['text', 'lcov', 'clover', 'html'], collectCoverageFrom: [ - 'src/**/*.{ts,tsx}', + 'src/**/*.ts', '!src/**/*.d.ts', - '!src/**/*.test.{ts,tsx}', + '!src/**/*.test.ts', '!src/types/**/*', '!src/polyfills.ts' ], @@ -49,5 +43,6 @@ module.exports = { lines: 80, statements: 80 } - } + }, + verbose: true }; \ No newline at end of file diff --git a/jest.setup.ts b/jest.setup.ts new file mode 100644 index 0000000..99f15e2 --- /dev/null +++ b/jest.setup.ts @@ -0,0 +1,15 @@ +import { config } from 'dotenv'; +import { resolve } from 'path'; + +// Load test environment variables +const envFile = process.env.NODE_ENV === 'test' ? '.env.test' : '.env'; +config({ path: resolve(__dirname, envFile) }); + +// Set default test environment variables if not provided +process.env.TEST_HASS_HOST = process.env.TEST_HASS_HOST || 'http://localhost:8123'; +process.env.TEST_HASS_TOKEN = process.env.TEST_HASS_TOKEN || 'test_token'; +process.env.TEST_HASS_SOCKET_URL = process.env.TEST_HASS_SOCKET_URL || 'ws://localhost:8123/api/websocket'; +process.env.TEST_PORT = process.env.TEST_PORT || '3001'; + +// Ensure test environment +process.env.NODE_ENV = 'test'; \ No newline at end of file