Merge pull request #5 from jango-blockchained/main

Comprehensive Home Assistant MCP Enhancement
This commit is contained in:
Tevon Strand-Brown
2025-01-31 20:38:01 -08:00
committed by GitHub
59 changed files with 11456 additions and 3816 deletions

31
.dockerignore Normal file
View File

@@ -0,0 +1,31 @@
# Dependencies
node_modules
npm-debug.log
yarn-debug.log
yarn-error.log
# Build output
dist
# Environment files
.env*
!.env.example
# Git
.git
.gitignore
# IDE
.vscode
.idea
# Test files
coverage
__tests__
jest.config.*
*.test.ts
# Misc
*.md
.DS_Store
*.log

6
.env.example Normal file
View File

@@ -0,0 +1,6 @@
NODE_ENV=development
HASS_HOST=http://homeassistant.local:8123
HASS_TOKEN=your_home_assistant_token
PORT=3000
HASS_SOCKET_URL=ws://homeassistant.local:8123/api/websocket
LOG_LEVEL=debug

49
.eslintrc.json Normal file
View File

@@ -0,0 +1,49 @@
{
"root": true,
"parser": "@typescript-eslint/parser",
"plugins": [
"@typescript-eslint"
],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:@typescript-eslint/recommended-requiring-type-checking"
],
"parserOptions": {
"project": "./tsconfig.json",
"ecmaVersion": "latest",
"sourceType": "module"
},
"rules": {
"no-console": "warn",
"@typescript-eslint/explicit-function-return-type": "warn",
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/strict-boolean-expressions": "warn",
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": [
"warn",
{
"argsIgnorePattern": "^_",
"varsIgnorePattern": "^_"
}
],
"@typescript-eslint/no-misused-promises": [
"warn",
{
"checksVoidReturn": {
"attributes": false
}
}
],
"@typescript-eslint/no-unsafe-assignment": "warn",
"@typescript-eslint/no-unsafe-member-access": "warn",
"@typescript-eslint/no-unsafe-call": "warn",
"@typescript-eslint/no-unsafe-return": "warn"
},
"ignorePatterns": [
"dist/",
"node_modules/",
"*.js",
"*.d.ts"
]
}

67
.gitignore vendored
View File

