Enhance project structure and testing capabilities

- Updated .dockerignore to include additional logs and IDE files, improving Docker build efficiency.
- Added .eslintrc.json for TypeScript linting configuration, ensuring code quality and consistency.
- Refactored Dockerfile to streamline the build process and utilize a slimmer Node.js image.
- Introduced jest-resolver.cjs and jest.setup.js for improved Jest testing configuration and setup.
- Updated jest.config.js to support ESM and added new test patterns for better test organization.
- Enhanced TypeScript schemas to include new device types (media_player, fan, lock, vacuum, scene, script, camera) for comprehensive validation.
- Added unit tests for device schemas and Home Assistant connection, improving test coverage and reliability.
- Updated README.md with new testing instructions and device control examples, enhancing user guidance.
This commit is contained in:
jango-blockchained
2024-12-17 15:07:40 +01:00
parent 3cd1ae58a5
commit 108524c7c4
30 changed files with 2385 additions and 130 deletions

View File

@@ -1,203 +0,0 @@
import { z } from 'zod';
import { DomainSchema } from '../schemas.js';
// Define types for tool and server
interface Tool {
name: string;
description: 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

@@ -1,48 +0,0 @@
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,6 +1,11 @@
import dotenv from 'dotenv';
// Load environment variables
dotenv.config();
export const HASS_CONFIG = {
BASE_URL: process.env.HASS_HOST || 'http://192.168.178.63:8123',
TOKEN: process.env.HASS_TOKEN,
SOCKET_URL: process.env.HASS_HOST || 'http://192.168.178.63:8123',
SOCKET_TOKEN: process.env.HASS_TOKEN,
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_SOCKET_TOKEN || '',
};

View File

@@ -1,14 +1,16 @@
import { CreateApplication, TServiceParams, StringConfig } from "@digital-alchemy/core";
import { LIB_HASS, PICK_ENTITY } from "@digital-alchemy/hass";
import { CreateApplication, TServiceParams, ServiceFunction } from "@digital-alchemy/core";
import { LIB_HASS } from "@digital-alchemy/hass";
import { DomainSchema } from "../schemas.js";
import { HASS_CONFIG } from "../config/hass.config.js";
type Environments = "development" | "production" | "test";
// 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]: (data: Record<string, any>) => Promise<void>;
[service: string]: HassServiceMethod;
};
};
@@ -17,17 +19,55 @@ interface HassInstance extends TServiceParams {
services: HassServices;
}
// Configuration type for application with more specific constraints
type ApplicationConfiguration = {
NODE_ENV: ServiceFunction<Environments>;
};
// Strict configuration type for Home Assistant
type HassConfiguration = {
BASE_URL: {
type: "string";
description: string;
required: true;
default: string;
};
TOKEN: {
type: "string";
description: string;
required: true;
default: string;
};
SOCKET_URL: {
type: "string";
description: string;
required: true;
default: string;
};
SOCKET_TOKEN: {
type: "string";
description: string;
required: true;
default: string;
};
};
// application
const MY_APP = CreateApplication({
const MY_APP = CreateApplication<ApplicationConfiguration, {}>({
configuration: {
NODE_ENV: {
type: "string",
default: "development",
enum: ["development", "production", "test"],
description: "Code runner addon can set with it's own NODE_ENV",
} satisfies StringConfig<Environments>,
},
},
services: {
NODE_ENV: () => {
// Directly return the default value or use process.env
return (process.env.NODE_ENV as Environments) || "development";
}
},
services: {},
libraries: [
{
...LIB_HASS,
@@ -62,12 +102,15 @@ const MY_APP = CreateApplication({
name: 'hass' as const
});
let hassInstance: HassInstance;
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 unknown as HassInstance;
hassInstance = instance as HassInstance;
}
return hassInstance;
}

View File

@@ -1,3 +1,4 @@
import './polyfills.js';
import { get_hass } from './hass/index.js';
import { LiteMCP } from 'litemcp';
import { z } from 'zod';

24
src/polyfills.ts Normal file
View File

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

View File

@@ -1,7 +1,21 @@
import { z } from "zod";
export const DomainSchema = z.enum(["light", "climate", "alarm_control_panel", "cover", "switch", "contact"]);
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
@@ -79,4 +93,137 @@ export const DeviceSchema = z.object({
export const ListDevicesResponseSchema = z.object({
_meta: z.object({}).optional(),
devices: z.array(DeviceSchema)
});
// Media Player
export const MediaPlayerAttributesSchema = z.object({
volume_level: z.number().optional(),
is_volume_muted: z.boolean().optional(),
media_content_id: z.string().optional(),
media_content_type: z.string().optional(),
media_duration: z.number().optional(),
media_position: z.number().optional(),
media_title: z.string().optional(),
source: z.string().optional(),
source_list: z.array(z.string()).optional(),
supported_features: z.number().optional(),
});
export const MediaPlayerSchema = z.object({
entity_id: z.string(),
state: z.string(),
state_attributes: MediaPlayerAttributesSchema,
});
// Fan
export const FanAttributesSchema = z.object({
percentage: z.number().optional(),
preset_mode: z.string().optional(),
preset_modes: z.array(z.string()).optional(),
oscillating: z.boolean().optional(),
direction: z.string().optional(),
supported_features: z.number().optional(),
});
export const FanSchema = z.object({
entity_id: z.string(),
state: z.string(),
state_attributes: FanAttributesSchema,
});
// Lock
export const LockAttributesSchema = z.object({
code_format: z.string().optional(),
changed_by: z.string().optional(),
locked: z.boolean(),
supported_features: z.number().optional(),
});
export const LockSchema = z.object({
entity_id: z.string(),
state: z.string(),
state_attributes: LockAttributesSchema,
});
// Vacuum
export const VacuumAttributesSchema = z.object({
battery_level: z.number().optional(),
fan_speed: z.string().optional(),
fan_speed_list: z.array(z.string()).optional(),
status: z.string().optional(),
supported_features: z.number().optional(),
});
export const VacuumSchema = z.object({
entity_id: z.string(),
state: z.string(),
state_attributes: VacuumAttributesSchema,
});
// Scene
export const SceneAttributesSchema = z.object({
entity_id: z.array(z.string()).optional(),
supported_features: z.number().optional(),
});
export const SceneSchema = z.object({
entity_id: z.string(),
state: z.string(),
state_attributes: SceneAttributesSchema,
});
// Script
export const ScriptAttributesSchema = z.object({
last_triggered: z.string().optional(),
mode: z.string().optional(),
variables: z.record(z.any()).optional(),
supported_features: z.number().optional(),
});
export const ScriptSchema = z.object({
entity_id: z.string(),
state: z.string(),
state_attributes: ScriptAttributesSchema,
});
// Camera
export const CameraAttributesSchema = z.object({
motion_detection: z.boolean().optional(),
frontend_stream_type: z.string().optional(),
supported_features: z.number().optional(),
});
export const CameraSchema = z.object({
entity_id: z.string(),
state: z.string(),
state_attributes: CameraAttributesSchema,
});
// Response schemas for new devices
export const ListMediaPlayersResponseSchema = z.object({
media_players: z.array(MediaPlayerSchema),
});
export const ListFansResponseSchema = z.object({
fans: z.array(FanSchema),
});
export const ListLocksResponseSchema = z.object({
locks: z.array(LockSchema),
});
export const ListVacuumsResponseSchema = z.object({
vacuums: z.array(VacuumSchema),
});
export const ListScenesResponseSchema = z.object({
scenes: z.array(SceneSchema),
});
export const ListScriptsResponseSchema = z.object({
scripts: z.array(ScriptSchema),
});
export const ListCamerasResponseSchema = z.object({
cameras: z.array(CameraSchema),
});