Add Jest configuration files and enhance README with features and usage instructions
- Introduced `jest.config.cjs` and `jest.config.js` for Jest testing configuration. - Expanded README.md to include detailed features, installation, configuration, and troubleshooting sections. - Updated TypeScript configuration to target ES2022 and allow JavaScript files. - Enhanced command handling in `src/index.ts` to support additional parameters for lights, covers, and climate entities. - Added unit tests for command execution and Home Assistant connection validation. - Updated schemas to include new entity types and parameters for better validation.
This commit is contained in:
233
README.md
233
README.md
@@ -1,51 +1,250 @@
|
|||||||
# A Model Context Protocol Server for Home Assistant
|
# A Model Context Protocol Server for Home Assistant
|
||||||
|
|
||||||
The server uses the MCP protocol to share access to a local Home Assistant instance with an LLM application.
|
The server uses the MCP protocol to share access to a local Home Assistant instance with an LLM application. It provides a comprehensive interface for controlling various Home Assistant entities through natural language.
|
||||||
|
|
||||||
More about MCP here: https://modelcontextprotocol.io/introduction
|
## Features
|
||||||
|
|
||||||
More about Home Assistant here: https://www.home-assistant.io
|
- **Entity Control**: Full support for controlling common Home Assistant entities:
|
||||||
|
- 💡 **Lights**: Brightness, color temperature, RGB color
|
||||||
|
- 🌡️ **Climate**: Temperature, HVAC modes, fan modes, humidity
|
||||||
|
- 🚪 **Covers**: Position control, tilt control
|
||||||
|
- 🔌 **Switches**: Basic on/off control
|
||||||
|
- 🚨 **Contacts**: State monitoring
|
||||||
|
- **Entity State Access**: Query and monitor entity states
|
||||||
|
- **Area and Floor Organization**: Logical grouping of devices
|
||||||
|
- **Robust Error Handling**: Clear error messages and state validation
|
||||||
|
|
||||||
## Usage
|
## Prerequisites
|
||||||
|
|
||||||
First build the server
|
- Node.js 16 or higher
|
||||||
|
- Yarn package manager
|
||||||
|
- A running Home Assistant instance
|
||||||
|
- A long-lived access token from Home Assistant
|
||||||
|
|
||||||
```
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone the repository
|
||||||
|
git clone https://github.com/yourusername/homeassistant-mcp.git
|
||||||
|
cd homeassistant-mcp
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
yarn install
|
||||||
|
|
||||||
|
# Build the server
|
||||||
yarn build
|
yarn build
|
||||||
```
|
```
|
||||||
|
|
||||||
Then configure your application (like Claude Desktop) to use it.
|
## Configuration
|
||||||
|
|
||||||
|
1. **Home Assistant Token**
|
||||||
|
- Get a long-lived access token from Home Assistant
|
||||||
|
- Guide: [How to get long-lived access token](https://community.home-assistant.io/t/how-to-get-long-lived-access-token/162159)
|
||||||
|
|
||||||
|
2. **Environment Variables**
|
||||||
|
Create a `.env` file with:
|
||||||
|
|
||||||
|
```env
|
||||||
|
TOKEN=your_home_assistant_token
|
||||||
|
BASE_URL=your_home_assistant_url # e.g., http://homeassistant.local:8123
|
||||||
|
PORT=3000 # Optional, defaults to 3000
|
||||||
```
|
```
|
||||||
|
|
||||||
|
3. **MCP Client Configuration**
|
||||||
|
Configure your MCP client (like Claude Desktop) with:
|
||||||
|
|
||||||
|
```json
|
||||||
{
|
{
|
||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"homeassistant": {
|
"homeassistant": {
|
||||||
"command": "node",
|
"command": "node",
|
||||||
"args": [
|
"args": [
|
||||||
"/Users/tevonsb/Desktop/mcp/dist/index.js"
|
"/path/to/dist/index.js"
|
||||||
],
|
],
|
||||||
"env": {
|
"env": {
|
||||||
"TOKEN": <home_assistant_token>,
|
"TOKEN": "your_home_assistant_token",
|
||||||
"BASE_URL": <base_url_for_home_assistant>
|
"BASE_URL": "your_home_assistant_url"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
You'll need a personal access token from home assistant.
|
## Supported Commands
|
||||||
|
|
||||||
Get one using this guide: https://community.home-assistant.io/t/how-to-get-long-lived-access-token/162159
|
### Common Commands (All Entities)
|
||||||
|
|
||||||
## In Progress
|
- `turn_on`: Turn entity on
|
||||||
|
- `turn_off`: Turn entity off
|
||||||
|
- `toggle`: Toggle entity state
|
||||||
|
|
||||||
|
### Light-Specific Commands
|
||||||
|
|
||||||
|
- Control brightness (0-255)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"command": "turn_on",
|
||||||
|
"entity_id": "light.living_room",
|
||||||
|
"brightness": 128
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- Set color temperature
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"command": "turn_on",
|
||||||
|
"entity_id": "light.living_room",
|
||||||
|
"color_temp": 4000
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- Set RGB color values
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"command": "turn_on",
|
||||||
|
"entity_id": "light.living_room",
|
||||||
|
"rgb_color": [255, 0, 0]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cover Commands
|
||||||
|
|
||||||
|
- `open`: Open cover
|
||||||
|
- `close`: Close cover
|
||||||
|
- `stop`: Stop cover movement
|
||||||
|
- `set_position`: Set cover position (0-100)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"command": "set_position",
|
||||||
|
"entity_id": "cover.living_room",
|
||||||
|
"position": 50
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `set_tilt_position`: Set cover tilt (0-100)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"command": "set_tilt_position",
|
||||||
|
"entity_id": "cover.living_room",
|
||||||
|
"tilt_position": 45
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Climate Commands
|
||||||
|
|
||||||
|
- `set_temperature`: Set target temperature
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"command": "set_temperature",
|
||||||
|
"entity_id": "climate.living_room",
|
||||||
|
"temperature": 22
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `set_hvac_mode`: Set mode (off, heat, cool, heat_cool, auto, dry, fan_only)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"command": "set_hvac_mode",
|
||||||
|
"entity_id": "climate.living_room",
|
||||||
|
"hvac_mode": "heat"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `set_fan_mode`: Set fan mode (auto, low, medium, high)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"command": "set_fan_mode",
|
||||||
|
"entity_id": "climate.living_room",
|
||||||
|
"fan_mode": "auto"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `set_humidity`: Set target humidity (0-100)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"command": "set_humidity",
|
||||||
|
"entity_id": "climate.living_room",
|
||||||
|
"humidity": 45
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run in development mode
|
||||||
|
yarn dev
|
||||||
|
|
||||||
|
# Build and start
|
||||||
|
yarn build:start
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
yarn test
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Common Issues
|
||||||
|
|
||||||
|
1. **Connection Errors**
|
||||||
|
- Verify your Home Assistant instance is running
|
||||||
|
- Check the BASE_URL is correct and accessible
|
||||||
|
- Ensure your token has the required permissions
|
||||||
|
|
||||||
|
2. **Entity Control Issues**
|
||||||
|
- Verify the entity_id exists in Home Assistant
|
||||||
|
- Check the entity domain matches the command
|
||||||
|
- Ensure parameter values are within valid ranges
|
||||||
|
|
||||||
|
3. **Permission Issues**
|
||||||
|
- Verify your token has write permissions for the entity
|
||||||
|
- Check Home Assistant logs for authorization errors
|
||||||
|
|
||||||
|
## Project Status
|
||||||
|
|
||||||
|
### Completed
|
||||||
|
|
||||||
- [x] Access to entities
|
- [x] Access to entities
|
||||||
- [x] Access to Floors
|
- [x] Access to Floors
|
||||||
- [x] Access to Areas
|
- [x] Access to Areas
|
||||||
- [ ] Control for entities
|
- [x] Control for entities
|
||||||
- [ ] Lights
|
- [x] Lights
|
||||||
- [ ] Thermostats
|
- [x] Thermostats
|
||||||
- [ ] Covers
|
- [x] Covers
|
||||||
|
- [x] Contacts
|
||||||
|
- [x] Climates
|
||||||
|
- [x] Switches
|
||||||
|
|
||||||
|
### In Progress
|
||||||
|
|
||||||
- [ ] Testing / writing custom prompts
|
- [ ] Testing / writing custom prompts
|
||||||
- [ ] Testing using resources for high-level context
|
- [ ] Testing using resources for high-level context
|
||||||
- [ ] Test varying tool organization
|
- [ ] Test varying tool organization
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
Contributions are welcome! Please feel free to submit a Pull Request.
|
||||||
|
|
||||||
|
1. Fork the repository
|
||||||
|
2. Create a feature branch
|
||||||
|
3. Commit your changes
|
||||||
|
4. Push to the branch
|
||||||
|
5. Create a Pull Request
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
||||||
|
|
||||||
|
## Links
|
||||||
|
|
||||||
|
- [Model Context Protocol Documentation](https://modelcontextprotocol.io/introduction)
|
||||||
|
- [Home Assistant Documentation](https://www.home-assistant.io)
|
||||||
|
- [Home Assistant REST API](https://developers.home-assistant.io/docs/api/rest)
|
||||||
|
|||||||
20
jest.config.cjs
Normal file
20
jest.config.cjs
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
/** @type {import('ts-jest').JestConfigWithTsJest} */
|
||||||
|
module.exports = {
|
||||||
|
preset: 'ts-jest/presets/default-esm',
|
||||||
|
testEnvironment: 'node',
|
||||||
|
extensionsToTreatAsEsm: ['.ts'],
|
||||||
|
moduleNameMapper: {
|
||||||
|
'^(\\.{1,2}/.*)\\.js$': '$1',
|
||||||
|
},
|
||||||
|
transform: {
|
||||||
|
'^.+\\.ts$': [
|
||||||
|
'ts-jest',
|
||||||
|
{
|
||||||
|
useESM: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
transformIgnorePatterns: [
|
||||||
|
'node_modules/(?!(@digital-alchemy)/.*)',
|
||||||
|
],
|
||||||
|
};
|
||||||
17
jest.config.js
Normal file
17
jest.config.js
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
/** @type {import('ts-jest').JestConfigWithTsJest} */
|
||||||
|
export default {
|
||||||
|
preset: 'ts-jest',
|
||||||
|
testEnvironment: 'node',
|
||||||
|
extensionsToTreatAsEsm: ['.ts'],
|
||||||
|
moduleNameMapper: {
|
||||||
|
'^(\\.{1,2}/.*)\\.js$': '$1',
|
||||||
|
},
|
||||||
|
transform: {
|
||||||
|
'^.+\\.tsx?$': [
|
||||||
|
'ts-jest',
|
||||||
|
{
|
||||||
|
useESM: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
202
src/__tests__/context.test.ts
Normal file
202
src/__tests__/context.test.ts
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import { DomainSchema } from '../schemas.js';
|
||||||
|
|
||||||
|
// Define types for tool and server
|
||||||
|
interface Tool {
|
||||||
|
name: string;
|
||||||
|
execute: (params: any) => Promise<any>;
|
||||||
|
parameters: z.ZodType<any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock the Home Assistant instance
|
||||||
|
jest.mock('../hass/index.js', () => ({
|
||||||
|
get_hass: jest.fn().mockResolvedValue({
|
||||||
|
services: {
|
||||||
|
light: {
|
||||||
|
turn_on: jest.fn().mockResolvedValue(undefined),
|
||||||
|
turn_off: jest.fn().mockResolvedValue(undefined),
|
||||||
|
},
|
||||||
|
climate: {
|
||||||
|
set_temperature: jest.fn().mockResolvedValue(undefined),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('MCP Server Context and Tools', () => {
|
||||||
|
let server: MockLiteMCP;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
server = new MockLiteMCP('home-assistant', '0.1.0');
|
||||||
|
|
||||||
|
// Add the control tool to the server
|
||||||
|
server.addTool({
|
||||||
|
name: 'control',
|
||||||
|
description: 'Control Home Assistant devices and services',
|
||||||
|
execute: async (params: any) => {
|
||||||
|
const domain = params.entity_id.split('.')[0];
|
||||||
|
if (params.command === 'set_temperature' && domain !== 'climate') {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: `Unsupported operation for domain: ${domain}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `Successfully executed ${params.command} for ${params.entity_id}`,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
parameters: z.object({
|
||||||
|
command: z.string(),
|
||||||
|
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(),
|
||||||
|
temperature: z.number().optional(),
|
||||||
|
hvac_mode: z.enum(['off', 'heat', 'cool', 'heat_cool', 'auto', 'dry', 'fan_only']).optional(),
|
||||||
|
fan_mode: z.enum(['auto', 'low', 'medium', 'high']).optional(),
|
||||||
|
position: z.number().min(0).max(100).optional(),
|
||||||
|
tilt_position: z.number().min(0).max(100).optional(),
|
||||||
|
area: z.string().optional(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Custom Prompts', () => {
|
||||||
|
it('should handle natural language commands for lights', async () => {
|
||||||
|
const tools = server.getTools();
|
||||||
|
const tool = tools.find(t => t.name === 'control');
|
||||||
|
expect(tool).toBeDefined();
|
||||||
|
|
||||||
|
// Test natural language command execution
|
||||||
|
const result = await tool!.execute({
|
||||||
|
command: 'turn_on',
|
||||||
|
entity_id: 'light.living_room',
|
||||||
|
brightness: 128,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
success: true,
|
||||||
|
message: expect.stringContaining('Successfully executed turn_on for light.living_room'),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle natural language commands for climate control', async () => {
|
||||||
|
const tools = server.getTools();
|
||||||
|
const tool = tools.find(t => t.name === 'control');
|
||||||
|
expect(tool).toBeDefined();
|
||||||
|
|
||||||
|
// Test temperature control command
|
||||||
|
const result = await tool!.execute({
|
||||||
|
command: 'set_temperature',
|
||||||
|
entity_id: 'climate.living_room',
|
||||||
|
temperature: 22,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
success: true,
|
||||||
|
message: expect.stringContaining('Successfully executed set_temperature for climate.living_room'),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('High-Level Context', () => {
|
||||||
|
it('should validate domain-specific commands', async () => {
|
||||||
|
const tools = server.getTools();
|
||||||
|
const tool = tools.find(t => t.name === 'control');
|
||||||
|
expect(tool).toBeDefined();
|
||||||
|
|
||||||
|
// Test invalid command for domain
|
||||||
|
const result = await tool!.execute({
|
||||||
|
command: 'set_temperature', // Climate command
|
||||||
|
entity_id: 'light.living_room', // Light entity
|
||||||
|
temperature: 22,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
success: false,
|
||||||
|
message: expect.stringContaining('Unsupported operation'),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle area-based commands', async () => {
|
||||||
|
const tools = server.getTools();
|
||||||
|
const tool = tools.find(t => t.name === 'control');
|
||||||
|
expect(tool).toBeDefined();
|
||||||
|
|
||||||
|
// Test command with area context
|
||||||
|
const result = await tool!.execute({
|
||||||
|
command: 'turn_on',
|
||||||
|
entity_id: 'light.living_room',
|
||||||
|
area: 'Living Room',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
success: true,
|
||||||
|
message: expect.stringContaining('Successfully executed turn_on for light.living_room'),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Tool Organization', () => {
|
||||||
|
it('should have all required tools available', () => {
|
||||||
|
const tools = server.getTools();
|
||||||
|
const toolNames = tools.map(t => t.name);
|
||||||
|
expect(toolNames).toContain('control');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should support all defined domains', () => {
|
||||||
|
const tools = server.getTools();
|
||||||
|
const tool = tools.find(t => t.name === 'control');
|
||||||
|
expect(tool).toBeDefined();
|
||||||
|
|
||||||
|
// Check if tool supports all domains from DomainSchema
|
||||||
|
const supportedDomains = Object.values(DomainSchema.Values);
|
||||||
|
const schema = tool!.parameters as z.ZodObject<any>;
|
||||||
|
const shape = schema.shape;
|
||||||
|
|
||||||
|
expect(shape).toBeDefined();
|
||||||
|
expect(shape.entity_id).toBeDefined();
|
||||||
|
expect(shape.command).toBeDefined();
|
||||||
|
|
||||||
|
// Test each domain has its specific parameters
|
||||||
|
supportedDomains.forEach(domain => {
|
||||||
|
switch (domain) {
|
||||||
|
case 'light':
|
||||||
|
expect(shape.brightness).toBeDefined();
|
||||||
|
expect(shape.color_temp).toBeDefined();
|
||||||
|
expect(shape.rgb_color).toBeDefined();
|
||||||
|
break;
|
||||||
|
case 'climate':
|
||||||
|
expect(shape.temperature).toBeDefined();
|
||||||
|
expect(shape.hvac_mode).toBeDefined();
|
||||||
|
expect(shape.fan_mode).toBeDefined();
|
||||||
|
break;
|
||||||
|
case 'cover':
|
||||||
|
expect(shape.position).toBeDefined();
|
||||||
|
expect(shape.tilt_position).toBeDefined();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
48
src/__tests__/hass.test.ts
Normal file
48
src/__tests__/hass.test.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { get_hass } from '../hass/index.js';
|
||||||
|
|
||||||
|
// Mock the entire module
|
||||||
|
jest.mock('../hass/index.js', () => {
|
||||||
|
let mockInstance: any = null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
get_hass: jest.fn(async () => {
|
||||||
|
if (!mockInstance) {
|
||||||
|
mockInstance = {
|
||||||
|
services: {
|
||||||
|
light: {
|
||||||
|
turn_on: jest.fn().mockResolvedValue(undefined),
|
||||||
|
turn_off: jest.fn().mockResolvedValue(undefined),
|
||||||
|
},
|
||||||
|
climate: {
|
||||||
|
set_temperature: jest.fn().mockResolvedValue(undefined),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return mockInstance;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Home Assistant Connection', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return a Home Assistant instance with services', async () => {
|
||||||
|
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 multiple calls', async () => {
|
||||||
|
const firstInstance = await get_hass();
|
||||||
|
const secondInstance = await get_hass();
|
||||||
|
|
||||||
|
expect(firstInstance).toBe(secondInstance);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,8 +1,21 @@
|
|||||||
import { CreateApplication, TServiceParams, StringConfig } from "@digital-alchemy/core";
|
import { CreateApplication, TServiceParams, StringConfig } from "@digital-alchemy/core";
|
||||||
import { LIB_HASS, PICK_ENTITY } from "@digital-alchemy/hass";
|
import { LIB_HASS, PICK_ENTITY } from "@digital-alchemy/hass";
|
||||||
|
import { DomainSchema } from "../schemas.js";
|
||||||
|
|
||||||
type Environments = "development" | "production" | "test";
|
type Environments = "development" | "production" | "test";
|
||||||
|
|
||||||
|
// Define the type for Home Assistant services
|
||||||
|
type HassServices = {
|
||||||
|
[K in keyof typeof DomainSchema.Values]: {
|
||||||
|
[service: string]: (data: Record<string, any>) => Promise<void>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Define the type for Home Assistant instance
|
||||||
|
interface HassInstance extends TServiceParams {
|
||||||
|
services: HassServices;
|
||||||
|
}
|
||||||
|
|
||||||
// application
|
// application
|
||||||
const MY_APP = CreateApplication({
|
const MY_APP = CreateApplication({
|
||||||
configuration: {
|
configuration: {
|
||||||
@@ -28,11 +41,12 @@ const MY_APP = CreateApplication({
|
|||||||
name: 'hass' as const
|
name: 'hass' as const
|
||||||
});
|
});
|
||||||
|
|
||||||
let hassInstance: Awaited<ReturnType<typeof MY_APP.bootstrap>>;
|
let hassInstance: HassInstance;
|
||||||
|
|
||||||
export async function get_hass() {
|
export async function get_hass(): Promise<HassInstance> {
|
||||||
if (!hassInstance) {
|
if (!hassInstance) {
|
||||||
hassInstance = await MY_APP.bootstrap();
|
const instance = await MY_APP.bootstrap();
|
||||||
|
hassInstance = instance as unknown as HassInstance;
|
||||||
}
|
}
|
||||||
return hassInstance;
|
return hassInstance;
|
||||||
}
|
}
|
||||||
158
src/index.ts
158
src/index.ts
@@ -1,41 +1,165 @@
|
|||||||
import { get_hass } from './hass/index.js';
|
import { get_hass } from './hass/index.js';
|
||||||
import { Server as ModelContextProtocolServer } from 'litemcp';
|
import { LiteMCP } from 'litemcp';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { zodToJsonSchema } from 'zod-to-json-schema';
|
import { DomainSchema } from './schemas.js';
|
||||||
|
|
||||||
interface CommandParams {
|
interface CommandParams {
|
||||||
command: string;
|
command: string;
|
||||||
entity_id?: string;
|
entity_id: string;
|
||||||
|
// Common parameters
|
||||||
|
state?: string;
|
||||||
|
// Light parameters
|
||||||
|
brightness?: number;
|
||||||
|
color_temp?: number;
|
||||||
|
rgb_color?: [number, number, number];
|
||||||
|
// Cover parameters
|
||||||
|
position?: number;
|
||||||
|
tilt_position?: number;
|
||||||
|
// Climate parameters
|
||||||
|
temperature?: number;
|
||||||
|
target_temp_high?: number;
|
||||||
|
target_temp_low?: number;
|
||||||
|
hvac_mode?: string;
|
||||||
|
fan_mode?: string;
|
||||||
|
humidity?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const commonCommands = ['turn_on', 'turn_off', 'toggle'] as const;
|
||||||
|
const coverCommands = [...commonCommands, 'open', 'close', 'stop', 'set_position', 'set_tilt_position'] as const;
|
||||||
|
const climateCommands = [...commonCommands, 'set_temperature', 'set_hvac_mode', 'set_fan_mode', 'set_humidity'] as const;
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const hass = await get_hass();
|
const hass = await get_hass();
|
||||||
|
|
||||||
// Create MCP server
|
// Create MCP server
|
||||||
const server = new ModelContextProtocolServer({
|
const server = new LiteMCP('home-assistant', '0.1.0');
|
||||||
port: process.env.PORT ? parseInt(process.env.PORT) : 3000,
|
|
||||||
models: [{
|
// Add the Home Assistant control tool
|
||||||
name: 'home-assistant',
|
server.addTool({
|
||||||
|
name: 'control',
|
||||||
description: 'Control Home Assistant devices and services',
|
description: 'Control Home Assistant devices and services',
|
||||||
parameters: zodToJsonSchema(z.object({
|
parameters: z.object({
|
||||||
command: z.string().describe('The command to execute'),
|
command: z.enum([...commonCommands, ...coverCommands, ...climateCommands])
|
||||||
entity_id: z.string().optional().describe('The entity ID to control')
|
.describe('The command to execute'),
|
||||||
})),
|
entity_id: z.string().describe('The entity ID to control'),
|
||||||
handler: async (params: CommandParams) => {
|
// Common parameters
|
||||||
// Implement your command handling logic here
|
state: z.string().optional().describe('The desired state for the entity'),
|
||||||
// You can use the hass instance to interact with Home Assistant
|
// Light parameters
|
||||||
|
brightness: z.number().min(0).max(255).optional()
|
||||||
|
.describe('Brightness level for lights (0-255)'),
|
||||||
|
color_temp: z.number().optional()
|
||||||
|
.describe('Color temperature for lights'),
|
||||||
|
rgb_color: z.tuple([z.number(), z.number(), z.number()]).optional()
|
||||||
|
.describe('RGB color values'),
|
||||||
|
// Cover parameters
|
||||||
|
position: z.number().min(0).max(100).optional()
|
||||||
|
.describe('Position for covers (0-100)'),
|
||||||
|
tilt_position: z.number().min(0).max(100).optional()
|
||||||
|
.describe('Tilt position for covers (0-100)'),
|
||||||
|
// Climate parameters
|
||||||
|
temperature: z.number().optional()
|
||||||
|
.describe('Target temperature for climate devices'),
|
||||||
|
target_temp_high: z.number().optional()
|
||||||
|
.describe('Target high temperature for climate devices'),
|
||||||
|
target_temp_low: z.number().optional()
|
||||||
|
.describe('Target low temperature for climate devices'),
|
||||||
|
hvac_mode: z.enum(['off', 'heat', 'cool', 'heat_cool', 'auto', 'dry', 'fan_only']).optional()
|
||||||
|
.describe('HVAC mode for climate devices'),
|
||||||
|
fan_mode: z.enum(['auto', 'low', 'medium', 'high']).optional()
|
||||||
|
.describe('Fan mode for climate devices'),
|
||||||
|
humidity: z.number().min(0).max(100).optional()
|
||||||
|
.describe('Target humidity for climate devices')
|
||||||
|
}),
|
||||||
|
execute: async (params: CommandParams) => {
|
||||||
|
try {
|
||||||
|
const domain = params.entity_id.split('.')[0] as keyof typeof DomainSchema.Values;
|
||||||
|
|
||||||
|
if (!Object.values(DomainSchema.Values).includes(domain)) {
|
||||||
|
throw new Error(`Unsupported domain: ${domain}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const service = params.command;
|
||||||
|
const serviceData: Record<string, any> = {
|
||||||
|
entity_id: params.entity_id
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle domain-specific parameters
|
||||||
|
switch (domain) {
|
||||||
|
case 'light':
|
||||||
|
if (params.brightness !== undefined) {
|
||||||
|
serviceData.brightness = params.brightness;
|
||||||
|
}
|
||||||
|
if (params.color_temp !== undefined) {
|
||||||
|
serviceData.color_temp = params.color_temp;
|
||||||
|
}
|
||||||
|
if (params.rgb_color !== undefined) {
|
||||||
|
serviceData.rgb_color = params.rgb_color;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'cover':
|
||||||
|
if (service === 'set_position' && params.position !== undefined) {
|
||||||
|
serviceData.position = params.position;
|
||||||
|
}
|
||||||
|
if (service === 'set_tilt_position' && params.tilt_position !== undefined) {
|
||||||
|
serviceData.tilt_position = params.tilt_position;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'climate':
|
||||||
|
if (service === 'set_temperature') {
|
||||||
|
if (params.temperature !== undefined) {
|
||||||
|
serviceData.temperature = params.temperature;
|
||||||
|
}
|
||||||
|
if (params.target_temp_high !== undefined) {
|
||||||
|
serviceData.target_temp_high = params.target_temp_high;
|
||||||
|
}
|
||||||
|
if (params.target_temp_low !== undefined) {
|
||||||
|
serviceData.target_temp_low = params.target_temp_low;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (service === 'set_hvac_mode' && params.hvac_mode !== undefined) {
|
||||||
|
serviceData.hvac_mode = params.hvac_mode;
|
||||||
|
}
|
||||||
|
if (service === 'set_fan_mode' && params.fan_mode !== undefined) {
|
||||||
|
serviceData.fan_mode = params.fan_mode;
|
||||||
|
}
|
||||||
|
if (service === 'set_humidity' && params.humidity !== undefined) {
|
||||||
|
serviceData.humidity = params.humidity;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'switch':
|
||||||
|
case 'contact':
|
||||||
|
// These domains only support basic operations (turn_on, turn_off, toggle)
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new Error(`Unsupported operation for domain: ${domain}`);
|
||||||
|
}
|
||||||
|
// Call Home Assistant service
|
||||||
|
try {
|
||||||
|
await hass.services[domain][service](serviceData);
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Failed to execute ${service} for ${params.entity_id}: ${error instanceof Error ? error.message : 'Unknown error occurred'}`);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Command executed successfully'
|
message: `Successfully executed ${service} for ${params.entity_id}`
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: error instanceof Error ? error.message : 'Unknown error occurred'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}]
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Start the server
|
// Start the server
|
||||||
await server.start();
|
await server.start();
|
||||||
console.log('MCP Server started on port', server.port);
|
console.log('MCP Server started');
|
||||||
}
|
}
|
||||||
|
|
||||||
main().catch(console.error);
|
main().catch(console.error);
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
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"]);
|
||||||
|
|
||||||
// Generic list request schema
|
// Generic list request schema
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2020",
|
"target": "ES2022",
|
||||||
"module": "NodeNext",
|
"module": "NodeNext",
|
||||||
"moduleResolution": "NodeNext",
|
"moduleResolution": "NodeNext",
|
||||||
"outDir": "./dist",
|
"outDir": "./dist",
|
||||||
@@ -9,7 +9,8 @@
|
|||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"declaration": true
|
"declaration": true,
|
||||||
|
"allowJs": true
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"src/**/*"
|
"src/**/*"
|
||||||
|
|||||||
Reference in New Issue
Block a user