@@ -1,3 +1,70 @@
# Dependencies
node_modules
dist
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
.cursorrules
# Environment variables
.env
.env.local
.env.development
.env.production
.env.test
venv/
ENV/
env/
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Docker
docker-compose.yml
docker-compose.yaml
docker-compose.override.yml
docker-compose.override.yaml
# IDEs and editors
.idea/
.vscode/
*.swp
*.swo
.DS_Store
Thumbs.db
# Home Assistant
.storage
.cloud
.google.token
home-assistant.log*
home-assistant_v2.db
home-assistant_v2.db-*
.
package-lock.json
yarn.lock
pnpm-lock.yaml
bun.lockb
coverage/*
coverage/

23
Dockerfile Normal file
View File

@@ -0,0 +1,23 @@
# Use Node.js 20 as the base image
FROM node:20-slim
# Install curl for healthcheck
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
# Set working directory
WORKDIR /app
# Copy source code first
COPY . .
# Install dependencies
RUN npm install
# Build TypeScript
RUN npm run build
# Expose the port the app runs on
EXPOSE 3000
# Start the application
CMD ["node", "dist/src/index.js"]

751
README.md
View File

@@ -1,63 +1,734 @@
# A Model Context Protocol Server for Home Assistant
[![smithery badge](https://smithery.ai/badge/@strandbrown/homeassistant-mcp)](https://smithery.ai/server/@strandbrown/homeassistant-mcp)
# Model Context Protocol Server for Home Assistant
The server uses the MCP protocol to share access to a local Home Assistant instance with an LLM application.
More about MCP here: https://modelcontextprotocol.io/introduction
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.
More about Home Assistant here: https://www.home-assistant.io
![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)
## Usage
## Features
### Installing via Smithery
- 🎮 **Device Control**: Control any Home Assistant device through natural language
- 🔄 **Real-time Updates**: Get instant updates through Server-Sent Events (SSE)
- 🤖 **Automation Management**: Create, update, and manage automations
- 📊 **State Monitoring**: Track and query device states
- 🔐 **Secure**: Token-based authentication and rate limiting
- 📱 **Mobile Ready**: Works with any HTTP-capable client
To install Home Assistant Server for Claude Desktop automatically via [Smithery](https://smithery.ai/server/@strandbrown/homeassistant-mcp):
## Real-time Updates with SSE
The server includes a powerful Server-Sent Events (SSE) system that provides real-time updates from your Home Assistant instance. This allows you to:
- 🔄 Get instant state changes for any device
- 📡 Monitor automation triggers and executions
- 🎯 Subscribe to specific domains or entities
- 📊 Track service calls and script executions
### Quick SSE Example
```javascript
const eventSource = new EventSource(
'http://localhost:3000/subscribe_events?token=YOUR_TOKEN&domain=light'
);
eventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
console.log('Update received:', data);
};
```
See [SSE_API.md](docs/SSE_API.md) for complete documentation of the SSE system.
## Table of Contents
- [Key Features](#key-features)
- [Prerequisites](#prerequisites)
- [Installation](#installation)
- [Basic Setup](#basic-setup)
- [Docker Setup (Recommended)](#docker-setup-recommended)
- [Configuration](#configuration)
- [Development](#development)
- [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)
- [Contributing](#contributing)
- [Resources](#resources)
- [License](#license)
## Key Features
### 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
- 🎵 **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
- Historical data access
- **Robust Architecture**
- Comprehensive error handling
- State validation
- Secure API integration
- TypeScript type safety
- Extensive test coverage
## Prerequisites
- **Node.js** 20.10.0 or higher
- **NPM** package manager
- **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
### Basic Setup
```bash
npx -y @smithery/cli install @strandbrown/homeassistant-mcp --client claude
# Clone the repository
git clone https://github.com/jango-blockchained/homeassistant-mcp.git
cd homeassistant-mcp
# Install dependencies
npm install
# Build the project
npm run build
```
### Manual Installation
### Docker Setup (Recommended)
First build the server
The project includes Docker support for easy deployment and consistent environments across different platforms.
```
yarn build
1. **Clone the repository:**
```bash
git clone https://github.com/jango-blockchained/homeassistant-mcp.git
cd homeassistant-mcp
```
2. **Configure environment:**
```bash
cp .env.example .env
```
Edit the `.env` file with your Home Assistant configuration:
```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
DEBUG=false
```
3. **Build and run with Docker Compose:**
```bash
# Build and start the containers
docker compose up -d
# View logs
docker compose logs -f
# Stop the service
docker compose down
```
4. **Verify the installation:**
The server should now be running at `http://localhost:3000`. You can check the health endpoint at `http://localhost:3000/health`.
5. **Update the application:**
```bash
# Pull the latest changes
git pull
# Rebuild and restart the containers
docker compose up -d --build
```
#### Docker Configuration
The Docker setup includes:
- Multi-stage build for optimal image size
- Health checks for container monitoring
- Volume mounting for environment configuration
- Automatic container restart on failure
- Exposed port 3000 for API access
#### Docker Compose Environment Variables
All environment variables can be configured in the `.env` file. The following variables are supported:
- `HASS_HOST`: Your Home Assistant instance URL
- `HASS_TOKEN`: Long-lived access token for Home Assistant
- `HASS_SOCKET_URL`: WebSocket URL for Home Assistant
- `PORT`: Server port (default: 3000)
- `NODE_ENV`: Environment (production/development)
- `DEBUG`: Enable debug mode (true/false)
## 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
```
Then configure your application (like Claude Desktop) to use it.
### 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
#### Common Entity Controls
```json
{
"mcpServers": {
"homeassistant": {
"command": "node",
"args": [
"/Users/tevonsb/Desktop/mcp/dist/index.js"
],
"env": {
"TOKEN": <home_assistant_token>,
"BASE_URL": <base_url_for_home_assistant>
}
}
}
"tool": "control",
"command": "turn_on", // or "turn_off", "toggle"
"entity_id": "light.living_room"
}
```
You'll need a personal access token from home assistant.
#### Light Control
```json
{
"tool": "control",
"command": "turn_on",
"entity_id": "light.living_room",
"brightness": 128,
"color_temp": 4000,
"rgb_color": [255, 0, 0]
}
```
Get one using this guide: https://community.home-assistant.io/t/how-to-get-long-lived-access-token/162159
### Add-on Management
## In Progress
#### List Available Add-ons
```json
{
"tool": "addon",
"action": "list"
}
```
- [x] Access to entities
- [x] Access to Floors
- [x] Access to Areas
- [x] Control for entities
- [x] Lights
- [x] Thermostats
- [x] Covers
- [ ] Testing / writing custom prompts
- [ ] Testing using resources for high-level context
- [ ] Test varying tool organization
#### Install Add-on
```json
{
"tool": "addon",
"action": "install",
"slug": "core_configurator",
"version": "5.6.0"
}
```
#### Manage Add-on State
```json
{
"tool": "addon",
"action": "start", // or "stop", "restart"
"slug": "core_configurator"
}
```
### Package Management
#### List HACS Packages
```json
{
"tool": "package",
"action": "list",
"category": "integration" // or "plugin", "theme", "python_script", "appdaemon", "netdaemon"
}
```
#### Install Package
```json
{
"tool": "package",
"action": "install",
"category": "integration",
"repository": "hacs/integration",
"version": "1.32.0"
}
```
### Automation Management
#### Create Automation
```json
{
"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"
}
}
]
}
}
```
#### Duplicate Automation
```json
{
"tool": "automation_config",
"action": "duplicate",
"automation_id": "automation.motion_light"
}
```
### 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
# Development mode with hot reload
npm run dev
# 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
- 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
- Jest testing setup
- TypeScript integration
- Environment variable management
- Home Assistant API integration
- Project documentation
🚧 **In Progress**
- 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
## Contributing
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
MIT License - See [LICENSE](LICENSE) file

View File

@@ -0,0 +1,204 @@
import { IntentClassifier } from '../../../src/ai/nlp/intent-classifier.js';
describe('IntentClassifier', () => {
let classifier: IntentClassifier;
beforeEach(() => {
classifier = new IntentClassifier();
});
describe('Basic Intent Classification', () => {
it('should classify turn_on commands', async () => {
const testCases = [
{
input: 'turn on the living room light',
entities: { parameters: {}, primary_target: 'light.living_room' },
expectedAction: 'turn_on'
},
{
input: 'switch on the kitchen lights',
entities: { parameters: {}, primary_target: 'light.kitchen' },
expectedAction: 'turn_on'
},
{
input: 'enable the bedroom lamp',
entities: { parameters: {}, primary_target: 'light.bedroom' },
expectedAction: 'turn_on'
}
];
for (const test of testCases) {
const result = await classifier.classify(test.input, test.entities);
expect(result.action).toBe(test.expectedAction);
expect(result.target).toBe(test.entities.primary_target);
expect(result.confidence).toBeGreaterThan(0.5);
}
});
it('should classify turn_off commands', async () => {
const testCases = [
{
input: 'turn off the living room light',
entities: { parameters: {}, primary_target: 'light.living_room' },
expectedAction: 'turn_off'
},
{
input: 'switch off the kitchen lights',
entities: { parameters: {}, primary_target: 'light.kitchen' },
expectedAction: 'turn_off'
},
{
input: 'disable the bedroom lamp',
entities: { parameters: {}, primary_target: 'light.bedroom' },
expectedAction: 'turn_off'
}
];
for (const test of testCases) {
const result = await classifier.classify(test.input, test.entities);
expect(result.action).toBe(test.expectedAction);
expect(result.target).toBe(test.entities.primary_target);
expect(result.confidence).toBeGreaterThan(0.5);
}
});
it('should classify set commands with parameters', async () => {
const testCases = [
{
input: 'set the living room light brightness to 50',
entities: {
parameters: { brightness: 50 },
primary_target: 'light.living_room'
},
expectedAction: 'set'
},
{
input: 'change the temperature to 72',
entities: {
parameters: { temperature: 72 },
primary_target: 'climate.living_room'
},
expectedAction: 'set'
},
{
input: 'adjust the kitchen light color to red',
entities: {
parameters: { color: 'red' },
primary_target: 'light.kitchen'
},
expectedAction: 'set'
}
];
for (const test of testCases) {
const result = await classifier.classify(test.input, test.entities);
expect(result.action).toBe(test.expectedAction);
expect(result.target).toBe(test.entities.primary_target);
expect(result.parameters).toEqual(test.entities.parameters);
expect(result.confidence).toBeGreaterThan(0.5);
}
});
it('should classify query commands', async () => {
const testCases = [
{
input: 'what is the living room temperature',
entities: { parameters: {}, primary_target: 'sensor.living_room_temperature' },
expectedAction: 'query'
},
{
input: 'get the kitchen light status',
entities: { parameters: {}, primary_target: 'light.kitchen' },
expectedAction: 'query'
},
{
input: 'show me the front door camera',
entities: { parameters: {}, primary_target: 'camera.front_door' },
expectedAction: 'query'
}
];
for (const test of testCases) {
const result = await classifier.classify(test.input, test.entities);
expect(result.action).toBe(test.expectedAction);
expect(result.target).toBe(test.entities.primary_target);
expect(result.confidence).toBeGreaterThan(0.5);
}
});
});
describe('Edge Cases and Error Handling', () => {
it('should handle empty input gracefully', async () => {
const result = await classifier.classify('', { parameters: {}, primary_target: '' });
expect(result.action).toBe('unknown');
expect(result.confidence).toBeLessThan(0.5);
});
it('should handle unknown commands with low confidence', async () => {
const result = await classifier.classify(
'do something random',
{ parameters: {}, primary_target: 'light.living_room' }
);
expect(result.action).toBe('unknown');
expect(result.confidence).toBeLessThan(0.5);
});
it('should handle missing entities gracefully', async () => {
const result = await classifier.classify(
'turn on the lights',
{ parameters: {}, primary_target: '' }
);
expect(result.action).toBe('turn_on');
expect(result.target).toBe('');
});
});
describe('Confidence Calculation', () => {
it('should assign higher confidence to exact matches', async () => {
const exactMatch = await classifier.classify(
'turn on',
{ parameters: {}, primary_target: 'light.living_room' }
);
const partialMatch = await classifier.classify(
'please turn on the lights if possible',
{ parameters: {}, primary_target: 'light.living_room' }
);
expect(exactMatch.confidence).toBeGreaterThan(partialMatch.confidence);
});
it('should boost confidence for polite phrases', async () => {
const politeRequest = await classifier.classify(
'please turn on the lights',
{ parameters: {}, primary_target: 'light.living_room' }
);
const basicRequest = await classifier.classify(
'turn on the lights',
{ parameters: {}, primary_target: 'light.living_room' }
);
expect(politeRequest.confidence).toBeGreaterThan(basicRequest.confidence);
});
});
describe('Context Inference', () => {
it('should infer set action when parameters are present', async () => {
const result = await classifier.classify(
'lights at 50%',
{
parameters: { brightness: 50 },
primary_target: 'light.living_room'
}
);
expect(result.action).toBe('set');
expect(result.parameters).toHaveProperty('brightness', 50);
});
it('should infer query action for question-like inputs', async () => {
const result = await classifier.classify(
'how warm is it',
{ parameters: {}, primary_target: 'sensor.temperature' }
);
expect(result.action).toBe('query');
expect(result.confidence).toBeGreaterThan(0.5);
});
});
});

View File

@@ -0,0 +1,87 @@
import { jest, describe, beforeEach, it, expect } from '@jest/globals';
import { z } from 'zod';
import { DomainSchema } from '../../src/schemas.js';
type MockResponse = { success: boolean };
// Define types for tool and server
interface Tool {
name: string;
description: string;
execute: (params: any) => Promise<MockResponse>;
parameters: z.ZodType<any>;
}
interface MockService {
[key: string]: jest.Mock<Promise<MockResponse>>;
}
interface MockServices {
light: {
turn_on: jest.Mock<Promise<MockResponse>>;
turn_off: jest.Mock<Promise<MockResponse>>;
};
climate: {
set_temperature: jest.Mock<Promise<MockResponse>>;
};
}
interface MockHassInstance {
services: MockServices;
}
// Mock LiteMCP class
class MockLiteMCP {
private tools: Tool[] = [];
constructor(public name: string, public version: string) { }
addTool(tool: Tool) {
this.tools.push(tool);
}
getTools() {
return this.tools;
}
}
const createMockFn = (): jest.Mock<Promise<MockResponse>> => {
return jest.fn<() => Promise<MockResponse>>().mockResolvedValue({ success: true });
};
// Mock the Home Assistant instance
const mockHassServices: MockHassInstance = {
services: {
light: {
turn_on: createMockFn(),
turn_off: createMockFn(),
},
climate: {
set_temperature: createMockFn(),
},
},
};
// Mock get_hass function
const get_hass = jest.fn<() => Promise<MockHassInstance>>().mockResolvedValue(mockHassServices);
describe('Context Tests', () => {
let mockTool: Tool;
beforeEach(() => {
mockTool = {
name: 'test_tool',
description: 'A test tool',
execute: jest.fn<(params: any) => Promise<MockResponse>>().mockResolvedValue({ success: true }),
parameters: z.object({
test: z.string()
})
};
});
// Add your test cases here
it('should execute tool successfully', async () => {
const result = await mockTool.execute({ test: 'value' });
expect(result.success).toBe(true);
});
});

View File

@@ -0,0 +1,215 @@
import { jest, describe, it, expect } from '@jest/globals';
import { ContextManager, ResourceType, RelationType, ResourceState } from '../../src/context/index.js';
describe('Context Manager', () => {
describe('Resource Management', () => {
const contextManager = new ContextManager();
it('should add resources', () => {
const resource: ResourceState = {
id: 'light.living_room',
type: ResourceType.DEVICE,
state: 'on',
attributes: {
name: 'Living Room Light'
},
lastUpdated: Date.now()
};
contextManager.addResource(resource);
const retrievedResource = contextManager.getResource(resource.id);
expect(retrievedResource).toEqual(resource);
});
it('should update resources', () => {
const resource: ResourceState = {
id: 'light.living_room',
type: ResourceType.DEVICE,
state: 'off',
attributes: {
name: 'Living Room Light'
},
lastUpdated: Date.now()
};
contextManager.updateResource(resource.id, { state: 'off' });
const retrievedResource = contextManager.getResource(resource.id);
expect(retrievedResource?.state).toBe('off');
});
it('should remove resources', () => {
const resourceId = 'light.living_room';
contextManager.removeResource(resourceId);
const retrievedResource = contextManager.getResource(resourceId);
expect(retrievedResource).toBeUndefined();
});
it('should get resources by type', () => {
const light1: ResourceState = {
id: 'light.living_room',
type: ResourceType.DEVICE,
state: 'on',
attributes: {
name: 'Living Room Light'
},
lastUpdated: Date.now()
};
const light2: ResourceState = {
id: 'light.kitchen',
type: ResourceType.DEVICE,
state: 'off',
attributes: {
name: 'Kitchen Light'
},
lastUpdated: Date.now()
};
contextManager.addResource(light1);
contextManager.addResource(light2);
const lights = contextManager.getResourcesByType(ResourceType.DEVICE);
expect(lights).toHaveLength(2);
expect(lights).toContainEqual(light1);
expect(lights).toContainEqual(light2);
});
});
describe('Relationship Management', () => {
const contextManager = new ContextManager();
it('should add relationships', () => {
const light: ResourceState = {
id: 'light.living_room',
type: ResourceType.DEVICE,
state: 'on',
attributes: {
name: 'Living Room Light'
},
lastUpdated: Date.now()
};
const room: ResourceState = {
id: 'room.living_room',
type: ResourceType.AREA,
state: 'active',
attributes: {
name: 'Living Room'
},
lastUpdated: Date.now()
};
contextManager.addResource(light);
contextManager.addResource(room);
const relationship = {
sourceId: light.id,
targetId: room.id,
type: RelationType.CONTAINS
};
contextManager.addRelationship(relationship);
const related = contextManager.getRelatedResources(relationship.sourceId);
expect(related.length).toBeGreaterThan(0);
expect(related[0]).toEqual(room);
});
it('should remove relationships', () => {
const sourceId = 'light.living_room';
const targetId = 'room.living_room';
contextManager.removeRelationship(sourceId, targetId, RelationType.CONTAINS);
const related = contextManager.getRelatedResources(sourceId);
expect(related).toHaveLength(0);
});
it('should get related resources with depth', () => {
const light: ResourceState = {
id: 'light.living_room',
type: ResourceType.DEVICE,
state: 'on',
attributes: {
name: 'Living Room Light'
},
lastUpdated: Date.now()
};
const room: ResourceState = {
id: 'room.living_room',
type: ResourceType.AREA,
state: 'active',
attributes: {
name: 'Living Room'
},
lastUpdated: Date.now()
};
contextManager.addResource(light);
contextManager.addResource(room);
contextManager.addRelationship({
sourceId: light.id,
targetId: room.id,
type: RelationType.CONTAINS
});
const relatedResources = contextManager.getRelatedResources(light.id, undefined, 1);
expect(relatedResources).toContainEqual(room);
});
});
describe('Resource Analysis', () => {
const contextManager = new ContextManager();
it('should analyze resource usage', () => {
const light: ResourceState = {
id: 'light.living_room',
type: ResourceType.DEVICE,
state: 'on',
attributes: {
name: 'Living Room Light',
brightness: 255,
color_temp: 400
},
lastUpdated: Date.now()
};
contextManager.addResource(light);
const analysis = contextManager.analyzeResourceUsage(light.id);
expect(analysis).toBeDefined();
expect(analysis.dependencies).toBeDefined();
expect(analysis.usage).toBeDefined();
});
});
describe('Event Subscriptions', () => {
const contextManager = new ContextManager();
it('should handle resource subscriptions', () => {
const callback = jest.fn();
const resourceId = 'light.living_room';
const resource: ResourceState = {
id: resourceId,
type: ResourceType.DEVICE,
state: 'on',
attributes: {
name: 'Living Room Light'
},
lastUpdated: Date.now()
};
contextManager.addResource(resource);
contextManager.subscribeToResource(resourceId, callback);
contextManager.updateResource(resourceId, { state: 'off' });
expect(callback).toHaveBeenCalled();
});
it('should handle type subscriptions', () => {
const callback = jest.fn();
const type = ResourceType.DEVICE;
const unsubscribe = contextManager.subscribeToType(type, callback);
const resource: ResourceState = {
id: 'light.kitchen',
type: ResourceType.DEVICE,
state: 'on',
attributes: {
name: 'Kitchen Light'
},
lastUpdated: Date.now()
};
contextManager.addResource(resource);
contextManager.updateResource(resource.id, { state: 'off' });
expect(callback).toHaveBeenCalled();
unsubscribe();
});
});
});

227
__tests__/hass/api.test.ts Normal file
View File

@@ -0,0 +1,227 @@
import { HassInstanceImpl } from '../../src/hass/index.js';
import * as HomeAssistant from '../../src/types/hass.js';
import { HassWebSocketClient } from '../../src/websocket/client.js';
// Add DOM types for WebSocket and events
type CloseEvent = {
code: number;
reason: string;
wasClean: boolean;
};
type MessageEvent = {
data: any;
type: string;
lastEventId: string;
};
type Event = {
type: string;
};
interface WebSocketLike {
send(data: string): void;
close(): void;
addEventListener(type: string, listener: (event: any) => void): void;
removeEventListener(type: string, listener: (event: any) => void): void;
dispatchEvent(event: Event): boolean;
onopen: ((event: Event) => void) | null;
onclose: ((event: CloseEvent) => void) | null;
onmessage: ((event: MessageEvent) => void) | null;
onerror: ((event: Event) => void) | null;
url: string;
readyState: number;
bufferedAmount: number;
extensions: string;
protocol: string;
binaryType: string;
}
interface MockWebSocketInstance extends WebSocketLike {
send: jest.Mock;
close: jest.Mock;
addEventListener: jest.Mock;
removeEventListener: jest.Mock;
dispatchEvent: jest.Mock;
}
interface MockWebSocketConstructor extends jest.Mock<MockWebSocketInstance> {
CONNECTING: 0;
OPEN: 1;
CLOSING: 2;
CLOSED: 3;
prototype: WebSocketLike;
}
// Mock the entire hass module
jest.mock('../../src/hass/index.js', () => ({
get_hass: jest.fn()
}));
describe('Home Assistant API', () => {
let hass: HassInstanceImpl;
let mockWs: MockWebSocketInstance;
let MockWebSocket: MockWebSocketConstructor;
beforeEach(() => {
hass = new HassInstanceImpl('http://localhost:8123', 'test_token');
mockWs = {
send: jest.fn(),
close: jest.fn(),
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
onopen: null,
onclose: null,
onmessage: null,
onerror: null,
url: '',
readyState: 1,
bufferedAmount: 0,
extensions: '',
protocol: '',
binaryType: 'blob'
} as MockWebSocketInstance;
// Create a mock WebSocket constructor
MockWebSocket = jest.fn().mockImplementation(() => mockWs) as MockWebSocketConstructor;
MockWebSocket.CONNECTING = 0;
MockWebSocket.OPEN = 1;
MockWebSocket.CLOSING = 2;
MockWebSocket.CLOSED = 3;
MockWebSocket.prototype = {} as WebSocketLike;
// Mock WebSocket globally
(global as any).WebSocket = MockWebSocket;
});
describe('State Management', () => {
it('should fetch all states', async () => {
const mockStates: HomeAssistant.Entity[] = [
{
entity_id: 'light.living_room',
state: 'on',
attributes: { brightness: 255 },
last_changed: '2024-01-01T00:00:00Z',
last_updated: '2024-01-01T00:00:00Z',
context: { id: '123', parent_id: null, user_id: null }
}
];
global.fetch = jest.fn().mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockStates)
});
const states = await hass.fetchStates();
expect(states).toEqual(mockStates);
expect(fetch).toHaveBeenCalledWith(
'http://localhost:8123/api/states',
expect.any(Object)
);
});
it('should fetch single state', async () => {
const mockState: HomeAssistant.Entity = {
entity_id: 'light.living_room',
state: 'on',
attributes: { brightness: 255 },
last_changed: '2024-01-01T00:00:00Z',
last_updated: '2024-01-01T00:00:00Z',
context: { id: '123', parent_id: null, user_id: null }
};
global.fetch = jest.fn().mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockState)
});
const state = await hass.fetchState('light.living_room');
expect(state).toEqual(mockState);
expect(fetch).toHaveBeenCalledWith(
'http://localhost:8123/api/states/light.living_room',
expect.any(Object)
);
});
it('should handle state fetch errors', async () => {
global.fetch = jest.fn().mockRejectedValueOnce(new Error('Failed to fetch states'));
await expect(hass.fetchStates()).rejects.toThrow('Failed to fetch states');
});
});
describe('Service Calls', () => {
it('should call service', async () => {
global.fetch = jest.fn().mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({})
});
await hass.callService('light', 'turn_on', {
entity_id: 'light.living_room',
brightness: 255
});
expect(fetch).toHaveBeenCalledWith(
'http://localhost:8123/api/services/light/turn_on',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
entity_id: 'light.living_room',
brightness: 255
})
})
);
});
it('should handle service call errors', async () => {
global.fetch = jest.fn().mockRejectedValueOnce(new Error('Service call failed'));
await expect(
hass.callService('invalid_domain', 'invalid_service', {})
).rejects.toThrow('Service call failed');
});
});
describe('Event Subscription', () => {
it('should subscribe to events', async () => {
const callback = jest.fn();
await hass.subscribeEvents(callback, 'state_changed');
expect(MockWebSocket).toHaveBeenCalledWith(
'ws://localhost:8123/api/websocket'
);
});
it('should handle subscription errors', async () => {
const callback = jest.fn();
MockWebSocket.mockImplementation(() => {
throw new Error('WebSocket connection failed');
});
await expect(
hass.subscribeEvents(callback, 'state_changed')
).rejects.toThrow('WebSocket connection failed');
});
});
describe('WebSocket connection', () => {
it('should connect to WebSocket endpoint', async () => {
await hass.subscribeEvents(() => { });
expect(MockWebSocket).toHaveBeenCalledWith(
'ws://localhost:8123/api/websocket'
);
});
it('should handle connection errors', async () => {
MockWebSocket.mockImplementation(() => {
throw new Error('Connection failed');
});
await expect(hass.subscribeEvents(() => { })).rejects.toThrow(
'Connection failed'
);
});
});
});

View File

@@ -0,0 +1,99 @@
import { jest, describe, beforeEach, afterAll, it, expect } from '@jest/globals';
import type { Mock } from 'jest-mock';
// Define types
interface MockResponse {
success: boolean;
}
type MockFn = () => Promise<MockResponse>;
interface MockService {
[key: string]: Mock<MockFn>;
}
interface MockServices {
light: {
turn_on: Mock<MockFn>;
turn_off: Mock<MockFn>;
};
climate: {
set_temperature: Mock<MockFn>;
};
}
interface MockHassInstance {
services: MockServices;
}
// Mock instance
let mockInstance: MockHassInstance | null = null;
const createMockFn = (): Mock<MockFn> => {
return jest.fn<MockFn>().mockImplementation(async () => ({ success: true }));
};
// Mock the digital-alchemy modules before tests
jest.unstable_mockModule('@digital-alchemy/core', () => ({
CreateApplication: jest.fn(() => ({
configuration: {},
bootstrap: async () => mockInstance,
services: {}
})),
TServiceParams: jest.fn()
}));
jest.unstable_mockModule('@digital-alchemy/hass', () => ({
LIB_HASS: {
configuration: {},
services: {}
}
}));
describe('Home Assistant Connection', () => {
// Backup the original environment
const originalEnv = { ...process.env };
beforeEach(() => {
// Clear all mocks
jest.clearAllMocks();
// Initialize mock instance
mockInstance = {
services: {
light: {
turn_on: createMockFn(),
turn_off: createMockFn(),
},
climate: {
set_temperature: createMockFn(),
},
},
};
// Reset environment variables
process.env = { ...originalEnv };
});
afterAll(() => {
// Restore original environment
process.env = originalEnv;
});
it('should return a Home Assistant instance with services', async () => {
const { get_hass } = await import('../../src/hass/index.js');
const hass = await get_hass();
expect(hass).toBeDefined();
expect(hass.services).toBeDefined();
expect(typeof hass.services.light.turn_on).toBe('function');
expect(typeof hass.services.light.turn_off).toBe('function');
expect(typeof hass.services.climate.set_temperature).toBe('function');
});
it('should reuse the same instance on subsequent calls', async () => {
const { get_hass } = await import('../../src/hass/index.js');
const firstInstance = await get_hass();
const secondInstance = await get_hass();
expect(firstInstance).toBe(secondInstance);
});
});

View File

@@ -0,0 +1,265 @@
import { jest, describe, beforeEach, afterEach, it, expect } from '@jest/globals';
import { WebSocket } from 'ws';
import { EventEmitter } from 'events';
// Define WebSocket mock types
type WebSocketCallback = (...args: any[]) => void;
type WebSocketEventHandler = (event: string, callback: WebSocketCallback) => void;
type WebSocketSendHandler = (data: string) => void;
type WebSocketCloseHandler = () => void;
type WebSocketMock = {
on: jest.MockedFunction<WebSocketEventHandler>;
send: jest.MockedFunction<WebSocketSendHandler>;
close: jest.MockedFunction<WebSocketCloseHandler>;
readyState: number;
OPEN: number;
};
// Mock WebSocket
jest.mock('ws', () => {
return {
WebSocket: jest.fn().mockImplementation(() => ({
on: jest.fn(),
send: jest.fn(),
close: jest.fn(),
readyState: 1,
OPEN: 1,
removeAllListeners: jest.fn()
}))
};
});
// Mock fetch globally
const mockFetch = jest.fn() as jest.MockedFunction<typeof fetch>;
global.fetch = mockFetch;
describe('Home Assistant Integration', () => {
describe('HassWebSocketClient', () => {
let client: any;
const mockUrl = 'ws://localhost:8123/api/websocket';
const mockToken = 'test_token';
beforeEach(async () => {
const { HassWebSocketClient } = await import('../../src/hass/index.js');
client = new HassWebSocketClient(mockUrl, mockToken);
});
afterEach(() => {
jest.clearAllMocks();
});
it('should create a WebSocket client with the provided URL and token', () => {
expect(client).toBeInstanceOf(EventEmitter);
expect(WebSocket).toHaveBeenCalledWith(mockUrl);
});
it('should connect and authenticate successfully', async () => {
const mockWs = (WebSocket as jest.MockedClass<typeof WebSocket>).mock.results[0].value as unknown as WebSocketMock;
const connectPromise = client.connect();
// Get and call the open callback
const openCallEntry = mockWs.on.mock.calls.find(call => call[0] === 'open');
if (!openCallEntry) throw new Error('Open callback not found');
const openCallback = openCallEntry[1];
openCallback();
// Verify authentication message
expect(mockWs.send).toHaveBeenCalledWith(
JSON.stringify({
type: 'auth',
access_token: mockToken
})
);
// Get and call the message callback
const messageCallEntry = mockWs.on.mock.calls.find(call => call[0] === 'message');
if (!messageCallEntry) throw new Error('Message callback not found');
const messageCallback = messageCallEntry[1];
messageCallback(JSON.stringify({ type: 'auth_ok' }));
await connectPromise;
});
it('should handle authentication failure', async () => {
const mockWs = (WebSocket as jest.MockedClass<typeof WebSocket>).mock.results[0].value as unknown as WebSocketMock;
const connectPromise = client.connect();
// Get and call the open callback
const openCallEntry = mockWs.on.mock.calls.find(call => call[0] === 'open');
if (!openCallEntry) throw new Error('Open callback not found');
const openCallback = openCallEntry[1];
openCallback();
// Get and call the message callback with auth failure
const messageCallEntry = mockWs.on.mock.calls.find(call => call[0] === 'message');
if (!messageCallEntry) throw new Error('Message callback not found');
const messageCallback = messageCallEntry[1];
messageCallback(JSON.stringify({ type: 'auth_invalid' }));
await expect(connectPromise).rejects.toThrow();
});
it('should handle connection errors', async () => {
const mockWs = (WebSocket as jest.MockedClass<typeof WebSocket>).mock.results[0].value as unknown as WebSocketMock;
const connectPromise = client.connect();
// Get and call the error callback
const errorCallEntry = mockWs.on.mock.calls.find(call => call[0] === 'error');
if (!errorCallEntry) throw new Error('Error callback not found');
const errorCallback = errorCallEntry[1];
errorCallback(new Error('Connection failed'));
await expect(connectPromise).rejects.toThrow('Connection failed');
});
it('should handle message parsing errors', async () => {
const mockWs = (WebSocket as jest.MockedClass<typeof WebSocket>).mock.results[0].value as unknown as WebSocketMock;
const connectPromise = client.connect();
// Get and call the open callback
const openCallEntry = mockWs.on.mock.calls.find(call => call[0] === 'open');
if (!openCallEntry) throw new Error('Open callback not found');
const openCallback = openCallEntry[1];
openCallback();
// Get and call the message callback with invalid JSON
const messageCallEntry = mockWs.on.mock.calls.find(call => call[0] === 'message');
if (!messageCallEntry) throw new Error('Message callback not found');
const messageCallback = messageCallEntry[1];
// Should emit error event
await expect(new Promise((resolve) => {
client.once('error', resolve);
messageCallback('invalid json');
})).resolves.toBeInstanceOf(Error);
});
});
describe('HassInstanceImpl', () => {
let instance: any;
const mockBaseUrl = 'http://localhost:8123';
const mockToken = 'test_token';
beforeEach(async () => {
const { HassInstanceImpl } = await import('../../src/hass/index.js');
instance = new HassInstanceImpl(mockBaseUrl, mockToken);
mockFetch.mockClear();
});
it('should create an instance with the provided URL and token', () => {
expect(instance.baseUrl).toBe(mockBaseUrl);
expect(instance.token).toBe(mockToken);
});
it('should fetch states successfully', async () => {
const mockStates = [
{
entity_id: 'light.living_room',
state: 'on',
attributes: {}
}
];
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => mockStates
} as Response);
const states = await instance.fetchStates();
expect(states).toEqual(mockStates);
expect(mockFetch).toHaveBeenCalledWith(
`${mockBaseUrl}/api/states`,
expect.objectContaining({
headers: {
Authorization: `Bearer ${mockToken}`,
'Content-Type': 'application/json'
}
})
);
});
it('should fetch single entity state successfully', async () => {
const mockState = {
entity_id: 'light.living_room',
state: 'on',
attributes: {}
};
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => mockState
} as Response);
const state = await instance.fetchState('light.living_room');
expect(state).toEqual(mockState);
expect(mockFetch).toHaveBeenCalledWith(
`${mockBaseUrl}/api/states/light.living_room`,
expect.objectContaining({
headers: {
Authorization: `Bearer ${mockToken}`,
'Content-Type': 'application/json'
}
})
);
});
it('should call service successfully', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({})
} as Response);
await instance.callService('light', 'turn_on', { entity_id: 'light.living_room' });
expect(mockFetch).toHaveBeenCalledWith(
`${mockBaseUrl}/api/services/light/turn_on`,
expect.objectContaining({
method: 'POST',
headers: {
Authorization: `Bearer ${mockToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ entity_id: 'light.living_room' })
})
);
});
});
describe('get_hass', () => {
const originalEnv = process.env;
beforeEach(() => {
process.env = { ...originalEnv };
process.env.HASS_HOST = 'http://localhost:8123';
process.env.HASS_TOKEN = 'test_token';
});
afterEach(() => {
process.env = originalEnv;
});
it('should return a development instance by default', async () => {
const { get_hass } = await import('../../src/hass/index.js');
const instance = await get_hass();
expect(instance.baseUrl).toBe('http://localhost:8123');
expect(instance.token).toBe('test_token');
});
it('should return a test instance when specified', async () => {
const { get_hass } = await import('../../src/hass/index.js');
const instance = await get_hass('test');
expect(instance.baseUrl).toBe('http://localhost:8123');
expect(instance.token).toBe('test_token');
});
it('should return a production instance when specified', async () => {
process.env.HASS_HOST = 'https://hass.example.com';
process.env.HASS_TOKEN = 'prod_token';
const { get_hass } = await import('../../src/hass/index.js');
const instance = await get_hass('production');
expect(instance.baseUrl).toBe('https://hass.example.com');
expect(instance.token).toBe('prod_token');
});
});
});

45
__tests__/helpers.test.ts Normal file
View File

@@ -0,0 +1,45 @@
import { jest, describe, it, expect } from '@jest/globals';
import { formatToolCall } from '../src/helpers.js';
describe('helpers', () => {
describe('formatToolCall', () => {
it('should format an object into the correct structure', () => {
const testObj = { name: 'test', value: 123 };
const result = formatToolCall(testObj);
expect(result).toEqual({
content: [{
type: 'text',
text: JSON.stringify(testObj, null, 2),
isError: false
}]
});
});
it('should handle error cases correctly', () => {
const testObj = { error: 'test error' };
const result = formatToolCall(testObj, true);
expect(result).toEqual({
content: [{
type: 'text',
text: JSON.stringify(testObj, null, 2),
isError: true
}]
});
});
it('should handle empty objects', () => {
const testObj = {};
const result = formatToolCall(testObj);
expect(result).toEqual({
content: [{
type: 'text',
text: '{}',
isError: false
}]
});
});
});
});

1130
__tests__/index.test.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,195 @@
import { PerformanceMonitor, PerformanceOptimizer, Metric } from '../../src/performance/index.js';
describe('Performance Module', () => {
describe('PerformanceMonitor', () => {
let monitor: PerformanceMonitor;
beforeEach(() => {
monitor = new PerformanceMonitor({
responseTime: 500,
memoryUsage: 1024 * 1024 * 512, // 512MB
cpuUsage: 70
});
});
afterEach(() => {
monitor.stop();
});
it('should collect metrics', () => {
const metricHandler = jest.fn();
monitor.on('metric', metricHandler);
monitor.start();
// Wait for first collection
return new Promise(resolve => setTimeout(() => {
expect(metricHandler).toHaveBeenCalled();
const calls = metricHandler.mock.calls;
// Verify memory metrics
expect(calls.some(([metric]: [Metric]) =>
metric.name === 'memory.heapUsed'
)).toBe(true);
expect(calls.some(([metric]: [Metric]) =>
metric.name === 'memory.heapTotal'
)).toBe(true);
expect(calls.some(([metric]: [Metric]) =>
metric.name === 'memory.rss'
)).toBe(true);
// Verify CPU metrics
expect(calls.some(([metric]: [Metric]) =>
metric.name === 'cpu.user'
)).toBe(true);
expect(calls.some(([metric]: [Metric]) =>
metric.name === 'cpu.system'
)).toBe(true);
resolve(true);
}, 100));
});
it('should emit threshold exceeded events', () => {
const thresholdHandler = jest.fn();
monitor = new PerformanceMonitor({
memoryUsage: 1, // Ensure threshold is exceeded
cpuUsage: 1
});
monitor.on('threshold_exceeded', thresholdHandler);
monitor.start();
return new Promise(resolve => setTimeout(() => {
expect(thresholdHandler).toHaveBeenCalled();
resolve(true);
}, 100));
});
it('should clean old metrics', () => {
const now = Date.now();
const oldMetric: Metric = {
name: 'test',
value: 1,
timestamp: now - 25 * 60 * 60 * 1000 // 25 hours old
};
const newMetric: Metric = {
name: 'test',
value: 2,
timestamp: now - 1000 // 1 second old
};
monitor.addMetric(oldMetric);
monitor.addMetric(newMetric);
const metrics = monitor.getMetrics(now - 24 * 60 * 60 * 1000);
expect(metrics).toHaveLength(1);
expect(metrics[0]).toEqual(newMetric);
});
it('should calculate metric averages', () => {
const now = Date.now();
const metrics: Metric[] = [
{ name: 'test', value: 1, timestamp: now - 3000 },
{ name: 'test', value: 2, timestamp: now - 2000 },
{ name: 'test', value: 3, timestamp: now - 1000 }
];
metrics.forEach(metric => monitor.addMetric(metric));
const average = monitor.calculateAverage(
'test',
now - 5000,
now
);
expect(average).toBe(2);
});
});
describe('PerformanceOptimizer', () => {
it('should process batches correctly', async () => {
const items = [1, 2, 3, 4, 5];
const batchSize = 2;
const processor = jest.fn(async (batch: number[]) =>
batch.map(n => n * 2)
);
const results = await PerformanceOptimizer.processBatch(
items,
batchSize,
processor
);
expect(results).toEqual([2, 4, 6, 8, 10]);
expect(processor).toHaveBeenCalledTimes(3); // 2 + 2 + 1 items
});
it('should debounce function calls', (done) => {
const fn = jest.fn();
const debounced = PerformanceOptimizer.debounce(fn, 100);
debounced();
debounced();
debounced();
setTimeout(() => {
expect(fn).not.toHaveBeenCalled();
}, 50);
setTimeout(() => {
expect(fn).toHaveBeenCalledTimes(1);
done();
}, 150);
});
it('should throttle function calls', (done) => {
const fn = jest.fn();
const throttled = PerformanceOptimizer.throttle(fn, 100);
throttled();
throttled();
throttled();
expect(fn).toHaveBeenCalledTimes(1);
setTimeout(() => {
throttled();
expect(fn).toHaveBeenCalledTimes(2);
done();
}, 150);
});
it('should optimize memory when threshold is exceeded', async () => {
const originalGc = global.gc;
global.gc = jest.fn();
const memoryUsage = process.memoryUsage;
const mockMemoryUsage = () => ({
heapUsed: 900,
heapTotal: 1000,
rss: 2000,
external: 0,
arrayBuffers: 0
});
Object.defineProperty(process, 'memoryUsage', {
value: mockMemoryUsage,
writable: true
});
await PerformanceOptimizer.optimizeMemory();
expect(global.gc).toHaveBeenCalled();
// Cleanup
Object.defineProperty(process, 'memoryUsage', {
value: memoryUsage,
writable: true
});
if (originalGc) {
global.gc = originalGc;
} else {
delete global.gc;
}
});
});
});

View File

@@ -0,0 +1,186 @@
import { MediaPlayerSchema, FanSchema, LockSchema, VacuumSchema, SceneSchema, ScriptSchema, CameraSchema, ListMediaPlayersResponseSchema, ListFansResponseSchema, ListLocksResponseSchema, ListVacuumsResponseSchema, ListScenesResponseSchema, ListScriptsResponseSchema, ListCamerasResponseSchema, } from '../../src/schemas';
describe('Device Schemas', () => {
describe('MediaPlayer Schema', () => {
it('should validate a valid media player entity', () => {
const mediaPlayer = {
entity_id: 'media_player.living_room',
state: 'playing',
state_attributes: {
volume_level: 0.5,
is_volume_muted: false,
media_content_id: 'spotify:playlist:xyz',
media_content_type: 'playlist',
media_title: 'My Playlist',
source: 'Spotify',
source_list: ['Spotify', 'Radio', 'TV'],
supported_features: 12345
}
};
expect(() => MediaPlayerSchema.parse(mediaPlayer)).not.toThrow();
});
it('should validate media player list response', () => {
const response = {
media_players: [{
entity_id: 'media_player.living_room',
state: 'playing',
state_attributes: {}
}]
};
expect(() => ListMediaPlayersResponseSchema.parse(response)).not.toThrow();
});
});
describe('Fan Schema', () => {
it('should validate a valid fan entity', () => {
const fan = {
entity_id: 'fan.bedroom',
state: 'on',
state_attributes: {
percentage: 50,
preset_mode: 'auto',
preset_modes: ['auto', 'low', 'medium', 'high'],
oscillating: true,
direction: 'forward',
supported_features: 12345
}
};
expect(() => FanSchema.parse(fan)).not.toThrow();
});
it('should validate fan list response', () => {
const response = {
fans: [{
entity_id: 'fan.bedroom',
state: 'on',
state_attributes: {}
}]
};
expect(() => ListFansResponseSchema.parse(response)).not.toThrow();
});
});
describe('Lock Schema', () => {
it('should validate a valid lock entity', () => {
const lock = {
entity_id: 'lock.front_door',
state: 'locked',
state_attributes: {
code_format: 'number',
changed_by: 'User',
locked: true,
supported_features: 12345
}
};
expect(() => LockSchema.parse(lock)).not.toThrow();
});
it('should validate lock list response', () => {
const response = {
locks: [{
entity_id: 'lock.front_door',
state: 'locked',
state_attributes: { locked: true }
}]
};
expect(() => ListLocksResponseSchema.parse(response)).not.toThrow();
});
});
describe('Vacuum Schema', () => {
it('should validate a valid vacuum entity', () => {
const vacuum = {
entity_id: 'vacuum.robot',
state: 'cleaning',
state_attributes: {
battery_level: 80,
fan_speed: 'medium',
fan_speed_list: ['low', 'medium', 'high'],
status: 'cleaning',
supported_features: 12345
}
};
expect(() => VacuumSchema.parse(vacuum)).not.toThrow();
});
it('should validate vacuum list response', () => {
const response = {
vacuums: [{
entity_id: 'vacuum.robot',
state: 'cleaning',
state_attributes: {}
}]
};
expect(() => ListVacuumsResponseSchema.parse(response)).not.toThrow();
});
});
describe('Scene Schema', () => {
it('should validate a valid scene entity', () => {
const scene = {
entity_id: 'scene.movie_night',
state: 'on',
state_attributes: {
entity_id: ['light.living_room', 'media_player.tv'],
supported_features: 12345
}
};
expect(() => SceneSchema.parse(scene)).not.toThrow();
});
it('should validate scene list response', () => {
const response = {
scenes: [{
entity_id: 'scene.movie_night',
state: 'on',
state_attributes: {}
}]
};
expect(() => ListScenesResponseSchema.parse(response)).not.toThrow();
});
});
describe('Script Schema', () => {
it('should validate a valid script entity', () => {
const script = {
entity_id: 'script.welcome_home',
state: 'on',
state_attributes: {
last_triggered: '2023-12-25T12:00:00Z',
mode: 'single',
variables: {
brightness: 100,
color: 'red'
},
supported_features: 12345
}
};
expect(() => ScriptSchema.parse(script)).not.toThrow();
});
it('should validate script list response', () => {
const response = {
scripts: [{
entity_id: 'script.welcome_home',
state: 'on',
state_attributes: {}
}]
};
expect(() => ListScriptsResponseSchema.parse(response)).not.toThrow();
});
});
describe('Camera Schema', () => {
it('should validate a valid camera entity', () => {
const camera = {
entity_id: 'camera.front_door',
state: 'recording',
state_attributes: {
motion_detection: true,
frontend_stream_type: 'hls',
supported_features: 12345
}
};
expect(() => CameraSchema.parse(camera)).not.toThrow();
});
it('should validate camera list response', () => {
const response = {
cameras: [{
entity_id: 'camera.front_door',
state: 'recording',
state_attributes: {}
}]
};
expect(() => ListCamerasResponseSchema.parse(response)).not.toThrow();
});
});
});
//# sourceMappingURL=devices.test.js.map

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,214 @@
import {
MediaPlayerSchema,
FanSchema,
LockSchema,
VacuumSchema,
SceneSchema,
ScriptSchema,
CameraSchema,
ListMediaPlayersResponseSchema,
ListFansResponseSchema,
ListLocksResponseSchema,
ListVacuumsResponseSchema,
ListScenesResponseSchema,
ListScriptsResponseSchema,
ListCamerasResponseSchema,
} from '../../src/schemas.js';
describe('Device Schemas', () => {
describe('Media Player Schema', () => {
it('should validate a valid media player entity', () => {
const mediaPlayer = {
entity_id: 'media_player.living_room',
state: 'playing',
state_attributes: {
volume_level: 0.5,
is_volume_muted: false,
media_content_id: 'spotify:playlist:xyz',
media_content_type: 'playlist',
media_title: 'My Playlist',
source: 'Spotify',
source_list: ['Spotify', 'Radio', 'TV'],
supported_features: 12345
}
};
expect(() => MediaPlayerSchema.parse(mediaPlayer)).not.toThrow();
});
it('should validate media player list response', () => {
const response = {
media_players: [{
entity_id: 'media_player.living_room',
state: 'playing',
state_attributes: {}
}]
};
expect(() => ListMediaPlayersResponseSchema.parse(response)).not.toThrow();
});
});
describe('Fan Schema', () => {
it('should validate a valid fan entity', () => {
const fan = {
entity_id: 'fan.bedroom',
state: 'on',
state_attributes: {
percentage: 50,
preset_mode: 'auto',
preset_modes: ['auto', 'low', 'medium', 'high'],
oscillating: true,
direction: 'forward',
supported_features: 12345
}
};
expect(() => FanSchema.parse(fan)).not.toThrow();
});
it('should validate fan list response', () => {
const response = {
fans: [{
entity_id: 'fan.bedroom',
state: 'on',
state_attributes: {}
}]
};
expect(() => ListFansResponseSchema.parse(response)).not.toThrow();
});
});
describe('Lock Schema', () => {
it('should validate a valid lock entity', () => {
const lock = {
entity_id: 'lock.front_door',
state: 'locked',
state_attributes: {
code_format: 'number',
changed_by: 'User',
locked: true,
supported_features: 12345
}
};
expect(() => LockSchema.parse(lock)).not.toThrow();
});
it('should validate lock list response', () => {
const response = {
locks: [{
entity_id: 'lock.front_door',
state: 'locked',
state_attributes: { locked: true }
}]
};
expect(() => ListLocksResponseSchema.parse(response)).not.toThrow();
});
});
describe('Vacuum Schema', () => {
it('should validate a valid vacuum entity', () => {
const vacuum = {
entity_id: 'vacuum.robot',
state: 'cleaning',
state_attributes: {
battery_level: 80,
fan_speed: 'medium',
fan_speed_list: ['low', 'medium', 'high'],
status: 'cleaning',
supported_features: 12345
}
};
expect(() => VacuumSchema.parse(vacuum)).not.toThrow();
});
it('should validate vacuum list response', () => {
const response = {
vacuums: [{
entity_id: 'vacuum.robot',
state: 'cleaning',
state_attributes: {}
}]
};
expect(() => ListVacuumsResponseSchema.parse(response)).not.toThrow();
});
});
describe('Scene Schema', () => {
it('should validate a valid scene entity', () => {
const scene = {
entity_id: 'scene.movie_night',
state: 'on',
state_attributes: {
entity_id: ['light.living_room', 'media_player.tv'],
supported_features: 12345
}
};
expect(() => SceneSchema.parse(scene)).not.toThrow();
});
it('should validate scene list response', () => {
const response = {
scenes: [{
entity_id: 'scene.movie_night',
state: 'on',
state_attributes: {}
}]
};
expect(() => ListScenesResponseSchema.parse(response)).not.toThrow();
});
});
describe('Script Schema', () => {
it('should validate a valid script entity', () => {
const script = {
entity_id: 'script.welcome_home',
state: 'on',
state_attributes: {
last_triggered: '2023-12-25T12:00:00Z',
mode: 'single',
variables: {
brightness: 100,
color: 'red'
},
supported_features: 12345
}
};
expect(() => ScriptSchema.parse(script)).not.toThrow();
});
it('should validate script list response', () => {
const response = {
scripts: [{
entity_id: 'script.welcome_home',
state: 'on',
state_attributes: {}
}]
};
expect(() => ListScriptsResponseSchema.parse(response)).not.toThrow();
});
});
describe('Camera Schema', () => {
it('should validate a valid camera entity', () => {
const camera = {
entity_id: 'camera.front_door',
state: 'recording',
state_attributes: {
motion_detection: true,
frontend_stream_type: 'hls',
supported_features: 12345
}
};
expect(() => CameraSchema.parse(camera)).not.toThrow();
});
it('should validate camera list response', () => {
const response = {
cameras: [{
entity_id: 'camera.front_door',
state: 'recording',
state_attributes: {}
}]
};
expect(() => ListCamerasResponseSchema.parse(response)).not.toThrow();
});
});
});

View File

@@ -0,0 +1,532 @@
import { entitySchema, serviceSchema, stateChangedEventSchema, configSchema, automationSchema, deviceControlSchema } from '../../src/schemas/hass.js';
import AjvModule from 'ajv';
const Ajv = AjvModule.default || AjvModule;
describe('Home Assistant Schemas', () => {
const ajv = new Ajv({ allErrors: true });
describe('Entity Schema', () => {
const validate = ajv.compile(entitySchema);
it('should validate a valid entity', () => {
const validEntity = {
entity_id: 'light.living_room',
state: 'on',
attributes: {
brightness: 255,
friendly_name: 'Living Room Light'
},
last_changed: '2024-01-01T00:00:00Z',
last_updated: '2024-01-01T00:00:00Z',
context: {
id: '123456',
parent_id: null,
user_id: null
}
};
expect(validate(validEntity)).toBe(true);
});
it('should reject entity with missing required fields', () => {
const invalidEntity = {
entity_id: 'light.living_room',
state: 'on'
// missing attributes, last_changed, last_updated, context
};
expect(validate(invalidEntity)).toBe(false);
expect(validate.errors).toBeDefined();
});
it('should validate entity with additional attributes', () => {
const entityWithExtraAttrs = {
entity_id: 'climate.living_room',
state: '22',
attributes: {
temperature: 22,
humidity: 45,
mode: 'auto',
custom_attr: 'value'
},
last_changed: '2024-01-01T00:00:00Z',
last_updated: '2024-01-01T00:00:00Z',
context: {
id: '123456',
parent_id: null,
user_id: null
}
};
expect(validate(entityWithExtraAttrs)).toBe(true);
});
it('should reject invalid entity_id format', () => {
const invalidEntityId = {
entity_id: 'invalid_format',
state: 'on',
attributes: {},
last_changed: '2024-01-01T00:00:00Z',
last_updated: '2024-01-01T00:00:00Z',
context: {
id: '123456',
parent_id: null,
user_id: null
}
};
expect(validate(invalidEntityId)).toBe(false);
});
});
describe('Service Schema', () => {
const validate = ajv.compile(serviceSchema);
it('should validate a basic service call', () => {
const basicService = {
domain: 'light',
service: 'turn_on',
target: {
entity_id: ['light.living_room']
}
};
expect(validate(basicService)).toBe(true);
});
it('should validate service call with multiple targets', () => {
const multiTargetService = {
domain: 'light',
service: 'turn_on',
target: {
entity_id: ['light.living_room', 'light.kitchen'],
device_id: ['device123', 'device456'],
area_id: ['living_room', 'kitchen']
},
service_data: {
brightness_pct: 100
}
};
expect(validate(multiTargetService)).toBe(true);
});
it('should validate service call without targets', () => {
const noTargetService = {
domain: 'homeassistant',
service: 'restart'
};
expect(validate(noTargetService)).toBe(true);
});
it('should reject service call with invalid target type', () => {
const invalidService = {
domain: 'light',
service: 'turn_on',
target: {
entity_id: 'not_an_array' // should be an array
}
};
expect(validate(invalidService)).toBe(false);
expect(validate.errors).toBeDefined();
});
});
describe('State Changed Event Schema', () => {
const validate = ajv.compile(stateChangedEventSchema);
it('should validate a valid state changed event', () => {
const validEvent = {
event_type: 'state_changed',
data: {
entity_id: 'light.living_room',
new_state: {
entity_id: 'light.living_room',
state: 'on',
attributes: {
brightness: 255
},
last_changed: '2024-01-01T00:00:00Z',
last_updated: '2024-01-01T00:00:00Z',
context: {
id: '123456',
parent_id: null,
user_id: null
}
},
old_state: {
entity_id: 'light.living_room',
state: 'off',
attributes: {},
last_changed: '2024-01-01T00:00:00Z',
last_updated: '2024-01-01T00:00:00Z',
context: {
id: '123456',
parent_id: null,
user_id: null
}
}
},
origin: 'LOCAL',
time_fired: '2024-01-01T00:00:00Z',
context: {
id: '123456',
parent_id: null,
user_id: null
}
};
expect(validate(validEvent)).toBe(true);
});
it('should validate event with null old_state', () => {
const newEntityEvent = {
event_type: 'state_changed',
data: {
entity_id: 'light.living_room',
new_state: {
entity_id: 'light.living_room',
state: 'on',
attributes: {},
last_changed: '2024-01-01T00:00:00Z',
last_updated: '2024-01-01T00:00:00Z',
context: {
id: '123456',
parent_id: null,
user_id: null
}
},
old_state: null
},
origin: 'LOCAL',
time_fired: '2024-01-01T00:00:00Z',
context: {
id: '123456',
parent_id: null,
user_id: null
}
};
expect(validate(newEntityEvent)).toBe(true);
});
it('should reject event with invalid event_type', () => {
const invalidEvent = {
event_type: 'wrong_type',
data: {
entity_id: 'light.living_room',
new_state: null,
old_state: null
},
origin: 'LOCAL',
time_fired: '2024-01-01T00:00:00Z',
context: {
id: '123456',
parent_id: null,
user_id: null
}
};
expect(validate(invalidEvent)).toBe(false);
expect(validate.errors).toBeDefined();
});
});
describe('Config Schema', () => {
const validate = ajv.compile(configSchema);
it('should validate a minimal config', () => {
const minimalConfig = {
latitude: 52.3731,
longitude: 4.8922,
elevation: 0,
unit_system: {
length: 'km',
mass: 'kg',
temperature: '°C',
volume: 'L'
},
location_name: 'Home',
time_zone: 'Europe/Amsterdam',
components: ['homeassistant'],
version: '2024.1.0'
};
expect(validate(minimalConfig)).toBe(true);
});
it('should reject config with missing required fields', () => {
const invalidConfig = {
latitude: 52.3731,
longitude: 4.8922
// missing other required fields
};
expect(validate(invalidConfig)).toBe(false);
expect(validate.errors).toBeDefined();
});
it('should reject config with invalid types', () => {
const invalidConfig = {
latitude: '52.3731', // should be number
longitude: 4.8922,
elevation: 0,
unit_system: {
length: 'km',
mass: 'kg',
temperature: '°C',
volume: 'L'
},
location_name: 'Home',
time_zone: 'Europe/Amsterdam',
components: ['homeassistant'],
version: '2024.1.0'
};
expect(validate(invalidConfig)).toBe(false);
expect(validate.errors).toBeDefined();
});
});
describe('Automation Schema', () => {
const validate = ajv.compile(automationSchema);
it('should validate a basic automation', () => {
const basicAutomation = {
alias: 'Turn on lights at sunset',
description: 'Automatically turn on lights when the sun sets',
trigger: [{
platform: 'sun',
event: 'sunset',
offset: '+00:30:00'
}],
action: [{
service: 'light.turn_on',
target: {
entity_id: ['light.living_room', 'light.kitchen']
},
data: {
brightness_pct: 70
}
}]
};
expect(validate(basicAutomation)).toBe(true);
});
it('should validate automation with conditions', () => {
const automationWithConditions = {
alias: 'Conditional Light Control',
mode: 'single',
trigger: [{
platform: 'state',
entity_id: 'binary_sensor.motion',
to: 'on'
}],
condition: [{
condition: 'and',
conditions: [
{
condition: 'time',
after: '22:00:00',
before: '06:00:00'
},
{
condition: 'state',
entity_id: 'input_boolean.guest_mode',
state: 'off'
}
]
}],
action: [{
service: 'light.turn_on',
target: {
entity_id: 'light.hallway'
}
}]
};
expect(validate(automationWithConditions)).toBe(true);
});
it('should validate automation with multiple triggers and actions', () => {
const complexAutomation = {
alias: 'Complex Automation',
mode: 'parallel',
trigger: [
{
platform: 'state',
entity_id: 'binary_sensor.door',
to: 'on'
},
{
platform: 'state',
entity_id: 'binary_sensor.window',
to: 'on'
}
],
condition: [{
condition: 'state',
entity_id: 'alarm_control_panel.home',
state: 'armed_away'
}],
action: [
{
service: 'notify.mobile_app',
data: {
message: 'Security alert: Movement detected!'
}
},
{
service: 'light.turn_on',
target: {
entity_id: 'light.all_lights'
}
},
{
service: 'camera.snapshot',
target: {
entity_id: 'camera.front_door'
}
}
]
};
expect(validate(complexAutomation)).toBe(true);
});
it('should reject automation without required fields', () => {
const invalidAutomation = {
description: 'Missing required fields'
// missing alias, trigger, and action
};
expect(validate(invalidAutomation)).toBe(false);
expect(validate.errors).toBeDefined();
});
it('should validate all automation modes', () => {
const modes = ['single', 'parallel', 'queued', 'restart'];
modes.forEach(mode => {
const automation = {
alias: `Test ${mode} mode`,
mode,
trigger: [{
platform: 'state',
entity_id: 'input_boolean.test',
to: 'on'
}],
action: [{
service: 'light.turn_on',
target: {
entity_id: 'light.test'
}
}]
};
expect(validate(automation)).toBe(true);
});
});
});
describe('Device Control Schema', () => {
const validate = ajv.compile(deviceControlSchema);
it('should validate light control command', () => {
const lightCommand = {
domain: 'light',
command: 'turn_on',
entity_id: 'light.living_room',
parameters: {
brightness: 255,
color_temp: 400,
transition: 2
}
};
expect(validate(lightCommand)).toBe(true);
});
it('should validate climate control command', () => {
const climateCommand = {
domain: 'climate',
command: 'set_temperature',
entity_id: 'climate.living_room',
parameters: {
temperature: 22.5,
hvac_mode: 'heat',
target_temp_high: 24,
target_temp_low: 20
}
};
expect(validate(climateCommand)).toBe(true);
});
it('should validate cover control command', () => {
const coverCommand = {
domain: 'cover',
command: 'set_position',
entity_id: 'cover.garage_door',
parameters: {
position: 50,
tilt_position: 45
}
};
expect(validate(coverCommand)).toBe(true);
});
it('should validate fan control command', () => {
const fanCommand = {
domain: 'fan',
command: 'set_speed',
entity_id: 'fan.bedroom',
parameters: {
speed: 'medium',
oscillating: true,
direction: 'forward'
}
};
expect(validate(fanCommand)).toBe(true);
});
it('should reject command with invalid domain', () => {
const invalidCommand = {
domain: 'invalid_domain',
command: 'turn_on',
entity_id: 'light.living_room'
};
expect(validate(invalidCommand)).toBe(false);
expect(validate.errors).toBeDefined();
});
it('should reject command with mismatched domain and entity_id', () => {
const mismatchedCommand = {
domain: 'light',
command: 'turn_on',
entity_id: 'switch.living_room' // mismatched domain
};
expect(validate(mismatchedCommand)).toBe(false);
});
it('should validate command with array of entity_ids', () => {
const multiEntityCommand = {
domain: 'light',
command: 'turn_on',
entity_id: ['light.living_room', 'light.kitchen'],
parameters: {
brightness: 255
}
};
expect(validate(multiEntityCommand)).toBe(true);
});
it('should validate scene activation command', () => {
const sceneCommand = {
domain: 'scene',
command: 'turn_on',
entity_id: 'scene.movie_night',
parameters: {
transition: 2
}
};
expect(validate(sceneCommand)).toBe(true);
});
it('should validate script execution command', () => {
const scriptCommand = {
domain: 'script',
command: 'turn_on',
entity_id: 'script.welcome_home',
parameters: {
variables: {
user: 'John',
delay: 5
}
}
};
expect(validate(scriptCommand)).toBe(true);
});
});
});

View File

@@ -0,0 +1,212 @@
import { TokenManager, validateRequest, sanitizeInput, errorHandler } from '../../src/security/index.js';
import { Request, Response } from 'express';
describe('Security Module', () => {
describe('TokenManager', () => {
const testToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiZXhwIjoxNzE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c';
const encryptionKey = 'test_encryption_key';
it('should encrypt and decrypt tokens', () => {
const encrypted = TokenManager.encryptToken(testToken, encryptionKey);
const decrypted = TokenManager.decryptToken(encrypted, encryptionKey);
expect(decrypted).toBe(testToken);
});
it('should validate tokens correctly', () => {
expect(TokenManager.validateToken(testToken)).toBe(true);
expect(TokenManager.validateToken('invalid_token')).toBe(false);
expect(TokenManager.validateToken('')).toBe(false);
});
it('should handle expired tokens', () => {
const expiredToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiZXhwIjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c';
expect(TokenManager.validateToken(expiredToken)).toBe(false);
});
});
describe('Request Validation', () => {
let mockRequest: Partial<Request>;
let mockResponse: Partial<Response>;
let mockNext: jest.Mock;
beforeEach(() => {
mockRequest = {
method: 'POST',
headers: {
'content-type': 'application/json',
authorization: 'Bearer validToken'
},
is: jest.fn().mockReturnValue(true),
body: { test: 'data' }
};
mockResponse = {
status: jest.fn().mockReturnThis(),
json: jest.fn()
};
mockNext = jest.fn();
});
it('should pass valid requests', () => {
validateRequest(
mockRequest as Request,
mockResponse as Response,
mockNext
);
expect(mockNext).toHaveBeenCalled();
expect(mockResponse.status).not.toHaveBeenCalled();
});
it('should reject invalid content type', () => {
mockRequest.is = jest.fn().mockReturnValue(false);
validateRequest(
mockRequest as Request,
mockResponse as Response,
mockNext
);
expect(mockResponse.status).toHaveBeenCalledWith(415);
expect(mockResponse.json).toHaveBeenCalledWith({
error: 'Unsupported Media Type - Content-Type must be application/json'
});
});
it('should reject missing token', () => {
mockRequest.headers = {};
validateRequest(
mockRequest as Request,
mockResponse as Response,
mockNext
);
expect(mockResponse.status).toHaveBeenCalledWith(401);
expect(mockResponse.json).toHaveBeenCalledWith({
error: 'Invalid or expired token'
});
});
it('should reject invalid request body', () => {
mockRequest.body = null;
validateRequest(
mockRequest as Request,
mockResponse as Response,
mockNext
);
expect(mockResponse.status).toHaveBeenCalledWith(400);
expect(mockResponse.json).toHaveBeenCalledWith({
error: 'Invalid request body'
});
});
});
describe('Input Sanitization', () => {
let mockRequest: Partial<Request>;
let mockResponse: Partial<Response>;
let mockNext: jest.Mock;
beforeEach(() => {
mockRequest = {
body: {}
};
mockResponse = {};
mockNext = jest.fn();
});
it('should sanitize HTML tags from request body', () => {
mockRequest.body = {
text: 'Test <script>alert("xss")</script>',
nested: {
html: '<img src="x" onerror="alert(1)">'
}
};
sanitizeInput(
mockRequest as Request,
mockResponse as Response,
mockNext
);
expect(mockRequest.body).toEqual({
text: 'Test alert("xss")',
nested: {
html: 'img src="x" onerror="alert(1)"'
}
});
expect(mockNext).toHaveBeenCalled();
});
it('should handle non-object body', () => {
mockRequest.body = 'string body';
sanitizeInput(
mockRequest as Request,
mockResponse as Response,
mockNext
);
expect(mockRequest.body).toBe('string body');
expect(mockNext).toHaveBeenCalled();
});
});
describe('Error Handler', () => {
let mockRequest: Partial<Request>;
let mockResponse: Partial<Response>;
let mockNext: jest.Mock;
const originalEnv = process.env.NODE_ENV;
beforeEach(() => {
mockRequest = {};
mockResponse = {
status: jest.fn().mockReturnThis(),
json: jest.fn()
};
mockNext = jest.fn();
});
afterAll(() => {
process.env.NODE_ENV = originalEnv;
});
it('should handle errors in production mode', () => {
process.env.NODE_ENV = 'production';
const error = new Error('Test error');
errorHandler(
error,
mockRequest as Request,
mockResponse as Response,
mockNext
);
expect(mockResponse.status).toHaveBeenCalledWith(500);
expect(mockResponse.json).toHaveBeenCalledWith({
error: 'Internal Server Error',
message: undefined
});
});
it('should include error message in development mode', () => {
process.env.NODE_ENV = 'development';
const error = new Error('Test error');
errorHandler(
error,
mockRequest as Request,
mockResponse as Response,
mockNext
);
expect(mockResponse.status).toHaveBeenCalledWith(500);
expect(mockResponse.json).toHaveBeenCalledWith({
error: 'Internal Server Error',
message: 'Test error'
});
});
});
});

View File

@@ -0,0 +1,248 @@
import { Request, Response } from 'express';
import {
validateRequest,
sanitizeInput,
errorHandler,
rateLimiter,
securityHeaders
} from '../../src/security/index.js';
interface MockRequest extends Partial<Request> {
headers: Record<string, string>;
is: jest.Mock;
}
describe('Security Middleware', () => {
let mockRequest: MockRequest;
let mockResponse: Partial<Response>;
let mockNext: jest.Mock;
beforeEach(() => {
mockRequest = {
method: 'POST',
headers: {
'content-type': 'application/json',
'authorization': 'Bearer validToken'
},
is: jest.fn().mockReturnValue(true),
body: { test: 'data' }
};
mockResponse = {
status: jest.fn().mockReturnThis(),
json: jest.fn(),
setHeader: jest.fn(),
set: jest.fn()
};
mockNext = jest.fn();
});
describe('Request Validation', () => {
it('should pass valid requests', () => {
validateRequest(
mockRequest as Request,
mockResponse as Response,
mockNext
);
expect(mockNext).toHaveBeenCalled();
expect(mockResponse.status).not.toHaveBeenCalled();
});
it('should reject requests with invalid content type', () => {
mockRequest.is = jest.fn().mockReturnValue(false);
validateRequest(
mockRequest as Request,
mockResponse as Response,
mockNext
);
expect(mockResponse.status).toHaveBeenCalledWith(415);
expect(mockResponse.json).toHaveBeenCalledWith({
error: 'Unsupported Media Type - Content-Type must be application/json'
});
});
it('should reject requests without authorization', () => {
mockRequest.headers = {};
validateRequest(
mockRequest as Request,
mockResponse as Response,
mockNext
);
expect(mockResponse.status).toHaveBeenCalledWith(401);
expect(mockResponse.json).toHaveBeenCalledWith({
error: 'Invalid or expired token'
});
});
it('should reject requests with invalid token', () => {
mockRequest.headers.authorization = 'Bearer invalid.token.format';
validateRequest(
mockRequest as Request,
mockResponse as Response,
mockNext
);
expect(mockResponse.status).toHaveBeenCalledWith(401);
});
it('should handle GET requests without body validation', () => {
mockRequest.method = 'GET';
mockRequest.body = undefined;
validateRequest(
mockRequest as Request,
mockResponse as Response,
mockNext
);
expect(mockNext).toHaveBeenCalled();
});
});
describe('Input Sanitization', () => {
it('should remove HTML tags from request body', () => {
mockRequest.body = {
text: '<script>alert("xss")</script>',
nested: {
html: '<img src="x" onerror="alert(1)">'
}
};
sanitizeInput(
mockRequest as Request,
mockResponse as Response,
mockNext
);
expect(mockRequest.body.text).not.toContain('<script>');
expect(mockRequest.body.nested.html).not.toContain('<img');
expect(mockNext).toHaveBeenCalled();
});
it('should handle non-object body', () => {
mockRequest.body = 'plain text';
sanitizeInput(
mockRequest as Request,
mockResponse as Response,
mockNext
);
expect(mockRequest.body).toBe('plain text');
expect(mockNext).toHaveBeenCalled();
});
it('should handle nested objects', () => {
mockRequest.body = {
level1: {
level2: {
text: '<p>test</p>'
}
}
};
sanitizeInput(
mockRequest as Request,
mockResponse as Response,
mockNext
);
expect(mockRequest.body.level1.level2.text).not.toContain('<p>');
expect(mockNext).toHaveBeenCalled();
});
it('should preserve safe content', () => {
mockRequest.body = {
text: 'Safe text without HTML',
number: 42,
boolean: true
};
const originalBody = { ...mockRequest.body };
sanitizeInput(
mockRequest as Request,
mockResponse as Response,
mockNext
);
expect(mockRequest.body).toEqual(originalBody);
expect(mockNext).toHaveBeenCalled();
});
});
describe('Error Handler', () => {
const originalEnv = process.env.NODE_ENV;
afterAll(() => {
process.env.NODE_ENV = originalEnv;
});
it('should handle errors in production mode', () => {
process.env.NODE_ENV = 'production';
const error = new Error('Test error');
errorHandler(
error,
mockRequest as Request,
mockResponse as Response,
mockNext
);
expect(mockResponse.status).toHaveBeenCalledWith(500);
expect(mockResponse.json).toHaveBeenCalledWith({
error: 'Internal Server Error',
message: undefined
});
});
it('should include error details in development mode', () => {
process.env.NODE_ENV = 'development';
const error = new Error('Test error');
errorHandler(
error,
mockRequest as Request,
mockResponse as Response,
mockNext
);
expect(mockResponse.status).toHaveBeenCalledWith(500);
expect(mockResponse.json).toHaveBeenCalledWith({
error: 'Internal Server Error',
message: 'Test error'
});
});
it('should handle non-Error objects', () => {
const error = 'String error message';
errorHandler(
error as any,
mockRequest as Request,
mockResponse as Response,
mockNext
);
expect(mockResponse.status).toHaveBeenCalledWith(500);
});
});
describe('Rate Limiter', () => {
it('should be configured with correct options', () => {
expect(rateLimiter).toBeDefined();
const middleware = rateLimiter as any;
expect(middleware.windowMs).toBeDefined();
expect(middleware.max).toBeDefined();
});
});
describe('Security Headers', () => {
it('should be configured with secure defaults', () => {
expect(securityHeaders).toBeDefined();
const middleware = securityHeaders as any;
expect(middleware.getDefaultDirectives).toBeDefined();
});
it('should set appropriate security headers', () => {
const mockRes = {
setHeader: jest.fn()
};
securityHeaders(mockRequest as Request, mockRes as any, mockNext);
expect(mockRes.setHeader).toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,127 @@
import { TokenManager } from '../../src/security/index.js';
describe('TokenManager', () => {
const validToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiZXhwIjoxNzE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c';
const expiredToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiZXhwIjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c';
const encryptionKey = 'test_encryption_key_12345';
describe('Token Encryption/Decryption', () => {
it('should encrypt and decrypt tokens successfully', () => {
const encrypted = TokenManager.encryptToken(validToken, encryptionKey);
const decrypted = TokenManager.decryptToken(encrypted, encryptionKey);
expect(decrypted).toBe(validToken);
});
it('should generate different encrypted values for same token', () => {
const encrypted1 = TokenManager.encryptToken(validToken, encryptionKey);
const encrypted2 = TokenManager.encryptToken(validToken, encryptionKey);
expect(encrypted1).not.toBe(encrypted2);
});
it('should handle empty tokens', () => {
expect(() => TokenManager.encryptToken('', encryptionKey)).toThrow();
});
it('should handle empty encryption keys', () => {
expect(() => TokenManager.encryptToken(validToken, '')).toThrow();
});
it('should fail decryption with wrong key', () => {
const encrypted = TokenManager.encryptToken(validToken, encryptionKey);
expect(() => TokenManager.decryptToken(encrypted, 'wrong_key')).toThrow();
});
});
describe('Token Validation', () => {
it('should validate correct tokens', () => {
expect(TokenManager.validateToken(validToken)).toBe(true);
});
it('should reject expired tokens', () => {
expect(TokenManager.validateToken(expiredToken)).toBe(false);
});
it('should reject malformed tokens', () => {
const malformedTokens = [
'not.a.token',
'invalid-token-format',
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9',
'',
'null',
'undefined'
];
malformedTokens.forEach(token => {
expect(TokenManager.validateToken(token)).toBe(false);
});
});
it('should reject tokens with invalid signature', () => {
const tamperedToken = validToken.slice(0, -1) + 'X';
expect(TokenManager.validateToken(tamperedToken)).toBe(false);
});
it('should handle tokens with missing expiration', () => {
const tokenWithoutExp = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c';
expect(TokenManager.validateToken(tokenWithoutExp)).toBe(true);
});
});
describe('Security Features', () => {
it('should use secure encryption algorithm', () => {
const encrypted = TokenManager.encryptToken(validToken, encryptionKey);
expect(encrypted).toMatch(/^[A-Za-z0-9+/=]+$/); // Base64 format
expect(encrypted.length).toBeGreaterThan(validToken.length); // Should include IV and tag
});
it('should prevent token tampering', () => {
const encrypted = TokenManager.encryptToken(validToken, encryptionKey);
const tampered = encrypted.slice(0, -1) + 'X';
expect(() => TokenManager.decryptToken(tampered, encryptionKey)).toThrow();
});
it('should use unique IVs for each encryption', () => {
const encrypted1 = TokenManager.encryptToken(validToken, encryptionKey);
const encrypted2 = TokenManager.encryptToken(validToken, encryptionKey);
const encrypted3 = TokenManager.encryptToken(validToken, encryptionKey);
// Each encryption should be different due to unique IVs
expect(new Set([encrypted1, encrypted2, encrypted3]).size).toBe(3);
});
it('should handle large tokens', () => {
const largeToken = validToken.repeat(10); // Create a much larger token
const encrypted = TokenManager.encryptToken(largeToken, encryptionKey);
const decrypted = TokenManager.decryptToken(encrypted, encryptionKey);
expect(decrypted).toBe(largeToken);
});
});
describe('Error Handling', () => {
it('should throw descriptive errors for invalid inputs', () => {
expect(() => TokenManager.encryptToken(null as any, encryptionKey))
.toThrow(/invalid/i);
expect(() => TokenManager.encryptToken(validToken, null as any))
.toThrow(/invalid/i);
expect(() => TokenManager.decryptToken('invalid-base64', encryptionKey))
.toThrow(/invalid/i);
});
it('should handle corrupted encrypted data', () => {
const encrypted = TokenManager.encryptToken(validToken, encryptionKey);
const corrupted = encrypted.substring(10); // Remove part of the encrypted data
expect(() => TokenManager.decryptToken(corrupted, encryptionKey))
.toThrow();
});
it('should handle invalid base64 input', () => {
expect(() => TokenManager.decryptToken('not-base64!@#$', encryptionKey))
.toThrow(/invalid/i);
});
it('should handle undefined and null inputs', () => {
expect(() => TokenManager.validateToken(undefined as any)).toBe(false);
expect(() => TokenManager.validateToken(null as any)).toBe(false);
});
});
});

View File

@@ -0,0 +1,204 @@
import { ToolRegistry, ToolCategory, EnhancedTool } from '../../src/tools/index.js';
describe('ToolRegistry', () => {
let registry: ToolRegistry;
let mockTool: EnhancedTool;
beforeEach(() => {
registry = new ToolRegistry();
mockTool = {
name: 'test_tool',
description: 'A test tool',
metadata: {
category: ToolCategory.DEVICE,
platform: 'test',
version: '1.0.0',
caching: {
enabled: true,
ttl: 1000
}
},
execute: jest.fn().mockResolvedValue({ success: true }),
validate: jest.fn().mockResolvedValue(true),
preExecute: jest.fn().mockResolvedValue(undefined),
postExecute: jest.fn().mockResolvedValue(undefined)
};
});
describe('Tool Registration', () => {
it('should register a tool successfully', () => {
registry.registerTool(mockTool);
const retrievedTool = registry.getTool('test_tool');
expect(retrievedTool).toBe(mockTool);
});
it('should categorize tools correctly', () => {
registry.registerTool(mockTool);
const deviceTools = registry.getToolsByCategory(ToolCategory.DEVICE);
expect(deviceTools).toContain(mockTool);
});
it('should handle multiple tools in the same category', () => {
const mockTool2 = {
...mockTool,
name: 'test_tool_2'
};
registry.registerTool(mockTool);
registry.registerTool(mockTool2);
const deviceTools = registry.getToolsByCategory(ToolCategory.DEVICE);
expect(deviceTools).toHaveLength(2);
expect(deviceTools).toContain(mockTool);
expect(deviceTools).toContain(mockTool2);
});
});
describe('Tool Execution', () => {
it('should execute a tool with all hooks', async () => {
registry.registerTool(mockTool);
await registry.executeTool('test_tool', { param: 'value' });
expect(mockTool.validate).toHaveBeenCalledWith({ param: 'value' });
expect(mockTool.preExecute).toHaveBeenCalledWith({ param: 'value' });
expect(mockTool.execute).toHaveBeenCalledWith({ param: 'value' });
expect(mockTool.postExecute).toHaveBeenCalled();
});
it('should throw error for non-existent tool', async () => {
await expect(registry.executeTool('non_existent', {}))
.rejects.toThrow('Tool non_existent not found');
});
it('should handle validation failure', async () => {
mockTool.validate = jest.fn().mockResolvedValue(false);
registry.registerTool(mockTool);
await expect(registry.executeTool('test_tool', {}))
.rejects.toThrow('Invalid parameters');
});
it('should execute without optional hooks', async () => {
const simpleTool: EnhancedTool = {
name: 'simple_tool',
description: 'A simple tool',
metadata: {
category: ToolCategory.SYSTEM,
platform: 'test',
version: '1.0.0'
},
execute: jest.fn().mockResolvedValue({ success: true })
};
registry.registerTool(simpleTool);
const result = await registry.executeTool('simple_tool', {});
expect(result).toEqual({ success: true });
});
});
describe('Caching', () => {
it('should cache tool results when enabled', async () => {
registry.registerTool(mockTool);
const params = { test: 'value' };
// First execution
await registry.executeTool('test_tool', params);
expect(mockTool.execute).toHaveBeenCalledTimes(1);
// Second execution within TTL
await registry.executeTool('test_tool', params);
expect(mockTool.execute).toHaveBeenCalledTimes(1);
});
it('should not cache results when disabled', async () => {
const uncachedTool: EnhancedTool = {
...mockTool,
metadata: {
...mockTool.metadata,
caching: {
enabled: false,
ttl: 1000
}
}
};
registry.registerTool(uncachedTool);
const params = { test: 'value' };
// Multiple executions
await registry.executeTool('test_tool', params);
await registry.executeTool('test_tool', params);
expect(uncachedTool.execute).toHaveBeenCalledTimes(2);
});
it('should expire cache after TTL', async () => {
mockTool.metadata.caching!.ttl = 100; // Short TTL for testing
registry.registerTool(mockTool);
const params = { test: 'value' };
// First execution
await registry.executeTool('test_tool', params);
// Wait for cache to expire
await new Promise(resolve => setTimeout(resolve, 150));
// Second execution after TTL
await registry.executeTool('test_tool', params);
expect(mockTool.execute).toHaveBeenCalledTimes(2);
});
it('should clean expired cache entries', async () => {
mockTool.metadata.caching!.ttl = 100;
registry.registerTool(mockTool);
const params = { test: 'value' };
// Execute and cache
await registry.executeTool('test_tool', params);
// Wait for cache to expire
await new Promise(resolve => setTimeout(resolve, 150));
// Clean cache
registry.cleanCache();
// Verify cache is cleaned by forcing a new execution
await registry.executeTool('test_tool', params);
expect(mockTool.execute).toHaveBeenCalledTimes(2);
});
});
describe('Category Management', () => {
it('should return empty array for unknown category', () => {
const tools = registry.getToolsByCategory('unknown' as ToolCategory);
expect(tools).toEqual([]);
});
it('should handle tools across multiple categories', () => {
const systemTool: EnhancedTool = {
...mockTool,
name: 'system_tool',
metadata: {
...mockTool.metadata,
category: ToolCategory.SYSTEM
}
};
const automationTool: EnhancedTool = {
...mockTool,
name: 'automation_tool',
metadata: {
...mockTool.metadata,
category: ToolCategory.AUTOMATION
}
};
registry.registerTool(mockTool); // DEVICE category
registry.registerTool(systemTool);
registry.registerTool(automationTool);
expect(registry.getToolsByCategory(ToolCategory.DEVICE)).toHaveLength(1);
expect(registry.getToolsByCategory(ToolCategory.SYSTEM)).toHaveLength(1);
expect(registry.getToolsByCategory(ToolCategory.AUTOMATION)).toHaveLength(1);
});
});
});

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,247 @@
import { WebSocket } from 'ws';
import { EventEmitter } from 'events';
import { HassWebSocketClient } from '../../src/websocket/client.js';
import * as HomeAssistant from '../../src/types/hass.js';
// Mock WebSocket
jest.mock('ws');
describe('WebSocket Event Handling', () => {
let client: HassWebSocketClient;
let mockWs: jest.Mocked<WebSocket>;
let eventEmitter: EventEmitter;
beforeEach(() => {
// Setup mock WebSocket
eventEmitter = new EventEmitter();
mockWs = {
on: jest.fn((event, callback) => eventEmitter.on(event, callback)),
send: jest.fn(),
close: jest.fn(),
readyState: WebSocket.OPEN
} as unknown as jest.Mocked<WebSocket>;
(WebSocket as jest.MockedClass<typeof WebSocket>).mockImplementation(() => mockWs);
// Create client instance with required options
client = new HassWebSocketClient('ws://localhost:8123/api/websocket', 'test_token', {
autoReconnect: true,
maxReconnectAttempts: 3,
reconnectDelay: 100
});
});
afterEach(() => {
jest.clearAllMocks();
eventEmitter.removeAllListeners();
client.disconnect();
});
describe('Connection Events', () => {
it('should handle successful connection', (done) => {
client.on('open', () => {
expect(mockWs.send).toHaveBeenCalled();
done();
});
eventEmitter.emit('open');
});
it('should handle connection errors', (done) => {
const error = new Error('Connection failed');
client.on('error', (err: Error) => {
expect(err).toBe(error);
done();
});
eventEmitter.emit('error', error);
});
it('should handle connection close', (done) => {
client.on('disconnected', () => {
expect(mockWs.close).toHaveBeenCalled();
done();
});
eventEmitter.emit('close');
});
});
describe('Authentication', () => {
it('should send authentication message on connect', () => {
const authMessage: HomeAssistant.AuthMessage = {
type: 'auth',
access_token: 'test_token'
};
client.connect();
expect(mockWs.send).toHaveBeenCalledWith(JSON.stringify(authMessage));
});
it('should handle successful authentication', (done) => {
client.on('auth_ok', () => {
done();
});
client.connect();
eventEmitter.emit('message', JSON.stringify({ type: 'auth_ok' }));
});
it('should handle authentication failure', (done) => {
client.on('auth_invalid', () => {
done();
});
client.connect();
eventEmitter.emit('message', JSON.stringify({ type: 'auth_invalid' }));
});
});
describe('Event Subscription', () => {
it('should handle state changed events', (done) => {
const stateEvent: HomeAssistant.StateChangedEvent = {
event_type: 'state_changed',
data: {
entity_id: 'light.living_room',
new_state: {
entity_id: 'light.living_room',
state: 'on',
attributes: { brightness: 255 },
last_changed: '2024-01-01T00:00:00Z',
last_updated: '2024-01-01T00:00:00Z',
context: {
id: '123',
parent_id: null,
user_id: null
}
},
old_state: {
entity_id: 'light.living_room',
state: 'off',
attributes: {},
last_changed: '2024-01-01T00:00:00Z',
last_updated: '2024-01-01T00:00:00Z',
context: {
id: '122',
parent_id: null,
user_id: null
}
}
},
origin: 'LOCAL',
time_fired: '2024-01-01T00:00:00Z',
context: {
id: '123',
parent_id: null,
user_id: null
}
};
client.on('event', (event) => {
expect(event.data.entity_id).toBe('light.living_room');
expect(event.data.new_state.state).toBe('on');
expect(event.data.old_state.state).toBe('off');
done();
});
eventEmitter.emit('message', JSON.stringify({ type: 'event', event: stateEvent }));
});
it('should subscribe to specific events', async () => {
const subscriptionId = 1;
const callback = jest.fn();
// Mock successful subscription
const subscribePromise = client.subscribeEvents('state_changed', callback);
eventEmitter.emit('message', JSON.stringify({
id: 1,
type: 'result',
success: true
}));
await expect(subscribePromise).resolves.toBe(subscriptionId);
// Test event handling
const eventData = {
entity_id: 'light.living_room',
state: 'on'
};
eventEmitter.emit('message', JSON.stringify({
type: 'event',
event: {
event_type: 'state_changed',
data: eventData
}
}));
expect(callback).toHaveBeenCalledWith(eventData);
});
it('should unsubscribe from events', async () => {
// First subscribe
const subscriptionId = await client.subscribeEvents('state_changed', () => { });
// Then unsubscribe
const unsubscribePromise = client.unsubscribeEvents(subscriptionId);
eventEmitter.emit('message', JSON.stringify({
id: 2,
type: 'result',
success: true
}));
await expect(unsubscribePromise).resolves.toBeUndefined();
});
});
describe('Message Handling', () => {
it('should handle malformed messages', (done) => {
client.on('error', (error: Error) => {
expect(error.message).toContain('Unexpected token');
done();
});
eventEmitter.emit('message', 'invalid json');
});
it('should handle unknown message types', (done) => {
const unknownMessage = {
type: 'unknown_type',
data: {}
};
client.on('error', (error: Error) => {
expect(error.message).toContain('Unknown message type');
done();
});
eventEmitter.emit('message', JSON.stringify(unknownMessage));
});
});
describe('Reconnection', () => {
it('should attempt to reconnect on connection loss', (done) => {
let reconnectAttempts = 0;
client.on('disconnected', () => {
reconnectAttempts++;
if (reconnectAttempts === 1) {
expect(WebSocket).toHaveBeenCalledTimes(2);
done();
}
});
eventEmitter.emit('close');
});
it('should re-authenticate after reconnection', (done) => {
client.connect();
client.on('auth_ok', () => {
done();
});
eventEmitter.emit('close');
eventEmitter.emit('open');
eventEmitter.emit('message', JSON.stringify({ type: 'auth_ok' }));
});
});
});

View File

@@ -0,0 +1,152 @@
#!/bin/bash
# macos-setup.sh
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
BLUE='\033[0;34m'
NC='\033[0m'
echo -e "${BLUE}Setting up MCP Integration for Claude Desktop${NC}"
# Function to compare version numbers
version_greater_equal() {
printf '%s\n' "$2" "$1" | sort -V -C
}
# Check if Homebrew is installed
if ! command -v brew &> /dev/null; then
echo -e "${RED}Homebrew is not installed. Installing Homebrew...${NC}"
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
fi
# Check if Node.js is installed
if ! command -v node &> /dev/null; then
echo -e "${RED}Node.js is not installed. Installing via Homebrew...${NC}"
brew install node@20
brew link node@20
else
NODE_VERSION=$(node -v)
if ! version_greater_equal "${NODE_VERSION//v/}" "20.10.0"; then
echo -e "${RED}Node.js version must be 20.10.0 or higher. Current version: $NODE_VERSION${NC}"
echo -e "${BLUE}Installing Node.js 20 via Homebrew...${NC}"
brew install node@20
brew link node@20
fi
fi
# Check if npm is installed
if ! command -v npm &> /dev/null; then
echo -e "${RED}npm is not installed. Please install npm and try again.${NC}"
exit 1
fi
# Check if jq is installed
if ! command -v jq &> /dev/null; then
echo -e "${RED}jq is not installed. Installing via Homebrew...${NC}"
brew install jq
fi
# Create MCP directory if it doesn't exist
MCP_DIR="$HOME/.mcp"
mkdir -p "$MCP_DIR"
# Clone the Home Assistant MCP repository
echo -e "${BLUE}Cloning Home Assistant MCP repository...${NC}"
git clone https://github.com/jango-blockchained/homeassistant-mcp.git "$MCP_DIR/homeassistant-mcp"
cd "$MCP_DIR/homeassistant-mcp"
# Install dependencies and build
echo -e "${BLUE}Installing dependencies and building...${NC}"
npm install
npm run build
# Create Claude Desktop config directory (macOS specific path)
CLAUDE_CONFIG_DIR="$HOME/Library/Application Support/Claude"
mkdir -p "$CLAUDE_CONFIG_DIR"
# Prompt for configurations
echo -e "${BLUE}Please enter your configurations:${NC}"
read -p "Home Assistant URL (e.g., http://homeassistant.local:8123): " HASS_HOST
read -p "Home Assistant Long-lived access token: " HASS_TOKEN
# Create .env file for Home Assistant
cat > "$MCP_DIR/homeassistant-mcp/.env" << EOL
NODE_ENV=production
HASS_HOST=$HASS_HOST
HASS_TOKEN=$HASS_TOKEN
PORT=3000
EOL
# Create base configuration for Home Assistant
CONFIG_JSON='{
"mcpServers": {
"homeassistant": {
"command": "node",
"args": [
"'$MCP_DIR'/homeassistant-mcp/dist/index.js"
],
"env": {
"HASS_TOKEN": "'$HASS_TOKEN'",
"HASS_HOST": "'$HASS_HOST'",
"NODE_ENV": "production",
"PORT": "3000"
}
}
}
}'
# Prompt for enabling Brave Search
read -p "Do you want to enable Brave Search integration? (y/n): " ENABLE_BRAVE_SEARCH
if [[ $ENABLE_BRAVE_SEARCH =~ ^[Yy]$ ]]; then
# Install Brave Search MCP globally only if enabled
echo -e "${BLUE}Installing Brave Search MCP...${NC}"
npm install -g @modelcontextprotocol/server-brave-search
read -p "Brave Search API Key: " BRAVE_API_KEY
# Add Brave Search to the configuration
CONFIG_JSON=$(echo $CONFIG_JSON | jq '.mcpServers += {
"brave-search": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-brave-search"],
"env": {
"BRAVE_API_KEY": "'$BRAVE_API_KEY'"
}
}
}')
fi
# Write the final configuration to file
echo $CONFIG_JSON | jq '.' > "$CLAUDE_CONFIG_DIR/claude_desktop_config.json"
# Set proper permissions
chmod 600 "$CLAUDE_CONFIG_DIR/claude_desktop_config.json"
chmod 600 "$MCP_DIR/homeassistant-mcp/.env"
echo -e "${GREEN}Installation complete!${NC}"
echo -e "${BLUE}Configuration files created at:${NC}"
echo " - $CLAUDE_CONFIG_DIR/claude_desktop_config.json"
echo " - $MCP_DIR/homeassistant-mcp/.env"
echo -e "${BLUE}To use the integration:${NC}"
echo "1. Make sure Claude Desktop is installed from https://claude.ai/download"
echo "2. Restart Claude Desktop"
echo "3. Home Assistant MCP integration is now available"
if [[ $ENABLE_BRAVE_SEARCH =~ ^[Yy]$ ]]; then
echo "4. Brave Search MCP integration is also available"
fi
echo -e "${RED}Note: Keep your access tokens and API keys secure and never share them with others${NC}"
# Optional: Test the installations
read -p "Would you like to test the installations? (y/n) " -n 1 -r
echo
if [[ $REPLY =~ ^[Yy]$ ]]; then
echo -e "${BLUE}Testing Home Assistant MCP connection...${NC}"
node "$MCP_DIR/homeassistant-mcp/dist/index.js" test
if [[ $ENABLE_BRAVE_SEARCH =~ ^[Yy]$ ]]; then
echo -e "${BLUE}Testing Brave Search MCP...${NC}"
npx @modelcontextprotocol/server-brave-search test
fi
fi

364
docs/API.md Normal file
View File

@@ -0,0 +1,364 @@
# Home Assistant MCP API Documentation
## Overview
The Home Assistant Model Context Protocol (MCP) Server provides a comprehensive API for managing and controlling your Home Assistant instance. This document outlines all available endpoints, their parameters, and includes practical examples.
## Authentication
All API requests require authentication using a Bearer token.
```http
Authorization: Bearer your_home_assistant_token
```
## Rate Limiting
The API implements rate limiting with the following defaults:
- 100 requests per 15-minute window per IP
- Model-specific limits:
- Claude: 100 requests/minute, 1000/hour
- GPT-4: 50 requests/minute, 500/hour
- Custom: 200 requests/minute, 2000/hour
## Endpoints
### 1. Natural Language Processing
#### POST /interpret
Interprets natural language commands for Home Assistant control.
**Request Body:**
```json
{
"input": "Turn on the living room lights",
"context": {
"user_id": "user123",
"session_id": "sess456",
"timestamp": "2024-03-20T10:30:00Z",
"location": "home",
"previous_actions": [],
"environment_state": {}
},
"model": "claude" // Optional: defaults to "claude"
}
```
**Response:**
```json
{
"natural_language": "I'll turn on the living room lights",
"structured_data": {
"success": true,
"action_taken": "turn_on",
"entities_affected": ["light.living_room"],
"state_changes": {
"state": "on"
}
},
"next_suggestions": [
"Would you like to adjust the brightness?",
"Should I turn on other lights?",
"Would you like to save this as a scene?"
],
"confidence": 0.95,
"context": {
"user_id": "user123",
"session_id": "sess456",
"timestamp": "2024-03-20T10:30:00Z"
}
}
```
### 2. Device Control
#### POST /control
Controls Home Assistant devices and services.
**Request Body:**
```json
{
"command": "turn_on",
"entity_id": "light.living_room",
"brightness": 255,
"color_temp": 4000,
"rgb_color": [255, 0, 0]
}
```
**Example Commands by Device Type:**
**1. Lights:**
```json
{
"command": "turn_on",
"entity_id": "light.bedroom",
"brightness": 128,
"color_temp": 3500
}
```
**2. Climate:**
```json
{
"command": "set_temperature",
"entity_id": "climate.living_room",
"temperature": 72,
"hvac_mode": "heat"
}
```
**3. Covers:**
```json
{
"command": "set_position",
"entity_id": "cover.garage",
"position": 50
}
```
### 3. History
#### GET /history
Retrieves state history for entities.
**Query Parameters:**
- `entity_id` (required): Entity ID to get history for
- `start_time` (optional): Start time in ISO format
- `end_time` (optional): End time in ISO format
- `minimal_response` (optional): Boolean to reduce response size
- `significant_changes_only` (optional): Boolean to filter minor changes
**Example Request:**
```http
GET /history?entity_id=light.living_room&start_time=2024-03-19T00:00:00Z&minimal_response=true
```
### 4. Scenes
#### GET /scenes
Lists all available scenes.
**Response:**
```json
{
"success": true,
"scenes": [
{
"entity_id": "scene.movie_time",
"name": "Movie Time",
"description": "Dim lights and lower blinds"
}
]
}
```
#### POST /scenes/activate
Activates a scene.
**Request Body:**
```json
{
"scene_id": "scene.movie_time"
}
```
### 5. Automations
#### GET /automations
Lists all automations.
**Response:**
```json
{
"success": true,
"automations": [
{
"entity_id": "automation.morning_routine",
"name": "Morning Routine",
"state": "on",
"last_triggered": "2024-03-20T06:00:00Z"
}
]
}
```
#### POST /automations
Creates or modifies automations.
**Request Body (Create):**
```json
{
"action": "create",
"config": {
"alias": "Morning Routine",
"description": "Turn on lights at sunrise",
"trigger": {
"platform": "sun",
"event": "sunrise"
},
"action": {
"service": "light.turn_on",
"target": {
"entity_id": "light.living_room"
}
}
}
}
```
### 6. Add-ons
#### GET /addons
Lists available add-ons.
**Response:**
```json
{
"success": true,
"addons": [
{
"name": "File Editor",
"slug": "core_configurator",
"description": "Simple browser-based file editor",
"version": "5.6.0",
"installed": true,
"available": true,
"state": "started"
}
]
}
```
#### POST /addons
Manages add-ons.
**Request Body (Install):**
```json
{
"action": "install",
"slug": "core_configurator",
"version": "5.6.0"
}
```
### 7. Package Management (HACS)
#### GET /packages
Lists available HACS packages.
**Query Parameters:**
- `category`: One of ["integration", "plugin", "theme", "python_script", "appdaemon", "netdaemon"]
**Response:**
```json
{
"success": true,
"packages": [
{
"name": "Custom Component",
"repository": "owner/repo",
"category": "integration",
"installed_version": "1.0.0",
"available_version": "1.1.0"
}
]
}
```
#### POST /packages
Manages HACS packages.
**Request Body (Install):**
```json
{
"action": "install",
"category": "integration",
"repository": "owner/repo",
"version": "1.1.0"
}
```
## Error Handling
All endpoints return errors in a consistent format:
```json
{
"error": {
"code": "ERROR_CODE",
"message": "Human readable error message",
"suggestion": "Suggestion to fix the error",
"recovery_options": [
"Option 1",
"Option 2"
]
}
}
```
Common HTTP status codes:
- 200: Success
- 400: Bad Request
- 401: Unauthorized
- 403: Forbidden
- 404: Not Found
- 429: Too Many Requests
- 500: Internal Server Error
## WebSocket Events
The server supports WebSocket connections for real-time updates.
### Connection
```javascript
const ws = new WebSocket('ws://your-server/api/websocket');
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
console.log('Received:', data);
};
```
### Event Subscription
```javascript
// Subscribe to all events
ws.send(JSON.stringify({
type: 'subscribe_events'
}));
// Subscribe to specific event type
ws.send(JSON.stringify({
type: 'subscribe_events',
event_type: 'state_changed'
}));
```
## Security Best Practices
1. Always use HTTPS in production
2. Store tokens securely
3. Implement proper token rotation
4. Monitor and log API usage
5. Regular security audits
## Rate Limiting Headers
The API includes rate limit information in response headers:
```http
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 95
X-RateLimit-Reset: 1621436800
```

364
docs/SSE_API.md Normal file
View File

@@ -0,0 +1,364 @@
# Home Assistant MCP Server-Sent Events (SSE) API Documentation
## Overview
The SSE API provides real-time updates from Home Assistant through a persistent connection. This allows clients to receive instant notifications about state changes, events, and other activities without polling.
## Quick Reference
### Available Endpoints
| Endpoint | Method | Description | Authentication |
|----------|---------|-------------|----------------|
| `/subscribe_events` | POST | Subscribe to real-time events and state changes | Required |
| `/get_sse_stats` | POST | Get statistics about current SSE connections | Required |
### Event Types Available
| Event Type | Description | Example Subscription |
|------------|-------------|---------------------|
| `state_changed` | Entity state changes | `events=state_changed` |
| `service_called` | Service call events | `events=service_called` |
| `automation_triggered` | Automation trigger events | `events=automation_triggered` |
| `script_executed` | Script execution events | `events=script_executed` |
| `ping` | Connection keepalive (system) | Automatic |
| `error` | Error notifications (system) | Automatic |
### Subscription Options
| Option | Description | Example |
|--------|-------------|---------|
| `entity_id` | Subscribe to specific entity | `entity_id=light.living_room` |
| `domain` | Subscribe to entire domain | `domain=light` |
| `events` | Subscribe to event types | `events=state_changed,automation_triggered` |
## Authentication
All SSE connections require authentication using your Home Assistant token.
```javascript
const token = 'YOUR_HASS_TOKEN';
```
## Endpoints
### Subscribe to Events
`POST /subscribe_events`
Subscribe to Home Assistant events and state changes.
#### Parameters
| Parameter | Type | Required | Description |
|------------|----------|----------|-------------|
| token | string | Yes | Your Home Assistant authentication token |
| events | string[] | No | Array of event types to subscribe to |
| entity_id | string | No | Specific entity ID to monitor |
| domain | string | No | Domain to monitor (e.g., "light", "switch") |
#### Example Request
```javascript
const eventSource = new EventSource(`http://localhost:3000/subscribe_events?token=${token}&entity_id=light.living_room&domain=switch&events=state_changed,automation_triggered`);
eventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
console.log('Received:', data);
};
eventSource.onerror = (error) => {
console.error('SSE Error:', error);
eventSource.close();
};
```
### Get SSE Statistics
`POST /get_sse_stats`
Get current statistics about SSE connections and subscriptions.
#### Parameters
| Parameter | Type | Required | Description |
|-----------|--------|----------|-------------|
| token | string | Yes | Your Home Assistant authentication token |
#### Example Request
```bash
curl -X POST http://localhost:3000/get_sse_stats \
-H "Content-Type: application/json" \
-d '{"token": "YOUR_HASS_TOKEN"}'
```
## Event Types
### Standard Events
1. **connection**
- Sent when a client connects successfully
```json
{
"type": "connection",
"status": "connected",
"id": "client_uuid",
"authenticated": true,
"timestamp": "2024-02-10T12:00:00.000Z"
}
```
2. **state_changed**
- Sent when an entity's state changes
```json
{
"type": "state_changed",
"data": {
"entity_id": "light.living_room",
"state": "on",
"attributes": {
"brightness": 255,
"color_temp": 370
},
"last_changed": "2024-02-10T12:00:00.000Z",
"last_updated": "2024-02-10T12:00:00.000Z"
},
"timestamp": "2024-02-10T12:00:00.000Z"
}
```
3. **service_called**
- Sent when a Home Assistant service is called
```json
{
"type": "service_called",
"data": {
"domain": "light",
"service": "turn_on",
"service_data": {
"entity_id": "light.living_room",
"brightness": 255
}
},
"timestamp": "2024-02-10T12:00:00.000Z"
}
```
4. **automation_triggered**
- Sent when an automation is triggered
```json
{
"type": "automation_triggered",
"data": {
"automation_id": "automation.morning_routine",
"trigger": {
"platform": "time",
"at": "07:00:00"
}
},
"timestamp": "2024-02-10T12:00:00.000Z"
}
```
5. **script_executed**
- Sent when a script is executed
```json
{
"type": "script_executed",
"data": {
"script_id": "script.welcome_home",
"execution_data": {
"status": "completed"
}
},
"timestamp": "2024-02-10T12:00:00.000Z"
}
```
### System Events
1. **ping**
- Sent every 30 seconds to keep the connection alive
```json
{
"type": "ping",
"timestamp": "2024-02-10T12:00:00.000Z"
}
```
2. **error**
- Sent when an error occurs
```json
{
"type": "error",
"error": "rate_limit_exceeded",
"message": "Too many requests, please try again later",
"timestamp": "2024-02-10T12:00:00.000Z"
}
```
## Rate Limiting
- Maximum 1000 requests per minute per client
- Rate limits are reset every minute
- Exceeding the rate limit will result in an error event
## Connection Management
- Maximum 100 concurrent clients
- Connections timeout after 5 minutes of inactivity
- Ping messages are sent every 30 seconds
- Clients should handle reconnection on connection loss
## Example Implementation
```javascript
class HomeAssistantSSE {
constructor(baseUrl, token) {
this.baseUrl = baseUrl;
this.token = token;
this.eventSource = null;
this.reconnectAttempts = 0;
this.maxReconnectAttempts = 5;
this.reconnectDelay = 1000;
}
connect(options = {}) {
const params = new URLSearchParams({
token: this.token,
...(options.events && { events: options.events.join(',') }),
...(options.entity_id && { entity_id: options.entity_id }),
...(options.domain && { domain: options.domain })
});
this.eventSource = new EventSource(`${this.baseUrl}/subscribe_events?${params}`);
this.eventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
this.handleEvent(data);
};
this.eventSource.onerror = (error) => {
console.error('SSE Error:', error);
this.handleError(error);
};
}
handleEvent(data) {
switch (data.type) {
case 'connection':
this.reconnectAttempts = 0;
console.log('Connected:', data);
break;
case 'ping':
// Connection is alive
break;
case 'error':
console.error('Server Error:', data);
break;
default:
// Handle other event types
console.log('Event:', data);
}
}
handleError(error) {
this.eventSource?.close();
if (this.reconnectAttempts < this.maxReconnectAttempts) {
this.reconnectAttempts++;
const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1);
console.log(`Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})`);
setTimeout(() => this.connect(), delay);
} else {
console.error('Max reconnection attempts reached');
}
}
disconnect() {
this.eventSource?.close();
this.eventSource = null;
}
}
// Usage example
const client = new HomeAssistantSSE('http://localhost:3000', 'YOUR_HASS_TOKEN');
client.connect({
events: ['state_changed', 'automation_triggered'],
domain: 'light'
});
```
## Best Practices
1. **Error Handling**
- Implement exponential backoff for reconnection attempts
- Handle connection timeouts gracefully
- Monitor for rate limit errors
2. **Resource Management**
- Close EventSource when no longer needed
- Limit subscriptions to necessary events/entities
- Handle cleanup on page unload
3. **Security**
- Never expose the authentication token in client-side code
- Use HTTPS in production
- Validate all incoming data
4. **Performance**
- Subscribe only to needed events
- Implement client-side event filtering
- Monitor memory usage for long-running connections
## Troubleshooting
### Common Issues
1. **Connection Failures**
- Verify your authentication token is valid
- Check server URL is accessible
- Ensure proper network connectivity
- Verify SSL/TLS configuration if using HTTPS
2. **Missing Events**
- Confirm subscription parameters are correct
- Check rate limiting status
- Verify entity/domain exists
- Monitor client-side event handlers
3. **Performance Issues**
- Reduce number of subscriptions
- Implement client-side filtering
- Monitor memory usage
- Check network latency
### Debugging Tips
1. Enable console logging:
```javascript
const client = new HomeAssistantSSE('http://localhost:3000', 'YOUR_HASS_TOKEN');
client.debug = true; // Enables detailed logging
```
2. Monitor network traffic:
```javascript
// Add event listeners for connection states
eventSource.addEventListener('open', () => {
console.log('Connection opened');
});
eventSource.addEventListener('error', (e) => {
console.log('Connection error:', e);
});
```
3. Track subscription status:
```javascript
// Get current subscriptions
const stats = await fetch('/get_sse_stats', {
headers: { 'Authorization': `Bearer ${token}` }
}).then(r => r.json());
console.log('Current subscriptions:', stats);
```

36
jest-resolver.cjs Normal file
View File

@@ -0,0 +1,36 @@
const path = require('path');
module.exports = (request, options) => {
// Handle chalk and related packages
if (request === 'chalk' || request === '#ansi-styles' || request === '#supports-color') {
return path.resolve(__dirname, 'node_modules', request.replace('#', ''));
}
// Handle .js extensions for TypeScript files
if (request.endsWith('.js')) {
const tsRequest = request.replace(/\.js$/, '.ts');
try {
return options.defaultResolver(tsRequest, options);
} catch (e) {
// If the .ts file doesn't exist, continue with the original request
}
}
// Call the default resolver
return options.defaultResolver(request, {
...options,
// Handle ESM modules
packageFilter: pkg => {
// Preserve ESM modules
if (pkg.type === 'module' && pkg.exports) {
// If there's a specific export for the current conditions, use that
if (pkg.exports.import) {
pkg.main = pkg.exports.import;
} else if (typeof pkg.exports === 'string') {
pkg.main = pkg.exports;
}
}
return pkg;
},
});
};

58
jest.config.cjs Normal file
View File

@@ -0,0 +1,58 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
preset: 'ts-jest/presets/default-esm',
testEnvironment: 'node',
extensionsToTreatAsEsm: ['.ts'],
moduleNameMapper: {
'^(\\.{1,2}/.*)\\.js$': '$1',
'^(\\.{1,2}/.*)\\.ts$': '$1',
'^chalk$': '<rootDir>/node_modules/chalk/source/index.js',
'#ansi-styles': '<rootDir>/node_modules/ansi-styles/index.js',
'#supports-color': '<rootDir>/node_modules/supports-color/index.js'
},
transform: {
'^.+\\.tsx?$': [
'ts-jest',
{
useESM: true,
tsconfig: 'tsconfig.json'
},
],
},
transformIgnorePatterns: [
'node_modules/(?!(@digital-alchemy|chalk|ansi-styles|supports-color)/.*)'
],
resolver: '<rootDir>/jest-resolver.cjs',
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
testMatch: [
'**/__tests__/helpers.test.ts',
'**/__tests__/schemas/devices.test.ts',
'**/__tests__/context/index.test.ts',
'**/__tests__/hass/index.test.ts'
],
globals: {
'ts-jest': {
useESM: true,
},
},
collectCoverage: true,
coverageDirectory: 'coverage',
coverageReporters: ['text', 'lcov', 'clover', 'html'],
collectCoverageFrom: [
'src/**/*.ts',
'!src/**/*.d.ts',
'!src/**/*.test.ts',
'!src/types/**/*',
'!src/polyfills.ts'
],
coverageThreshold: {
global: {
branches: 50,
functions: 50,
lines: 50,
statements: 50
}
},
verbose: true,
testTimeout: 30000
};

