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:
jango-blockchained
2024-12-16 13:30:20 +01:00
parent 8bcc8c2b03
commit b55a75df54
9 changed files with 701 additions and 76 deletions

View 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;
}
});
});
});
});

View 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);
});
});

View File

@@ -1,8 +1,21 @@
import { CreateApplication, TServiceParams, StringConfig } from "@digital-alchemy/core";
import { LIB_HASS, PICK_ENTITY } from "@digital-alchemy/hass";
import { DomainSchema } from "../schemas.js";
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
const MY_APP = CreateApplication({
configuration: {
@@ -28,11 +41,12 @@ const MY_APP = CreateApplication({
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) {
hassInstance = await MY_APP.bootstrap();
const instance = await MY_APP.bootstrap();
hassInstance = instance as unknown as HassInstance;
}
return hassInstance;
}

View File

@@ -1,41 +1,165 @@
import { get_hass } from './hass/index.js';
import { Server as ModelContextProtocolServer } from 'litemcp';
import { LiteMCP } from 'litemcp';
import { z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';
import { DomainSchema } from './schemas.js';
interface CommandParams {
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() {
const hass = await get_hass();
// Create MCP server
const server = new ModelContextProtocolServer({
port: process.env.PORT ? parseInt(process.env.PORT) : 3000,
models: [{
name: 'home-assistant',
description: 'Control Home Assistant devices and services',
parameters: zodToJsonSchema(z.object({
command: z.string().describe('The command to execute'),
entity_id: z.string().optional().describe('The entity ID to control')
})),
handler: async (params: CommandParams) => {
// Implement your command handling logic here
// You can use the hass instance to interact with Home Assistant
const server = new LiteMCP('home-assistant', '0.1.0');
// Add the Home Assistant control tool
server.addTool({
name: 'control',
description: 'Control Home Assistant devices and services',
parameters: z.object({
command: z.enum([...commonCommands, ...coverCommands, ...climateCommands])
.describe('The command to execute'),
entity_id: z.string().describe('The entity ID to control'),
// Common parameters
state: z.string().optional().describe('The desired state for the entity'),
// 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 {
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
await server.start();
console.log('MCP Server started on port', server.port);
console.log('MCP Server started');
}
main().catch(console.error);

View File

@@ -1,7 +1,7 @@
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
@@ -53,30 +53,30 @@ export const ListAlarmsResponseSchema = z.object({
// Devices
export const DeviceSchema = z.object({
id: z.string(),
name: z.string(),
name_by_user: z.string().optional(),
model: z.string(),
model_id: z.string().nullable(),
manufacturer: z.string(),
area_id: z.string().nullable(),
config_entries: z.array(z.string()),
primary_config_entry: z.string(),
connections: z.array(z.tuple([z.string(), z.string()])),
configuration_url: z.string().nullable(),
disabled_by: z.string().nullable(),
entry_type: z.string().nullable(),
hw_version: z.string().nullable(),
sw_version: z.string().nullable(),
via_device_id: z.string().nullable(),
created_at: z.number(),
modified_at: z.number(),
identifiers: z.array(z.any()),
labels: z.array(z.string()),
serial_number: z.string().optional()
id: z.string(),
name: z.string(),
name_by_user: z.string().optional(),
model: z.string(),
model_id: z.string().nullable(),
manufacturer: z.string(),
area_id: z.string().nullable(),
config_entries: z.array(z.string()),
primary_config_entry: z.string(),
connections: z.array(z.tuple([z.string(), z.string()])),
configuration_url: z.string().nullable(),
disabled_by: z.string().nullable(),
entry_type: z.string().nullable(),
hw_version: z.string().nullable(),
sw_version: z.string().nullable(),
via_device_id: z.string().nullable(),
created_at: z.number(),
modified_at: z.number(),
identifiers: z.array(z.any()),
labels: z.array(z.string()),
serial_number: z.string().optional()
});
export const ListDevicesResponseSchema = z.object({
_meta: z.object({}).optional(),
devices: z.array(DeviceSchema)
_meta: z.object({}).optional(),
devices: z.array(DeviceSchema)
});