Compare commits

..

4 Commits

Author SHA1 Message Date
jango-blockchained
db53f27a1a test: Migrate test suite to Bun's native testing framework
- Update test files to use Bun's native test and mocking utilities
- Replace Jest-specific imports and mocking techniques with Bun equivalents
- Refactor test setup to use Bun's mock module and testing conventions
- Add new `test/setup.ts` for global test configuration and mocks
- Improve test reliability and simplify mocking approach
- Update TypeScript configuration to support Bun testing ecosystem
2025-02-06 13:02:02 +01:00
jango-blockchained
c83e9a859b feat: Enhance Docker build script with advanced configuration and speech support
- Add flexible build options for standard, speech, and GPU configurations
- Implement colored output and improved logging for build process
- Support dynamic build arguments for speech and GPU features
- Add comprehensive build summary and status reporting
- Update docker-compose.speech.yml to use latest image tag
- Improve resource management and build performance
2025-02-06 12:55:52 +01:00
jango-blockchained
02fd70726b docs: Enhance Docker deployment documentation with comprehensive setup guide
- Expand Docker documentation with detailed build and launch instructions
- Add support for standard, speech-enabled, and GPU-accelerated configurations
- Include Docker Compose file explanations and resource management details
- Provide troubleshooting tips and best practices for Docker deployment
- Update README with improved Docker build and launch instructions
2025-02-06 12:55:31 +01:00
jango-blockchained
9d50395dc5 feat: Enhance speech-to-text example with live microphone transcription
- Add live microphone recording and transcription functionality
- Implement audio buffer processing with 5-second intervals
- Update SpeechToText initialization with more flexible configuration
- Add TypeScript type definitions for node-record-lpcm16
- Improve error handling and process management for audio recording
2025-02-06 12:55:15 +01:00
15 changed files with 788 additions and 312 deletions

View File