21
jest.config.js Normal file
View File

@@ -0,0 +1,21 @@
/** @type {import('jest').Config} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
moduleNameMapper: {
'^(\\.{1,2}/.*)\\.js$': '$1',
},
transform: {
'^.+\\.tsx?$': ['ts-jest', {
useESM: true
}]
},
extensionsToTreatAsEsm: ['.ts'],
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
testMatch: ['**/__tests__/**/*.test.ts'],
collectCoverage: true,
coverageDirectory: 'coverage',
transformIgnorePatterns: [
'node_modules/(?!(chalk|#ansi-styles|#supports-color)/)'
]
};

32
jest.setup.cjs Normal file
View File

@@ -0,0 +1,32 @@
// Mock chalk module
jest.mock('chalk', () => ({
default: {
red: (text) => text,
green: (text) => text,
yellow: (text) => text,
blue: (text) => text,
magenta: (text) => text,
cyan: (text) => text,
white: (text) => text,
gray: (text) => text,
grey: (text) => text,
black: (text) => text,
bold: (text) => text,
dim: (text) => text,
italic: (text) => text,
underline: (text) => text,
inverse: (text) => text,
hidden: (text) => text,
strikethrough: (text) => text,
visible: (text) => text,
}
}));
// Mock environment variables
process.env.HASS_URL = 'http://localhost:8123';
process.env.HASS_TOKEN = 'test_token';
process.env.CLAUDE_API_KEY = 'test_api_key';
process.env.CLAUDE_MODEL = 'test_model';
// Global Jest settings
jest.setTimeout(30000); // 30 seconds timeout

