Merge pull request #5 from jango-blockchained/main
Comprehensive Home Assistant MCP Enhancement
This commit is contained in:
31
.dockerignore
Normal file
31
.dockerignore
Normal 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
6
.env.example
Normal 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
49
.eslintrc.json
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
69
.gitignore
vendored
69
.gitignore
vendored
@@ -1,3 +1,70 @@
|
|||||||
|
# Dependencies
|
||||||
node_modules
|
node_modules
|
||||||
dist
|
dist
|
||||||
.env
|
__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
23
Dockerfile
Normal 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
751
README.md
@@ -1,63 +1,734 @@
|
|||||||
# A Model Context Protocol Server for Home Assistant
|
# Model Context Protocol Server for Home Assistant
|
||||||
|
|
||||||
[](https://smithery.ai/server/@strandbrown/homeassistant-mcp)
|
|
||||||
|
|
||||||
The server uses the MCP protocol to share access to a local Home Assistant instance with an LLM application.
|
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
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
## 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
|
```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.
|
||||||
|
|
||||||
```
|
1. **Clone the repository:**
|
||||||
yarn build
|
```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": {
|
"tool": "control",
|
||||||
"homeassistant": {
|
"command": "turn_on", // or "turn_off", "toggle"
|
||||||
"command": "node",
|
"entity_id": "light.living_room"
|
||||||
"args": [
|
|
||||||
"/Users/tevonsb/Desktop/mcp/dist/index.js"
|
|
||||||
],
|
|
||||||
"env": {
|
|
||||||
"TOKEN": <home_assistant_token>,
|
|
||||||
"BASE_URL": <base_url_for_home_assistant>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
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
|
#### Install Add-on
|
||||||
- [x] Access to Floors
|
```json
|
||||||
- [x] Access to Areas
|
{
|
||||||
- [x] Control for entities
|
"tool": "addon",
|
||||||
- [x] Lights
|
"action": "install",
|
||||||
- [x] Thermostats
|
"slug": "core_configurator",
|
||||||
- [x] Covers
|
"version": "5.6.0"
|
||||||
- [ ] Testing / writing custom prompts
|
}
|
||||||
- [ ] Testing using resources for high-level context
|
```
|
||||||
- [ ] Test varying tool organization
|
|
||||||
|
#### 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
|
||||||
|
|||||||
204
__tests__/ai/nlp/intent-classifier.test.ts
Normal file
204
__tests__/ai/nlp/intent-classifier.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
87
__tests__/context/context.test.ts
Normal file
87
__tests__/context/context.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
215
__tests__/context/index.test.ts
Normal file
215
__tests__/context/index.test.ts
Normal 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
227
__tests__/hass/api.test.ts
Normal 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'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
99
__tests__/hass/hass.test.ts
Normal file
99
__tests__/hass/hass.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
265
__tests__/hass/index.test.ts
Normal file
265
__tests__/hass/index.test.ts
Normal 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
45
__tests__/helpers.test.ts
Normal 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
1130
__tests__/index.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
195
__tests__/performance/index.test.ts
Normal file
195
__tests__/performance/index.test.ts
Normal 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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
186
__tests__/schemas/devices.test.js
Normal file
186
__tests__/schemas/devices.test.js
Normal 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
|
||||||
1
__tests__/schemas/devices.test.js.map
Normal file
1
__tests__/schemas/devices.test.js.map
Normal file
File diff suppressed because one or more lines are too long
214
__tests__/schemas/devices.test.ts
Normal file
214
__tests__/schemas/devices.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
532
__tests__/schemas/hass.test.ts
Normal file
532
__tests__/schemas/hass.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
212
__tests__/security/index.test.ts
Normal file
212
__tests__/security/index.test.ts
Normal 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'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
248
__tests__/security/middleware.test.ts
Normal file
248
__tests__/security/middleware.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
127
__tests__/security/token-manager.test.ts
Normal file
127
__tests__/security/token-manager.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
204
__tests__/tools/tool-registry.test.ts
Normal file
204
__tests__/tools/tool-registry.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
1
__tests__/websocket/client.test.ts
Normal file
1
__tests__/websocket/client.test.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
247
__tests__/websocket/events.test.ts
Normal file
247
__tests__/websocket/events.test.ts
Normal 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' }));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
152
claude-desktop-macos-setup.sh
Normal file
152
claude-desktop-macos-setup.sh
Normal 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
364
docs/API.md
Normal 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
364
docs/SSE_API.md
Normal 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
36
jest-resolver.cjs
Normal 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
58
jest.config.cjs
Normal 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
21
jest.config.js
Normal 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
32
jest.setup.cjs
Normal 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
31
jest.setup.js
Normal 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
84
jest.setup.ts
Normal 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();
|
||||||
|
});
|
||||||
74
package.json
74
package.json
@@ -1,51 +1,51 @@
|
|||||||
{
|
{
|
||||||
"name": "@strandbrown/homeassistant-mcp",
|
"name": "jango-blockchained/homeassistant-mcp",
|
||||||
"version": "0.1.0",
|
"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",
|
"main": "dist/index.js",
|
||||||
"types": "dist/index.d.ts",
|
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"build": "npx tsc",
|
||||||
"test": "jest",
|
"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",
|
"prepare": "npm run build",
|
||||||
"start": "node dist/index.js",
|
"clean": "rimraf dist",
|
||||||
"dev": "ts-node src/index.ts",
|
"types:check": "tsc --noEmit",
|
||||||
"build:start": "npm run build && npm run start"
|
"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": {
|
"dependencies": {
|
||||||
"@digital-alchemy/core": "^24.11.4",
|
"@digital-alchemy/core": "^24.11.4",
|
||||||
"@digital-alchemy/hass": "^24.11.4",
|
"@digital-alchemy/hass": "^24.11.4",
|
||||||
"@modelcontextprotocol/sdk": "^1.0.3",
|
"ajv": "^8.12.0",
|
||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.3.1",
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"express-rate-limit": "^7.1.5",
|
||||||
|
"helmet": "^7.1.0",
|
||||||
"litemcp": "^0.7.0",
|
"litemcp": "^0.7.0",
|
||||||
"zod": "^3.24.1",
|
"uuid": "^9.0.1",
|
||||||
"zod-to-json-schema": "^3.24.1"
|
"ws": "^8.16.0",
|
||||||
|
"zod": "^3.22.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@digital-alchemy/type-writer": "^24.11.3",
|
"@types/ajv": "^1.0.0",
|
||||||
"@types/express": "^5.0.0",
|
"@types/express": "^4.17.21",
|
||||||
"@types/jest": "^29.5.11",
|
"@types/express-rate-limit": "^6.0.0",
|
||||||
"@types/node": "^20.11.0",
|
"@types/helmet": "^4.0.0",
|
||||||
"@types/socket.io": "^3.0.1",
|
"@types/jest": "^29.5.14",
|
||||||
|
"@types/node": "^20.17.16",
|
||||||
|
"@types/uuid": "^9.0.8",
|
||||||
|
"@types/ws": "^8.5.10",
|
||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
"nodemon": "^3.0.2",
|
"rimraf": "^5.0.5",
|
||||||
"ts-jest": "^29.1.1",
|
"ts-jest": "^29.1.2",
|
||||||
"ts-node": "^10.9.2",
|
"tsx": "^4.7.0",
|
||||||
"typescript": "^5.7.2"
|
"typescript": "^5.3.3"
|
||||||
},
|
},
|
||||||
"type": "module",
|
"author": "Jango Blockchained",
|
||||||
"publishConfig": {
|
"license": "MIT"
|
||||||
"access": "public"
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
207
src/ai/endpoints/ai-router.ts
Normal file
207
src/ai/endpoints/ai-router.ts
Normal 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;
|
||||||
135
src/ai/nlp/context-analyzer.ts
Normal file
135
src/ai/nlp/context-analyzer.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
103
src/ai/nlp/entity-extractor.ts
Normal file
103
src/ai/nlp/entity-extractor.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
177
src/ai/nlp/intent-classifier.ts
Normal file
177
src/ai/nlp/intent-classifier.ts
Normal 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
132
src/ai/nlp/processor.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
135
src/ai/templates/prompt-templates.ts
Normal file
135
src/ai/templates/prompt-templates.ts
Normal 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
123
src/ai/types/index.ts
Normal 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
11
src/config/hass.config.ts
Normal 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
226
src/context/index.ts
Normal 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();
|
||||||
@@ -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";
|
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
|
// application
|
||||||
const MY_APP = CreateApplication({
|
const MY_APP = CreateApplication<ApplicationConfiguration, {}>({
|
||||||
configuration: {
|
configuration: {
|
||||||
NODE_ENV: {
|
NODE_ENV: {
|
||||||
type: "string",
|
type: "string",
|
||||||
default: "development",
|
default: "development",
|
||||||
enum: ["development", "production", "test"],
|
enum: ["development", "production", "test"],
|
||||||
description: "Code runner addon can set with it's own NODE_ENV",
|
description: "Code runner addon can set with it's own NODE_ENV",
|
||||||
} satisfies StringConfig<Environments>,
|
},
|
||||||
},
|
},
|
||||||
services: {},
|
services: {
|
||||||
libraries: [LIB_HASS],
|
NODE_ENV: () => {
|
||||||
name: 'boilerplate'
|
// 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 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 async function get_hass() {
|
export class HassWebSocketClient extends EventEmitter {
|
||||||
return hass;
|
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;
|
||||||
}
|
}
|
||||||
1514
src/index.ts
1514
src/index.ts
File diff suppressed because it is too large
Load Diff
222
src/performance/index.ts
Normal file
222
src/performance/index.ts
Normal 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();
|
||||||
215
src/platforms/macos/integration.ts
Normal file
215
src/platforms/macos/integration.ts
Normal 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
24
src/polyfills.ts
Normal 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 { };
|
||||||
195
src/schemas.ts
195
src/schemas.ts
@@ -1,7 +1,21 @@
|
|||||||
import { z } from "zod";
|
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
|
// Generic list request schema
|
||||||
|
|
||||||
@@ -53,30 +67,163 @@ export const ListAlarmsResponseSchema = z.object({
|
|||||||
// Devices
|
// Devices
|
||||||
|
|
||||||
export const DeviceSchema = z.object({
|
export const DeviceSchema = z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
name_by_user: z.string().optional(),
|
name_by_user: z.string().optional(),
|
||||||
model: z.string(),
|
model: z.string(),
|
||||||
model_id: z.string().nullable(),
|
model_id: z.string().nullable(),
|
||||||
manufacturer: z.string(),
|
manufacturer: z.string(),
|
||||||
area_id: z.string().nullable(),
|
area_id: z.string().nullable(),
|
||||||
config_entries: z.array(z.string()),
|
config_entries: z.array(z.string()),
|
||||||
primary_config_entry: z.string(),
|
primary_config_entry: z.string(),
|
||||||
connections: z.array(z.tuple([z.string(), z.string()])),
|
connections: z.array(z.tuple([z.string(), z.string()])),
|
||||||
configuration_url: z.string().nullable(),
|
configuration_url: z.string().nullable(),
|
||||||
disabled_by: z.string().nullable(),
|
disabled_by: z.string().nullable(),
|
||||||
entry_type: z.string().nullable(),
|
entry_type: z.string().nullable(),
|
||||||
hw_version: z.string().nullable(),
|
hw_version: z.string().nullable(),
|
||||||
sw_version: z.string().nullable(),
|
sw_version: z.string().nullable(),
|
||||||
via_device_id: z.string().nullable(),
|
via_device_id: z.string().nullable(),
|
||||||
created_at: z.number(),
|
created_at: z.number(),
|
||||||
modified_at: z.number(),
|
modified_at: z.number(),
|
||||||
identifiers: z.array(z.any()),
|
identifiers: z.array(z.any()),
|
||||||
labels: z.array(z.string()),
|
labels: z.array(z.string()),
|
||||||
serial_number: z.string().optional()
|
serial_number: z.string().optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ListDevicesResponseSchema = z.object({
|
export const ListDevicesResponseSchema = z.object({
|
||||||
_meta: z.object({}).optional(),
|
_meta: z.object({}).optional(),
|
||||||
devices: z.array(DeviceSchema)
|
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
268
src/schemas/hass.ts
Normal 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
190
src/security/index.ts
Normal 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
370
src/sse/index.ts
Normal 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
191
src/tools/index.ts
Normal 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
81
src/types/hass.d.ts
vendored
Normal 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
86
src/types/hass.ts
Normal 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
174
src/websocket/client.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
156
tsconfig.json
156
tsconfig.json
@@ -1,115 +1,47 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"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": {
|
"paths": {
|
||||||
"@hass/*": ["./src/hass/*"],
|
"@src/*": [
|
||||||
"@server/*": ["./src/server/*"]
|
"src/*"
|
||||||
},
|
],
|
||||||
/* Visit https://aka.ms/tsconfig to read more about this file */
|
"@tests/*": [
|
||||||
|
"__tests__/*"
|
||||||
/* 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. */
|
"include": [
|
||||||
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
|
"src/**/*",
|
||||||
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
|
"__tests__/**/*"
|
||||||
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
|
],
|
||||||
|
"exclude": [
|
||||||
/* Language and Environment */
|
"node_modules",
|
||||||
"target": "ES2018", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
|
"**/__tests__/**/*.ts",
|
||||||
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
|
"**/*.test.ts"
|
||||||
// "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. */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user