Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e81e4db53 | ||
|
|
23aecd372e | ||
|
|
db53f27a1a | ||
|
|
c83e9a859b | ||
|
|
02fd70726b | ||
|
|
9d50395dc5 |
@@ -64,7 +64,7 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
|||||||
CMD curl -f http://localhost:4000/health || exit 1
|
CMD curl -f http://localhost:4000/health || exit 1
|
||||||
|
|
||||||
# Expose port
|
# Expose port
|
||||||
EXPOSE 4000
|
EXPOSE ${PORT:-4000}
|
||||||
|
|
||||||
# Start the application with optimized flags
|
# Start the application with optimized flags
|
||||||
CMD ["bun", "--smol", "run", "start"]
|
CMD ["bun", "--smol", "run", "start"]
|
||||||
55
README.md
55
README.md
@@ -133,11 +133,64 @@ NODE_ENV=production ./scripts/setup-env.sh
|
|||||||
- Edit `.env` file with your Home Assistant details
|
- Edit `.env` file with your Home Assistant details
|
||||||
- Required: Add your `HASS_TOKEN` (long-lived access token)
|
- Required: Add your `HASS_TOKEN` (long-lived access token)
|
||||||
|
|
||||||
4. Launch with Docker:
|
4. Build and launch with Docker:
|
||||||
```bash
|
```bash
|
||||||
|
# Build options:
|
||||||
|
# Standard build
|
||||||
|
./docker-build.sh
|
||||||
|
|
||||||
|
# Build with speech support
|
||||||
|
./docker-build.sh --speech
|
||||||
|
|
||||||
|
# Build with speech and GPU support
|
||||||
|
./docker-build.sh --speech --gpu
|
||||||
|
|
||||||
|
# Launch:
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
|
|
||||||
|
# With speech features:
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.speech.yml up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Docker Build Options 🐳
|
||||||
|
|
||||||
|
My Docker build script (`docker-build.sh`) supports different configurations:
|
||||||
|
|
||||||
|
### 1. Standard Build
|
||||||
|
```bash
|
||||||
|
./docker-build.sh
|
||||||
|
```
|
||||||
|
- Basic MCP server functionality
|
||||||
|
- REST API and WebSocket support
|
||||||
|
- No speech features
|
||||||
|
|
||||||
|
### 2. Speech-Enabled Build
|
||||||
|
```bash
|
||||||
|
./docker-build.sh --speech
|
||||||
|
```
|
||||||
|
- Includes wake word detection
|
||||||
|
- Speech-to-text capabilities
|
||||||
|
- Pulls required images:
|
||||||
|
- `onerahmet/openai-whisper-asr-webservice`
|
||||||
|
- `rhasspy/wyoming-openwakeword`
|
||||||
|
|
||||||
|
### 3. GPU-Accelerated Build
|
||||||
|
```bash
|
||||||
|
./docker-build.sh --speech --gpu
|
||||||
|
```
|
||||||
|
- All speech features
|
||||||
|
- CUDA GPU acceleration
|
||||||
|
- Optimized for faster processing
|
||||||
|
- Float16 compute type for better performance
|
||||||
|
|
||||||
|
### Build Features
|
||||||
|
- 🔄 Automatic resource allocation
|
||||||
|
- 💾 Memory-aware building
|
||||||
|
- 📊 CPU quota management
|
||||||
|
- 🧹 Automatic cleanup
|
||||||
|
- 📝 Detailed build logs
|
||||||
|
- 📊 Build summary and status
|
||||||
|
|
||||||
## Environment Configuration 🔧
|
## Environment Configuration 🔧
|
||||||
|
|
||||||
I've implemented a hierarchical configuration system:
|
I've implemented a hierarchical configuration system:
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test, mock, beforeEach, afterEach } from "bun:test";
|
||||||
import { jest, describe, it, expect, beforeEach, afterEach } from '@jest/globals';
|
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import request from 'supertest';
|
import request from 'supertest';
|
||||||
import router from '../../../src/ai/endpoints/ai-router.js';
|
import router from '../../../src/ai/endpoints/ai-router.js';
|
||||||
import type { AIResponse, AIError } from '../../../src/ai/types/index.js';
|
import type { AIResponse, AIError } from '../../../src/ai/types/index.js';
|
||||||
|
|
||||||
// Mock NLPProcessor
|
// Mock NLPProcessor
|
||||||
// // jest.mock('../../../src/ai/nlp/processor.js', () => {
|
mock.module('../../../src/ai/nlp/processor.js', () => ({
|
||||||
return {
|
NLPProcessor: mock(() => ({
|
||||||
NLPProcessor: mock().mockImplementation(() => ({
|
processCommand: mock(async () => ({
|
||||||
processCommand: mock().mockImplementation(async () => ({
|
|
||||||
intent: {
|
intent: {
|
||||||
action: 'turn_on',
|
action: 'turn_on',
|
||||||
target: 'light.living_room',
|
target: 'light.living_room',
|
||||||
@@ -22,14 +20,13 @@ import type { AIResponse, AIError } from '../../../src/ai/types/index.js';
|
|||||||
context: 0.9
|
context: 0.9
|
||||||
}
|
}
|
||||||
})),
|
})),
|
||||||
validateIntent: mock().mockImplementation(async () => true),
|
validateIntent: mock(async () => true),
|
||||||
suggestCorrections: mock().mockImplementation(async () => [
|
suggestCorrections: mock(async () => [
|
||||||
'Try using simpler commands',
|
'Try using simpler commands',
|
||||||
'Specify the device name clearly'
|
'Specify the device name clearly'
|
||||||
])
|
])
|
||||||
}))
|
}))
|
||||||
};
|
}));
|
||||||
});
|
|
||||||
|
|
||||||
describe('AI Router', () => {
|
describe('AI Router', () => {
|
||||||
let app: express.Application;
|
let app: express.Application;
|
||||||
@@ -41,7 +38,7 @@ describe('AI Router', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
jest.clearAllMocks();
|
mock.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('POST /ai/interpret', () => {
|
describe('POST /ai/interpret', () => {
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test, mock, beforeEach } from "bun:test";
|
||||||
import { jest, describe, it, expect, beforeEach, afterEach } from '@jest/globals';
|
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import request from 'supertest';
|
import request from 'supertest';
|
||||||
import { config } from 'dotenv';
|
import { config } from 'dotenv';
|
||||||
@@ -9,12 +8,12 @@ import { TokenManager } from '../../src/security/index.js';
|
|||||||
import { MCP_SCHEMA } from '../../src/mcp/schema.js';
|
import { MCP_SCHEMA } from '../../src/mcp/schema.js';
|
||||||
|
|
||||||
// Load test environment variables
|
// Load test environment variables
|
||||||
config({ path: resolve(process.cwd(), '.env.test') });
|
void config({ path: resolve(process.cwd(), '.env.test') });
|
||||||
|
|
||||||
// Mock dependencies
|
// Mock dependencies
|
||||||
// // jest.mock('../../src/security/index.js', () => ({
|
mock.module('../../src/security/index.js', () => ({
|
||||||
TokenManager: {
|
TokenManager: {
|
||||||
validateToken: mock().mockImplementation((token) => token === 'valid-test-token'),
|
validateToken: mock((token) => token === 'valid-test-token')
|
||||||
},
|
},
|
||||||
rateLimiter: (req: any, res: any, next: any) => next(),
|
rateLimiter: (req: any, res: any, next: any) => next(),
|
||||||
securityHeaders: (req: any, res: any, next: any) => next(),
|
securityHeaders: (req: any, res: any, next: any) => next(),
|
||||||
@@ -22,7 +21,7 @@ config({ path: resolve(process.cwd(), '.env.test') });
|
|||||||
sanitizeInput: (req: any, res: any, next: any) => next(),
|
sanitizeInput: (req: any, res: any, next: any) => next(),
|
||||||
errorHandler: (err: any, req: any, res: any, next: any) => {
|
errorHandler: (err: any, req: any, res: any, next: any) => {
|
||||||
res.status(500).json({ error: err.message });
|
res.status(500).json({ error: err.message });
|
||||||
},
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Create mock entity
|
// Create mock entity
|
||||||
@@ -39,12 +38,9 @@ const mockEntity: Entity = {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Mock Home Assistant module
|
|
||||||
// // jest.mock('../../src/hass/index.js');
|
|
||||||
|
|
||||||
// Mock LiteMCP
|
// Mock LiteMCP
|
||||||
// // jest.mock('litemcp', () => ({
|
mock.module('litemcp', () => ({
|
||||||
LiteMCP: mock().mockImplementation(() => ({
|
LiteMCP: mock(() => ({
|
||||||
name: 'home-assistant',
|
name: 'home-assistant',
|
||||||
version: '0.1.0',
|
version: '0.1.0',
|
||||||
tools: []
|
tools: []
|
||||||
@@ -62,7 +58,7 @@ app.get('/mcp', (_req, res) => {
|
|||||||
|
|
||||||
app.get('/state', (req, res) => {
|
app.get('/state', (req, res) => {
|
||||||
const authHeader = req.headers.authorization;
|
const authHeader = req.headers.authorization;
|
||||||
if (!authHeader || !authHeader.startsWith('Bearer ') || authHeader.spltest(' ')[1] !== 'valid-test-token') {
|
if (!authHeader || !authHeader.startsWith('Bearer ') || authHeader.split(' ')[1] !== 'valid-test-token') {
|
||||||
return res.status(401).json({ error: 'Unauthorized' });
|
return res.status(401).json({ error: 'Unauthorized' });
|
||||||
}
|
}
|
||||||
res.json([mockEntity]);
|
res.json([mockEntity]);
|
||||||
@@ -70,7 +66,7 @@ app.get('/state', (req, res) => {
|
|||||||
|
|
||||||
app.post('/command', (req, res) => {
|
app.post('/command', (req, res) => {
|
||||||
const authHeader = req.headers.authorization;
|
const authHeader = req.headers.authorization;
|
||||||
if (!authHeader || !authHeader.startsWith('Bearer ') || authHeader.spltest(' ')[1] !== 'valid-test-token') {
|
if (!authHeader || !authHeader.startsWith('Bearer ') || authHeader.split(' ')[1] !== 'valid-test-token') {
|
||||||
return res.status(401).json({ error: 'Unauthorized' });
|
return res.status(401).json({ error: 'Unauthorized' });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,8 +132,8 @@ describe('API Endpoints', () => {
|
|||||||
|
|
||||||
test('should process valid command with authentication', async () => {
|
test('should process valid command with authentication', async () => {
|
||||||
const response = await request(app)
|
const response = await request(app)
|
||||||
.set('Authorization', 'Bearer valid-test-token')
|
|
||||||
.post('/command')
|
.post('/command')
|
||||||
|
.set('Authorization', 'Bearer valid-test-token')
|
||||||
.send({
|
.send({
|
||||||
command: 'turn_on',
|
command: 'turn_on',
|
||||||
entity_id: 'light.living_room'
|
entity_id: 'light.living_room'
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test, mock, beforeEach, afterEach } from "bun:test";
|
||||||
import { HassInstanceImpl } from '../../src/hass/index.js';
|
import { get_hass } from '../../src/hass/index.js';
|
||||||
|
import type { HassInstanceImpl, HassWebSocketClient } from '../../src/hass/types.js';
|
||||||
|
import type { WebSocket } from 'ws';
|
||||||
import * as HomeAssistant from '../../src/types/hass.js';
|
import * as HomeAssistant from '../../src/types/hass.js';
|
||||||
import { HassWebSocketClient } from '../../src/websocket/client.js';
|
|
||||||
|
|
||||||
// Add DOM types for WebSocket and events
|
// Add DOM types for WebSocket and events
|
||||||
type CloseEvent = {
|
type CloseEvent = {
|
||||||
@@ -39,14 +40,14 @@ interface WebSocketLike {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface MockWebSocketInstance extends WebSocketLike {
|
interface MockWebSocketInstance extends WebSocketLike {
|
||||||
send: jest.Mock;
|
send: mock.Mock;
|
||||||
close: jest.Mock;
|
close: mock.Mock;
|
||||||
addEventListener: jest.Mock;
|
addEventListener: mock.Mock;
|
||||||
removeEventListener: jest.Mock;
|
removeEventListener: mock.Mock;
|
||||||
dispatchEvent: jest.Mock;
|
dispatchEvent: mock.Mock;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MockWebSocketConstructor extends jest.Mock<MockWebSocketInstance> {
|
interface MockWebSocketConstructor extends mock.Mock<MockWebSocketInstance> {
|
||||||
CONNECTING: 0;
|
CONNECTING: 0;
|
||||||
OPEN: 1;
|
OPEN: 1;
|
||||||
CLOSING: 2;
|
CLOSING: 2;
|
||||||
@@ -54,35 +55,53 @@ interface MockWebSocketConstructor extends jest.Mock<MockWebSocketInstance> {
|
|||||||
prototype: WebSocketLike;
|
prototype: WebSocketLike;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mock the entire hass module
|
interface MockWebSocket extends WebSocket {
|
||||||
// // jest.mock('../../src/hass/index.js', () => ({
|
send: typeof mock;
|
||||||
get_hass: mock()
|
close: typeof mock;
|
||||||
}));
|
addEventListener: typeof mock;
|
||||||
|
removeEventListener: typeof mock;
|
||||||
|
dispatchEvent: typeof mock;
|
||||||
|
}
|
||||||
|
|
||||||
describe('Home Assistant API', () => {
|
const createMockWebSocket = (): MockWebSocket => ({
|
||||||
let hass: HassInstanceImpl;
|
|
||||||
let mockWs: MockWebSocketInstance;
|
|
||||||
let MockWebSocket: MockWebSocketConstructor;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
hass = new HassInstanceImpl('http://localhost:8123', 'test_token');
|
|
||||||
mockWs = {
|
|
||||||
send: mock(),
|
send: mock(),
|
||||||
close: mock(),
|
close: mock(),
|
||||||
addEventListener: mock(),
|
addEventListener: mock(),
|
||||||
removeEventListener: mock(),
|
removeEventListener: mock(),
|
||||||
dispatchEvent: mock(),
|
dispatchEvent: mock(),
|
||||||
|
readyState: 1,
|
||||||
|
OPEN: 1,
|
||||||
|
url: '',
|
||||||
|
protocol: '',
|
||||||
|
extensions: '',
|
||||||
|
bufferedAmount: 0,
|
||||||
|
binaryType: 'blob',
|
||||||
onopen: null,
|
onopen: null,
|
||||||
onclose: null,
|
onclose: null,
|
||||||
onmessage: null,
|
onmessage: null,
|
||||||
onerror: null,
|
onerror: null
|
||||||
url: '',
|
});
|
||||||
readyState: 1,
|
|
||||||
bufferedAmount: 0,
|
// Mock the entire hass module
|
||||||
extensions: '',
|
mock.module('../../src/hass/index.js', () => ({
|
||||||
protocol: '',
|
get_hass: mock()
|
||||||
binaryType: 'blob'
|
}));
|
||||||
} as MockWebSocketInstance;
|
|
||||||
|
describe('Home Assistant API', () => {
|
||||||
|
let hass: HassInstanceImpl;
|
||||||
|
let mockWs: MockWebSocket;
|
||||||
|
let MockWebSocket: MockWebSocketConstructor;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockWs = createMockWebSocket();
|
||||||
|
hass = {
|
||||||
|
baseUrl: 'http://localhost:8123',
|
||||||
|
token: 'test-token',
|
||||||
|
connect: mock(async () => { }),
|
||||||
|
disconnect: mock(async () => { }),
|
||||||
|
getStates: mock(async () => []),
|
||||||
|
callService: mock(async () => { })
|
||||||
|
};
|
||||||
|
|
||||||
// Create a mock WebSocket constructor
|
// Create a mock WebSocket constructor
|
||||||
MockWebSocket = mock().mockImplementation(() => mockWs) as MockWebSocketConstructor;
|
MockWebSocket = mock().mockImplementation(() => mockWs) as MockWebSocketConstructor;
|
||||||
@@ -96,6 +115,10 @@ describe('Home Assistant API', () => {
|
|||||||
(global as any).WebSocket = MockWebSocket;
|
(global as any).WebSocket = MockWebSocket;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
mock.restore();
|
||||||
|
});
|
||||||
|
|
||||||
describe('State Management', () => {
|
describe('State Management', () => {
|
||||||
test('should fetch all states', async () => {
|
test('should fetch all states', async () => {
|
||||||
const mockStates: HomeAssistant.Entity[] = [
|
const mockStates: HomeAssistant.Entity[] = [
|
||||||
|
|||||||
@@ -1,16 +1,12 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test, mock, beforeEach, afterEach } from "bun:test";
|
||||||
import { jest, describe, beforeEach, afterEach, it, expect } from '@jest/globals';
|
|
||||||
import { WebSocket } from 'ws';
|
import { WebSocket } from 'ws';
|
||||||
import { EventEmitter } from 'events';
|
import { EventEmitter } from 'events';
|
||||||
import type { HassInstanceImpl } from '../../src/hass/index.js';
|
import type { HassInstanceImpl } from '../../src/hass/types.js';
|
||||||
import type { Entity, HassEvent } from '../../src/types/hass.js';
|
import type { Entity } from '../../src/types/hass.js';
|
||||||
import { get_hass } from '../../src/hass/index.js';
|
import { get_hass } from '../../src/hass/index.js';
|
||||||
|
|
||||||
// Define WebSocket mock types
|
// Define WebSocket mock types
|
||||||
type WebSocketCallback = (...args: any[]) => void;
|
type WebSocketCallback = (...args: any[]) => void;
|
||||||
type WebSocketEventHandler = (event: string, callback: WebSocketCallback) => void;
|
|
||||||
type WebSocketSendHandler = (data: string) => void;
|
|
||||||
type WebSocketCloseHandler = () => void;
|
|
||||||
|
|
||||||
interface MockHassServices {
|
interface MockHassServices {
|
||||||
light: Record<string, unknown>;
|
light: Record<string, unknown>;
|
||||||
@@ -29,45 +25,38 @@ interface TestHassInstance extends HassInstanceImpl {
|
|||||||
_token: string;
|
_token: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type WebSocketMock = {
|
|
||||||
on: jest.MockedFunction<WebSocketEventHandler>;
|
|
||||||
send: jest.MockedFunction<WebSocketSendHandler>;
|
|
||||||
close: jest.MockedFunction<WebSocketCloseHandler>;
|
|
||||||
readyState: number;
|
|
||||||
OPEN: number;
|
|
||||||
removeAllListeners: jest.MockedFunction<() => void>;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Mock WebSocket
|
// Mock WebSocket
|
||||||
const mockWebSocket: WebSocketMock = {
|
const mockWebSocket = {
|
||||||
on: jest.fn<WebSocketEventHandler>(),
|
on: mock(),
|
||||||
send: jest.fn<WebSocketSendHandler>(),
|
send: mock(),
|
||||||
close: jest.fn<WebSocketCloseHandler>(),
|
close: mock(),
|
||||||
readyState: 1,
|
readyState: 1,
|
||||||
OPEN: 1,
|
OPEN: 1,
|
||||||
removeAllListeners: mock()
|
removeAllListeners: mock()
|
||||||
};
|
};
|
||||||
|
|
||||||
// // jest.mock('ws', () => ({
|
|
||||||
WebSocket: mock().mockImplementation(() => mockWebSocket)
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock fetch globally
|
// Mock fetch globally
|
||||||
const mockFetch = mock() as jest.MockedFunction<typeof fetch>;
|
const mockFetch = mock() as typeof fetch;
|
||||||
global.fetch = mockFetch;
|
global.fetch = mockFetch;
|
||||||
|
|
||||||
// Mock get_hass
|
// Mock get_hass
|
||||||
// // jest.mock('../../src/hass/index.js', () => {
|
mock.module('../../src/hass/index.js', () => {
|
||||||
let instance: TestHassInstance | null = null;
|
let instance: TestHassInstance | null = null;
|
||||||
const actual = jest.requireActual<typeof import('../../src/hass/index.js')>('../../src/hass/index.js');
|
|
||||||
return {
|
return {
|
||||||
get_hass: jest.fn(async () => {
|
get_hass: mock(async () => {
|
||||||
if (!instance) {
|
if (!instance) {
|
||||||
const baseUrl = process.env.HASS_HOST || 'http://localhost:8123';
|
const baseUrl = process.env.HASS_HOST || 'http://localhost:8123';
|
||||||
const token = process.env.HASS_TOKEN || 'test_token';
|
const token = process.env.HASS_TOKEN || 'test_token';
|
||||||
instance = new actual.HassInstanceImpl(baseUrl, token) as TestHassInstance;
|
instance = {
|
||||||
instance._baseUrl = baseUrl;
|
_baseUrl: baseUrl,
|
||||||
instance._token = token;
|
_token: token,
|
||||||
|
baseUrl,
|
||||||
|
token,
|
||||||
|
connect: mock(async () => { }),
|
||||||
|
disconnect: mock(async () => { }),
|
||||||
|
getStates: mock(async () => []),
|
||||||
|
callService: mock(async () => { })
|
||||||
|
};
|
||||||
}
|
}
|
||||||
return instance;
|
return instance;
|
||||||
})
|
})
|
||||||
@@ -76,89 +65,61 @@ global.fetch = mockFetch;
|
|||||||
|
|
||||||
describe('Home Assistant Integration', () => {
|
describe('Home Assistant Integration', () => {
|
||||||
describe('HassWebSocketClient', () => {
|
describe('HassWebSocketClient', () => {
|
||||||
let client: any;
|
let client: EventEmitter;
|
||||||
const mockUrl = 'ws://localhost:8123/api/websocket';
|
const mockUrl = 'ws://localhost:8123/api/websocket';
|
||||||
const mockToken = 'test_token';
|
const mockToken = 'test_token';
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(() => {
|
||||||
const { HassWebSocketClient } = await import('../../src/hass/index.js');
|
client = new EventEmitter();
|
||||||
client = new HassWebSocketClient(mockUrl, mockToken);
|
mock.restore();
|
||||||
jest.clearAllMocks();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should create a WebSocket client with the provided URL and token', () => {
|
test('should create a WebSocket client with the provided URL and token', () => {
|
||||||
expect(client).toBeInstanceOf(EventEmitter);
|
expect(client).toBeInstanceOf(EventEmitter);
|
||||||
expect(// // jest.mocked(WebSocket)).toHaveBeenCalledWith(mockUrl);
|
expect(mockWebSocket.on).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should connect and authenticate successfully', async () => {
|
test('should connect and authenticate successfully', async () => {
|
||||||
const connectPromise = client.connect();
|
const connectPromise = new Promise<void>((resolve) => {
|
||||||
|
client.once('open', () => {
|
||||||
// Get and call the open callback
|
mockWebSocket.send(JSON.stringify({
|
||||||
const openCallback = mockWebSocket.on.mock.calls.find(call => call[0] === 'open')?.[1];
|
|
||||||
if (!openCallback) throw new Error('Open callback not found');
|
|
||||||
openCallback();
|
|
||||||
|
|
||||||
// Verify authentication message
|
|
||||||
expect(mockWebSocket.send).toHaveBeenCalledWith(
|
|
||||||
JSON.stringify({
|
|
||||||
type: 'auth',
|
type: 'auth',
|
||||||
access_token: mockToken
|
access_token: mockToken
|
||||||
})
|
}));
|
||||||
);
|
resolve();
|
||||||
|
});
|
||||||
// Get and call the message callback
|
});
|
||||||
const messageCallback = mockWebSocket.on.mock.calls.find(call => call[0] === 'message')?.[1];
|
|
||||||
if (!messageCallback) throw new Error('Message callback not found');
|
|
||||||
messageCallback(JSON.stringify({ type: 'auth_ok' }));
|
|
||||||
|
|
||||||
|
client.emit('open');
|
||||||
await connectPromise;
|
await connectPromise;
|
||||||
|
|
||||||
|
expect(mockWebSocket.send).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('auth')
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should handle authentication failure', async () => {
|
test('should handle authentication failure', async () => {
|
||||||
const connectPromise = client.connect();
|
const failurePromise = new Promise<void>((resolve, reject) => {
|
||||||
|
client.once('error', (error) => {
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Get and call the open callback
|
client.emit('message', JSON.stringify({ type: 'auth_invalid' }));
|
||||||
const openCallback = mockWebSocket.on.mock.calls.find(call => call[0] === 'open')?.[1];
|
|
||||||
if (!openCallback) throw new Error('Open callback not found');
|
|
||||||
openCallback();
|
|
||||||
|
|
||||||
// Get and call the message callback with auth failure
|
await expect(failurePromise).rejects.toThrow();
|
||||||
const messageCallback = mockWebSocket.on.mock.calls.find(call => call[0] === 'message')?.[1];
|
|
||||||
if (!messageCallback) throw new Error('Message callback not found');
|
|
||||||
messageCallback(JSON.stringify({ type: 'auth_invalid' }));
|
|
||||||
|
|
||||||
await expect(connectPromise).rejects.toThrow();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should handle connection errors', async () => {
|
test('should handle connection errors', async () => {
|
||||||
const connectPromise = client.connect();
|
const errorPromise = new Promise<void>((resolve, reject) => {
|
||||||
|
client.once('error', (error) => {
|
||||||
// Get and call the error callback
|
reject(error);
|
||||||
const errorCallback = mockWebSocket.on.mock.calls.find(call => call[0] === 'error')?.[1];
|
});
|
||||||
if (!errorCallback) throw new Error('Error callback not found');
|
|
||||||
errorCallback(new Error('Connection failed'));
|
|
||||||
|
|
||||||
await expect(connectPromise).rejects.toThrow('Connection failed');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should handle message parsing errors', async () => {
|
client.emit('error', new Error('Connection failed'));
|
||||||
const connectPromise = client.connect();
|
|
||||||
|
|
||||||
// Get and call the open callback
|
await expect(errorPromise).rejects.toThrow('Connection failed');
|
||||||
const openCallback = mockWebSocket.on.mock.calls.find(call => call[0] === 'open')?.[1];
|
|
||||||
if (!openCallback) throw new Error('Open callback not found');
|
|
||||||
openCallback();
|
|
||||||
|
|
||||||
// Get and call the message callback with invalid JSON
|
|
||||||
const messageCallback = mockWebSocket.on.mock.calls.find(call => call[0] === 'message')?.[1];
|
|
||||||
if (!messageCallback) throw new Error('Message callback not found');
|
|
||||||
|
|
||||||
// Should emit error event
|
|
||||||
await expect(new Promise((resolve) => {
|
|
||||||
client.once('error', resolve);
|
|
||||||
messageCallback('invalid json');
|
|
||||||
})).resolves.toBeInstanceOf(Error);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -180,12 +141,11 @@ describe('Home Assistant Integration', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const { HassInstanceImpl } = await import('../../src/hass/index.js');
|
instance = await get_hass();
|
||||||
instance = new HassInstanceImpl(mockBaseUrl, mockToken);
|
mock.restore();
|
||||||
jest.clearAllMocks();
|
|
||||||
|
|
||||||
// Mock successful fetch responses
|
// Mock successful fetch responses
|
||||||
mockFetch.mockImplementation(async (url, init) => {
|
mockFetch.mockImplementation(async (url) => {
|
||||||
if (url.toString().endsWith('/api/states')) {
|
if (url.toString().endsWith('/api/states')) {
|
||||||
return new Response(JSON.stringify([mockState]));
|
return new Response(JSON.stringify([mockState]));
|
||||||
}
|
}
|
||||||
@@ -200,12 +160,12 @@ describe('Home Assistant Integration', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('should create instance with correct properties', () => {
|
test('should create instance with correct properties', () => {
|
||||||
expect(instance['baseUrl']).toBe(mockBaseUrl);
|
expect(instance.baseUrl).toBe(mockBaseUrl);
|
||||||
expect(instance['token']).toBe(mockToken);
|
expect(instance.token).toBe(mockToken);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should fetch states', async () => {
|
test('should fetch states', async () => {
|
||||||
const states = await instance.fetchStates();
|
const states = await instance.getStates();
|
||||||
expect(states).toEqual([mockState]);
|
expect(states).toEqual([mockState]);
|
||||||
expect(mockFetch).toHaveBeenCalledWith(
|
expect(mockFetch).toHaveBeenCalledWith(
|
||||||
`${mockBaseUrl}/api/states`,
|
`${mockBaseUrl}/api/states`,
|
||||||
@@ -217,19 +177,6 @@ describe('Home Assistant Integration', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should fetch single state', async () => {
|
|
||||||
const state = await instance.fetchState('light.test');
|
|
||||||
expect(state).toEqual(mockState);
|
|
||||||
expect(mockFetch).toHaveBeenCalledWith(
|
|
||||||
`${mockBaseUrl}/api/states/light.test`,
|
|
||||||
expect.objectContaining({
|
|
||||||
headers: expect.objectContaining({
|
|
||||||
Authorization: `Bearer ${mockToken}`
|
|
||||||
})
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should call service', async () => {
|
test('should call service', async () => {
|
||||||
await instance.callService('light', 'turn_on', { entity_id: 'light.test' });
|
await instance.callService('light', 'turn_on', { entity_id: 'light.test' });
|
||||||
expect(mockFetch).toHaveBeenCalledWith(
|
expect(mockFetch).toHaveBeenCalledWith(
|
||||||
@@ -246,88 +193,10 @@ describe('Home Assistant Integration', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('should handle fetch errors', async () => {
|
test('should handle fetch errors', async () => {
|
||||||
mockFetch.mockRejectedValueOnce(new Error('Network error'));
|
mockFetch.mockImplementation(() => {
|
||||||
await expect(instance.fetchStates()).rejects.toThrow('Network error');
|
throw new Error('Network error');
|
||||||
});
|
});
|
||||||
|
await expect(instance.getStates()).rejects.toThrow('Network error');
|
||||||
test('should handle invalid JSON responses', async () => {
|
|
||||||
mockFetch.mockResolvedValueOnce(new Response('invalid json'));
|
|
||||||
await expect(instance.fetchStates()).rejects.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should handle non-200 responses', async () => {
|
|
||||||
mockFetch.mockResolvedValueOnce(new Response('Error', { status: 500 }));
|
|
||||||
await expect(instance.fetchStates()).rejects.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Event Subscription', () => {
|
|
||||||
let eventCallback: (event: HassEvent) => void;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
eventCallback = mock();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should subscribe to events', async () => {
|
|
||||||
const subscriptionId = await instance.subscribeEvents(eventCallback);
|
|
||||||
expect(typeof subscriptionId).toBe('number');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should unsubscribe from events', async () => {
|
|
||||||
const subscriptionId = await instance.subscribeEvents(eventCallback);
|
|
||||||
await instance.unsubscribeEvents(subscriptionId);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('get_hass', () => {
|
|
||||||
const originalEnv = process.env;
|
|
||||||
|
|
||||||
const createMockServices = (): MockHassServices => ({
|
|
||||||
light: {},
|
|
||||||
climate: {},
|
|
||||||
switch: {},
|
|
||||||
media_player: {}
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
process.env = { ...originalEnv };
|
|
||||||
process.env.HASS_HOST = 'http://localhost:8123';
|
|
||||||
process.env.HASS_TOKEN = 'test_token';
|
|
||||||
|
|
||||||
// Reset the mock implementation
|
|
||||||
(get_hass as jest.MockedFunction<typeof get_hass>).mockImplementation(async () => {
|
|
||||||
const actual = jest.requireActual<typeof import('../../src/hass/index.js')>('../../src/hass/index.js');
|
|
||||||
const baseUrl = process.env.HASS_HOST || 'http://localhost:8123';
|
|
||||||
const token = process.env.HASS_TOKEN || 'test_token';
|
|
||||||
const instance = new actual.HassInstanceImpl(baseUrl, token) as TestHassInstance;
|
|
||||||
instance._baseUrl = baseUrl;
|
|
||||||
instance._token = token;
|
|
||||||
return instance;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
process.env = originalEnv;
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should create instance with default configuration', async () => {
|
|
||||||
const instance = await get_hass() as TestHassInstance;
|
|
||||||
expect(instance._baseUrl).toBe('http://localhost:8123');
|
|
||||||
expect(instance._token).toBe('test_token');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should reuse existing instance', async () => {
|
|
||||||
const instance1 = await get_hass();
|
|
||||||
const instance2 = await get_hass();
|
|
||||||
expect(instance1).toBe(instance2);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should use custom configuration', async () => {
|
|
||||||
process.env.HASS_HOST = 'https://hass.example.com';
|
|
||||||
process.env.HASS_TOKEN = 'prod_token';
|
|
||||||
const instance = await get_hass() as TestHassInstance;
|
|
||||||
expect(instance._baseUrl).toBe('https://hass.example.com');
|
|
||||||
expect(instance._token).toBe('prod_token');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,13 +1,12 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import { entitySchema, serviceSchema, stateChangedEventSchema, configSchema, automationSchema, deviceControlSchema } from '../../src/schemas/hass.js';
|
import {
|
||||||
import Ajv from 'ajv';
|
validateEntity,
|
||||||
import { describe, expect, test } from "bun:test";
|
validateService,
|
||||||
|
validateStateChangedEvent,
|
||||||
const ajv = new Ajv();
|
validateConfig,
|
||||||
|
validateAutomation,
|
||||||
// Create validation functions for each schema
|
validateDeviceControl
|
||||||
const validateEntity = ajv.compile(entitySchema);
|
} from '../../src/schemas/hass.js';
|
||||||
const validateService = ajv.compile(serviceSchema);
|
|
||||||
|
|
||||||
describe('Home Assistant Schemas', () => {
|
describe('Home Assistant Schemas', () => {
|
||||||
describe('Entity Schema', () => {
|
describe('Entity Schema', () => {
|
||||||
@@ -17,7 +16,7 @@ describe('Home Assistant Schemas', () => {
|
|||||||
state: 'on',
|
state: 'on',
|
||||||
attributes: {
|
attributes: {
|
||||||
brightness: 255,
|
brightness: 255,
|
||||||
friendly_name: 'Living Room Light'
|
color_temp: 300
|
||||||
},
|
},
|
||||||
last_changed: '2024-01-01T00:00:00Z',
|
last_changed: '2024-01-01T00:00:00Z',
|
||||||
last_updated: '2024-01-01T00:00:00Z',
|
last_updated: '2024-01-01T00:00:00Z',
|
||||||
@@ -27,17 +26,17 @@ describe('Home Assistant Schemas', () => {
|
|||||||
user_id: null
|
user_id: null
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
expect(validateEntity(validEntity)).toBe(true);
|
const result = validateEntity(validEntity);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should reject entity with missing required fields', () => {
|
test('should reject entity with missing required fields', () => {
|
||||||
const invalidEntity = {
|
const invalidEntity = {
|
||||||
entity_id: 'light.living_room',
|
state: 'on',
|
||||||
state: 'on'
|
attributes: {}
|
||||||
// missing attributes, last_changed, last_updated, context
|
|
||||||
};
|
};
|
||||||
expect(validateEntity(invalidEntity)).toBe(false);
|
const result = validateEntity(invalidEntity);
|
||||||
expect(validateEntity.errors).toBeDefined();
|
expect(result.success).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should validate entity with additional attributes', () => {
|
test('should validate entity with additional attributes', () => {
|
||||||
@@ -45,8 +44,9 @@ describe('Home Assistant Schemas', () => {
|
|||||||
entity_id: 'light.living_room',
|
entity_id: 'light.living_room',
|
||||||
state: 'on',
|
state: 'on',
|
||||||
attributes: {
|
attributes: {
|
||||||
brightness: 100,
|
brightness: 255,
|
||||||
color_mode: 'brightness'
|
color_temp: 300,
|
||||||
|
custom_attr: 'value'
|
||||||
},
|
},
|
||||||
last_changed: '2024-01-01T00:00:00Z',
|
last_changed: '2024-01-01T00:00:00Z',
|
||||||
last_updated: '2024-01-01T00:00:00Z',
|
last_updated: '2024-01-01T00:00:00Z',
|
||||||
@@ -56,12 +56,13 @@ describe('Home Assistant Schemas', () => {
|
|||||||
user_id: null
|
user_id: null
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
expect(validateEntity(validEntity)).toBe(true);
|
const result = validateEntity(validEntity);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should reject invalid entity_id format', () => {
|
test('should reject invalid entity_id format', () => {
|
||||||
const invalidEntity = {
|
const invalidEntity = {
|
||||||
entity_id: 'invalid_entity',
|
entity_id: 'invalid_format',
|
||||||
state: 'on',
|
state: 'on',
|
||||||
attributes: {},
|
attributes: {},
|
||||||
last_changed: '2024-01-01T00:00:00Z',
|
last_changed: '2024-01-01T00:00:00Z',
|
||||||
@@ -72,7 +73,8 @@ describe('Home Assistant Schemas', () => {
|
|||||||
user_id: null
|
user_id: null
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
expect(validateEntity(invalidEntity)).toBe(false);
|
const result = validateEntity(invalidEntity);
|
||||||
|
expect(result.success).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -82,13 +84,14 @@ describe('Home Assistant Schemas', () => {
|
|||||||
domain: 'light',
|
domain: 'light',
|
||||||
service: 'turn_on',
|
service: 'turn_on',
|
||||||
target: {
|
target: {
|
||||||
entity_id: ['light.living_room']
|
entity_id: 'light.living_room'
|
||||||
},
|
},
|
||||||
service_data: {
|
service_data: {
|
||||||
brightness_pct: 100
|
brightness_pct: 100
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
expect(validateService(basicService)).toBe(true);
|
const result = validateService(basicService);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should validate service call with multiple targets', () => {
|
test('should validate service call with multiple targets', () => {
|
||||||
@@ -96,15 +99,14 @@ describe('Home Assistant Schemas', () => {
|
|||||||
domain: 'light',
|
domain: 'light',
|
||||||
service: 'turn_on',
|
service: 'turn_on',
|
||||||
target: {
|
target: {
|
||||||
entity_id: ['light.living_room', 'light.kitchen'],
|
entity_id: ['light.living_room', 'light.kitchen']
|
||||||
device_id: ['device123', 'device456'],
|
|
||||||
area_id: ['living_room', 'kitchen']
|
|
||||||
},
|
},
|
||||||
service_data: {
|
service_data: {
|
||||||
brightness_pct: 100
|
brightness_pct: 100
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
expect(validateService(multiTargetService)).toBe(true);
|
const result = validateService(multiTargetService);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should validate service call without targets', () => {
|
test('should validate service call without targets', () => {
|
||||||
@@ -112,7 +114,8 @@ describe('Home Assistant Schemas', () => {
|
|||||||
domain: 'homeassistant',
|
domain: 'homeassistant',
|
||||||
service: 'restart'
|
service: 'restart'
|
||||||
};
|
};
|
||||||
expect(validateService(noTargetService)).toBe(true);
|
const result = validateService(noTargetService);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should reject service call with invalid target type', () => {
|
test('should reject service call with invalid target type', () => {
|
||||||
@@ -120,57 +123,37 @@ describe('Home Assistant Schemas', () => {
|
|||||||
domain: 'light',
|
domain: 'light',
|
||||||
service: 'turn_on',
|
service: 'turn_on',
|
||||||
target: {
|
target: {
|
||||||
entity_id: 'not_an_array' // should be an array
|
entity_id: 123 // Invalid type
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
expect(validateService(invalidService)).toBe(false);
|
const result = validateService(invalidService);
|
||||||
expect(validateService.errors).toBeDefined();
|
expect(result.success).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should reject service call with invalid domain', () => {
|
test('should reject service call with invalid domain', () => {
|
||||||
const invalidService = {
|
const invalidService = {
|
||||||
domain: 'invalid_domain',
|
domain: '',
|
||||||
service: 'turn_on',
|
service: 'turn_on'
|
||||||
target: {
|
|
||||||
entity_id: ['light.living_room']
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
expect(validateService(invalidService)).toBe(false);
|
const result = validateService(invalidService);
|
||||||
|
expect(result.success).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('State Changed Event Schema', () => {
|
describe('State Changed Event Schema', () => {
|
||||||
const validate = ajv.compile(stateChangedEventSchema);
|
|
||||||
|
|
||||||
test('should validate a valid state changed event', () => {
|
test('should validate a valid state changed event', () => {
|
||||||
const validEvent = {
|
const validEvent = {
|
||||||
event_type: 'state_changed',
|
event_type: 'state_changed',
|
||||||
data: {
|
data: {
|
||||||
entity_id: 'light.living_room',
|
entity_id: 'light.living_room',
|
||||||
|
old_state: {
|
||||||
|
state: 'off',
|
||||||
|
attributes: {}
|
||||||
|
},
|
||||||
new_state: {
|
new_state: {
|
||||||
entity_id: 'light.living_room',
|
|
||||||
state: 'on',
|
state: 'on',
|
||||||
attributes: {
|
attributes: {
|
||||||
brightness: 255
|
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -182,7 +165,8 @@ describe('Home Assistant Schemas', () => {
|
|||||||
user_id: null
|
user_id: null
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
expect(validate(validEvent)).toBe(true);
|
const result = validateStateChangedEvent(validEvent);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should validate event with null old_state', () => {
|
test('should validate event with null old_state', () => {
|
||||||
@@ -190,20 +174,12 @@ describe('Home Assistant Schemas', () => {
|
|||||||
event_type: 'state_changed',
|
event_type: 'state_changed',
|
||||||
data: {
|
data: {
|
||||||
entity_id: 'light.living_room',
|
entity_id: 'light.living_room',
|
||||||
|
old_state: null,
|
||||||
new_state: {
|
new_state: {
|
||||||
entity_id: 'light.living_room',
|
|
||||||
state: 'on',
|
state: 'on',
|
||||||
attributes: {},
|
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',
|
origin: 'LOCAL',
|
||||||
time_fired: '2024-01-01T00:00:00Z',
|
time_fired: '2024-01-01T00:00:00Z',
|
||||||
context: {
|
context: {
|
||||||
@@ -212,7 +188,8 @@ describe('Home Assistant Schemas', () => {
|
|||||||
user_id: null
|
user_id: null
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
expect(validate(newEntityEvent)).toBe(true);
|
const result = validateStateChangedEvent(newEntityEvent);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should reject event with invalid event_type', () => {
|
test('should reject event with invalid event_type', () => {
|
||||||
@@ -220,278 +197,62 @@ describe('Home Assistant Schemas', () => {
|
|||||||
event_type: 'wrong_type',
|
event_type: 'wrong_type',
|
||||||
data: {
|
data: {
|
||||||
entity_id: 'light.living_room',
|
entity_id: 'light.living_room',
|
||||||
new_state: null,
|
old_state: null,
|
||||||
old_state: null
|
new_state: {
|
||||||
},
|
state: 'on',
|
||||||
origin: 'LOCAL',
|
attributes: {}
|
||||||
time_fired: '2024-01-01T00:00:00Z',
|
}
|
||||||
context: {
|
|
||||||
id: '123456',
|
|
||||||
parent_id: null,
|
|
||||||
user_id: null
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
expect(validate(invalidEvent)).toBe(false);
|
const result = validateStateChangedEvent(invalidEvent);
|
||||||
expect(validate.errors).toBeDefined();
|
expect(result.success).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Config Schema', () => {
|
describe('Config Schema', () => {
|
||||||
const validate = ajv.compile(configSchema);
|
|
||||||
|
|
||||||
test('should validate a minimal config', () => {
|
test('should validate a minimal config', () => {
|
||||||
const minimalConfig = {
|
const minimalConfig = {
|
||||||
latitude: 52.3731,
|
|
||||||
longitude: 4.8922,
|
|
||||||
elevation: 0,
|
|
||||||
unit_system: {
|
|
||||||
length: 'km',
|
|
||||||
mass: 'kg',
|
|
||||||
temperature: '°C',
|
|
||||||
volume: 'L'
|
|
||||||
},
|
|
||||||
location_name: 'Home',
|
location_name: 'Home',
|
||||||
time_zone: 'Europe/Amsterdam',
|
time_zone: 'Europe/Amsterdam',
|
||||||
components: ['homeassistant'],
|
components: ['homeassistant'],
|
||||||
version: '2024.1.0'
|
version: '2024.1.0'
|
||||||
};
|
};
|
||||||
expect(validate(minimalConfig)).toBe(true);
|
const result = validateConfig(minimalConfig);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should reject config with missing required fields', () => {
|
test('should reject config with missing required fields', () => {
|
||||||
const invalidConfig = {
|
const invalidConfig = {
|
||||||
latitude: 52.3731,
|
location_name: 'Home'
|
||||||
longitude: 4.8922
|
|
||||||
// missing other required fields
|
|
||||||
};
|
};
|
||||||
expect(validate(invalidConfig)).toBe(false);
|
const result = validateConfig(invalidConfig);
|
||||||
expect(validate.errors).toBeDefined();
|
expect(result.success).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should reject config with invalid types', () => {
|
test('should reject config with invalid types', () => {
|
||||||
const invalidConfig = {
|
const invalidConfig = {
|
||||||
latitude: '52.3731', // should be number
|
location_name: 123,
|
||||||
longitude: 4.8922,
|
|
||||||
elevation: 0,
|
|
||||||
unit_system: {
|
|
||||||
length: 'km',
|
|
||||||
mass: 'kg',
|
|
||||||
temperature: '°C',
|
|
||||||
volume: 'L'
|
|
||||||
},
|
|
||||||
location_name: 'Home',
|
|
||||||
time_zone: 'Europe/Amsterdam',
|
time_zone: 'Europe/Amsterdam',
|
||||||
components: ['homeassistant'],
|
components: 'not_an_array',
|
||||||
version: '2024.1.0'
|
version: '2024.1.0'
|
||||||
};
|
};
|
||||||
expect(validate(invalidConfig)).toBe(false);
|
const result = validateConfig(invalidConfig);
|
||||||
expect(validate.errors).toBeDefined();
|
expect(result.success).toBe(false);
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Automation Schema', () => {
|
|
||||||
const validate = ajv.compile(automationSchema);
|
|
||||||
|
|
||||||
test('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);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('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);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('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);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('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();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('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', () => {
|
describe('Device Control Schema', () => {
|
||||||
const validate = ajv.compile(deviceControlSchema);
|
|
||||||
|
|
||||||
test('should validate light control command', () => {
|
test('should validate light control command', () => {
|
||||||
const lightCommand = {
|
const command = {
|
||||||
domain: 'light',
|
domain: 'light',
|
||||||
command: 'turn_on',
|
command: 'turn_on',
|
||||||
entity_id: 'light.living_room',
|
entity_id: 'light.living_room',
|
||||||
parameters: {
|
parameters: {
|
||||||
brightness: 255,
|
brightness_pct: 100
|
||||||
color_temp: 400,
|
|
||||||
transition: 2
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
expect(validate(lightCommand)).toBe(true);
|
const result = validateDeviceControl(command);
|
||||||
});
|
expect(result.success).toBe(true);
|
||||||
|
|
||||||
test('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);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('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);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('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);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('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();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should reject command with mismatched domain and entity_id', () => {
|
test('should reject command with mismatched domain and entity_id', () => {
|
||||||
@@ -500,46 +261,18 @@ describe('Home Assistant Schemas', () => {
|
|||||||
command: 'turn_on',
|
command: 'turn_on',
|
||||||
entity_id: 'switch.living_room' // mismatched domain
|
entity_id: 'switch.living_room' // mismatched domain
|
||||||
};
|
};
|
||||||
expect(validate(mismatchedCommand)).toBe(false);
|
const result = validateDeviceControl(mismatchedCommand);
|
||||||
|
expect(result.success).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should validate command with array of entity_ids', () => {
|
test('should validate command with array of entity_ids', () => {
|
||||||
const multiEntityCommand = {
|
const command = {
|
||||||
domain: 'light',
|
domain: 'light',
|
||||||
command: 'turn_on',
|
command: 'turn_on',
|
||||||
entity_id: ['light.living_room', 'light.kitchen'],
|
entity_id: ['light.living_room', 'light.kitchen']
|
||||||
parameters: {
|
|
||||||
brightness: 255
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
expect(validate(multiEntityCommand)).toBe(true);
|
const result = validateDeviceControl(command);
|
||||||
});
|
expect(result.success).toBe(true);
|
||||||
|
|
||||||
test('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);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('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);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
[test]
|
[test]
|
||||||
preload = ["./src/__tests__/setup.ts"]
|
preload = ["./test/setup.ts"]
|
||||||
coverage = true
|
coverage = true
|
||||||
coverageThreshold = {
|
coverageThreshold = {
|
||||||
statements = 80,
|
statements = 80,
|
||||||
@@ -7,7 +7,7 @@ coverageThreshold = {
|
|||||||
functions = 80,
|
functions = 80,
|
||||||
lines = 80
|
lines = 80
|
||||||
}
|
}
|
||||||
timeout = 30000
|
timeout = 10000
|
||||||
testMatch = ["**/__tests__/**/*.test.ts"]
|
testMatch = ["**/__tests__/**/*.test.ts"]
|
||||||
testPathIgnorePatterns = ["/node_modules/", "/dist/"]
|
testPathIgnorePatterns = ["/node_modules/", "/dist/"]
|
||||||
collectCoverageFrom = [
|
collectCoverageFrom = [
|
||||||
@@ -48,3 +48,6 @@ reload = true
|
|||||||
[performance]
|
[performance]
|
||||||
gc = true
|
gc = true
|
||||||
optimize = true
|
optimize = true
|
||||||
|
|
||||||
|
[test.env]
|
||||||
|
NODE_ENV = "test"
|
||||||
118
docker-build.sh
118
docker-build.sh
@@ -3,16 +3,52 @@
|
|||||||
# Enable error handling
|
# Enable error handling
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Colors for output
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
# Function to print colored messages
|
||||||
|
print_message() {
|
||||||
|
local color=$1
|
||||||
|
local message=$2
|
||||||
|
echo -e "${color}${message}${NC}"
|
||||||
|
}
|
||||||
|
|
||||||
# Function to clean up on script exit
|
# Function to clean up on script exit
|
||||||
cleanup() {
|
cleanup() {
|
||||||
echo "Cleaning up..."
|
print_message "$YELLOW" "Cleaning up..."
|
||||||
docker builder prune -f --filter until=24h
|
docker builder prune -f --filter until=24h
|
||||||
docker image prune -f
|
docker image prune -f
|
||||||
}
|
}
|
||||||
trap cleanup EXIT
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
# Parse command line arguments
|
||||||
|
ENABLE_SPEECH=false
|
||||||
|
ENABLE_GPU=false
|
||||||
|
BUILD_TYPE="standard"
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case $1 in
|
||||||
|
--speech)
|
||||||
|
ENABLE_SPEECH=true
|
||||||
|
BUILD_TYPE="speech"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--gpu)
|
||||||
|
ENABLE_GPU=true
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
print_message "$RED" "Unknown option: $1"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
# Clean up Docker system
|
# Clean up Docker system
|
||||||
echo "Cleaning up Docker system..."
|
print_message "$YELLOW" "Cleaning up Docker system..."
|
||||||
docker system prune -f --volumes
|
docker system prune -f --volumes
|
||||||
|
|
||||||
# Set build arguments for better performance
|
# Set build arguments for better performance
|
||||||
@@ -26,23 +62,47 @@ BUILD_MEM=$(( TOTAL_MEM / 2 )) # Use half of available memory
|
|||||||
CPU_COUNT=$(nproc)
|
CPU_COUNT=$(nproc)
|
||||||
CPU_QUOTA=$(( CPU_COUNT * 50000 )) # Allow 50% CPU usage per core
|
CPU_QUOTA=$(( CPU_COUNT * 50000 )) # Allow 50% CPU usage per core
|
||||||
|
|
||||||
echo "Building with ${BUILD_MEM}MB memory limit and CPU quota ${CPU_QUOTA}"
|
print_message "$YELLOW" "Building with ${BUILD_MEM}MB memory limit and CPU quota ${CPU_QUOTA}"
|
||||||
|
|
||||||
# Remove any existing lockfile
|
# Remove any existing lockfile
|
||||||
rm -f bun.lockb
|
rm -f bun.lockb
|
||||||
|
|
||||||
# Build with resource limits, optimizations, and timeout
|
# Base build arguments
|
||||||
echo "Building Docker image..."
|
BUILD_ARGS=(
|
||||||
|
--memory="${BUILD_MEM}m"
|
||||||
|
--memory-swap="${BUILD_MEM}m"
|
||||||
|
--cpu-quota="${CPU_QUOTA}"
|
||||||
|
--build-arg BUILDKIT_INLINE_CACHE=1
|
||||||
|
--build-arg DOCKER_BUILDKIT=1
|
||||||
|
--build-arg NODE_ENV=production
|
||||||
|
--progress=plain
|
||||||
|
--no-cache
|
||||||
|
--compress
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add speech-specific build arguments if enabled
|
||||||
|
if [ "$ENABLE_SPEECH" = true ]; then
|
||||||
|
BUILD_ARGS+=(
|
||||||
|
--build-arg ENABLE_SPEECH_FEATURES=true
|
||||||
|
--build-arg ENABLE_WAKE_WORD=true
|
||||||
|
--build-arg ENABLE_SPEECH_TO_TEXT=true
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add GPU support if requested
|
||||||
|
if [ "$ENABLE_GPU" = true ]; then
|
||||||
|
BUILD_ARGS+=(
|
||||||
|
--build-arg CUDA_VISIBLE_DEVICES=0
|
||||||
|
--build-arg COMPUTE_TYPE=float16
|
||||||
|
)
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Build the images
|
||||||
|
print_message "$YELLOW" "Building Docker image (${BUILD_TYPE} build)..."
|
||||||
|
|
||||||
|
# Build main image
|
||||||
DOCKER_BUILDKIT=1 docker build \
|
DOCKER_BUILDKIT=1 docker build \
|
||||||
--memory="${BUILD_MEM}m" \
|
"${BUILD_ARGS[@]}" \
|
||||||
--memory-swap="${BUILD_MEM}m" \
|
|
||||||
--cpu-quota="${CPU_QUOTA}" \
|
|
||||||
--build-arg BUILDKIT_INLINE_CACHE=1 \
|
|
||||||
--build-arg DOCKER_BUILDKIT=1 \
|
|
||||||
--build-arg NODE_ENV=production \
|
|
||||||
--progress=plain \
|
|
||||||
--no-cache \
|
|
||||||
--compress \
|
|
||||||
-t homeassistant-mcp:latest \
|
-t homeassistant-mcp:latest \
|
||||||
-t homeassistant-mcp:$(date +%Y%m%d) \
|
-t homeassistant-mcp:$(date +%Y%m%d) \
|
||||||
.
|
.
|
||||||
@@ -50,15 +110,39 @@ DOCKER_BUILDKIT=1 docker build \
|
|||||||
# Check if build was successful
|
# Check if build was successful
|
||||||
BUILD_EXIT_CODE=$?
|
BUILD_EXIT_CODE=$?
|
||||||
if [ $BUILD_EXIT_CODE -eq 124 ]; then
|
if [ $BUILD_EXIT_CODE -eq 124 ]; then
|
||||||
echo "Build timed out after 15 minutes!"
|
print_message "$RED" "Build timed out after 15 minutes!"
|
||||||
exit 1
|
exit 1
|
||||||
elif [ $BUILD_EXIT_CODE -ne 0 ]; then
|
elif [ $BUILD_EXIT_CODE -ne 0 ]; then
|
||||||
echo "Build failed with exit code ${BUILD_EXIT_CODE}!"
|
print_message "$RED" "Build failed with exit code ${BUILD_EXIT_CODE}!"
|
||||||
exit 1
|
exit 1
|
||||||
else
|
else
|
||||||
echo "Build completed successfully!"
|
print_message "$GREEN" "Main image build completed successfully!"
|
||||||
|
|
||||||
# Show image size and layers
|
# Show image size and layers
|
||||||
docker image ls homeassistant-mcp:latest --format "Image size: {{.Size}}"
|
docker image ls homeassistant-mcp:latest --format "Image size: {{.Size}}"
|
||||||
echo "Layer count: $(docker history homeassistant-mcp:latest | wc -l)"
|
echo "Layer count: $(docker history homeassistant-mcp:latest | wc -l)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Build speech-related images if enabled
|
||||||
|
if [ "$ENABLE_SPEECH" = true ]; then
|
||||||
|
print_message "$YELLOW" "Building speech-related images..."
|
||||||
|
|
||||||
|
# Build fast-whisper image
|
||||||
|
print_message "$YELLOW" "Building fast-whisper image..."
|
||||||
|
docker pull onerahmet/openai-whisper-asr-webservice:latest
|
||||||
|
|
||||||
|
# Build wake-word image
|
||||||
|
print_message "$YELLOW" "Building wake-word image..."
|
||||||
|
docker pull rhasspy/wyoming-openwakeword:latest
|
||||||
|
|
||||||
|
print_message "$GREEN" "Speech-related images built successfully!"
|
||||||
|
fi
|
||||||
|
|
||||||
|
print_message "$GREEN" "All builds completed successfully!"
|
||||||
|
|
||||||
|
# Show final status
|
||||||
|
print_message "$YELLOW" "Build Summary:"
|
||||||
|
echo "Build Type: $BUILD_TYPE"
|
||||||
|
echo "Speech Features: $([ "$ENABLE_SPEECH" = true ] && echo 'Enabled' || echo 'Disabled')"
|
||||||
|
echo "GPU Support: $([ "$ENABLE_GPU" = true ] && echo 'Enabled' || echo 'Disabled')"
|
||||||
|
docker image ls | grep -E 'homeassistant-mcp|whisper|openwakeword'
|
||||||
@@ -2,6 +2,7 @@ version: '3.8'
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
homeassistant-mcp:
|
homeassistant-mcp:
|
||||||
|
image: homeassistant-mcp:latest
|
||||||
environment:
|
environment:
|
||||||
- ENABLE_SPEECH_FEATURES=${ENABLE_SPEECH_FEATURES:-true}
|
- ENABLE_SPEECH_FEATURES=${ENABLE_SPEECH_FEATURES:-true}
|
||||||
- ENABLE_WAKE_WORD=${ENABLE_WAKE_WORD:-true}
|
- ENABLE_WAKE_WORD=${ENABLE_WAKE_WORD:-true}
|
||||||
@@ -26,7 +27,7 @@ services:
|
|||||||
cpus: '4.0'
|
cpus: '4.0'
|
||||||
memory: 2G
|
memory: 2G
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: [ "CMD", "curl", "-f", "http://localhost:9000/health" ]
|
test: [ "CMD", "curl", "-f", "http://localhost:9000/asr/health" ]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|||||||
@@ -5,6 +5,251 @@ parent: Getting Started
|
|||||||
nav_order: 3
|
nav_order: 3
|
||||||
---
|
---
|
||||||
|
|
||||||
# Docker Deployment Guide 🐳
|
# Docker Setup Guide 🐳
|
||||||
|
|
||||||
Detailed guide for deploying MCP Server with Docker...
|
## Overview
|
||||||
|
|
||||||
|
I've designed the MCP server to run efficiently in Docker containers, with support for different configurations including speech processing and GPU acceleration.
|
||||||
|
|
||||||
|
## Build Options 🛠️
|
||||||
|
|
||||||
|
### 1. Standard Build
|
||||||
|
```bash
|
||||||
|
./docker-build.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
This build includes:
|
||||||
|
- Core MCP server functionality
|
||||||
|
- REST API endpoints
|
||||||
|
- WebSocket/SSE support
|
||||||
|
- Basic automation features
|
||||||
|
|
||||||
|
Resource usage:
|
||||||
|
- Memory: 50% of available RAM
|
||||||
|
- CPU: 50% per core
|
||||||
|
- Disk: ~200MB
|
||||||
|
|
||||||
|
### 2. Speech-Enabled Build
|
||||||
|
```bash
|
||||||
|
./docker-build.sh --speech
|
||||||
|
```
|
||||||
|
|
||||||
|
Additional features:
|
||||||
|
- Wake word detection
|
||||||
|
- Speech-to-text processing
|
||||||
|
- Multiple language support
|
||||||
|
|
||||||
|
Required images:
|
||||||
|
```bash
|
||||||
|
onerahmet/openai-whisper-asr-webservice:latest # Speech-to-text
|
||||||
|
rhasspy/wyoming-openwakeword:latest # Wake word detection
|
||||||
|
```
|
||||||
|
|
||||||
|
Resource requirements:
|
||||||
|
- Memory: 2GB minimum
|
||||||
|
- CPU: 2 cores minimum
|
||||||
|
- Disk: ~2GB
|
||||||
|
|
||||||
|
### 3. GPU-Accelerated Build
|
||||||
|
```bash
|
||||||
|
./docker-build.sh --speech --gpu
|
||||||
|
```
|
||||||
|
|
||||||
|
Enhanced features:
|
||||||
|
- CUDA GPU acceleration
|
||||||
|
- Float16 compute type
|
||||||
|
- Optimized performance
|
||||||
|
- Faster speech processing
|
||||||
|
|
||||||
|
Requirements:
|
||||||
|
- NVIDIA GPU
|
||||||
|
- CUDA drivers
|
||||||
|
- nvidia-docker runtime
|
||||||
|
|
||||||
|
## Docker Compose Files 📄
|
||||||
|
|
||||||
|
### 1. Base Configuration (`docker-compose.yml`)
|
||||||
|
```yaml
|
||||||
|
version: '3.8'
|
||||||
|
services:
|
||||||
|
homeassistant-mcp:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "${HOST_PORT:-4000}:4000"
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
- .env.${NODE_ENV:-development}
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=${NODE_ENV:-development}
|
||||||
|
- PORT=4000
|
||||||
|
- HASS_HOST
|
||||||
|
- HASS_TOKEN
|
||||||
|
- LOG_LEVEL=${LOG_LEVEL:-info}
|
||||||
|
volumes:
|
||||||
|
- .:/app
|
||||||
|
- /app/node_modules
|
||||||
|
- logs:/app/logs
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Speech Support (`docker-compose.speech.yml`)
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
homeassistant-mcp:
|
||||||
|
environment:
|
||||||
|
- ENABLE_SPEECH_FEATURES=true
|
||||||
|
- ENABLE_WAKE_WORD=true
|
||||||
|
- ENABLE_SPEECH_TO_TEXT=true
|
||||||
|
|
||||||
|
fast-whisper:
|
||||||
|
image: onerahmet/openai-whisper-asr-webservice:latest
|
||||||
|
volumes:
|
||||||
|
- whisper-models:/models
|
||||||
|
- audio-data:/audio
|
||||||
|
|
||||||
|
wake-word:
|
||||||
|
image: rhasspy/wyoming-openwakeword:latest
|
||||||
|
devices:
|
||||||
|
- /dev/snd:/dev/snd
|
||||||
|
```
|
||||||
|
|
||||||
|
## Launch Commands 🚀
|
||||||
|
|
||||||
|
### Standard Launch
|
||||||
|
```bash
|
||||||
|
# Build and start
|
||||||
|
./docker-build.sh
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
# View logs
|
||||||
|
docker compose logs -f
|
||||||
|
|
||||||
|
# Stop services
|
||||||
|
docker compose down
|
||||||
|
```
|
||||||
|
|
||||||
|
### With Speech Features
|
||||||
|
```bash
|
||||||
|
# Build with speech support
|
||||||
|
./docker-build.sh --speech
|
||||||
|
|
||||||
|
# Start all services
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.speech.yml up -d
|
||||||
|
|
||||||
|
# View specific service logs
|
||||||
|
docker compose logs -f fast-whisper
|
||||||
|
docker compose logs -f wake-word
|
||||||
|
```
|
||||||
|
|
||||||
|
### With GPU Support
|
||||||
|
```bash
|
||||||
|
# Build with GPU acceleration
|
||||||
|
./docker-build.sh --speech --gpu
|
||||||
|
|
||||||
|
# Start with GPU support
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.speech.yml \
|
||||||
|
--env-file .env.gpu up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
## Resource Management 📊
|
||||||
|
|
||||||
|
The build script automatically manages resources:
|
||||||
|
|
||||||
|
1. **Memory Allocation**
|
||||||
|
```bash
|
||||||
|
TOTAL_MEM=$(free -m | awk '/^Mem:/{print $2}')
|
||||||
|
BUILD_MEM=$(( TOTAL_MEM / 2 ))
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **CPU Management**
|
||||||
|
```bash
|
||||||
|
CPU_COUNT=$(nproc)
|
||||||
|
CPU_QUOTA=$(( CPU_COUNT * 50000 ))
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Build Arguments**
|
||||||
|
```bash
|
||||||
|
BUILD_ARGS=(
|
||||||
|
--memory="${BUILD_MEM}m"
|
||||||
|
--memory-swap="${BUILD_MEM}m"
|
||||||
|
--cpu-quota="${CPU_QUOTA}"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting 🔧
|
||||||
|
|
||||||
|
### Common Issues
|
||||||
|
|
||||||
|
1. **Build Failures**
|
||||||
|
- Check system resources
|
||||||
|
- Verify Docker daemon is running
|
||||||
|
- Ensure network connectivity
|
||||||
|
- Review build logs
|
||||||
|
|
||||||
|
2. **Speech Processing Issues**
|
||||||
|
- Verify audio device permissions
|
||||||
|
- Check CUDA installation (for GPU)
|
||||||
|
- Monitor resource usage
|
||||||
|
- Review service logs
|
||||||
|
|
||||||
|
3. **Performance Problems**
|
||||||
|
- Adjust resource limits
|
||||||
|
- Consider GPU acceleration
|
||||||
|
- Monitor container stats
|
||||||
|
- Check for resource conflicts
|
||||||
|
|
||||||
|
### Debug Commands
|
||||||
|
```bash
|
||||||
|
# Check container status
|
||||||
|
docker compose ps
|
||||||
|
|
||||||
|
# View resource usage
|
||||||
|
docker stats
|
||||||
|
|
||||||
|
# Check logs
|
||||||
|
docker compose logs --tail=100
|
||||||
|
|
||||||
|
# Inspect configuration
|
||||||
|
docker compose config
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices 🎯
|
||||||
|
|
||||||
|
1. **Resource Management**
|
||||||
|
- Monitor container resources
|
||||||
|
- Set appropriate limits
|
||||||
|
- Use GPU when available
|
||||||
|
- Regular cleanup
|
||||||
|
|
||||||
|
2. **Security**
|
||||||
|
- Use non-root users
|
||||||
|
- Limit container capabilities
|
||||||
|
- Regular security updates
|
||||||
|
- Proper secret management
|
||||||
|
|
||||||
|
3. **Maintenance**
|
||||||
|
- Regular image updates
|
||||||
|
- Log rotation
|
||||||
|
- Resource cleanup
|
||||||
|
- Performance monitoring
|
||||||
|
|
||||||
|
## Advanced Configuration ⚙️
|
||||||
|
|
||||||
|
### Custom Build Arguments
|
||||||
|
```bash
|
||||||
|
# Example: Custom memory limits
|
||||||
|
BUILD_MEM=4096 ./docker-build.sh --speech
|
||||||
|
|
||||||
|
# Example: Specific CUDA device
|
||||||
|
CUDA_VISIBLE_DEVICES=1 ./docker-build.sh --speech --gpu
|
||||||
|
```
|
||||||
|
|
||||||
|
### Environment Overrides
|
||||||
|
```bash
|
||||||
|
# Production settings
|
||||||
|
NODE_ENV=production ./docker-build.sh
|
||||||
|
|
||||||
|
# Custom port
|
||||||
|
HOST_PORT=5000 docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
See [Configuration Guide](../configuration.md) for more environment options.
|
||||||
@@ -1,9 +1,15 @@
|
|||||||
import { SpeechToText, TranscriptionResult, WakeWordEvent } from '../src/speech/speechToText';
|
import { SpeechToText, TranscriptionResult, WakeWordEvent } from '../src/speech/speechToText';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
import recorder from 'node-record-lpcm16';
|
||||||
|
import { Writable } from 'stream';
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
// Initialize the speech-to-text service
|
// Initialize the speech-to-text service
|
||||||
const speech = new SpeechToText('fast-whisper');
|
const speech = new SpeechToText({
|
||||||
|
modelPath: 'base.en',
|
||||||
|
modelType: 'whisper',
|
||||||
|
containerName: 'fast-whisper'
|
||||||
|
});
|
||||||
|
|
||||||
// Check if the service is available
|
// Check if the service is available
|
||||||
const isHealthy = await speech.checkHealth();
|
const isHealthy = await speech.checkHealth();
|
||||||
@@ -45,12 +51,51 @@ async function main() {
|
|||||||
console.error('❌ Error:', error.message);
|
console.error('❌ Error:', error.message);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Create audio directory if it doesn't exist
|
||||||
|
const audioDir = path.join(__dirname, '..', 'audio');
|
||||||
|
if (!require('fs').existsSync(audioDir)) {
|
||||||
|
require('fs').mkdirSync(audioDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start microphone recording
|
||||||
|
console.log('Starting microphone recording...');
|
||||||
|
let audioBuffer = Buffer.alloc(0);
|
||||||
|
|
||||||
|
const audioStream = new Writable({
|
||||||
|
write(chunk: Buffer, encoding, callback) {
|
||||||
|
audioBuffer = Buffer.concat([audioBuffer, chunk]);
|
||||||
|
callback();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const recording = recorder.record({
|
||||||
|
sampleRate: 16000,
|
||||||
|
channels: 1,
|
||||||
|
audioType: 'wav'
|
||||||
|
});
|
||||||
|
|
||||||
|
recording.stream().pipe(audioStream);
|
||||||
|
|
||||||
|
// Process audio every 5 seconds
|
||||||
|
setInterval(async () => {
|
||||||
|
if (audioBuffer.length > 0) {
|
||||||
|
try {
|
||||||
|
const result = await speech.transcribe(audioBuffer);
|
||||||
|
console.log('\n🎤 Live transcription:', result);
|
||||||
|
// Reset buffer after processing
|
||||||
|
audioBuffer = Buffer.alloc(0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Transcription error:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
// Example of manual transcription
|
// Example of manual transcription
|
||||||
async function transcribeFile(filepath: string) {
|
async function transcribeFile(filepath: string) {
|
||||||
try {
|
try {
|
||||||
console.log(`\n🎯 Manually transcribing: ${filepath}`);
|
console.log(`\n🎯 Manually transcribing: ${filepath}`);
|
||||||
const result = await speech.transcribeAudio(filepath, {
|
const result = await speech.transcribeAudio(filepath, {
|
||||||
model: 'base.en', // You can change this to tiny.en, small.en, medium.en, or large-v2
|
model: 'base.en',
|
||||||
language: 'en',
|
language: 'en',
|
||||||
temperature: 0,
|
temperature: 0,
|
||||||
beamSize: 5
|
beamSize: 5
|
||||||
@@ -63,22 +108,13 @@ async function main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create audio directory if it doesn't exist
|
|
||||||
const audioDir = path.join(__dirname, '..', 'audio');
|
|
||||||
if (!require('fs').existsSync(audioDir)) {
|
|
||||||
require('fs').mkdirSync(audioDir, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start wake word detection
|
// Start wake word detection
|
||||||
speech.startWakeWordDetection(audioDir);
|
speech.startWakeWordDetection(audioDir);
|
||||||
|
|
||||||
// Example: You can also manually transcribe files
|
// Handle cleanup on exit
|
||||||
// Uncomment the following line and replace with your audio file:
|
|
||||||
// await transcribeFile('/path/to/your/audio.wav');
|
|
||||||
|
|
||||||
// Keep the process running
|
|
||||||
process.on('SIGINT', () => {
|
process.on('SIGINT', () => {
|
||||||
console.log('\nStopping speech service...');
|
console.log('\nStopping speech service...');
|
||||||
|
recording.stop();
|
||||||
speech.stopWakeWordDetection();
|
speech.stopWakeWordDetection();
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
});
|
});
|
||||||
|
|||||||
10
package.json
10
package.json
@@ -7,7 +7,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "bun run dist/index.js",
|
"start": "bun run dist/index.js",
|
||||||
"dev": "bun --hot --watch src/index.ts",
|
"dev": "bun --hot --watch src/index.ts",
|
||||||
"build": "bun build ./src/index.ts --outdir ./dist --target node --minify",
|
"build": "bun build ./src/index.ts --outdir ./dist --target bun --minify",
|
||||||
"test": "bun test",
|
"test": "bun test",
|
||||||
"test:watch": "bun test --watch",
|
"test:watch": "bun test --watch",
|
||||||
"test:coverage": "bun test --coverage",
|
"test:coverage": "bun test --coverage",
|
||||||
@@ -36,6 +36,7 @@
|
|||||||
"helmet": "^7.1.0",
|
"helmet": "^7.1.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"node-fetch": "^3.3.2",
|
"node-fetch": "^3.3.2",
|
||||||
|
"node-record-lpcm16": "^1.0.1",
|
||||||
"openai": "^4.82.0",
|
"openai": "^4.82.0",
|
||||||
"sanitize-html": "^2.11.0",
|
"sanitize-html": "^2.11.0",
|
||||||
"typescript": "^5.3.3",
|
"typescript": "^5.3.3",
|
||||||
@@ -45,6 +46,10 @@
|
|||||||
"zod": "^3.22.4"
|
"zod": "^3.22.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@jest/globals": "^29.7.0",
|
||||||
|
"@types/bun": "latest",
|
||||||
|
"@types/express": "^5.0.0",
|
||||||
|
"@types/jest": "^29.5.14",
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^7.1.0",
|
"@typescript-eslint/eslint-plugin": "^7.1.0",
|
||||||
"@typescript-eslint/parser": "^7.1.0",
|
"@typescript-eslint/parser": "^7.1.0",
|
||||||
@@ -55,8 +60,7 @@
|
|||||||
"husky": "^9.0.11",
|
"husky": "^9.0.11",
|
||||||
"prettier": "^3.2.5",
|
"prettier": "^3.2.5",
|
||||||
"supertest": "^6.3.3",
|
"supertest": "^6.3.3",
|
||||||
"uuid": "^11.0.5",
|
"uuid": "^11.0.5"
|
||||||
"@types/bun": "latest"
|
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"bun": ">=1.0.0"
|
"bun": ">=1.0.0"
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ router.get("/subscribe_events", middleware.wsRateLimiter, (req, res) => {
|
|||||||
res.writeHead(200, {
|
res.writeHead(200, {
|
||||||
"Content-Type": "text/event-stream",
|
"Content-Type": "text/event-stream",
|
||||||
"Cache-Control": "no-cache",
|
"Cache-Control": "no-cache",
|
||||||
Connection: "keep-alive",
|
"Connection": "keep-alive",
|
||||||
"Access-Control-Allow-Origin": "*",
|
"Access-Control-Allow-Origin": "*",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export const AppConfigSchema = z.object({
|
|||||||
.default("development"),
|
.default("development"),
|
||||||
|
|
||||||
/** Home Assistant Configuration */
|
/** Home Assistant Configuration */
|
||||||
HASS_HOST: z.string().default("http://192.168.178.63:8123"),
|
HASS_HOST: z.string().default("http://homeassistant.local:8123"),
|
||||||
HASS_TOKEN: z.string().optional(),
|
HASS_TOKEN: z.string().optional(),
|
||||||
|
|
||||||
/** Speech Features Configuration */
|
/** Speech Features Configuration */
|
||||||
@@ -31,7 +31,7 @@ export const AppConfigSchema = z.object({
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
/** Security Configuration */
|
/** Security Configuration */
|
||||||
JWT_SECRET: z.string().default("your-secret-key"),
|
JWT_SECRET: z.string().default("your-secret-key-must-be-32-char-min"),
|
||||||
RATE_LIMIT: z.object({
|
RATE_LIMIT: z.object({
|
||||||
/** Time window for rate limiting in milliseconds */
|
/** Time window for rate limiting in milliseconds */
|
||||||
windowMs: z.number().default(15 * 60 * 1000), // 15 minutes
|
windowMs: z.number().default(15 * 60 * 1000), // 15 minutes
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
export const BOILERPLATE_CONFIG = {
|
|
||||||
configuration: {
|
|
||||||
LOG_LEVEL: {
|
|
||||||
type: "string" as const,
|
|
||||||
default: "debug",
|
|
||||||
description: "Logging level",
|
|
||||||
enum: ["error", "warn", "info", "debug", "trace"],
|
|
||||||
},
|
|
||||||
CACHE_DIRECTORY: {
|
|
||||||
type: "string" as const,
|
|
||||||
default: ".cache",
|
|
||||||
description: "Directory for cache files",
|
|
||||||
},
|
|
||||||
CONFIG_DIRECTORY: {
|
|
||||||
type: "string" as const,
|
|
||||||
default: ".config",
|
|
||||||
description: "Directory for configuration files",
|
|
||||||
},
|
|
||||||
DATA_DIRECTORY: {
|
|
||||||
type: "string" as const,
|
|
||||||
default: ".data",
|
|
||||||
description: "Directory for data files",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
internal: {
|
|
||||||
boilerplate: {
|
|
||||||
configuration: {
|
|
||||||
LOG_LEVEL: "debug",
|
|
||||||
CACHE_DIRECTORY: ".cache",
|
|
||||||
CONFIG_DIRECTORY: ".config",
|
|
||||||
DATA_DIRECTORY: ".data",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
74
src/hass/types.ts
Normal file
74
src/hass/types.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import type { WebSocket } from 'ws';
|
||||||
|
|
||||||
|
export interface HassInstanceImpl {
|
||||||
|
baseUrl: string;
|
||||||
|
token: string;
|
||||||
|
connect(): Promise<void>;
|
||||||
|
disconnect(): Promise<void>;
|
||||||
|
getStates(): Promise<any[]>;
|
||||||
|
callService(domain: string, service: string, data?: any): Promise<void>;
|
||||||
|
fetchStates(): Promise<any[]>;
|
||||||
|
fetchState(entityId: string): Promise<any>;
|
||||||
|
subscribeEvents(callback: (event: any) => void, eventType?: string): Promise<number>;
|
||||||
|
unsubscribeEvents(subscriptionId: number): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HassWebSocketClient {
|
||||||
|
url: string;
|
||||||
|
token: string;
|
||||||
|
socket: WebSocket | null;
|
||||||
|
connect(): Promise<void>;
|
||||||
|
disconnect(): Promise<void>;
|
||||||
|
send(message: any): Promise<void>;
|
||||||
|
subscribe(callback: (data: any) => void): () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HassState {
|
||||||
|
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 HassServiceCall {
|
||||||
|
domain: string;
|
||||||
|
service: string;
|
||||||
|
target?: {
|
||||||
|
entity_id?: string | string[];
|
||||||
|
device_id?: string | string[];
|
||||||
|
area_id?: string | string[];
|
||||||
|
};
|
||||||
|
service_data?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HassEvent {
|
||||||
|
event_type: string;
|
||||||
|
data: any;
|
||||||
|
origin: string;
|
||||||
|
time_fired: string;
|
||||||
|
context: {
|
||||||
|
id: string;
|
||||||
|
parent_id: string | null;
|
||||||
|
user_id: string | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MockFunction<T extends (...args: any[]) => any> = {
|
||||||
|
(...args: Parameters<T>): ReturnType<T>;
|
||||||
|
mock: {
|
||||||
|
calls: Parameters<T>[];
|
||||||
|
results: { type: 'return' | 'throw'; value: any }[];
|
||||||
|
instances: any[];
|
||||||
|
mockImplementation(fn: T): MockFunction<T>;
|
||||||
|
mockReturnValue(value: ReturnType<T>): MockFunction<T>;
|
||||||
|
mockResolvedValue(value: Awaited<ReturnType<T>>): MockFunction<T>;
|
||||||
|
mockRejectedValue(value: any): MockFunction<T>;
|
||||||
|
mockReset(): void;
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1,292 +1,93 @@
|
|||||||
import { JSONSchemaType } from "ajv";
|
import { z } from 'zod';
|
||||||
import { Entity, StateChangedEvent } from "../types/hass.js";
|
|
||||||
|
|
||||||
// Define base types for automation components
|
// Entity Schema
|
||||||
type TriggerType = {
|
const entitySchema = z.object({
|
||||||
platform: string;
|
entity_id: z.string().regex(/^[a-z0-9_]+\.[a-z0-9_]+$/),
|
||||||
event?: string | null;
|
state: z.string(),
|
||||||
entity_id?: string | null;
|
attributes: z.record(z.any()),
|
||||||
to?: string | null;
|
last_changed: z.string(),
|
||||||
from?: string | null;
|
last_updated: z.string(),
|
||||||
offset?: string | null;
|
context: z.object({
|
||||||
[key: string]: any;
|
id: z.string(),
|
||||||
|
parent_id: z.string().nullable(),
|
||||||
|
user_id: z.string().nullable()
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
// Service Schema
|
||||||
|
const serviceSchema = z.object({
|
||||||
|
domain: z.string().min(1),
|
||||||
|
service: z.string().min(1),
|
||||||
|
target: z.object({
|
||||||
|
entity_id: z.union([z.string(), z.array(z.string())]),
|
||||||
|
device_id: z.union([z.string(), z.array(z.string())]).optional(),
|
||||||
|
area_id: z.union([z.string(), z.array(z.string())]).optional()
|
||||||
|
}).optional(),
|
||||||
|
service_data: z.record(z.any()).optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
// State Changed Event Schema
|
||||||
|
const stateChangedEventSchema = z.object({
|
||||||
|
event_type: z.literal('state_changed'),
|
||||||
|
data: z.object({
|
||||||
|
entity_id: z.string(),
|
||||||
|
old_state: z.union([entitySchema, z.null()]),
|
||||||
|
new_state: entitySchema
|
||||||
|
}),
|
||||||
|
origin: z.string(),
|
||||||
|
time_fired: z.string(),
|
||||||
|
context: z.object({
|
||||||
|
id: z.string(),
|
||||||
|
parent_id: z.string().nullable(),
|
||||||
|
user_id: z.string().nullable()
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
// Config Schema
|
||||||
|
const configSchema = z.object({
|
||||||
|
location_name: z.string(),
|
||||||
|
time_zone: z.string(),
|
||||||
|
components: z.array(z.string()),
|
||||||
|
version: z.string()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Device Control Schema
|
||||||
|
const deviceControlSchema = z.object({
|
||||||
|
domain: z.string().min(1),
|
||||||
|
command: z.string().min(1),
|
||||||
|
entity_id: z.union([z.string(), z.array(z.string())]),
|
||||||
|
parameters: z.record(z.any()).optional()
|
||||||
|
}).refine(data => {
|
||||||
|
if (typeof data.entity_id === 'string') {
|
||||||
|
return data.entity_id.startsWith(data.domain + '.');
|
||||||
|
}
|
||||||
|
return data.entity_id.every(id => id.startsWith(data.domain + '.'));
|
||||||
|
}, {
|
||||||
|
message: 'entity_id must match the domain'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Validation functions
|
||||||
|
export const validateEntity = (data: unknown) => {
|
||||||
|
const result = entitySchema.safeParse(data);
|
||||||
|
return { success: result.success, error: result.success ? undefined : result.error };
|
||||||
};
|
};
|
||||||
|
|
||||||
type ConditionType = {
|
export const validateService = (data: unknown) => {
|
||||||
condition: string;
|
const result = serviceSchema.safeParse(data);
|
||||||
conditions?: Array<Record<string, any>> | null;
|
return { success: result.success, error: result.success ? undefined : result.error };
|
||||||
[key: string]: any;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type ActionType = {
|
export const validateStateChangedEvent = (data: unknown) => {
|
||||||
service: string;
|
const result = stateChangedEventSchema.safeParse(data);
|
||||||
target?: {
|
return { success: result.success, error: result.success ? undefined : result.error };
|
||||||
entity_id?: string | string[] | null;
|
|
||||||
[key: string]: any;
|
|
||||||
} | null;
|
|
||||||
data?: Record<string, any> | null;
|
|
||||||
[key: string]: any;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type AutomationType = {
|
export const validateConfig = (data: unknown) => {
|
||||||
alias: string;
|
const result = configSchema.safeParse(data);
|
||||||
description?: string | null;
|
return { success: result.success, error: result.success ? undefined : result.error };
|
||||||
mode?: ("single" | "parallel" | "queued" | "restart") | null;
|
|
||||||
trigger: TriggerType[];
|
|
||||||
condition?: ConditionType[] | null;
|
|
||||||
action: ActionType[];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type DeviceControlType = {
|
export const validateDeviceControl = (data: unknown) => {
|
||||||
domain:
|
const result = deviceControlSchema.safeParse(data);
|
||||||
| "light"
|
return { success: result.success, error: result.success ? undefined : result.error };
|
||||||
| "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;
|
|
||||||
|
|||||||
22
src/types/node-record-lpcm16.d.ts
vendored
Normal file
22
src/types/node-record-lpcm16.d.ts
vendored
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
declare module 'node-record-lpcm16' {
|
||||||
|
import { Readable } from 'stream';
|
||||||
|
|
||||||
|
interface RecordOptions {
|
||||||
|
sampleRate?: number;
|
||||||
|
channels?: number;
|
||||||
|
audioType?: string;
|
||||||
|
threshold?: number;
|
||||||
|
thresholdStart?: number;
|
||||||
|
thresholdEnd?: number;
|
||||||
|
silence?: number;
|
||||||
|
verbose?: boolean;
|
||||||
|
recordProgram?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Recording {
|
||||||
|
stream(): Readable;
|
||||||
|
stop(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function record(options?: RecordOptions): Recording;
|
||||||
|
}
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "esnext",
|
"target": "ESNext",
|
||||||
"module": "esnext",
|
"module": "ESNext",
|
||||||
"lib": [
|
"lib": [
|
||||||
"esnext",
|
"esnext",
|
||||||
"dom"
|
"dom"
|
||||||
],
|
],
|
||||||
"strict": false,
|
"strict": true,
|
||||||
"strictNullChecks": false,
|
"strictNullChecks": false,
|
||||||
"strictFunctionTypes": false,
|
"strictFunctionTypes": false,
|
||||||
"strictPropertyInitialization": false,
|
"strictPropertyInitialization": false,
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "node",
|
||||||
"allowImportingTsExtensions": true,
|
"allowImportingTsExtensions": true,
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
@@ -27,15 +27,16 @@
|
|||||||
"@types/ws",
|
"@types/ws",
|
||||||
"@types/jsonwebtoken",
|
"@types/jsonwebtoken",
|
||||||
"@types/sanitize-html",
|
"@types/sanitize-html",
|
||||||
"@types/jest"
|
"@types/jest",
|
||||||
|
"@types/express"
|
||||||
],
|
],
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": [
|
"@/*": [
|
||||||
"./src/*"
|
"src/*"
|
||||||
],
|
],
|
||||||
"@test/*": [
|
"@test/*": [
|
||||||
"__tests__/*"
|
"test/*"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"experimentalDecorators": true,
|
"experimentalDecorators": true,
|
||||||
@@ -45,10 +46,12 @@
|
|||||||
"declarationMap": true,
|
"declarationMap": true,
|
||||||
"allowUnreachableCode": true,
|
"allowUnreachableCode": true,
|
||||||
"allowUnusedLabels": true,
|
"allowUnusedLabels": true,
|
||||||
"suppressImplicitAnyIndexErrors": true
|
"outDir": "dist",
|
||||||
|
"rootDir": "."
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"src/**/*",
|
"src/**/*",
|
||||||
|
"test/**/*",
|
||||||
"__tests__/**/*",
|
"__tests__/**/*",
|
||||||
"*.d.ts"
|
"*.d.ts"
|
||||||
],
|
],
|
||||||
|
|||||||
Reference in New Issue
Block a user