31
jest.setup.js Normal file
View File

@@ -0,0 +1,31 @@
import { jest } from '@jest/globals';
// Mock environment variables
process.env.HASS_URL = 'http://localhost:8123';
process.env.HASS_TOKEN = 'test_token';
process.env.CLAUDE_API_KEY = 'test_api_key';
process.env.CLAUDE_MODEL = 'test_model';
// Global Jest settings
jest.setTimeout(30000); // 30 seconds timeout
// Mock semver to avoid the SemVer constructor issue
jest.mock('semver', () => ({
default: class SemVer {
constructor(version) {
this.version = version;
}
toString() {
return this.version;
}
},
valid: (v) => v,
clean: (v) => v,
satisfies: () => true,
gt: () => false,
gte: () => true,
lt: () => false,
lte: () => true,
eq: () => true,
neq: () => false,
}));

84
jest.setup.ts Normal file
View File

@@ -0,0 +1,84 @@
import { jest } from '@jest/globals';
import dotenv from 'dotenv';
import { TextEncoder, TextDecoder } from 'util';
// Load test environment variables
dotenv.config({ path: '.env.test' });
// Set test environment
process.env.NODE_ENV = 'test';
process.env.HASS_URL = 'http://localhost:8123';
process.env.HASS_TOKEN = 'test_token';
process.env.CLAUDE_API_KEY = 'test_api_key';
process.env.CLAUDE_MODEL = 'test_model';
// Add TextEncoder and TextDecoder to global scope
Object.defineProperty(global, 'TextEncoder', {
value: TextEncoder,
writable: true
});
Object.defineProperty(global, 'TextDecoder', {
value: TextDecoder,
writable: true
});
// Configure console for tests
const originalConsole = { ...console };
global.console = {
...console,
log: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
info: jest.fn(),
debug: jest.fn(),
};
// Increase test timeout
jest.setTimeout(30000);
// Mock WebSocket
jest.mock('ws', () => {
return {
WebSocket: jest.fn().mockImplementation(() => ({
on: jest.fn(),
send: jest.fn(),
close: jest.fn(),
removeAllListeners: jest.fn()
}))
};
});
// Mock chalk
const createChalkMock = () => {
const handler = {
get(target: any, prop: string) {
if (prop === 'default') {
return createChalkMock();
}
return typeof prop === 'string' ? createChalkMock() : target[prop];
},
apply(target: any, thisArg: any, args: any[]) {
return args[0];
}
};
return new Proxy(() => { }, handler);
};
jest.mock('chalk', () => createChalkMock());
// Mock ansi-styles
jest.mock('ansi-styles', () => ({}), { virtual: true });
// Mock supports-color
jest.mock('supports-color', () => ({}), { virtual: true });
// Reset mocks between tests
beforeEach(() => {
jest.clearAllMocks();
});
// Cleanup after tests
afterEach(() => {
jest.clearAllTimers();
});