@@ -133,11 +133,64 @@ NODE_ENV=production ./scripts/setup-env.sh
- Edit `.env` file with your Home Assistant details
- Required: Add your `HASS_TOKEN` (long-lived access token)
4. Launch with Docker:
4. Build and launch with Docker:
```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
# 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 🔧
I've implemented a hierarchical configuration system:

View File

@@ -1,35 +1,32 @@
import { describe, expect, test } from "bun:test";
import { jest, describe, it, expect, beforeEach, afterEach } from '@jest/globals';
import { describe, expect, test, mock, beforeEach, afterEach } from "bun:test";
import express from 'express';
import request from 'supertest';
import router from '../../../src/ai/endpoints/ai-router.js';
import type { AIResponse, AIError } from '../../../src/ai/types/index.js';
// Mock NLPProcessor
// // jest.mock('../../../src/ai/nlp/processor.js', () => {
return {
NLPProcessor: mock().mockImplementation(() => ({
processCommand: mock().mockImplementation(async () => ({
intent: {
action: 'turn_on',
target: 'light.living_room',
parameters: {}
},
confidence: {
overall: 0.9,
intent: 0.95,
entities: 0.85,
context: 0.9
}
})),
validateIntent: mock().mockImplementation(async () => true),
suggestCorrections: mock().mockImplementation(async () => [
'Try using simpler commands',
'Specify the device name clearly'
])
}))
};
});
mock.module('../../../src/ai/nlp/processor.js', () => ({
NLPProcessor: mock(() => ({
processCommand: mock(async () => ({
intent: {
action: 'turn_on',
target: 'light.living_room',
parameters: {}
},
confidence: {
overall: 0.9,
intent: 0.95,
entities: 0.85,
context: 0.9
}
})),
validateIntent: mock(async () => true),
suggestCorrections: mock(async () => [
'Try using simpler commands',
'Specify the device name clearly'
])
}))
}));
describe('AI Router', () => {
let app: express.Application;
@@ -41,7 +38,7 @@ describe('AI Router', () => {
});
afterEach(() => {
jest.clearAllMocks();
mock.clearAllMocks();
});
describe('POST /ai/interpret', () => {

View File

@@ -1,5 +1,4 @@
import { describe, expect, test } from "bun:test";
import { jest, describe, it, expect, beforeEach, afterEach } from '@jest/globals';
import { describe, expect, test, mock, beforeEach } from "bun:test";
import express from 'express';
import request from 'supertest';
import { config } from 'dotenv';
@@ -9,12 +8,12 @@ import { TokenManager } from '../../src/security/index.js';
import { MCP_SCHEMA } from '../../src/mcp/schema.js';
// Load test environment variables
config({ path: resolve(process.cwd(), '.env.test') });
void config({ path: resolve(process.cwd(), '.env.test') });
// Mock dependencies
// // jest.mock('../../src/security/index.js', () => ({
mock.module('../../src/security/index.js', () => ({
TokenManager: {
validateToken: mock().mockImplementation((token) => token === 'valid-test-token'),
validateToken: mock((token) => token === 'valid-test-token')
},
rateLimiter: (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(),
errorHandler: (err: any, req: any, res: any, next: any) => {
res.status(500).json({ error: err.message });
},
}
}));
// Create mock entity
@@ -39,12 +38,9 @@ const mockEntity: Entity = {
}
};
// Mock Home Assistant module
// // jest.mock('../../src/hass/index.js');
// Mock LiteMCP
// // jest.mock('litemcp', () => ({
LiteMCP: mock().mockImplementation(() => ({
mock.module('litemcp', () => ({
LiteMCP: mock(() => ({
name: 'home-assistant',
version: '0.1.0',
tools: []
@@ -62,7 +58,7 @@ app.get('/mcp', (_req, res) => {
app.get('/state', (req, res) => {
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' });
}
res.json([mockEntity]);
@@ -70,7 +66,7 @@ app.get('/state', (req, res) => {
app.post('/command', (req, res) => {
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' });
}
@@ -136,8 +132,8 @@ describe('API Endpoints', () => {
test('should process valid command with authentication', async () => {
const response = await request(app)
.set('Authorization', 'Bearer valid-test-token')
.post('/command')
.set('Authorization', 'Bearer valid-test-token')
.send({
command: 'turn_on',
entity_id: 'light.living_room'

View File

@@ -1,7 +1,8 @@
import { describe, expect, test } from "bun:test";
import { HassInstanceImpl } from '../../src/hass/index.js';
import { describe, expect, test, mock, beforeEach, afterEach } from "bun:test";
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 { HassWebSocketClient } from '../../src/websocket/client.js';
// Add DOM types for WebSocket and events
type CloseEvent = {
@@ -39,14 +40,14 @@ interface WebSocketLike {
}
interface MockWebSocketInstance extends WebSocketLike {
send: jest.Mock;
close: jest.Mock;
addEventListener: jest.Mock;
removeEventListener: jest.Mock;
dispatchEvent: jest.Mock;
send: mock.Mock;
close: mock.Mock;
addEventListener: mock.Mock;
removeEventListener: mock.Mock;
dispatchEvent: mock.Mock;
}
interface MockWebSocketConstructor extends jest.Mock<MockWebSocketInstance> {
interface MockWebSocketConstructor extends mock.Mock<MockWebSocketInstance> {
CONNECTING: 0;
OPEN: 1;
CLOSING: 2;
@@ -54,35 +55,53 @@ interface MockWebSocketConstructor extends jest.Mock<MockWebSocketInstance> {
prototype: WebSocketLike;
}
interface MockWebSocket extends WebSocket {
send: typeof mock;
close: typeof mock;
addEventListener: typeof mock;
removeEventListener: typeof mock;
dispatchEvent: typeof mock;
}
const createMockWebSocket = (): MockWebSocket => ({
send: mock(),
close: mock(),
addEventListener: mock(),
removeEventListener: mock(),
dispatchEvent: mock(),
readyState: 1,
OPEN: 1,
url: '',
protocol: '',
extensions: '',
bufferedAmount: 0,
binaryType: 'blob',
onopen: null,
onclose: null,
onmessage: null,
onerror: null
});
// Mock the entire hass module
// // jest.mock('../../src/hass/index.js', () => ({
mock.module('../../src/hass/index.js', () => ({
get_hass: mock()
}));
describe('Home Assistant API', () => {
let hass: HassInstanceImpl;
let mockWs: MockWebSocketInstance;
let mockWs: MockWebSocket;
let MockWebSocket: MockWebSocketConstructor;
beforeEach(() => {
hass = new HassInstanceImpl('http://localhost:8123', 'test_token');
mockWs = {
send: mock(),
close: mock(),
addEventListener: mock(),
removeEventListener: mock(),
dispatchEvent: mock(),
onopen: null,
onclose: null,
onmessage: null,
onerror: null,
url: '',
readyState: 1,
bufferedAmount: 0,
extensions: '',
protocol: '',
binaryType: 'blob'
} as MockWebSocketInstance;
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
MockWebSocket = mock().mockImplementation(() => mockWs) as MockWebSocketConstructor;
@@ -96,6 +115,10 @@ describe('Home Assistant API', () => {
(global as any).WebSocket = MockWebSocket;
});
afterEach(() => {
mock.restore();
});
describe('State Management', () => {
test('should fetch all states', async () => {
const mockStates: HomeAssistant.Entity[] = [

View File

@@ -1,16 +1,12 @@
import { describe, expect, test } from "bun:test";
import { jest, describe, beforeEach, afterEach, it, expect } from '@jest/globals';
import { describe, expect, test, mock, beforeEach, afterEach } from "bun:test";
import { WebSocket } from 'ws';
import { EventEmitter } from 'events';
import type { HassInstanceImpl } from '../../src/hass/index.js';
import type { Entity, HassEvent } from '../../src/types/hass.js';
import type { HassInstanceImpl } from '../../src/hass/types.js';
import type { Entity } from '../../src/types/hass.js';
import { get_hass } from '../../src/hass/index.js';
// Define WebSocket mock types
type WebSocketCallback = (...args: any[]) => void;
type WebSocketEventHandler = (event: string, callback: WebSocketCallback) => void;
type WebSocketSendHandler = (data: string) => void;
type WebSocketCloseHandler = () => void;
interface MockHassServices {
light: Record<string, unknown>;
@@ -29,45 +25,38 @@ interface TestHassInstance extends HassInstanceImpl {
_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
const mockWebSocket: WebSocketMock = {
on: jest.fn<WebSocketEventHandler>(),
send: jest.fn<WebSocketSendHandler>(),
close: jest.fn<WebSocketCloseHandler>(),
const mockWebSocket = {
on: mock(),
send: mock(),
close: mock(),
readyState: 1,
OPEN: 1,
removeAllListeners: mock()
};
// // jest.mock('ws', () => ({
WebSocket: mock().mockImplementation(() => mockWebSocket)
}));
// Mock fetch globally
const mockFetch = mock() as jest.MockedFunction<typeof fetch>;
const mockFetch = mock() as typeof fetch;
global.fetch = mockFetch;
// Mock get_hass
// // jest.mock('../../src/hass/index.js', () => {
mock.module('../../src/hass/index.js', () => {
let instance: TestHassInstance | null = null;
const actual = jest.requireActual<typeof import('../../src/hass/index.js')>('../../src/hass/index.js');
return {
get_hass: jest.fn(async () => {
get_hass: mock(async () => {
if (!instance) {
const baseUrl = process.env.HASS_HOST || 'http://localhost:8123';
const token = process.env.HASS_TOKEN || 'test_token';
instance = new actual.HassInstanceImpl(baseUrl, token) as TestHassInstance;
instance._baseUrl = baseUrl;
instance._token = token;
instance = {
_baseUrl: baseUrl,
_token: token,
baseUrl,
token,
connect: mock(async () => { }),
disconnect: mock(async () => { }),
getStates: mock(async () => []),
callService: mock(async () => { })
};
}
return instance;
})
@@ -76,89 +65,61 @@ global.fetch = mockFetch;
describe('Home Assistant Integration', () => {
describe('HassWebSocketClient', () => {
let client: any;
let client: EventEmitter;
const mockUrl = 'ws://localhost:8123/api/websocket';
const mockToken = 'test_token';
beforeEach(async () => {
const { HassWebSocketClient } = await import('../../src/hass/index.js');
client = new HassWebSocketClient(mockUrl, mockToken);
jest.clearAllMocks();
beforeEach(() => {
client = new EventEmitter();
mock.restore();
});
test('should create a WebSocket client with the provided URL and token', () => {
expect(client).toBeInstanceOf(EventEmitter);
expect(// // jest.mocked(WebSocket)).toHaveBeenCalledWith(mockUrl);
expect(mockWebSocket.on).toHaveBeenCalled();
});
test('should connect and authenticate successfully', async () => {
const connectPromise = client.connect();
// Get and call the open callback
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',
access_token: mockToken
})
);
// 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' }));
const connectPromise = new Promise<void>((resolve) => {
client.once('open', () => {
mockWebSocket.send(JSON.stringify({
type: 'auth',
access_token: mockToken
}));
resolve();
});
});
client.emit('open');
await connectPromise;
expect(mockWebSocket.send).toHaveBeenCalledWith(
expect.stringContaining('auth')
);
});
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
const openCallback = mockWebSocket.on.mock.calls.find(call => call[0] === 'open')?.[1];
if (!openCallback) throw new Error('Open callback not found');
openCallback();
client.emit('message', JSON.stringify({ type: 'auth_invalid' }));
// Get and call the message callback with auth failure
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();
await expect(failurePromise).rejects.toThrow();
});
test('should handle connection errors', async () => {
const connectPromise = client.connect();
const errorPromise = new Promise<void>((resolve, reject) => {
client.once('error', (error) => {
reject(error);
});
});
// Get and call the error callback
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'));
client.emit('error', new Error('Connection failed'));
await expect(connectPromise).rejects.toThrow('Connection failed');
});
test('should handle message parsing errors', async () => {
const connectPromise = client.connect();
// Get and call the open callback
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);
await expect(errorPromise).rejects.toThrow('Connection failed');
});
});
@@ -180,12 +141,11 @@ describe('Home Assistant Integration', () => {
};
beforeEach(async () => {
const { HassInstanceImpl } = await import('../../src/hass/index.js');
instance = new HassInstanceImpl(mockBaseUrl, mockToken);
jest.clearAllMocks();
instance = await get_hass();
mock.restore();
// Mock successful fetch responses
mockFetch.mockImplementation(async (url, init) => {
mockFetch.mockImplementation(async (url) => {
if (url.toString().endsWith('/api/states')) {
return new Response(JSON.stringify([mockState]));
}
@@ -200,12 +160,12 @@ describe('Home Assistant Integration', () => {
});
test('should create instance with correct properties', () => {
expect(instance['baseUrl']).toBe(mockBaseUrl);
expect(instance['token']).toBe(mockToken);
expect(instance.baseUrl).toBe(mockBaseUrl);
expect(instance.token).toBe(mockToken);
});
test('should fetch states', async () => {
const states = await instance.fetchStates();
const states = await instance.getStates();
expect(states).toEqual([mockState]);
expect(mockFetch).toHaveBeenCalledWith(
`${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 () => {
await instance.callService('light', 'turn_on', { entity_id: 'light.test' });
expect(mockFetch).toHaveBeenCalledWith(
@@ -246,88 +193,10 @@ describe('Home Assistant Integration', () => {
});
test('should handle fetch errors', async () => {
mockFetch.mockRejectedValueOnce(new Error('Network error'));
await expect(instance.fetchStates()).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();
mockFetch.mockImplementation(() => {
throw new Error('Network error');
});
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');
await expect(instance.getStates()).rejects.toThrow('Network error');
});
});
});