View File

@@ -1,51 +1,51 @@
{
"name": "@strandbrown/homeassistant-mcp",
"name": "jango-blockchained/homeassistant-mcp",
"version": "0.1.0",
"description": "Home Assistant Model Context Protocol Server",
"description": "Model Context Protocol Server for Home Assistant",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc",
"test": "jest",
"build": "npx tsc",
"start": "node dist/src/index.js",
"dev": "tsx watch src/index.ts",
"test": "NODE_OPTIONS=--experimental-vm-modules jest --config=jest.config.cjs",
"test:coverage": "NODE_OPTIONS=--experimental-vm-modules jest --config=jest.config.cjs --coverage",
"test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --config=jest.config.cjs --watch",
"lint": "eslint src --ext .ts",
"lint:fix": "eslint src --ext .ts --fix",
"prepare": "npm run build",
"start": "node dist/index.js",
"dev": "ts-node src/index.ts",
"build:start": "npm run build && npm run start"
"clean": "rimraf dist",
"types:check": "tsc --noEmit",
"types:install": "npm install --save-dev @types/node @types/jest"
},
"author": "Tevon Strand-Brown",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/tevonsb/homeassistant-mcp.git"
},
"keywords": [
"home-assistant",
"mcp",
"model-context-protocol"
],
"dependencies": {
"@digital-alchemy/core": "^24.11.4",
"@digital-alchemy/hass": "^24.11.4",
"@modelcontextprotocol/sdk": "^1.0.3",
"dotenv": "^16.4.7",
"ajv": "^8.12.0",
"dotenv": "^16.3.1",
"express": "^4.18.2",
"express-rate-limit": "^7.1.5",
"helmet": "^7.1.0",
"litemcp": "^0.7.0",
"zod": "^3.24.1",
"zod-to-json-schema": "^3.24.1"
"uuid": "^9.0.1",
"ws": "^8.16.0",
"zod": "^3.22.4"
},
"devDependencies": {
"@digital-alchemy/type-writer": "^24.11.3",
"@types/express": "^5.0.0",
"@types/jest": "^29.5.11",
"@types/node": "^20.11.0",
"@types/socket.io": "^3.0.1",
"@types/ajv": "^1.0.0",
"@types/express": "^4.17.21",
"@types/express-rate-limit": "^6.0.0",
"@types/helmet": "^4.0.0",
"@types/jest": "^29.5.14",
"@types/node": "^20.17.16",
"@types/uuid": "^9.0.8",
"@types/ws": "^8.5.10",
"jest": "^29.7.0",
"nodemon": "^3.0.2",
"ts-jest": "^29.1.1",
"ts-node": "^10.9.2",
"typescript": "^5.7.2"
"rimraf": "^5.0.5",
"ts-jest": "^29.1.2",
"tsx": "^4.7.0",
"typescript": "^5.3.3"
},
"type": "module",
"publishConfig": {
"access": "public"
}
"author": "Jango Blockchained",
"license": "MIT"
}

View File

@@ -0,0 +1,207 @@
import express from 'express';
import { z } from 'zod';
import { NLPProcessor } from '../nlp/processor.js';
import { AIRateLimit, AIContext, AIResponse, AIError, AIModel } from '../types/index.js';
import rateLimit from 'express-rate-limit';
const router = express.Router();
const nlpProcessor = new NLPProcessor();
// Rate limiting configuration
const rateLimitConfig: AIRateLimit = {
requests_per_minute: 100,
requests_per_hour: 1000,
concurrent_requests: 10,
model_specific_limits: {
claude: {
requests_per_minute: 100,
requests_per_hour: 1000
},
gpt4: {
requests_per_minute: 50,
requests_per_hour: 500
},
custom: {
requests_per_minute: 200,
requests_per_hour: 2000
}
}
};
// Request validation schemas
const interpretRequestSchema = z.object({
input: z.string(),
context: z.object({
user_id: z.string(),
session_id: z.string(),
timestamp: z.string(),
location: z.string(),
previous_actions: z.array(z.any()),
environment_state: z.record(z.any())
}),
model: z.enum(['claude', 'gpt4', 'custom']).optional()
});
// Rate limiters
const globalLimiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: rateLimitConfig.requests_per_minute
});
const modelSpecificLimiter = (model: string) => rateLimit({
windowMs: 60 * 1000,
max: rateLimitConfig.model_specific_limits[model as AIModel]?.requests_per_minute ||
rateLimitConfig.requests_per_minute
});
// Error handler middleware
const errorHandler = (
error: Error,
req: express.Request,
res: express.Response,
next: express.NextFunction
) => {
const aiError: AIError = {
code: 'PROCESSING_ERROR',
message: error.message,
suggestion: 'Please try again with a different command format',
recovery_options: [
'Simplify your command',
'Use standard command patterns',
'Check device names and parameters'
],
context: req.body.context
};
res.status(500).json({ error: aiError });
};
// Endpoints
router.post(
'/interpret',
globalLimiter,
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
try {
const { input, context, model = 'claude' } = interpretRequestSchema.parse(req.body);
// Apply model-specific rate limiting
modelSpecificLimiter(model)(req, res, async () => {
const { intent, confidence, error } = await nlpProcessor.processCommand(input, context);
if (error) {
return res.status(400).json({ error });
}
const isValid = await nlpProcessor.validateIntent(intent, confidence);
if (!isValid) {
const suggestions = await nlpProcessor.suggestCorrections(input, {
code: 'INVALID_INTENT',
message: 'Could not understand the command with high confidence',
suggestion: 'Please try rephrasing your command',
recovery_options: [],
context
});
return res.status(400).json({
error: {
code: 'INVALID_INTENT',
message: 'Could not understand the command with high confidence',
suggestion: 'Please try rephrasing your command',
recovery_options: suggestions,
context
}
});
}
const response: AIResponse = {
natural_language: `I'll ${intent.action} the ${intent.target.split('.').pop()}`,
structured_data: {
success: true,
action_taken: intent.action,
entities_affected: [intent.target],
state_changes: intent.parameters
},
next_suggestions: [
'Would you like to adjust any settings?',
'Should I perform this action in other rooms?',
'Would you like to schedule this action?'
],
confidence,
context
};
res.json(response);
});
} catch (error) {
next(error);
}
}
);
router.post(
'/execute',
globalLimiter,
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
try {
const { intent, context, model = 'claude' } = req.body;
// Apply model-specific rate limiting
modelSpecificLimiter(model)(req, res, async () => {
// Execute the intent through Home Assistant
// This would integrate with your existing Home Assistant service
const response: AIResponse = {
natural_language: `Successfully executed ${intent.action} on ${intent.target}`,
structured_data: {
success: true,
action_taken: intent.action,
entities_affected: [intent.target],
state_changes: intent.parameters
},
next_suggestions: [
'Would you like to verify the state?',
'Should I perform any related actions?',
'Would you like to undo this action?'
],
confidence: { overall: 1, intent: 1, entities: 1, context: 1 },
context
};
res.json(response);
});
} catch (error) {
next(error);
}
}
);
router.get(
'/suggestions',
globalLimiter,
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
try {
const { context, model = 'claude' } = req.body;
// Apply model-specific rate limiting
modelSpecificLimiter(model)(req, res, async () => {
// Generate context-aware suggestions
const suggestions = [
'Turn on the lights in the living room',
'Set the temperature to 72 degrees',
'Show me the current state of all devices',
'Start the evening routine'
];
res.json({ suggestions });
});
} catch (error) {
next(error);
}
}
);
// Apply error handler
router.use(errorHandler);
export default router;

View File

@@ -0,0 +1,135 @@
import { AIContext, AIIntent } from '../types/index.js';
interface ContextAnalysis {
confidence: number;
relevant_params: Record<string, any>;
}
interface ContextRule {
condition: (context: AIContext, intent: AIIntent) => boolean;
relevance: number;
params?: (context: AIContext) => Record<string, any>;
}
export class ContextAnalyzer {
private contextRules: ContextRule[];
constructor() {
this.contextRules = [
// Location-based context
{
condition: (context, intent) =>
Boolean(context.location && intent.target.includes(context.location.toLowerCase())),
relevance: 0.8,
params: (context) => ({ location: context.location })
},
// Time-based context
{
condition: (context) => {
const hour = new Date(context.timestamp).getHours();
return hour >= 0 && hour <= 23;
},
relevance: 0.6,
params: (context) => ({
time_of_day: this.getTimeOfDay(new Date(context.timestamp))
})
},
// Previous action context
{
condition: (context, intent) => {
const recentActions = context.previous_actions.slice(-3);
return recentActions.some(action =>
action.target === intent.target ||
action.action === intent.action
);
},
relevance: 0.7,
params: (context) => ({
recent_action: context.previous_actions[context.previous_actions.length - 1]
})
},
// Environment state context
{
condition: (context, intent) => {
return Object.keys(context.environment_state).some(key =>
intent.target.includes(key) ||
intent.parameters[key] !== undefined
);
},
relevance: 0.9,
params: (context) => ({ environment: context.environment_state })
}
];
}
async analyze(intent: AIIntent, context: AIContext): Promise<ContextAnalysis> {
let totalConfidence = 0;
let relevantParams: Record<string, any> = {};
let applicableRules = 0;
for (const rule of this.contextRules) {
if (rule.condition(context, intent)) {
totalConfidence += rule.relevance;
applicableRules++;
if (rule.params) {
relevantParams = {
...relevantParams,
...rule.params(context)
};
}
}
}
// Calculate normalized confidence
const confidence = applicableRules > 0
? totalConfidence / applicableRules
: 0.5; // Default confidence if no rules apply
return {
confidence,
relevant_params: relevantParams
};
}
private getTimeOfDay(date: Date): string {
const hour = date.getHours();
if (hour >= 5 && hour < 12) return 'morning';
if (hour >= 12 && hour < 17) return 'afternoon';
if (hour >= 17 && hour < 22) return 'evening';
return 'night';
}
async updateContextRules(newRules: ContextRule[]): Promise<void> {
this.contextRules = [...this.contextRules, ...newRules];
}
async validateContext(context: AIContext): Promise<boolean> {
// Validate required context fields
if (!context.timestamp || !context.user_id || !context.session_id) {
return false;
}
// Validate timestamp format
const timestamp = new Date(context.timestamp);
if (isNaN(timestamp.getTime())) {
return false;
}
// Validate previous actions array
if (!Array.isArray(context.previous_actions)) {
return false;
}
// Validate environment state
if (typeof context.environment_state !== 'object' || context.environment_state === null) {
return false;
}
return true;
}
}

View File

@@ -0,0 +1,103 @@
import { AIContext } from '../types/index.js';
interface ExtractedEntities {
primary_target: string;
parameters: Record<string, any>;
confidence: number;
}
export class EntityExtractor {
private deviceNameMap: Map<string, string>;
private parameterPatterns: Map<string, RegExp>;
constructor() {
this.deviceNameMap = new Map();
this.parameterPatterns = new Map();
this.initializePatterns();
}
private initializePatterns(): void {
// Device name variations
this.deviceNameMap.set('living room light', 'light.living_room');
this.deviceNameMap.set('kitchen light', 'light.kitchen');
this.deviceNameMap.set('bedroom light', 'light.bedroom');
// Parameter patterns
this.parameterPatterns.set('brightness', /(\d+)\s*(%|percent)|bright(ness)?\s+(\d+)/i);
this.parameterPatterns.set('temperature', /(\d+)\s*(degrees?|°)[CF]?/i);
this.parameterPatterns.set('color', /(red|green|blue|white|warm|cool)/i);
}
async extract(input: string): Promise<ExtractedEntities> {
const entities: ExtractedEntities = {
primary_target: '',
parameters: {},
confidence: 0
};
try {
// Find device name
for (const [key, value] of this.deviceNameMap) {
if (input.toLowerCase().includes(key)) {
entities.primary_target = value;
break;
}
}
// Extract parameters
for (const [param, pattern] of this.parameterPatterns) {
const match = input.match(pattern);
if (match) {
entities.parameters[param] = this.normalizeParameterValue(param, match[1]);
}
}
// Calculate confidence based on matches
entities.confidence = this.calculateConfidence(entities, input);
return entities;
} catch (error) {
console.error('Entity extraction error:', error);
return {
primary_target: '',
parameters: {},
confidence: 0
};
}
}
private normalizeParameterValue(parameter: string, value: string): number | string {
switch (parameter) {
case 'brightness':
return Math.min(100, Math.max(0, parseInt(value)));
case 'temperature':
return parseInt(value);
case 'color':
return value.toLowerCase();
default:
return value;
}
}
private calculateConfidence(entities: ExtractedEntities, input: string): number {
let confidence = 0;
// Device confidence
if (entities.primary_target) {
confidence += 0.5;
}
// Parameter confidence
const paramCount = Object.keys(entities.parameters).length;
confidence += paramCount * 0.25;
// Normalize confidence to 0-1 range
return Math.min(1, confidence);
}
async updateDeviceMap(devices: Record<string, string>): Promise<void> {
for (const [key, value] of Object.entries(devices)) {
this.deviceNameMap.set(key, value);
}
}
}

View File

@@ -0,0 +1,177 @@
interface ClassifiedIntent {
action: string;
target: string;
confidence: number;
parameters: Record<string, any>;
raw_input: string;
}
interface ActionPattern {
action: string;
patterns: RegExp[];
parameters?: string[];
}
export class IntentClassifier {
private actionPatterns: ActionPattern[];
constructor() {
this.actionPatterns = [
{
action: 'turn_on',
patterns: [
/turn\s+on/i,
/switch\s+on/i,
/enable/i,
/activate/i
]
},
{
action: 'turn_off',
patterns: [
/turn\s+off/i,
/switch\s+off/i,
/disable/i,
/deactivate/i
]
},
{
action: 'set',
patterns: [
/set\s+(?:the\s+)?(.+)\s+to/i,
/change\s+(?:the\s+)?(.+)\s+to/i,
/adjust\s+(?:the\s+)?(.+)\s+to/i
],
parameters: ['brightness', 'temperature', 'color']
},
{
action: 'query',
patterns: [
/what\s+is/i,
/get\s+(?:the\s+)?(.+)/i,
/show\s+(?:the\s+)?(.+)/i,
/tell\s+me/i
]
}
];
}
async classify(
input: string,
extractedEntities: { parameters: Record<string, any>; primary_target: string }
): Promise<ClassifiedIntent> {
let bestMatch: ClassifiedIntent = {
action: '',
target: '',
confidence: 0,
parameters: {},
raw_input: input
};
for (const actionPattern of this.actionPatterns) {
for (const pattern of actionPattern.patterns) {
const match = input.match(pattern);
if (match) {
const confidence = this.calculateConfidence(match[0], input);
if (confidence > bestMatch.confidence) {
bestMatch = {
action: actionPattern.action,
target: extractedEntities.primary_target,
confidence,
parameters: this.extractActionParameters(actionPattern, match, extractedEntities),
raw_input: input
};
}
}
}
}
// If no match found, try to infer from context
if (!bestMatch.action) {
bestMatch = this.inferFromContext(input, extractedEntities);
}
return bestMatch;
}
private calculateConfidence(match: string, input: string): number {
// Base confidence from match length relative to input length
const lengthRatio = match.length / input.length;
let confidence = lengthRatio * 0.7;
// Boost confidence for exact matches
if (match.toLowerCase() === input.toLowerCase()) {
confidence += 0.3;
}
// Additional confidence for specific keywords
const keywords = ['please', 'can you', 'would you'];
for (const keyword of keywords) {
if (input.toLowerCase().includes(keyword)) {
confidence += 0.1;
}
}
return Math.min(1, confidence);
}
private extractActionParameters(
actionPattern: ActionPattern,
match: RegExpMatchArray,
extractedEntities: { parameters: Record<string, any>; primary_target: string }
): Record<string, any> {
const parameters: Record<string, any> = {};
// Copy relevant extracted entities
if (actionPattern.parameters) {
for (const param of actionPattern.parameters) {
if (extractedEntities.parameters[param] !== undefined) {
parameters[param] = extractedEntities.parameters[param];
}
}
}
// Extract additional parameters from match groups
if (match.length > 1 && match[1]) {
parameters.raw_parameter = match[1].trim();
}
return parameters;
}
private inferFromContext(
input: string,
extractedEntities: { parameters: Record<string, any>; primary_target: string }
): ClassifiedIntent {
// Default to 'set' action if parameters are present
if (Object.keys(extractedEntities.parameters).length > 0) {
return {
action: 'set',
target: extractedEntities.primary_target,
confidence: 0.5,
parameters: extractedEntities.parameters,
raw_input: input
};
}
// Default to 'query' for question-like inputs
if (input.match(/^(what|when|where|who|how|why)/i)) {
return {
action: 'query',
target: extractedEntities.primary_target || 'system',
confidence: 0.6,
parameters: {},
raw_input: input
};
}
// Fallback with low confidence
return {
action: 'unknown',
target: extractedEntities.primary_target || 'system',
confidence: 0.3,
parameters: {},
raw_input: input
};
}
}

132
src/ai/nlp/processor.ts Normal file
View File

@@ -0,0 +1,132 @@
import { AIIntent, AIContext, AIConfidence, AIError } from '../types/index.js';
import { EntityExtractor } from './entity-extractor.js';
import { IntentClassifier } from './intent-classifier.js';
import { ContextAnalyzer } from './context-analyzer.js';
export class NLPProcessor {
private entityExtractor: EntityExtractor;
private intentClassifier: IntentClassifier;
private contextAnalyzer: ContextAnalyzer;
constructor() {
this.entityExtractor = new EntityExtractor();
this.intentClassifier = new IntentClassifier();
this.contextAnalyzer = new ContextAnalyzer();
}
async processCommand(
input: string,
context: AIContext
): Promise<{
intent: AIIntent;
confidence: AIConfidence;
error?: AIError;
}> {
try {
// Extract entities from the input
const entities = await this.entityExtractor.extract(input);
// Classify the intent
const intent = await this.intentClassifier.classify(input, entities);
// Analyze context relevance
const contextRelevance = await this.contextAnalyzer.analyze(intent, context);
// Calculate confidence scores
const confidence: AIConfidence = {
overall: (intent.confidence + entities.confidence + contextRelevance.confidence) / 3,
intent: intent.confidence,
entities: entities.confidence,
context: contextRelevance.confidence
};
// Create structured intent
const structuredIntent: AIIntent = {
action: intent.action,
target: entities.primary_target,
parameters: {
...entities.parameters,
...intent.parameters,
context_parameters: contextRelevance.relevant_params
},
raw_input: input
};
return {
intent: structuredIntent,
confidence
};
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
return {
intent: {
action: 'error',
target: 'system',
parameters: {},
raw_input: input
},
confidence: {
overall: 0,
intent: 0,
entities: 0,
context: 0
},
error: {
code: 'NLP_PROCESSING_ERROR',
message: errorMessage,
suggestion: 'Please try rephrasing your command',
recovery_options: [
'Use simpler language',
'Break down the command into smaller parts',
'Specify the target device explicitly'
],
context
}
};
}
}
async validateIntent(
intent: AIIntent,
confidence: AIConfidence,
threshold = 0.7
): Promise<boolean> {
return (
confidence.overall >= threshold &&
confidence.intent >= threshold &&
confidence.entities >= threshold &&
confidence.context >= threshold
);
}
async suggestCorrections(
input: string,
error: AIError
): Promise<string[]> {
// Implement correction suggestions based on the error
const suggestions: string[] = [];
if (error.code === 'ENTITY_NOT_FOUND') {
suggestions.push(
'Try specifying the device name more clearly',
'Use the exact device name from your Home Assistant setup'
);
}
if (error.code === 'AMBIGUOUS_INTENT') {
suggestions.push(
'Please specify what you want to do with the device',
'Use action words like "turn on", "set", "adjust"'
);
}
if (error.code === 'CONTEXT_MISMATCH') {
suggestions.push(
'Specify the location if referring to a device',
'Clarify which device you mean in the current context'
);
}
return suggestions;
}
}

View File

@@ -0,0 +1,135 @@
import { AIModel } from '../types/index.js';
interface PromptTemplate {
system: string;
user: string;
examples: Array<{
user: string;
assistant: string;
}>;
}
interface PromptVariables {
device_name?: string;
location?: string;
action?: string;
parameters?: Record<string, any>;
context?: Record<string, any>;
[key: string]: any;
}
class PromptTemplates {
private templates: Record<AIModel, PromptTemplate>;
constructor() {
this.templates = {
[AIModel.CLAUDE]: {
system: `You are Claude, an AI assistant specialized in home automation control through natural language.
Your role is to interpret user commands and translate them into specific device control actions.
Always maintain context awareness and consider user preferences and patterns.
Provide clear, concise responses and suggest relevant follow-up actions.`,
user: `Control the {device_name} in the {location} by {action} with parameters: {parameters}.
Current context: {context}`,
examples: [
{
user: "Turn on the living room lights",
assistant: "I'll turn on the lights in the living room. Would you like me to set a specific brightness level?"
},
{
user: "Set the temperature to 72 degrees",
assistant: "I'll set the temperature to 72°F. I'll monitor the temperature and let you know when it reaches the target."
}
]
},
[AIModel.GPT4]: {
system: `You are a home automation assistant powered by GPT-4.
Focus on precise command interpretation and execution.
Maintain high accuracy in device control and parameter settings.
Provide feedback on action success and system state changes.`,
user: `Command: {action} {device_name} in {location}
Parameters: {parameters}
Context: {context}`,
examples: [
{
user: "Dim the bedroom lights to 50%",
assistant: "Setting bedroom light brightness to 50%. The change has been applied successfully."
},
{
user: "Start the evening routine",
assistant: "Initiating evening routine: dimming lights, adjusting temperature, and enabling security system."
}
]
},
[AIModel.CUSTOM]: {
system: `Custom home automation assistant configuration.
Adapt to user preferences and patterns.
Learn from interactions and optimize responses.
Provide detailed feedback and suggestions.`,
user: `Action requested: {action}
Target device: {device_name}
Location: {location}
Parameters: {parameters}
Current context: {context}`,
examples: [
{
user: "Make it cooler in here",
assistant: "Based on your preferences, I'll lower the temperature by 2 degrees. Current temperature is 74°F, adjusting to 72°F."
},
{
user: "Set up movie mode",
assistant: "Activating movie mode: dimming lights to 20%, closing blinds, setting TV input to HDMI 1, and adjusting sound system."
}
]
}
};
}
getTemplate(model: AIModel): PromptTemplate {
return this.templates[model];
}
formatPrompt(model: AIModel, variables: PromptVariables): string {
const template = this.getTemplate(model);
let prompt = template.user;
// Replace variables in the prompt
for (const [key, value] of Object.entries(variables)) {
const placeholder = `{${key}}`;
if (typeof value === 'object') {
prompt = prompt.replace(placeholder, JSON.stringify(value));
} else {
prompt = prompt.replace(placeholder, String(value));
}
}
return prompt;
}
getSystemPrompt(model: AIModel): string {
return this.templates[model].system;
}
getExamples(model: AIModel): Array<{ user: string; assistant: string }> {
return this.templates[model].examples;
}
addExample(
model: AIModel,
example: { user: string; assistant: string }
): void {
this.templates[model].examples.push(example);
}
updateSystemPrompt(model: AIModel, newPrompt: string): void {
this.templates[model].system = newPrompt;
}
createCustomTemplate(
model: AIModel.CUSTOM,
template: PromptTemplate
): void {
this.templates[model] = template;
}
}
export default new PromptTemplates();

123
src/ai/types/index.ts Normal file
View File

@@ -0,0 +1,123 @@
import { z } from 'zod';
// AI Model Types
export enum AIModel {
CLAUDE = 'claude',
GPT4 = 'gpt4',
CUSTOM = 'custom'
}
// AI Confidence Level
export interface AIConfidence {
overall: number;
intent: number;
entities: number;
context: number;
}
// AI Intent
export interface AIIntent {
action: string;
target: string;
parameters: Record<string, any>;
raw_input: string;
}
// AI Context
export interface AIContext {
user_id: string;
session_id: string;
timestamp: string;
location: string;
previous_actions: AIIntent[];
environment_state: Record<string, any>;
}
// AI Response
export interface AIResponse {
natural_language: string;
structured_data: {
success: boolean;
action_taken: string;
entities_affected: string[];
state_changes: Record<string, any>;
};
next_suggestions: string[];
confidence: AIConfidence;
context: AIContext;
}
// AI Error
export interface AIError {
code: string;
message: string;
suggestion: string;
recovery_options: string[];
context: AIContext;
}
// Rate Limiting
export interface AIRateLimit {
requests_per_minute: number;
requests_per_hour: number;
concurrent_requests: number;
model_specific_limits: Record<AIModel, {
requests_per_minute: number;
requests_per_hour: number;
}>;
}
// Zod Schemas
export const AIConfidenceSchema = z.object({
overall: z.number().min(0).max(1),
intent: z.number().min(0).max(1),
entities: z.number().min(0).max(1),
context: z.number().min(0).max(1)
});
export const AIIntentSchema = z.object({
action: z.string(),
target: z.string(),
parameters: z.record(z.any()),
raw_input: z.string()
});
export const AIContextSchema = z.object({
user_id: z.string(),
session_id: z.string(),
timestamp: z.string(),
location: z.string(),
previous_actions: z.array(AIIntentSchema),
environment_state: z.record(z.any())
});
export const AIResponseSchema = z.object({
natural_language: z.string(),
structured_data: z.object({
success: z.boolean(),
action_taken: z.string(),
entities_affected: z.array(z.string()),
state_changes: z.record(z.any())
}),
next_suggestions: z.array(z.string()),
confidence: AIConfidenceSchema,
context: AIContextSchema
});
export const AIErrorSchema = z.object({
code: z.string(),
message: z.string(),
suggestion: z.string(),
recovery_options: z.array(z.string()),
context: AIContextSchema
});
export const AIRateLimitSchema = z.object({
requests_per_minute: z.number(),
requests_per_hour: z.number(),
concurrent_requests: z.number(),
model_specific_limits: z.record(z.object({
requests_per_minute: z.number(),
requests_per_hour: z.number()
}))
});

11
src/config/hass.config.ts Normal file
View File

@@ -0,0 +1,11 @@
import dotenv from 'dotenv';
// Load environment variables
dotenv.config();
export const HASS_CONFIG = {
BASE_URL: process.env.HASS_HOST || 'http://homeassistant.local:8123',
TOKEN: process.env.HASS_TOKEN || '',
SOCKET_URL: process.env.HASS_SOCKET_URL || '',
SOCKET_TOKEN: process.env.HASS_TOKEN || '',
};

226
src/context/index.ts Normal file
View File

@@ -0,0 +1,226 @@
import { EventEmitter } from 'events';
// Resource types
export enum ResourceType {
DEVICE = 'device',
AREA = 'area',
USER = 'user',
AUTOMATION = 'automation',
SCENE = 'scene',
SCRIPT = 'script',
GROUP = 'group'
}
// Resource state interface
export interface ResourceState {
id: string;
type: ResourceType;
state: any;
attributes: Record<string, any>;
lastUpdated: number;
context?: Record<string, any>;
}
// Resource relationship types
export enum RelationType {
CONTAINS = 'contains',
CONTROLS = 'controls',
TRIGGERS = 'triggers',
DEPENDS_ON = 'depends_on',
GROUPS = 'groups'
}
// Resource relationship interface
export interface ResourceRelationship {
sourceId: string;
targetId: string;
type: RelationType;
metadata?: Record<string, any>;
}
// Context manager class
export class ContextManager extends EventEmitter {
private resources: Map<string, ResourceState> = new Map();
private relationships: ResourceRelationship[] = [];
private stateHistory: Map<string, ResourceState[]> = new Map();
private historyLimit = 100;
constructor() {
super();
}
// Resource management
public addResource(resource: ResourceState): void {
this.resources.set(resource.id, resource);
this.emit('resource_added', resource);
}
public updateResource(id: string, update: Partial<ResourceState>): void {
const resource = this.resources.get(id);
if (resource) {
// Store current state in history
this.addToHistory(resource);
// Update resource
const updatedResource = {
...resource,
...update,
lastUpdated: Date.now()
};
this.resources.set(id, updatedResource);
this.emit('resource_updated', updatedResource);
}
}
public removeResource(id: string): void {
const resource = this.resources.get(id);
if (resource) {
this.resources.delete(id);
// Remove related relationships
this.relationships = this.relationships.filter(
rel => rel.sourceId !== id && rel.targetId !== id
);
this.emit('resource_removed', resource);
}
}
// Relationship management
public addRelationship(relationship: ResourceRelationship): void {
this.relationships.push(relationship);
this.emit('relationship_added', relationship);
}
public removeRelationship(sourceId: string, targetId: string, type: RelationType): void {
const index = this.relationships.findIndex(
rel => rel.sourceId === sourceId && rel.targetId === targetId && rel.type === type
);
if (index !== -1) {
const removed = this.relationships.splice(index, 1)[0];
this.emit('relationship_removed', removed);
}
}
// History management
private addToHistory(state: ResourceState): void {
const history = this.stateHistory.get(state.id) || [];
history.push({ ...state });
if (history.length > this.historyLimit) {
history.shift();
}
this.stateHistory.set(state.id, history);
}
public getHistory(id: string): ResourceState[] {
return this.stateHistory.get(id) || [];
}
// Context queries
public getResource(id: string): ResourceState | undefined {
return this.resources.get(id);
}
public getResourcesByType(type: ResourceType): ResourceState[] {
return Array.from(this.resources.values()).filter(
resource => resource.type === type
);
}
public getRelatedResources(
id: string,
type?: RelationType,
depth: number = 1
): ResourceState[] {
const related = new Set<ResourceState>();
const visited = new Set<string>();
const traverse = (currentId: string, currentDepth: number) => {
if (currentDepth > depth || visited.has(currentId)) return;
visited.add(currentId);
this.relationships
.filter(rel =>
(rel.sourceId === currentId || rel.targetId === currentId) &&
(!type || rel.type === type)
)
.forEach(rel => {
const relatedId = rel.sourceId === currentId ? rel.targetId : rel.sourceId;
const relatedResource = this.resources.get(relatedId);
if (relatedResource) {
related.add(relatedResource);
traverse(relatedId, currentDepth + 1);
}
});
};
traverse(id, 0);
return Array.from(related);
}
// Context analysis
public analyzeResourceUsage(id: string): {
dependencies: string[];
dependents: string[];
groups: string[];
usage: {
triggerCount: number;
controlCount: number;
groupCount: number;
};
} {
const dependencies = this.relationships
.filter(rel => rel.sourceId === id && rel.type === RelationType.DEPENDS_ON)
.map(rel => rel.targetId);
const dependents = this.relationships
.filter(rel => rel.targetId === id && rel.type === RelationType.DEPENDS_ON)
.map(rel => rel.sourceId);
const groups = this.relationships
.filter(rel => rel.targetId === id && rel.type === RelationType.GROUPS)
.map(rel => rel.sourceId);
const usage = {
triggerCount: this.relationships.filter(
rel => rel.sourceId === id && rel.type === RelationType.TRIGGERS
).length,
controlCount: this.relationships.filter(
rel => rel.sourceId === id && rel.type === RelationType.CONTROLS
).length,
groupCount: groups.length
};
return { dependencies, dependents, groups, usage };
}
// Event subscriptions
public subscribeToResource(
id: string,
callback: (state: ResourceState) => void
): () => void {
const handler = (resource: ResourceState) => {
if (resource.id === id) {
callback(resource);
}
};
this.on('resource_updated', handler);
return () => this.off('resource_updated', handler);
}
public subscribeToType(
type: ResourceType,
callback: (state: ResourceState) => void
): () => void {
const handler = (resource: ResourceState) => {
if (resource.type === type) {
callback(resource);
}
};
this.on('resource_updated', handler);
return () => this.off('resource_updated', handler);
}
}
// Export context manager instance
export const contextManager = new ContextManager();

View File