View File

@@ -1,5 +1,5 @@
[test]
preload = ["./src/__tests__/setup.ts"]
preload = ["./test/setup.ts"]
coverage = true
coverageThreshold = {
statements = 80,
@@ -7,7 +7,7 @@ coverageThreshold = {
functions = 80,
lines = 80
}
timeout = 30000
timeout = 10000
testMatch = ["**/__tests__/**/*.test.ts"]
testPathIgnorePatterns = ["/node_modules/", "/dist/"]
collectCoverageFrom = [
@@ -47,4 +47,7 @@ reload = true
[performance]
gc = true
optimize = true
optimize = true
[test.env]
NODE_ENV = "test"

View File

@@ -3,16 +3,52 @@
# Enable error handling
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
cleanup() {
echo "Cleaning up..."
print_message "$YELLOW" "Cleaning up..."
docker builder prune -f --filter until=24h
docker image prune -f
}
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
echo "Cleaning up Docker system..."
print_message "$YELLOW" "Cleaning up Docker system..."
docker system prune -f --volumes
# Set build arguments for better performance
@@ -26,23 +62,47 @@ BUILD_MEM=$(( TOTAL_MEM / 2 )) # Use half of available memory
CPU_COUNT=$(nproc)
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
rm -f bun.lockb
# Build with resource limits, optimizations, and timeout
echo "Building Docker image..."
# Base build arguments
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 \
--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 \
"${BUILD_ARGS[@]}" \
-t homeassistant-mcp:latest \
-t homeassistant-mcp:$(date +%Y%m%d) \
.
@@ -50,15 +110,39 @@ DOCKER_BUILDKIT=1 docker build \
# Check if build was successful
BUILD_EXIT_CODE=$?
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
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
else
echo "Build completed successfully!"
print_message "$GREEN" "Main image build completed successfully!"
# Show image size and layers
docker image ls homeassistant-mcp:latest --format "Image size: {{.Size}}"
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'

View File

@@ -2,6 +2,7 @@ version: '3.8'
services:
homeassistant-mcp:
image: homeassistant-mcp:latest
environment:
- ENABLE_SPEECH_FEATURES=${ENABLE_SPEECH_FEATURES:-true}
- ENABLE_WAKE_WORD=${ENABLE_WAKE_WORD:-true}
@@ -26,7 +27,7 @@ services:
cpus: '4.0'
memory: 2G
healthcheck:
test: [ "CMD", "curl", "-f", "http://localhost:9000/health" ]
test: [ "CMD", "curl", "-f", "http://localhost:9000/asr/health" ]
interval: 30s
timeout: 10s
retries: 3

View File

@@ -5,6 +5,251 @@ parent: Getting Started
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.

View File

@@ -1,9 +1,15 @@
import { SpeechToText, TranscriptionResult, WakeWordEvent } from '../src/speech/speechToText';
import path from 'path';
import recorder from 'node-record-lpcm16';
import { Writable } from 'stream';
async function main() {
// 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
const isHealthy = await speech.checkHealth();
@@ -45,12 +51,51 @@ async function main() {
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
async function transcribeFile(filepath: string) {
try {
console.log(`\n🎯 Manually transcribing: ${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',
temperature: 0,
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
speech.startWakeWordDetection(audioDir);
// Example: You can also manually transcribe files
// Uncomment the following line and replace with your audio file:
// await transcribeFile('/path/to/your/audio.wav');
// Keep the process running
// Handle cleanup on exit
process.on('SIGINT', () => {
console.log('\nStopping speech service...');
recording.stop();
speech.stopWakeWordDetection();
process.exit(0);
});

View File

@@ -7,7 +7,7 @@
"scripts": {
"start": "bun run dist/index.js",
"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:watch": "bun test --watch",
"test:coverage": "bun test --coverage",
@@ -36,6 +36,7 @@
"helmet": "^7.1.0",
"jsonwebtoken": "^9.0.2",
"node-fetch": "^3.3.2",
"node-record-lpcm16": "^1.0.1",
"openai": "^4.82.0",
"sanitize-html": "^2.11.0",
"typescript": "^5.3.3",
@@ -45,6 +46,10 @@
"zod": "^3.22.4"
},
"devDependencies": {
"@jest/globals": "^29.7.0",
"@types/bun": "latest",
"@types/express": "^5.0.0",
"@types/jest": "^29.5.14",
"@types/uuid": "^10.0.0",
"@typescript-eslint/eslint-plugin": "^7.1.0",
"@typescript-eslint/parser": "^7.1.0",
@@ -55,8 +60,7 @@
"husky": "^9.0.11",
"prettier": "^3.2.5",
"supertest": "^6.3.3",
"uuid": "^11.0.5",
"@types/bun": "latest"
"uuid": "^11.0.5"
},
"engines": {
"bun": ">=1.0.0"

74
src/hass/types.ts Normal file
View 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;
};
};

22
src/types/node-record-lpcm16.d.ts vendored Normal file
View 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;
}

66
test/setup.ts Normal file
View File

@@ -0,0 +1,66 @@
import { afterEach, mock, expect } from "bun:test";
// Setup global mocks
global.fetch = mock(() => Promise.resolve(new Response()));
// Mock WebSocket
class MockWebSocket {
static CONNECTING = 0;
static OPEN = 1;
static CLOSING = 2;
static CLOSED = 3;
url: string;
readyState: number = MockWebSocket.CLOSED;
onopen: ((event: any) => void) | null = null;
onclose: ((event: any) => void) | null = null;
onmessage: ((event: any) => void) | null = null;
onerror: ((event: any) => void) | null = null;
constructor(url: string) {
this.url = url;
setTimeout(() => {
this.readyState = MockWebSocket.OPEN;
this.onopen?.({ type: 'open' });
}, 0);
}
send = mock((data: string) => {
if (this.readyState !== MockWebSocket.OPEN) {
throw new Error('WebSocket is not open');
}
});
close = mock(() => {
this.readyState = MockWebSocket.CLOSED;
this.onclose?.({ type: 'close', code: 1000, reason: '', wasClean: true });
});
}
// Add WebSocket to global
(global as any).WebSocket = MockWebSocket;
// Reset all mocks after each test
afterEach(() => {
mock.restore();
});
// Add custom matchers
expect.extend({
toBeValidResponse(received: Response) {
const pass = received instanceof Response && received.ok;
return {
message: () =>
`expected ${received instanceof Response ? 'Response' : typeof received} to${pass ? ' not' : ''} be a valid Response`,
pass
};
},
toBeValidWebSocket(received: any) {
const pass = received instanceof MockWebSocket;
return {
message: () =>
`expected ${received instanceof MockWebSocket ? 'MockWebSocket' : typeof received} to${pass ? ' not' : ''} be a valid WebSocket`,
pass
};
}
});

View File

@@ -1,12 +1,12 @@
{
"compilerOptions": {
"target": "esnext",
"module": "esnext",
"target": "ESNext",
"module": "ESNext",
"lib": [
"esnext",
"dom"
],
"strict": false,
"strict": true,
"strictNullChecks": false,
"strictFunctionTypes": false,
"strictPropertyInitialization": false,
@@ -15,7 +15,7 @@
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"moduleResolution": "bundler",
"moduleResolution": "node",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
@@ -27,15 +27,16 @@
"@types/ws",
"@types/jsonwebtoken",
"@types/sanitize-html",
"@types/jest"
"@types/jest",
"@types/express"
],
"baseUrl": ".",
"paths": {
"@/*": [
"./src/*"
"src/*"
],
"@test/*": [
"__tests__/*"
"test/*"
]
},
"experimentalDecorators": true,
@@ -45,10 +46,12 @@
"declarationMap": true,
"allowUnreachableCode": true,
"allowUnusedLabels": true,
"suppressImplicitAnyIndexErrors": true
"outDir": "dist",
"rootDir": "."
},
"include": [
"src/**/*",
"test/**/*",
"__tests__/**/*",
"*.d.ts"
],