@@ -1,27 +1,429 @@
import { CreateApplication, TServiceParams, StringConfig } from "@digital-alchemy/core";
import { CreateApplication, TServiceParams, ServiceFunction, AlsExtension, GetApisResult, ILogger, InternalDefinition, TContext, TInjectedConfig, TLifecycleBase, TScheduler } from "@digital-alchemy/core";
import { Area, Backup, CallProxy, Configure, Device, EntityManager, EventsService, FetchAPI, FetchInternals, Floor, IDByExtension, Label, LIB_HASS, ReferenceService, Registry, WebsocketAPI, Zone } from "@digital-alchemy/hass";
import { DomainSchema } from "../schemas.js";
import { HASS_CONFIG } from "../config/hass.config.js";
import { WebSocket } from 'ws';
import { EventEmitter } from 'events';
import * as HomeAssistant from '../types/hass.js';
type Environments = "development" | "production" | "test";
import { LIB_HASS } from "@digital-alchemy/hass";
// Define the type for Home Assistant services
type HassServiceMethod = (data: Record<string, unknown>) => Promise<void>;
type HassServices = {
[K in keyof typeof DomainSchema.Values]: {
[service: string]: HassServiceMethod;
};
};
// Define the type for Home Assistant instance
interface HassInstance extends TServiceParams {
baseUrl: string;
token: string;
wsClient: HassWebSocketClient | undefined;
services: HassServices;
als: AlsExtension;
context: TContext;
event: EventEmitter<[never]>;
internal: InternalDefinition;
lifecycle: TLifecycleBase;
logger: ILogger;
scheduler: TScheduler;
config: TInjectedConfig;
params: TServiceParams;
hass: GetApisResult<{
area: typeof Area;
backup: typeof Backup;
call: typeof CallProxy;
configure: typeof Configure;
device: typeof Device;
entity: typeof EntityManager;
events: typeof EventsService;
fetch: typeof FetchAPI;
floor: typeof Floor;
idBy: typeof IDByExtension;
internals: typeof FetchInternals;
label: typeof Label;
refBy: typeof ReferenceService;
registry: typeof Registry;
socket: typeof WebsocketAPI;
zone: typeof Zone;
}>;
}
// Configuration type for application with more specific constraints
type ApplicationConfiguration = {
NODE_ENV: ServiceFunction<Environments>;
};
// Strict configuration type for Home Assistant
type HassConfiguration = {
BASE_URL: {
type: "string";
description: string;
required: true;
default: string;
};
TOKEN: {
type: "string";
description: string;
required: true;
default: string;
};
SOCKET_URL: {
type: "string";
description: string;
required: true;
default: string;
};
SOCKET_TOKEN: {
type: "string";
description: string;
required: true;
default: string;
};
};
// application
const MY_APP = CreateApplication({
const MY_APP = CreateApplication<ApplicationConfiguration, {}>({
configuration: {
NODE_ENV: {
type: "string",
default: "development",
enum: ["development", "production", "test"],
description: "Code runner addon can set with it's own NODE_ENV",
} satisfies StringConfig<Environments>,
},
},
services: {},
libraries: [LIB_HASS],
name: 'boilerplate'
services: {
NODE_ENV: () => {
// Directly return the default value or use process.env
return (process.env.NODE_ENV as Environments) || "development";
}
},
libraries: [
{
...LIB_HASS,
configuration: {
BASE_URL: {
type: "string",
description: "Home Assistant base URL",
required: true,
default: HASS_CONFIG.BASE_URL
},
TOKEN: {
type: "string",
description: "Home Assistant long-lived access token",
required: true,
default: HASS_CONFIG.TOKEN
},
SOCKET_URL: {
type: "string",
description: "Home Assistant WebSocket URL",
required: true,
default: HASS_CONFIG.SOCKET_URL
},
SOCKET_TOKEN: {
type: "string",
description: "Home Assistant WebSocket token",
required: true,
default: HASS_CONFIG.SOCKET_TOKEN
}
}
}
],
name: 'hass' as const
});
const hass = await MY_APP.bootstrap()
export async function get_hass() {
return hass;
export interface HassConfig {
host: string;
token: string;
}
const CONFIG: Record<string, HassConfig> = {
development: {
host: process.env.HASS_HOST || 'http://localhost:8123',
token: process.env.HASS_TOKEN || ''
},
production: {
host: process.env.HASS_HOST || '',
token: process.env.HASS_TOKEN || ''
},
test: {
host: 'http://localhost:8123',
token: 'test_token'
}
};
export class HassWebSocketClient extends EventEmitter {
private ws: WebSocket | null = null;
private messageId = 1;
private subscriptions = new Map<number, (data: any) => void>();
private reconnectAttempts = 0;
private options: {
autoReconnect: boolean;
maxReconnectAttempts: number;
reconnectDelay: number;
};
constructor(
private url: string,
private token: string,
options: Partial<typeof HassWebSocketClient.prototype.options> = {}
) {
super();
this.options = {
autoReconnect: true,
maxReconnectAttempts: 3,
reconnectDelay: 1000,
...options
};
}
async connect(): Promise<void> {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
return;
}
return new Promise((resolve, reject) => {
this.ws = new WebSocket(this.url);
this.ws.on('open', () => {
this.emit('open');
const authMessage: HomeAssistant.AuthMessage = {
type: 'auth',
access_token: this.token
};
this.ws?.send(JSON.stringify(authMessage));
});
this.ws.on('message', (data: string) => {
try {
const message = JSON.parse(data);
this.handleMessage(message);
} catch (error) {
this.emit('error', new Error('Failed to parse message'));
}
});
this.ws.on('close', () => {
this.emit('disconnected');
if (this.options.autoReconnect && this.reconnectAttempts < this.options.maxReconnectAttempts) {
setTimeout(() => {
this.reconnectAttempts++;
this.connect();
}, this.options.reconnectDelay);
}
});
this.ws.on('error', (error) => {
this.emit('error', error);
reject(error);
});
});
}
private handleMessage(message: any): void {
switch (message.type) {
case 'auth_ok':
this.emit('auth_ok');
break;
case 'auth_invalid':
this.emit('auth_invalid');
break;
case 'result':
// Handle command results
break;
case 'event':
if (message.event) {
this.emit('event', message.event);
const subscription = this.subscriptions.get(message.id);
if (subscription) {
subscription(message.event.data);
}
}
break;
default:
this.emit('error', new Error(`Unknown message type: ${message.type}`));
}
}
async subscribeEvents(callback: (data: any) => void, eventType?: string): Promise<number> {
const id = this.messageId++;
const message = {
id,
type: 'subscribe_events',
event_type: eventType
};
return new Promise((resolve, reject) => {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
reject(new Error('WebSocket not connected'));
return;
}
this.subscriptions.set(id, callback);
this.ws.send(JSON.stringify(message));
resolve(id);
});
}
async unsubscribeEvents(subscriptionId: number): Promise<void> {
const message = {
id: this.messageId++,
type: 'unsubscribe_events',
subscription: subscriptionId
};
return new Promise((resolve, reject) => {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
reject(new Error('WebSocket not connected'));
return;
}
this.ws.send(JSON.stringify(message));
this.subscriptions.delete(subscriptionId);
resolve();
});
}
disconnect(): void {
if (this.ws) {
this.ws.close();
this.ws = null;
}
}
}
export class HassInstanceImpl implements HassInstance {
public readonly baseUrl: string;
public readonly token: string;
public wsClient: HassWebSocketClient | undefined;
public services!: HassServices;
public als!: AlsExtension;
public context!: TContext;
public event!: EventEmitter<[never]>;
public internal!: InternalDefinition;
public lifecycle!: TLifecycleBase;
public logger!: ILogger;
public scheduler!: TScheduler;
public config!: TInjectedConfig;
public params!: TServiceParams;
public hass!: GetApisResult<{
area: typeof Area;
backup: typeof Backup;
call: typeof CallProxy;
configure: typeof Configure;
device: typeof Device;
entity: typeof EntityManager;
events: typeof EventsService;
fetch: typeof FetchAPI;
floor: typeof Floor;
idBy: typeof IDByExtension;
internals: typeof FetchInternals;
label: typeof Label;
refBy: typeof ReferenceService;
registry: typeof Registry;
socket: typeof WebsocketAPI;
zone: typeof Zone;
}>;
constructor(baseUrl: string, token: string) {
this.baseUrl = baseUrl;
this.token = token;
this.initialize();
}
private initialize() {
// Initialize all required properties with proper type instantiation
this.services = {} as HassServices;
this.als = {} as AlsExtension;
this.context = {} as TContext;
this.event = new EventEmitter();
this.internal = {} as InternalDefinition;
this.lifecycle = {} as TLifecycleBase;
this.logger = {} as ILogger;
this.scheduler = {} as TScheduler;
this.config = {} as TInjectedConfig;
this.params = {} as TServiceParams;
this.hass = {} as GetApisResult<any>;
}
async fetchStates(): Promise<HomeAssistant.Entity[]> {
const response = await fetch(`${this.baseUrl}/api/states`, {
headers: {
Authorization: `Bearer ${this.token}`,
'Content-Type': 'application/json',
},
});
if (!response.ok) {
throw new Error(`Failed to fetch states: ${response.statusText}`);
}
const data = await response.json();
return data as HomeAssistant.Entity[];
}
async fetchState(entityId: string): Promise<HomeAssistant.Entity> {
const response = await fetch(`${this.baseUrl}/api/states/${entityId}`, {
headers: {
Authorization: `Bearer ${this.token}`,
'Content-Type': 'application/json',
},
});
if (!response.ok) {
throw new Error(`Failed to fetch state: ${response.statusText}`);
}
const data = await response.json();
return data as HomeAssistant.Entity;
}
async callService(domain: string, service: string, data: Record<string, any>): Promise<void> {
const response = await fetch(`${this.baseUrl}/api/services/${domain}/${service}`, {
method: 'POST',
headers: {
Authorization: `Bearer ${this.token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error(`Service call failed: ${response.statusText}`);
}
}
async subscribeEvents(callback: (event: HomeAssistant.Event) => void, eventType?: string): Promise<number> {
if (!this.wsClient) {
this.wsClient = new HassWebSocketClient(
this.baseUrl.replace(/^http/, 'ws') + '/api/websocket',
this.token
);
await this.wsClient.connect();
}
return this.wsClient.subscribeEvents(callback, eventType);
}
async unsubscribeEvents(subscriptionId: number): Promise<void> {
if (this.wsClient) {
await this.wsClient.unsubscribeEvents(subscriptionId);
}
}
}
let hassInstance: HassInstance | null = null;
export async function get_hass(): Promise<HassInstance> {
if (!hassInstance) {
// Safely get configuration keys, providing an empty object as fallback
const _sortedConfigKeys = Object.keys(MY_APP.configuration ?? {}).sort();
const instance = await MY_APP.bootstrap();
hassInstance = instance as HassInstance;
}
return hassInstance;
}

File diff suppressed because it is too large Load Diff

222
src/performance/index.ts Normal file
View File

@@ -0,0 +1,222 @@
import { performance } from 'perf_hooks';
import { EventEmitter } from 'events';
// Performance metrics types
export interface Metric {
name: string;
value: number;
timestamp: number;
metadata?: Record<string, any>;
}
export interface PerformanceThresholds {
responseTime: number; // milliseconds
memoryUsage: number; // bytes
cpuUsage: number; // percentage
}
// Performance monitoring class
export class PerformanceMonitor extends EventEmitter {
private metrics: Metric[] = [];
private thresholds: PerformanceThresholds;
private samplingInterval: number;
private retentionPeriod: number;
private intervalId?: NodeJS.Timeout;
constructor(
thresholds: Partial<PerformanceThresholds> = {},
samplingInterval = 5000, // 5 seconds
retentionPeriod = 24 * 60 * 60 * 1000 // 24 hours
) {
super();
this.thresholds = {
responseTime: thresholds.responseTime || 1000, // 1 second
memoryUsage: thresholds.memoryUsage || 1024 * 1024 * 1024, // 1 GB
cpuUsage: thresholds.cpuUsage || 80 // 80%
};
this.samplingInterval = samplingInterval;
this.retentionPeriod = retentionPeriod;
}
// Start monitoring
public start(): void {
this.intervalId = setInterval(() => {
this.collectMetrics();
this.cleanOldMetrics();
}, this.samplingInterval);
}
// Stop monitoring
public stop(): void {
if (this.intervalId) {
clearInterval(this.intervalId);
}
}
// Collect system metrics
private collectMetrics(): void {
const now = Date.now();
const memoryUsage = process.memoryUsage();
const cpuUsage = process.cpuUsage();
// Memory metrics
this.addMetric({
name: 'memory.heapUsed',
value: memoryUsage.heapUsed,
timestamp: now
});
this.addMetric({
name: 'memory.heapTotal',
value: memoryUsage.heapTotal,
timestamp: now
});
this.addMetric({
name: 'memory.rss',
value: memoryUsage.rss,
timestamp: now
});
// CPU metrics
this.addMetric({
name: 'cpu.user',
value: cpuUsage.user,
timestamp: now
});
this.addMetric({
name: 'cpu.system',
value: cpuUsage.system,
timestamp: now
});
// Check thresholds
this.checkThresholds();
}
// Add a metric
public addMetric(metric: Metric): void {
this.metrics.push(metric);
this.emit('metric', metric);
}
// Clean old metrics
private cleanOldMetrics(): void {
const cutoff = Date.now() - this.retentionPeriod;
this.metrics = this.metrics.filter(metric => metric.timestamp > cutoff);
}
// Check if metrics exceed thresholds
private checkThresholds(): void {
const memoryUsage = process.memoryUsage().heapUsed;
if (memoryUsage > this.thresholds.memoryUsage) {
this.emit('threshold_exceeded', {
type: 'memory',
value: memoryUsage,
threshold: this.thresholds.memoryUsage
});
}
const cpuUsage = process.cpuUsage();
const totalCPU = cpuUsage.user + cpuUsage.system;
const cpuPercentage = (totalCPU / (process.uptime() * 1000000)) * 100;
if (cpuPercentage > this.thresholds.cpuUsage) {
this.emit('threshold_exceeded', {
type: 'cpu',
value: cpuPercentage,
threshold: this.thresholds.cpuUsage
});
}
}
// Get metrics for a specific time range
public getMetrics(
startTime: number,
endTime: number = Date.now(),
metricName?: string
): Metric[] {
return this.metrics.filter(metric =>
metric.timestamp >= startTime &&
metric.timestamp <= endTime &&
(!metricName || metric.name === metricName)
);
}
// Calculate average for a metric
public calculateAverage(
metricName: string,
startTime: number,
endTime: number = Date.now()
): number {
const metrics = this.getMetrics(startTime, endTime, metricName);
if (metrics.length === 0) return 0;
return metrics.reduce((sum, metric) => sum + metric.value, 0) / metrics.length;
}
}
// Performance optimization utilities
export class PerformanceOptimizer {
private static readonly GC_THRESHOLD = 0.9; // 90% heap usage
// Optimize memory usage
public static async optimizeMemory(): Promise<void> {
const memoryUsage = process.memoryUsage();
const heapUsageRatio = memoryUsage.heapUsed / memoryUsage.heapTotal;
if (heapUsageRatio > this.GC_THRESHOLD) {
if (global.gc) {
global.gc();
}
}
}
// Batch processing utility
public static async processBatch<T, R>(
items: T[],
batchSize: number,
processor: (batch: T[]) => Promise<R[]>
): Promise<R[]> {
const results: R[] = [];
for (let i = 0; i < items.length; i += batchSize) {
const batch = items.slice(i, i + batchSize);
const batchResults = await processor(batch);
results.push(...batchResults);
await new Promise(resolve => setTimeout(resolve, 0)); // Yield to event loop
}
return results;
}
// Debounce utility
public static debounce<T extends (...args: any[]) => any>(
func: T,
wait: number
): (...args: Parameters<T>) => void {
let timeout: NodeJS.Timeout;
return (...args: Parameters<T>) => {
clearTimeout(timeout);
timeout = setTimeout(() => func(...args), wait);
};
}
// Throttle utility
public static throttle<T extends (...args: any[]) => any>(
func: T,
limit: number
): (...args: Parameters<T>) => void {
let inThrottle = false;
return (...args: Parameters<T>) => {
if (!inThrottle) {
func(...args);
inThrottle = true;
setTimeout(() => (inThrottle = false), limit);
}
};
}
}
// Export performance monitoring instance
export const performanceMonitor = new PerformanceMonitor();
// Start monitoring on module load
performanceMonitor.start();

View File

@@ -0,0 +1,215 @@
import { exec } from 'child_process';
import { promisify } from 'util';
import { EventEmitter } from 'events';
const execAsync = promisify(exec);
interface MacOSNotification {
title: string;
message: string;
subtitle?: string;
sound?: boolean;
}
interface MacOSPermissions {
notifications: boolean;
automation: boolean;
accessibility: boolean;
}
class MacOSIntegration extends EventEmitter {
private permissions: MacOSPermissions;
constructor() {
super();
this.permissions = {
notifications: false,
automation: false,
accessibility: false
};
}
async initialize(): Promise<void> {
await this.checkPermissions();
await this.registerSystemEvents();
}
async checkPermissions(): Promise<MacOSPermissions> {
try {
// Check notification permissions
const { stdout: notifPerms } = await execAsync(
'osascript -e \'tell application "System Events" to get properties\''
);
this.permissions.notifications = notifPerms.includes('notifications enabled:true');
// Check automation permissions
const { stdout: autoPerms } = await execAsync(
'osascript -e \'tell application "System Events" to get UI elements enabled\''
);
this.permissions.automation = autoPerms.includes('true');
// Check accessibility permissions
const { stdout: accessPerms } = await execAsync(
'osascript -e \'tell application "System Events" to get processes\''
);
this.permissions.accessibility = !accessPerms.includes('error');
return this.permissions;
} catch (error) {
console.error('Error checking permissions:', error);
return this.permissions;
}
}
async sendNotification(notification: MacOSNotification): Promise<void> {
if (!this.permissions.notifications) {
throw new Error('Notification permission not granted');
}
const script = `
display notification "${notification.message}"${notification.subtitle ? ` with subtitle "${notification.subtitle}"` : ''
} with title "${notification.title}"${notification.sound ? ' sound name "default"' : ''
}
`;
try {
await execAsync(`osascript -e '${script}'`);
} catch (error) {
console.error('Error sending notification:', error);
throw error;
}
}
async registerSystemEvents(): Promise<void> {
if (!this.permissions.automation) {
throw new Error('Automation permission not granted');
}
// Monitor system events
const script = `
tell application "System Events"
set eventList to {}
-- Monitor display sleep/wake
tell application "System Events"
set displayState to get sleeping
if displayState then
set end of eventList to "display_sleep"
else
set end of eventList to "display_wake"
end if
end tell
-- Monitor power source changes
tell application "System Events"
set powerSource to get power source
set end of eventList to "power_" & powerSource
end tell
return eventList
end tell
`;
try {
const { stdout } = await execAsync(`osascript -e '${script}'`);
const events = stdout.split(',').map(e => e.trim());
events.forEach(event => this.emit('system_event', event));
} catch (error) {
console.error('Error monitoring system events:', error);
}
}
async executeAutomation(script: string): Promise<string> {
if (!this.permissions.automation) {
throw new Error('Automation permission not granted');
}
try {
const { stdout } = await execAsync(`osascript -e '${script}'`);
return stdout;
} catch (error) {
console.error('Error executing automation:', error);
throw error;
}
}
async getSystemInfo(): Promise<Record<string, any>> {
const info: Record<string, any> = {};
try {
// Get macOS version
const { stdout: version } = await execAsync('sw_vers -productVersion');
info.os_version = version.trim();
// Get hardware info
const { stdout: hardware } = await execAsync('system_profiler SPHardwareDataType');
info.hardware = this.parseSystemProfile(hardware);
// Get power info
const { stdout: power } = await execAsync('pmset -g batt');
info.power = this.parsePowerInfo(power);
// Get network info
const { stdout: network } = await execAsync('networksetup -listallhardwareports');
info.network = this.parseNetworkInfo(network);
return info;
} catch (error) {
console.error('Error getting system info:', error);
throw error;
}
}
private parseSystemProfile(output: string): Record<string, any> {
const info: Record<string, any> = {};
const lines = output.split('\n');
for (const line of lines) {
const [key, value] = line.split(':').map(s => s.trim());
if (key && value) {
info[key.toLowerCase().replace(/\s+/g, '_')] = value;
}
}
return info;
}
private parsePowerInfo(output: string): Record<string, any> {
const info: Record<string, any> = {};
const lines = output.split('\n');
for (const line of lines) {
if (line.includes('Now drawing from')) {
info.power_source = line.includes('Battery') ? 'battery' : 'ac_power';
} else if (line.includes('%')) {
const matches = line.match(/(\d+)%/);
if (matches) {
info.battery_percentage = parseInt(matches[1]);
}
}
}
return info;
}
private parseNetworkInfo(output: string): Record<string, any> {
const info: Record<string, any> = {};
const lines = output.split('\n');
let currentInterface: string | null = null;
for (const line of lines) {
if (line.includes('Hardware Port:')) {
currentInterface = line.split(':')[1].trim();
info[currentInterface] = {};
} else if (currentInterface && line.includes('Device:')) {
info[currentInterface].device = line.split(':')[1].trim();
} else if (currentInterface && line.includes('Ethernet Address:')) {
info[currentInterface].mac = line.split(':')[1].trim();
}
}
return info;
}
}
export default MacOSIntegration;

24
src/polyfills.ts Normal file
View File

@@ -0,0 +1,24 @@
// Extend global Array interface to include toSorted and toReversed methods
declare global {
interface Array<T> {
toSorted(compareFn?: (a: T, b: T) => number): T[];
toReversed(): T[];
}
}
// Polyfill for toSorted method
if (typeof Array.prototype.toSorted !== 'function') {
Array.prototype.toSorted = function <T>(compareFn?: (a: T, b: T) => number): T[] {
return [...this].sort(compareFn);
};
}
// Polyfill for toReversed method
if (typeof Array.prototype.toReversed !== 'function') {
Array.prototype.toReversed = function <T>(): T[] {
return [...this].reverse();
};
}
// Export an empty object to make this a module
export { };

View File

@@ -1,7 +1,21 @@
import { z } from "zod";
export const DomainSchema = z.enum(["light", "climate", "alarm_control_panel", "cover", "switch"]);
export const DomainSchema = z.enum([
"light",
"climate",
"alarm_control_panel",
"cover",
"switch",
"contact",
"media_player",
"fan",
"lock",
"vacuum",
"scene",
"script",
"camera"
]);
// Generic list request schema
@@ -53,30 +67,163 @@ export const ListAlarmsResponseSchema = z.object({
// Devices
export const DeviceSchema = z.object({
id: z.string(),
name: z.string(),
name_by_user: z.string().optional(),
model: z.string(),
model_id: z.string().nullable(),
manufacturer: z.string(),
area_id: z.string().nullable(),
config_entries: z.array(z.string()),
primary_config_entry: z.string(),
connections: z.array(z.tuple([z.string(), z.string()])),
configuration_url: z.string().nullable(),
disabled_by: z.string().nullable(),
entry_type: z.string().nullable(),
hw_version: z.string().nullable(),
sw_version: z.string().nullable(),
via_device_id: z.string().nullable(),
created_at: z.number(),
modified_at: z.number(),
identifiers: z.array(z.any()),
labels: z.array(z.string()),
serial_number: z.string().optional()
id: z.string(),
name: z.string(),
name_by_user: z.string().optional(),
model: z.string(),
model_id: z.string().nullable(),
manufacturer: z.string(),
area_id: z.string().nullable(),
config_entries: z.array(z.string()),
primary_config_entry: z.string(),
connections: z.array(z.tuple([z.string(), z.string()])),
configuration_url: z.string().nullable(),
disabled_by: z.string().nullable(),
entry_type: z.string().nullable(),
hw_version: z.string().nullable(),
sw_version: z.string().nullable(),
via_device_id: z.string().nullable(),
created_at: z.number(),
modified_at: z.number(),
identifiers: z.array(z.any()),
labels: z.array(z.string()),
serial_number: z.string().optional()
});
export const ListDevicesResponseSchema = z.object({
_meta: z.object({}).optional(),
devices: z.array(DeviceSchema)
_meta: z.object({}).optional(),
devices: z.array(DeviceSchema)
});
// Media Player
export const MediaPlayerAttributesSchema = z.object({
volume_level: z.number().optional(),
is_volume_muted: z.boolean().optional(),
media_content_id: z.string().optional(),
media_content_type: z.string().optional(),
media_duration: z.number().optional(),
media_position: z.number().optional(),
media_title: z.string().optional(),
source: z.string().optional(),
source_list: z.array(z.string()).optional(),
supported_features: z.number().optional(),
});
export const MediaPlayerSchema = z.object({
entity_id: z.string(),
state: z.string(),
state_attributes: MediaPlayerAttributesSchema,
});
// Fan
export const FanAttributesSchema = z.object({
percentage: z.number().optional(),
preset_mode: z.string().optional(),
preset_modes: z.array(z.string()).optional(),
oscillating: z.boolean().optional(),
direction: z.string().optional(),
supported_features: z.number().optional(),
});
export const FanSchema = z.object({
entity_id: z.string(),
state: z.string(),
state_attributes: FanAttributesSchema,
});
// Lock
export const LockAttributesSchema = z.object({
code_format: z.string().optional(),
changed_by: z.string().optional(),
locked: z.boolean(),
supported_features: z.number().optional(),
});
export const LockSchema = z.object({
entity_id: z.string(),
state: z.string(),
state_attributes: LockAttributesSchema,
});
// Vacuum
export const VacuumAttributesSchema = z.object({
battery_level: z.number().optional(),
fan_speed: z.string().optional(),
fan_speed_list: z.array(z.string()).optional(),
status: z.string().optional(),
supported_features: z.number().optional(),
});
export const VacuumSchema = z.object({
entity_id: z.string(),
state: z.string(),
state_attributes: VacuumAttributesSchema,
});
// Scene
export const SceneAttributesSchema = z.object({
entity_id: z.array(z.string()).optional(),
supported_features: z.number().optional(),
});
export const SceneSchema = z.object({
entity_id: z.string(),
state: z.string(),
state_attributes: SceneAttributesSchema,
});
// Script
export const ScriptAttributesSchema = z.object({
last_triggered: z.string().optional(),
mode: z.string().optional(),
variables: z.record(z.any()).optional(),
supported_features: z.number().optional(),
});
export const ScriptSchema = z.object({
entity_id: z.string(),
state: z.string(),
state_attributes: ScriptAttributesSchema,
});
// Camera
export const CameraAttributesSchema = z.object({
motion_detection: z.boolean().optional(),
frontend_stream_type: z.string().optional(),
supported_features: z.number().optional(),
});
export const CameraSchema = z.object({
entity_id: z.string(),
state: z.string(),
state_attributes: CameraAttributesSchema,
});
// Response schemas for new devices
export const ListMediaPlayersResponseSchema = z.object({
media_players: z.array(MediaPlayerSchema),
});
export const ListFansResponseSchema = z.object({
fans: z.array(FanSchema),
});
export const ListLocksResponseSchema = z.object({
locks: z.array(LockSchema),
});
export const ListVacuumsResponseSchema = z.object({
vacuums: z.array(VacuumSchema),
});
export const ListScenesResponseSchema = z.object({
scenes: z.array(SceneSchema),
});
export const ListScriptsResponseSchema = z.object({
scripts: z.array(ScriptSchema),
});
export const ListCamerasResponseSchema = z.object({
cameras: z.array(CameraSchema),
});

268
src/schemas/hass.ts Normal file
View File

@@ -0,0 +1,268 @@
import { JSONSchemaType } from 'ajv';
import { Entity, StateChangedEvent } from '../types/hass.js';
// Define base types for automation components
type TriggerType = {
platform: string;
event?: string | null;
entity_id?: string | null;
to?: string | null;
from?: string | null;
offset?: string | null;
[key: string]: any;
};
type ConditionType = {
condition: string;
conditions?: Array<Record<string, any>> | null;
[key: string]: any;
};
type ActionType = {
service: string;
target?: {
entity_id?: string | string[] | null;
[key: string]: any;
} | null;
data?: Record<string, any> | null;
[key: string]: any;
};
type AutomationType = {
alias: string;
description?: string | null;
mode?: ('single' | 'parallel' | 'queued' | 'restart') | null;
trigger: TriggerType[];
condition?: ConditionType[] | null;
action: ActionType[];
};
type DeviceControlType = {
domain: 'light' | 'switch' | 'climate' | 'cover' | 'fan' | 'scene' | 'script' | 'media_player';
command: string;
entity_id: string | string[];
parameters?: Record<string, any> | null;
};
// Define missing types
export interface Service {
name: string;
description: string;
target?: {
entity?: string[];
device?: string[];
area?: string[];
} | null;
fields: Record<string, any>;
}
export interface Config {
components: string[];
config_dir: string;
elevation: number;
latitude: number;
longitude: number;
location_name: string;
time_zone: string;
unit_system: {
length: string;
mass: string;
temperature: string;
volume: string;
};
version: string;
}
// Define base schemas
const contextSchema = {
type: 'object',
properties: {
id: { type: 'string' },
parent_id: { type: 'string', nullable: true },
user_id: { type: 'string', nullable: true }
},
required: ['id', 'parent_id', 'user_id'],
additionalProperties: false
} as const;
// Entity schema
export const entitySchema = {
type: 'object',
properties: {
entity_id: { type: 'string' },
state: { type: 'string' },
attributes: {
type: 'object',
additionalProperties: true
},
last_changed: { type: 'string' },
last_updated: { type: 'string' },
context: contextSchema
},
required: ['entity_id', 'state', 'attributes', 'last_changed', 'last_updated', 'context'],
additionalProperties: false
} as const;
// Service schema
export const serviceSchema = {
type: 'object',
properties: {
name: { type: 'string' },
description: { type: 'string' },
target: {
type: 'object',
nullable: true,
properties: {
entity: { type: 'array', items: { type: 'string' }, nullable: true },
device: { type: 'array', items: { type: 'string' }, nullable: true },
area: { type: 'array', items: { type: 'string' }, nullable: true }
},
required: [],
additionalProperties: false
},
fields: {
type: 'object',
additionalProperties: true
}
},
required: ['name', 'description', 'fields'],
additionalProperties: false
} as const;
// Define the trigger schema without type assertion
export const triggerSchema = {
type: 'object',
properties: {
platform: { type: 'string' },
event: { type: 'string', nullable: true },
entity_id: { type: 'string', nullable: true },
to: { type: 'string', nullable: true },
from: { type: 'string', nullable: true },
offset: { type: 'string', nullable: true }
},
required: ['platform'],
additionalProperties: true
};
// Define the automation schema
export const automationSchema = {
type: 'object',
properties: {
alias: { type: 'string' },
description: { type: 'string', nullable: true },
mode: {
type: 'string',
enum: ['single', 'parallel', 'queued', 'restart'],
nullable: true
},
trigger: {
type: 'array',
items: triggerSchema
},
condition: {
type: 'array',
items: {
type: 'object',
additionalProperties: true
},
nullable: true
},
action: {
type: 'array',
items: {
type: 'object',
additionalProperties: true
}
}
},
required: ['alias', 'trigger', 'action'],
additionalProperties: false
};
export const deviceControlSchema: JSONSchemaType<DeviceControlType> = {
type: 'object',
properties: {
domain: {
type: 'string',
enum: ['light', 'switch', 'climate', 'cover', 'fan', 'scene', 'script', 'media_player']
},
command: { type: 'string' },
entity_id: {
anyOf: [
{ type: 'string' },
{
type: 'array',
items: { type: 'string' }
}
]
},
parameters: {
type: 'object',
nullable: true,
additionalProperties: true
}
},
required: ['domain', 'command', 'entity_id'],
additionalProperties: false
};
// State changed event schema
export const stateChangedEventSchema = {
type: 'object',
properties: {
event_type: { type: 'string', const: 'state_changed' },
data: {
type: 'object',
properties: {
entity_id: { type: 'string' },
new_state: { ...entitySchema, nullable: true },
old_state: { ...entitySchema, nullable: true }
},
required: ['entity_id', 'new_state', 'old_state'],
additionalProperties: false
},
origin: { type: 'string' },
time_fired: { type: 'string' },
context: contextSchema
},
required: ['event_type', 'data', 'origin', 'time_fired', 'context'],
additionalProperties: false
} as const;
// Config schema
export const configSchema = {
type: 'object',
properties: {
components: { type: 'array', items: { type: 'string' } },
config_dir: { type: 'string' },
elevation: { type: 'number' },
latitude: { type: 'number' },
longitude: { type: 'number' },
location_name: { type: 'string' },
time_zone: { type: 'string' },
unit_system: {
type: 'object',
properties: {
length: { type: 'string' },
mass: { type: 'string' },
temperature: { type: 'string' },
volume: { type: 'string' }
},
required: ['length', 'mass', 'temperature', 'volume'],
additionalProperties: false
},
version: { type: 'string' }
},
required: [
'components',
'config_dir',
'elevation',
'latitude',
'longitude',
'location_name',
'time_zone',
'unit_system',
'version'
],
additionalProperties: false
} as const;

190
src/security/index.ts Normal file
View File

@@ -0,0 +1,190 @@
import crypto from 'crypto';
import { Request, Response, NextFunction } from 'express';
import rateLimit from 'express-rate-limit';
import helmet from 'helmet';
import { HelmetOptions } 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 configuration
const helmetConfig: HelmetOptions = {
contentSecurityPolicy: {
useDefaults: true,
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'unsafe-inline'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", 'data:', 'https:'],
connectSrc: ["'self'", 'wss:', 'https:']
}
},
dnsPrefetchControl: true,
frameguard: true,
hidePoweredBy: true,
hsts: true,
ieNoOpen: true,
noSniff: true,
referrerPolicy: {
policy: ['no-referrer', 'strict-origin-when-cross-origin']
}
};
// Security headers middleware
export const securityHeaders = helmet(helmetConfig);
// 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) {
// Skip validation for health endpoint
if (req.path === '/health') {
return next();
}
// 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 = [
helmet(helmetConfig),
rateLimit({
windowMs: 15 * 60 * 1000,
max: 100
}),
validateRequest,
sanitizeInput,
errorHandler
];

370
src/sse/index.ts Normal file
View File

@@ -0,0 +1,370 @@
import { EventEmitter } from 'events';
import { HassEntity, HassEvent, StateChangedEvent } from '../types/hass.js';
interface RateLimit {
count: number;
lastReset: number;
}
export interface SSEClient {
id: string;
send: (data: string) => void;
subscriptions: {
entities: Set<string>;
events: Set<string>;
domains: Set<string>;
};
authenticated: boolean;
rateLimit: RateLimit;
lastPing: number;
connectionTime: number;
}
export class SSEManager extends EventEmitter {
private clients: Map<string, SSEClient> = new Map();
private static instance: SSEManager | null = null;
private entityStates: Map<string, HassEntity> = new Map();
// Configuration
private readonly MAX_CLIENTS = 100;
private readonly RATE_LIMIT_WINDOW = 60000; // 1 minute
private readonly RATE_LIMIT_MAX_REQUESTS = 1000;
private readonly CLIENT_TIMEOUT = 300000; // 5 minutes
private readonly PING_INTERVAL = 30000; // 30 seconds
private constructor() {
super();
console.log('Initializing SSE Manager...');
this.startMaintenanceInterval();
}
private startMaintenanceInterval() {
setInterval(() => {
this.performMaintenance();
}, 60000); // Run every minute
}
private performMaintenance() {
const now = Date.now();
// Check each client for timeouts and rate limits
for (const [clientId, client] of this.clients.entries()) {
// Remove inactive clients
if (now - client.lastPing > this.CLIENT_TIMEOUT) {
console.log(`Removing inactive client: ${clientId}`);
this.removeClient(clientId);
continue;
}
// Reset rate limits if window has passed
if (now - client.rateLimit.lastReset > this.RATE_LIMIT_WINDOW) {
client.rateLimit.count = 0;
client.rateLimit.lastReset = now;
}
}
// Log statistics
console.log(`Maintenance complete - Active clients: ${this.clients.size}`);
}
static getInstance(): SSEManager {
if (!SSEManager.instance) {
SSEManager.instance = new SSEManager();
}
return SSEManager.instance;
}
addClient(client: { id: string; send: (data: string) => void }, token?: string): SSEClient | null {
// Check maximum client limit
if (this.clients.size >= this.MAX_CLIENTS) {
console.warn('Maximum client limit reached, rejecting new connection');
return null;
}
const now = Date.now();
const sseClient: SSEClient = {
id: client.id,
send: client.send,
subscriptions: {
entities: new Set<string>(),
events: new Set<string>(),
domains: new Set<string>()
},
authenticated: this.validateToken(token),
rateLimit: {
count: 0,
lastReset: now
},
lastPing: now,
connectionTime: now
};
this.clients.set(client.id, sseClient);
console.log(`SSE client connected: ${client.id} (authenticated: ${sseClient.authenticated})`);
// Start ping interval for this client
this.startClientPing(client.id);
// Send initial connection success message
this.sendToClient(sseClient, {
type: 'connection',
status: 'connected',
id: client.id,
authenticated: sseClient.authenticated,
timestamp: new Date().toISOString()
});
return sseClient;
}
private startClientPing(clientId: string) {
const interval = setInterval(() => {
const client = this.clients.get(clientId);
if (!client) {
clearInterval(interval);
return;
}
this.sendToClient(client, {
type: 'ping',
timestamp: new Date().toISOString()
});
}, this.PING_INTERVAL);
}
removeClient(clientId: string) {
if (this.clients.has(clientId)) {
this.clients.delete(clientId);
console.log(`SSE client disconnected: ${clientId}`);
}
}
subscribeToEntity(clientId: string, entityId: string) {
const client = this.clients.get(clientId);
if (client?.authenticated) {
client.subscriptions.entities.add(entityId);
console.log(`Client ${clientId} subscribed to entity: ${entityId}`);
// Send current state if available
const currentState = this.entityStates.get(entityId);
if (currentState) {
this.sendToClient(client, {
type: 'state_changed',
data: {
entity_id: currentState.entity_id,
state: currentState.state,
attributes: currentState.attributes,
last_changed: currentState.last_changed,
last_updated: currentState.last_updated
}
});
}
}
}
subscribeToDomain(clientId: string, domain: string) {
const client = this.clients.get(clientId);
if (client?.authenticated) {
client.subscriptions.domains.add(domain);
console.log(`Client ${clientId} subscribed to domain: ${domain}`);
}
}
subscribeToEvent(clientId: string, eventType: string) {
const client = this.clients.get(clientId);
if (client?.authenticated) {
client.subscriptions.events.add(eventType);
console.log(`Client ${clientId} subscribed to event: ${eventType}`);
}
}
broadcastStateChange(entity: HassEntity) {
// Update stored state
this.entityStates.set(entity.entity_id, entity);
const domain = entity.entity_id.split('.')[0];
const message = {
type: 'state_changed',
data: {
entity_id: entity.entity_id,
state: entity.state,
attributes: entity.attributes,
last_changed: entity.last_changed,
last_updated: entity.last_updated
},
timestamp: new Date().toISOString()
};
console.log(`Broadcasting state change for ${entity.entity_id}`);
// Send to relevant subscribers only
for (const client of this.clients.values()) {
if (!client.authenticated) continue;
if (
client.subscriptions.entities.has(entity.entity_id) ||
client.subscriptions.domains.has(domain) ||
client.subscriptions.events.has('state_changed')
) {
this.sendToClient(client, message);
}
}
}
broadcastEvent(event: HassEvent) {
const message = {
type: event.event_type,
data: event.data,
origin: event.origin,
time_fired: event.time_fired,
context: event.context,
timestamp: new Date().toISOString()
};
console.log(`Broadcasting event: ${event.event_type}`);
// Send to relevant subscribers only
for (const client of this.clients.values()) {
if (!client.authenticated) continue;
if (client.subscriptions.events.has(event.event_type)) {
this.sendToClient(client, message);
}
}
}
private sendToClient(client: SSEClient, data: any) {
try {
// Check rate limit
const now = Date.now();
if (now - client.rateLimit.lastReset > this.RATE_LIMIT_WINDOW) {
client.rateLimit.count = 0;
client.rateLimit.lastReset = now;
}
if (client.rateLimit.count >= this.RATE_LIMIT_MAX_REQUESTS) {
console.warn(`Rate limit exceeded for client ${client.id}`);
this.sendToClient(client, {
type: 'error',
error: 'rate_limit_exceeded',
message: 'Too many requests, please try again later',
timestamp: new Date().toISOString()
});
return;
}
client.rateLimit.count++;
client.lastPing = now;
client.send(JSON.stringify(data));
} catch (error) {
console.error(`Error sending message to client ${client.id}:`, error);
this.removeClient(client.id);
}
}
private validateToken(token?: string): boolean {
if (!token) return false;
// Compare with HASS_TOKEN from environment
return token === process.env.HASS_TOKEN;
}
// Utility methods
getConnectedClients(): number {
return this.clients.size;
}
getClientSubscriptions(clientId: string) {
return this.clients.get(clientId)?.subscriptions;
}
getEntityState(entityId: string): HassEntity | undefined {
return this.entityStates.get(entityId);
}
// Add new event types
broadcastServiceCall(domain: string, service: string, data: any) {
const message = {
type: 'service_called',
data: {
domain,
service,
service_data: data
},
timestamp: new Date().toISOString()
};
this.broadcastToSubscribers('service_called', message);
}
broadcastAutomationTriggered(automationId: string, trigger: any) {
const message = {
type: 'automation_triggered',
data: {
automation_id: automationId,
trigger
},
timestamp: new Date().toISOString()
};
this.broadcastToSubscribers('automation_triggered', message);
}
broadcastScriptExecuted(scriptId: string, data: any) {
const message = {
type: 'script_executed',
data: {
script_id: scriptId,
execution_data: data
},
timestamp: new Date().toISOString()
};
this.broadcastToSubscribers('script_executed', message);
}
private broadcastToSubscribers(eventType: string, message: any) {
for (const client of this.clients.values()) {
if (!client.authenticated) continue;
if (client.subscriptions.events.has(eventType)) {
this.sendToClient(client, message);
}
}
}
// Add statistics methods
getStatistics() {
const now = Date.now();
const stats = {
total_clients: this.clients.size,
authenticated_clients: 0,
total_subscriptions: 0,
clients_by_connection_time: {
less_than_1m: 0,
less_than_5m: 0,
less_than_1h: 0,
more_than_1h: 0
},
total_entities_tracked: this.entityStates.size
};
for (const client of this.clients.values()) {
if (client.authenticated) stats.authenticated_clients++;
stats.total_subscriptions +=
client.subscriptions.entities.size +
client.subscriptions.events.size +
client.subscriptions.domains.size;
const connectionDuration = now - client.connectionTime;
if (connectionDuration < 60000) stats.clients_by_connection_time.less_than_1m++;
else if (connectionDuration < 300000) stats.clients_by_connection_time.less_than_5m++;
else if (connectionDuration < 3600000) stats.clients_by_connection_time.less_than_1h++;
else stats.clients_by_connection_time.more_than_1h++;
}
return stats;
}
}
export const sseManager = SSEManager.getInstance();

191
src/tools/index.ts Normal file
View File

@@ -0,0 +1,191 @@
import { z } from 'zod';
import { get_hass } from '../hass/index.js';
// Tool category types
export enum ToolCategory {
DEVICE = 'device',
SYSTEM = 'system',
AUTOMATION = 'automation'
}
// Tool priority levels
export enum ToolPriority {
HIGH = 'high',
MEDIUM = 'medium',
LOW = 'low'
}
interface ToolParameters {
[key: string]: any;
}
interface Tool<Params extends ToolParameters = ToolParameters> {
name: string;
description: string;
execute(params: Params): Promise<any>;
}
interface ToolMetadata {
category: ToolCategory;
platform: string;
version: string;
caching?: {
enabled: boolean;
ttl: number;
};
}
// Enhanced tool interface
export interface EnhancedTool extends Tool {
metadata: ToolMetadata;
validate?: (params: any) => Promise<boolean>;
preExecute?: (params: any) => Promise<void>;
postExecute?: (result: any) => Promise<void>;
}
// Tool registry for managing and organizing tools
export class ToolRegistry {
private tools: Map<string, EnhancedTool> = new Map();
private categories: Map<ToolCategory, Set<string>> = new Map();
private cache: Map<string, { data: any; timestamp: number }> = new Map();
constructor() {
// Initialize categories
Object.values(ToolCategory).forEach(category => {
this.categories.set(category, new Set());
});
}
// Register a new tool
public registerTool(tool: EnhancedTool): void {
this.tools.set(tool.name, tool);
this.categories.get(tool.metadata.category)?.add(tool.name);
}
// Get tool by name
public getTool(name: string): EnhancedTool | undefined {
return this.tools.get(name);
}
// Get all tools in a category
public getToolsByCategory(category: ToolCategory): EnhancedTool[] {
const toolNames = this.categories.get(category);
if (!toolNames) return [];
return Array.from(toolNames).map(name => this.tools.get(name)!);
}
// Execute a tool with validation and hooks
public async executeTool(name: string, params: any): Promise<any> {
const tool = this.tools.get(name);
if (!tool) {
throw new Error(`Tool ${name} not found`);
}
// Check cache if enabled
if (tool.metadata.caching?.enabled) {
const cacheKey = `${name}:${JSON.stringify(params)}`;
const cached = this.cache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < tool.metadata.caching.ttl) {
return cached.data;
}
}
// Validate parameters
if (tool.validate) {
const isValid = await tool.validate(params);
if (!isValid) {
throw new Error('Invalid parameters');
}
}
// Pre-execution hook
if (tool.preExecute) {
await tool.preExecute(params);
}
// Execute tool
const result = await tool.execute(params);
// Post-execution hook
if (tool.postExecute) {
await tool.postExecute(result);
}
// Update cache if enabled
if (tool.metadata.caching?.enabled) {
const cacheKey = `${name}:${JSON.stringify(params)}`;
this.cache.set(cacheKey, {
data: result,
timestamp: Date.now()
});
}
return result;
}
// Clean up expired cache entries
public cleanCache(): void {
const now = Date.now();
for (const [key, value] of this.cache.entries()) {
const tool = this.tools.get(key.split(':')[0]);
if (tool?.metadata.caching?.ttl && now - value.timestamp > tool.metadata.caching.ttl) {
this.cache.delete(key);
}
}
}
}
// Create and export the global tool registry
export const toolRegistry = new ToolRegistry();
// Tool decorator for easy registration
function registerTool(metadata: Partial<ToolMetadata>) {
return function (constructor: any) {
return constructor;
};
}
// Example usage:
@registerTool({
category: ToolCategory.DEVICE,
platform: 'hass',
version: '1.0.0',
caching: {
enabled: true,
ttl: 60000
}
})
export class LightControlTool implements EnhancedTool {
name = 'light_control';
description = 'Control light devices';
metadata: ToolMetadata = {
category: ToolCategory.DEVICE,
platform: 'hass',
version: '1.0.0',
caching: {
enabled: true,
ttl: 60000
}
};
parameters = z.object({
command: z.enum(['turn_on', 'turn_off', 'toggle']),
entity_id: z.string(),
brightness: z.number().min(0).max(255).optional(),
color_temp: z.number().optional(),
rgb_color: z.tuple([z.number(), z.number(), z.number()]).optional()
});
async validate(params: any): Promise<boolean> {
try {
this.parameters.parse(params);
return true;
} catch {
return false;
}
}
async execute(params: any): Promise<any> {
// Implementation here
return { success: true };
}
}

81
src/types/hass.d.ts vendored Normal file
View File

@@ -0,0 +1,81 @@
declare namespace HomeAssistant {
interface Entity {
entity_id: string;
state: string;
attributes: Record<string, any>;
last_changed: string;
last_updated: string;
context: {
id: string;
parent_id?: string;
user_id?: string;
};
}
interface Service {
domain: string;
service: string;
target?: {
entity_id?: string | string[];
device_id?: string | string[];
area_id?: string | string[];
};
service_data?: Record<string, any>;
}
interface WebsocketMessage {
type: string;
id?: number;
[key: string]: any;
}
interface AuthMessage extends WebsocketMessage {
type: 'auth';
access_token: string;
}
interface SubscribeEventsMessage extends WebsocketMessage {
type: 'subscribe_events';
event_type?: string;
}
interface StateChangedEvent {
event_type: 'state_changed';
data: {
entity_id: string;
new_state: Entity | null;
old_state: Entity | null;
};
origin: string;
time_fired: string;
context: {
id: string;
parent_id?: string;
user_id?: string;
};
}
interface Config {
latitude: number;
longitude: number;
elevation: number;
unit_system: {
length: string;
mass: string;
temperature: string;
volume: string;
};
location_name: string;
time_zone: string;
components: string[];
version: string;
}
interface ApiError {
code: string;
message: string;
details?: Record<string, any>;
}
}
export = HomeAssistant;

86
src/types/hass.ts Normal file
View File

@@ -0,0 +1,86 @@
export interface AuthMessage {
type: 'auth';
access_token: string;
}
export interface ResultMessage {
id: number;
type: 'result';
success: boolean;
result?: any;
}
export interface WebSocketError {
code: string;
message: string;
}
export interface Event {
event_type: string;
data: any;
origin: string;
time_fired: string;
context: {
id: string;
parent_id: string | null;
user_id: string | null;
};
}
export interface Entity {
entity_id: string;
state: string;
attributes: Record<string, any>;
last_changed: string;
last_updated: string;
context: {
id: string;
parent_id: string | null;
user_id: string | null;
};
}
export interface StateChangedEvent extends Event {
event_type: 'state_changed';
data: {
entity_id: string;
new_state: Entity | null;
old_state: Entity | null;
};
}
export interface HassEntity {
entity_id: string;
state: string;
attributes: Record<string, any>;
last_changed?: string;
last_updated?: string;
context?: {
id: string;
parent_id?: string;
user_id?: string;
};
}
export interface HassState {
entity_id: string;
state: string;
attributes: {
friendly_name?: string;
description?: string;
[key: string]: any;
};
}
export interface HassEvent {
event_type: string;
data: any;
origin: string;
time_fired: string;
context: {
id: string;
parent_id?: string;
user_id?: string;
};
}

174
src/websocket/client.ts Normal file
View File

@@ -0,0 +1,174 @@
import WebSocket from 'ws';
import { EventEmitter } from 'events';
export class HassWebSocketClient extends EventEmitter {
private ws: WebSocket | null = null;
private messageId = 1;
private authenticated = false;
private reconnectAttempts = 0;
private maxReconnectAttempts = 5;
private reconnectDelay = 1000;
private subscriptions = new Map<string, (data: any) => void>();
constructor(
private url: string,
private token: string,
private options: {
autoReconnect?: boolean;
maxReconnectAttempts?: number;
reconnectDelay?: number;
} = {}
) {
super();
this.maxReconnectAttempts = options.maxReconnectAttempts || 5;
this.reconnectDelay = options.reconnectDelay || 1000;
}
public async connect(): Promise<void> {
return new Promise((resolve, reject) => {
try {
this.ws = new WebSocket(this.url);
this.ws.on('open', () => {
this.authenticate();
});
this.ws.on('message', (data: string) => {
const message = JSON.parse(data);
this.handleMessage(message);
});
this.ws.on('close', () => {
this.handleDisconnect();
});
this.ws.on('error', (error) => {
this.emit('error', error);
reject(error);
});
this.once('auth_ok', () => {
this.authenticated = true;
this.reconnectAttempts = 0;
resolve();
});
this.once('auth_invalid', () => {
reject(new Error('Authentication failed'));
});
} catch (error) {
reject(error);
}
});
}
private authenticate(): void {
this.send({
type: 'auth',
access_token: this.token
});
}
private handleMessage(message: any): void {
switch (message.type) {
case 'auth_required':
this.authenticate();
break;
case 'auth_ok':
this.emit('auth_ok');
break;
case 'auth_invalid':
this.emit('auth_invalid');
break;
case 'event':
this.handleEvent(message);
break;
case 'result':
this.emit(`result_${message.id}`, message);
break;
}
}
private handleEvent(message: any): void {
const subscription = this.subscriptions.get(message.event.event_type);
if (subscription) {
subscription(message.event.data);
}
this.emit('event', message.event);
}
private handleDisconnect(): void {
this.authenticated = false;
this.emit('disconnected');
if (this.options.autoReconnect && this.reconnectAttempts < this.maxReconnectAttempts) {
setTimeout(() => {
this.reconnectAttempts++;
this.connect().catch((error) => {
this.emit('error', error);
});
}, this.reconnectDelay * Math.pow(2, this.reconnectAttempts));
}
}
public async subscribeEvents(eventType: string, callback: (data: any) => void): Promise<number> {
if (!this.authenticated) {
throw new Error('Not authenticated');
}
const id = this.messageId++;
this.subscriptions.set(eventType, callback);
return new Promise((resolve, reject) => {
this.send({
id,
type: 'subscribe_events',
event_type: eventType
});
this.once(`result_${id}`, (message) => {
if (message.success) {
resolve(id);
} else {
reject(new Error(message.error?.message || 'Subscription failed'));
}
});
});
}
public async unsubscribeEvents(subscription: number): Promise<void> {
if (!this.authenticated) {
throw new Error('Not authenticated');
}
const id = this.messageId++;
return new Promise((resolve, reject) => {
this.send({
id,
type: 'unsubscribe_events',
subscription
});
this.once(`result_${id}`, (message) => {
if (message.success) {
resolve();
} else {
reject(new Error(message.error?.message || 'Unsubscribe failed'));
}
});
});
}
private send(message: any): void {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(message));
}
}
public disconnect(): void {
if (this.ws) {
this.ws.close();
this.ws = null;
}
}
}

View File

@@ -1,115 +1,47 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "./dist",
"rootDir": ".",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"sourceMap": true,
"allowJs": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"types": [
"node",
"jest"
],
"typeRoots": [
"./node_modules/@types"
],
"lib": [
"ES2022"
],
"baseUrl": ".",
"paths": {
"@hass/*": ["./src/hass/*"],
"@server/*": ["./src/server/*"]
},
/* Visit https://aka.ms/tsconfig to read more about this file */
/* Projects */
// "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
// "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
/* Language and Environment */
"target": "ES2018", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
// "jsx": "preserve", /* Specify what JSX code is generated. */
// "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
// "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
/* Modules */
"module": "node16", /* Specify what module code is generated. */
// "rootDir": "./", /* Specify the root folder within your source files. */
"moduleResolution": "node16", /* Specify how TypeScript looks up a file from a given module specifier. */
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
// "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
// "types": [], /* Specify type package names to be included without being referenced in a source file. */
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
// "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
// "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */
// "rewriteRelativeImportExtensions": true, /* Rewrite '.ts', '.tsx', '.mts', and '.cts' file extensions in relative import paths to their JavaScript equivalent in output files. */
// "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */
// "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */
// "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */
// "noUncheckedSideEffectImports": true, /* Check side effect imports. */
// "resolveJsonModule": true, /* Enable importing .json files. */
// "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */
// "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
/* JavaScript Support */
// "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
/* Emit */
// "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
// "sourceMap": true, /* Create source map files for emitted JavaScript files. */
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
// "noEmit": true, /* Disable emitting files from a compilation. */
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
"outDir": "./dist/", /* Specify an output folder for all emitted files. */
// "removeComments": true, /* Disable emitting comments. */
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
// "newLine": "crlf", /* Set the newline character for emitting files. */
// "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
// "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
// "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
/* Interop Constraints */
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
// "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */
// "isolatedDeclarations": true, /* Require sufficient annotation on exports so other tools can trivially generate declaration files. */
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
"esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
/* Type Checking */
"strict": true, /* Enable all strict type-checking options. */
// "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
// "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
// "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
// "strictBuiltinIteratorReturn": true, /* Built-in iterators are instantiated with a 'TReturn' type of 'undefined' instead of 'any'. */
// "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
// "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
// "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
// "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
/* Completeness */
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
"skipLibCheck": true /* Skip type checking all .d.ts files. */
}
"@src/*": [
"src/*"
],
"@tests/*": [
"__tests__/*"
]
}
},
"include": [
"src/**/*",
"__tests__/**/*"
],
"exclude": [
"node_modules",
"**/__tests__/**/*.ts",
"**/*.test.ts"
]
}

3331
yarn.lock

File diff suppressed because it is too large Load Diff