test: Refactor WebSocket and speech tests for improved mocking and reliability
- Update WebSocket client test suite with more robust mocking - Enhance SpeechToText test coverage with improved event simulation - Simplify test setup and reduce complexity of mock implementations - Remove unnecessary test audio files and cleanup test directories - Improve error handling and event verification in test scenarios
This commit is contained in:
@@ -1,149 +1,149 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test, beforeEach, afterEach, mock, spyOn } from "bun:test";
|
||||||
import { describe, expect, test, beforeEach, afterEach, mock } from "bun:test";
|
|
||||||
import type { Mock } from "bun:test";
|
import type { Mock } from "bun:test";
|
||||||
import type { Express, Application } from 'express';
|
import type { Elysia } from "elysia";
|
||||||
import type { Logger } from 'winston';
|
|
||||||
|
|
||||||
// Types for our mocks
|
// Create mock instances
|
||||||
interface MockApp {
|
const mockApp = {
|
||||||
use: Mock<() => void>;
|
use: mock(() => mockApp),
|
||||||
listen: Mock<(port: number, callback: () => void) => { close: Mock<() => void> }>;
|
get: mock(() => mockApp),
|
||||||
}
|
post: mock(() => mockApp),
|
||||||
|
listen: mock((port: number, callback?: () => void) => {
|
||||||
interface MockLiteMCPInstance {
|
callback?.();
|
||||||
addTool: Mock<() => void>;
|
return mockApp;
|
||||||
start: Mock<() => Promise<void>>;
|
|
||||||
}
|
|
||||||
|
|
||||||
type MockLogger = {
|
|
||||||
info: Mock<(message: string) => void>;
|
|
||||||
error: Mock<(message: string) => void>;
|
|
||||||
debug: Mock<(message: string) => void>;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Mock express
|
|
||||||
const mockApp: MockApp = {
|
|
||||||
use: mock(() => undefined),
|
|
||||||
listen: mock((port: number, callback: () => void) => {
|
|
||||||
callback();
|
|
||||||
return { close: mock(() => undefined) };
|
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
const mockExpress = mock(() => mockApp);
|
|
||||||
|
|
||||||
// Mock LiteMCP instance
|
// Create mock constructors
|
||||||
const mockLiteMCPInstance: MockLiteMCPInstance = {
|
const MockElysia = mock(() => mockApp);
|
||||||
addTool: mock(() => undefined),
|
const mockCors = mock(() => (app: any) => app);
|
||||||
start: mock(() => Promise.resolve())
|
const mockSwagger = mock(() => (app: any) => app);
|
||||||
|
const mockSpeechService = {
|
||||||
|
initialize: mock(() => Promise.resolve()),
|
||||||
|
shutdown: mock(() => Promise.resolve())
|
||||||
};
|
};
|
||||||
const mockLiteMCP = mock((name: string, version: string) => mockLiteMCPInstance);
|
|
||||||
|
|
||||||
// Mock logger
|
// Mock the modules
|
||||||
const mockLogger: MockLogger = {
|
const mockModules = {
|
||||||
info: mock((message: string) => undefined),
|
Elysia: MockElysia,
|
||||||
error: mock((message: string) => undefined),
|
cors: mockCors,
|
||||||
debug: mock((message: string) => undefined)
|
swagger: mockSwagger,
|
||||||
|
speechService: mockSpeechService,
|
||||||
|
config: mock(() => ({})),
|
||||||
|
resolve: mock((...args: string[]) => args.join('/')),
|
||||||
|
z: { object: mock(() => ({})), enum: mock(() => ({})) }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock module resolution
|
||||||
|
const mockResolver = {
|
||||||
|
resolve(specifier: string) {
|
||||||
|
const mocks: Record<string, any> = {
|
||||||
|
'elysia': { Elysia: mockModules.Elysia },
|
||||||
|
'@elysiajs/cors': { cors: mockModules.cors },
|
||||||
|
'@elysiajs/swagger': { swagger: mockModules.swagger },
|
||||||
|
'../speech/index.js': { speechService: mockModules.speechService },
|
||||||
|
'dotenv': { config: mockModules.config },
|
||||||
|
'path': { resolve: mockModules.resolve },
|
||||||
|
'zod': { z: mockModules.z }
|
||||||
|
};
|
||||||
|
return mocks[specifier] || {};
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('Server Initialization', () => {
|
describe('Server Initialization', () => {
|
||||||
let originalEnv: NodeJS.ProcessEnv;
|
let originalEnv: NodeJS.ProcessEnv;
|
||||||
|
let consoleLog: Mock<typeof console.log>;
|
||||||
|
let consoleError: Mock<typeof console.error>;
|
||||||
|
let originalResolve: any;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// Store original environment
|
// Store original environment
|
||||||
originalEnv = { ...process.env };
|
originalEnv = { ...process.env };
|
||||||
|
|
||||||
// Setup mocks
|
// Mock console methods
|
||||||
(globalThis as any).express = mockExpress;
|
consoleLog = mock(() => { });
|
||||||
(globalThis as any).LiteMCP = mockLiteMCP;
|
consoleError = mock(() => { });
|
||||||
(globalThis as any).logger = mockLogger;
|
console.log = consoleLog;
|
||||||
|
console.error = consoleError;
|
||||||
|
|
||||||
// Reset all mocks
|
// Reset all mocks
|
||||||
mockApp.use.mockReset();
|
for (const key in mockModules) {
|
||||||
mockApp.listen.mockReset();
|
const module = mockModules[key as keyof typeof mockModules];
|
||||||
mockLogger.info.mockReset();
|
if (typeof module === 'object' && module !== null) {
|
||||||
mockLogger.error.mockReset();
|
Object.values(module).forEach(value => {
|
||||||
mockLogger.debug.mockReset();
|
if (typeof value === 'function' && 'mock' in value) {
|
||||||
mockLiteMCP.mockReset();
|
(value as Mock<any>).mockReset();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else if (typeof module === 'function' && 'mock' in module) {
|
||||||
|
(module as Mock<any>).mockReset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set default environment variables
|
||||||
|
process.env.NODE_ENV = 'test';
|
||||||
|
process.env.PORT = '4000';
|
||||||
|
|
||||||
|
// Setup module resolution mock
|
||||||
|
originalResolve = (globalThis as any).Bun?.resolveSync;
|
||||||
|
(globalThis as any).Bun = {
|
||||||
|
...(globalThis as any).Bun,
|
||||||
|
resolveSync: (specifier: string) => mockResolver.resolve(specifier)
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
// Restore original environment
|
// Restore original environment
|
||||||
process.env = originalEnv;
|
process.env = originalEnv;
|
||||||
|
|
||||||
// Clean up mocks
|
// Restore module resolution
|
||||||
delete (globalThis as any).express;
|
if (originalResolve) {
|
||||||
delete (globalThis as any).LiteMCP;
|
(globalThis as any).Bun.resolveSync = originalResolve;
|
||||||
delete (globalThis as any).logger;
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should start Express server when not in Claude mode', async () => {
|
test('should initialize server with middleware', async () => {
|
||||||
// Set OpenAI mode
|
// Import and initialize server
|
||||||
process.env.PROCESSOR_TYPE = 'openai';
|
const mod = await import('../src/index');
|
||||||
|
|
||||||
// Import the main module
|
// Verify server initialization
|
||||||
await import('../src/index.js');
|
expect(MockElysia.mock.calls.length).toBe(1);
|
||||||
|
expect(mockCors.mock.calls.length).toBe(1);
|
||||||
|
expect(mockSwagger.mock.calls.length).toBe(1);
|
||||||
|
|
||||||
// Verify Express server was initialized
|
// Verify console output
|
||||||
expect(mockExpress.mock.calls.length).toBeGreaterThan(0);
|
const logCalls = consoleLog.mock.calls;
|
||||||
expect(mockApp.use.mock.calls.length).toBeGreaterThan(0);
|
expect(logCalls.some(call =>
|
||||||
expect(mockApp.listen.mock.calls.length).toBeGreaterThan(0);
|
typeof call.args[0] === 'string' &&
|
||||||
|
call.args[0].includes('Server is running on port')
|
||||||
const infoMessages = mockLogger.info.mock.calls.map(([msg]) => msg);
|
)).toBe(true);
|
||||||
expect(infoMessages.some(msg => msg.includes('Server is running on port'))).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should not start Express server in Claude mode', async () => {
|
test('should initialize speech service when enabled', async () => {
|
||||||
// Set Claude mode
|
// Enable speech service
|
||||||
process.env.PROCESSOR_TYPE = 'claude';
|
process.env.SPEECH_ENABLED = 'true';
|
||||||
|
|
||||||
// Import the main module
|
// Import and initialize server
|
||||||
await import('../src/index.js');
|
const mod = await import('../src/index');
|
||||||
|
|
||||||
// Verify Express server was not initialized
|
// Verify speech service initialization
|
||||||
expect(mockExpress.mock.calls.length).toBe(0);
|
expect(mockSpeechService.initialize.mock.calls.length).toBe(1);
|
||||||
expect(mockApp.use.mock.calls.length).toBe(0);
|
|
||||||
expect(mockApp.listen.mock.calls.length).toBe(0);
|
|
||||||
|
|
||||||
const infoMessages = mockLogger.info.mock.calls.map(([msg]) => msg);
|
|
||||||
expect(infoMessages).toContain('Running in Claude mode - Express server disabled');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should initialize LiteMCP in both modes', async () => {
|
test('should handle server shutdown gracefully', async () => {
|
||||||
// Test OpenAI mode
|
// Enable speech service for shutdown test
|
||||||
process.env.PROCESSOR_TYPE = 'openai';
|
process.env.SPEECH_ENABLED = 'true';
|
||||||
await import('../src/index.js');
|
|
||||||
|
|
||||||
expect(mockLiteMCP.mock.calls.length).toBeGreaterThan(0);
|
// Import and initialize server
|
||||||
const [name, version] = mockLiteMCP.mock.calls[0] ?? [];
|
const mod = await import('../src/index');
|
||||||
expect(name).toBe('home-assistant');
|
|
||||||
expect(typeof version).toBe('string');
|
|
||||||
|
|
||||||
// Reset for next test
|
// Simulate SIGTERM
|
||||||
mockLiteMCP.mockReset();
|
process.emit('SIGTERM');
|
||||||
|
|
||||||
// Test Claude mode
|
// Verify shutdown behavior
|
||||||
process.env.PROCESSOR_TYPE = 'claude';
|
expect(mockSpeechService.shutdown.mock.calls.length).toBe(1);
|
||||||
await import('../src/index.js');
|
expect(consoleLog.mock.calls.some(call =>
|
||||||
|
typeof call.args[0] === 'string' &&
|
||||||
expect(mockLiteMCP.mock.calls.length).toBeGreaterThan(0);
|
call.args[0].includes('Shutting down gracefully')
|
||||||
const [name2, version2] = mockLiteMCP.mock.calls[0] ?? [];
|
)).toBe(true);
|
||||||
expect(name2).toBe('home-assistant');
|
|
||||||
expect(typeof version2).toBe('string');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should handle missing PROCESSOR_TYPE (default to Express server)', async () => {
|
|
||||||
// Remove PROCESSOR_TYPE
|
|
||||||
delete process.env.PROCESSOR_TYPE;
|
|
||||||
|
|
||||||
// Import the main module
|
|
||||||
await import('../src/index.js');
|
|
||||||
|
|
||||||
// Verify Express server was initialized (default behavior)
|
|
||||||
expect(mockExpress.mock.calls.length).toBeGreaterThan(0);
|
|
||||||
expect(mockApp.use.mock.calls.length).toBeGreaterThan(0);
|
|
||||||
expect(mockApp.listen.mock.calls.length).toBeGreaterThan(0);
|
|
||||||
|
|
||||||
const infoMessages = mockLogger.info.mock.calls.map(([msg]) => msg);
|
|
||||||
expect(infoMessages.some(msg => msg.includes('Server is running on port'))).toBe(true);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,81 +1,79 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test, beforeEach, afterEach, mock, spyOn } from "bun:test";
|
||||||
import { SpeechToText, TranscriptionResult, WakeWordEvent, TranscriptionError, TranscriptionOptions } from '../../src/speech/speechToText';
|
import type { Mock } from "bun:test";
|
||||||
import { EventEmitter } from 'events';
|
import { EventEmitter } from "events";
|
||||||
import fs from 'fs';
|
import { SpeechToText, TranscriptionError, type TranscriptionOptions } from "../../src/speech/speechToText";
|
||||||
import path from 'path';
|
import type { SpeechToTextConfig } from "../../src/speech/types";
|
||||||
import { spawn } from 'child_process';
|
import type { ChildProcess } from "child_process";
|
||||||
import { describe, expect, beforeEach, afterEach, it, mock, spyOn } from 'bun:test';
|
|
||||||
|
|
||||||
// Mock child_process spawn
|
interface MockProcess extends EventEmitter {
|
||||||
const spawnMock = mock((cmd: string, args: string[]) => ({
|
stdout: EventEmitter;
|
||||||
stdout: new EventEmitter(),
|
stderr: EventEmitter;
|
||||||
stderr: new EventEmitter(),
|
kill: Mock<() => void>;
|
||||||
on: (event: string, cb: (code: number) => void) => {
|
}
|
||||||
if (event === 'close') setTimeout(() => cb(0), 0);
|
|
||||||
}
|
type SpawnFn = {
|
||||||
}));
|
(cmds: string[], options?: Record<string, unknown>): ChildProcess;
|
||||||
|
};
|
||||||
|
|
||||||
describe('SpeechToText', () => {
|
describe('SpeechToText', () => {
|
||||||
|
let spawnMock: Mock<SpawnFn>;
|
||||||
|
let mockProcess: MockProcess;
|
||||||
let speechToText: SpeechToText;
|
let speechToText: SpeechToText;
|
||||||
const testAudioDir = path.join(import.meta.dir, 'test_audio');
|
|
||||||
const mockConfig = {
|
|
||||||
containerName: 'test-whisper',
|
|
||||||
modelPath: '/models/whisper',
|
|
||||||
modelType: 'base.en'
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
speechToText = new SpeechToText(mockConfig);
|
// Create mock process
|
||||||
// Create test audio directory if it doesn't exist
|
mockProcess = new EventEmitter() as MockProcess;
|
||||||
if (!fs.existsSync(testAudioDir)) {
|
mockProcess.stdout = new EventEmitter();
|
||||||
fs.mkdirSync(testAudioDir, { recursive: true });
|
mockProcess.stderr = new EventEmitter();
|
||||||
}
|
mockProcess.kill = mock(() => { });
|
||||||
// Reset spawn mock
|
|
||||||
spawnMock.mockReset();
|
// Create spawn mock
|
||||||
|
spawnMock = mock((cmds: string[], options?: Record<string, unknown>) => mockProcess as unknown as ChildProcess);
|
||||||
|
(globalThis as any).Bun = { spawn: spawnMock };
|
||||||
|
|
||||||
|
// Initialize SpeechToText
|
||||||
|
const config: SpeechToTextConfig = {
|
||||||
|
modelPath: '/test/model',
|
||||||
|
modelType: 'base.en',
|
||||||
|
containerName: 'test-container'
|
||||||
|
};
|
||||||
|
speechToText = new SpeechToText(config);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
speechToText.stopWakeWordDetection();
|
// Cleanup
|
||||||
// Clean up test files
|
mockProcess.removeAllListeners();
|
||||||
if (fs.existsSync(testAudioDir)) {
|
mockProcess.stdout.removeAllListeners();
|
||||||
fs.rmSync(testAudioDir, { recursive: true, force: true });
|
mockProcess.stderr.removeAllListeners();
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Initialization', () => {
|
describe('Initialization', () => {
|
||||||
test('should create instance with default config', () => {
|
test('should create instance with default config', () => {
|
||||||
const instance = new SpeechToText({ modelPath: '/models/whisper', modelType: 'base.en' });
|
const config: SpeechToTextConfig = {
|
||||||
expect(instance instanceof EventEmitter).toBe(true);
|
modelPath: '/test/model',
|
||||||
expect(instance instanceof SpeechToText).toBe(true);
|
modelType: 'base.en'
|
||||||
|
};
|
||||||
|
const instance = new SpeechToText(config);
|
||||||
|
expect(instance).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should initialize successfully', async () => {
|
test('should initialize successfully', async () => {
|
||||||
const initSpy = spyOn(speechToText, 'initialize');
|
const result = await speechToText.initialize();
|
||||||
await speechToText.initialize();
|
expect(result).toBeUndefined();
|
||||||
expect(initSpy).toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should not initialize twice', async () => {
|
test('should not initialize twice', async () => {
|
||||||
await speechToText.initialize();
|
await speechToText.initialize();
|
||||||
const initSpy = spyOn(speechToText, 'initialize');
|
const result = await speechToText.initialize();
|
||||||
await speechToText.initialize();
|
expect(result).toBeUndefined();
|
||||||
expect(initSpy.mock.calls.length).toBe(1);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Health Check', () => {
|
describe('Health Check', () => {
|
||||||
test('should return true when Docker container is running', async () => {
|
test('should return true when Docker container is running', async () => {
|
||||||
const mockProcess = {
|
// Setup mock process
|
||||||
stdout: new EventEmitter(),
|
|
||||||
stderr: new EventEmitter(),
|
|
||||||
on: (event: string, cb: (code: number) => void) => {
|
|
||||||
if (event === 'close') setTimeout(() => cb(0), 0);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
spawnMock.mockImplementation(() => mockProcess);
|
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
mockProcess.stdout.emtest('data', Buffer.from('Up 2 hours'));
|
mockProcess.stdout.emit('data', Buffer.from('Up 2 hours'));
|
||||||
}, 0);
|
}, 0);
|
||||||
|
|
||||||
const result = await speechToText.checkHealth();
|
const result = await speechToText.checkHealth();
|
||||||
@@ -83,23 +81,20 @@ describe('SpeechToText', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('should return false when Docker container is not running', async () => {
|
test('should return false when Docker container is not running', async () => {
|
||||||
const mockProcess = {
|
// Setup mock process
|
||||||
stdout: new EventEmitter(),
|
setTimeout(() => {
|
||||||
stderr: new EventEmitter(),
|
mockProcess.stdout.emit('data', Buffer.from('No containers found'));
|
||||||
on: (event: string, cb: (code: number) => void) => {
|
}, 0);
|
||||||
if (event === 'close') setTimeout(() => cb(1), 0);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
spawnMock.mockImplementation(() => mockProcess);
|
|
||||||
|
|
||||||
const result = await speechToText.checkHealth();
|
const result = await speechToText.checkHealth();
|
||||||
expect(result).toBe(false);
|
expect(result).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should handle Docker command errors', async () => {
|
test('should handle Docker command errors', async () => {
|
||||||
spawnMock.mockImplementation(() => {
|
// Setup mock process
|
||||||
throw new Error('Docker not found');
|
setTimeout(() => {
|
||||||
});
|
mockProcess.stderr.emit('data', Buffer.from('Docker error'));
|
||||||
|
}, 0);
|
||||||
|
|
||||||
const result = await speechToText.checkHealth();
|
const result = await speechToText.checkHealth();
|
||||||
expect(result).toBe(false);
|
expect(result).toBe(false);
|
||||||
@@ -108,51 +103,48 @@ describe('SpeechToText', () => {
|
|||||||
|
|
||||||
describe('Wake Word Detection', () => {
|
describe('Wake Word Detection', () => {
|
||||||
test('should detect wake word and emit event', async () => {
|
test('should detect wake word and emit event', async () => {
|
||||||
const testFile = path.join(testAudioDir, 'wake_word_test_123456.wav');
|
// Setup mock process
|
||||||
const testMetadata = `${testFile}.json`;
|
setTimeout(() => {
|
||||||
|
mockProcess.stdout.emit('data', Buffer.from('Wake word detected'));
|
||||||
|
}, 0);
|
||||||
|
|
||||||
return new Promise<void>((resolve) => {
|
const wakeWordPromise = new Promise<void>((resolve) => {
|
||||||
speechToText.startWakeWordDetection(testAudioDir);
|
speechToText.on('wake_word', () => {
|
||||||
|
|
||||||
speechToText.on('wake_word', (event: WakeWordEvent) => {
|
|
||||||
expect(event).toBeDefined();
|
|
||||||
expect(event.audioFile).toBe(testFile);
|
|
||||||
expect(event.metadataFile).toBe(testMetadata);
|
|
||||||
expect(event.timestamp).toBe('123456');
|
|
||||||
resolve();
|
resolve();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create a test audio file to trigger the event
|
|
||||||
fs.writeFileSync(testFile, 'test audio content');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
speechToText.startWakeWordDetection();
|
||||||
|
await wakeWordPromise;
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should handle non-wake-word files', async () => {
|
test('should handle non-wake-word files', async () => {
|
||||||
const testFile = path.join(testAudioDir, 'regular_audio.wav');
|
// Setup mock process
|
||||||
let eventEmitted = false;
|
|
||||||
|
|
||||||
return new Promise<void>((resolve) => {
|
|
||||||
speechToText.startWakeWordDetection(testAudioDir);
|
|
||||||
|
|
||||||
speechToText.on('wake_word', () => {
|
|
||||||
eventEmitted = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
fs.writeFileSync(testFile, 'test audio content');
|
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
expect(eventEmitted).toBe(false);
|
mockProcess.stdout.emit('data', Buffer.from('Processing audio'));
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
const wakeWordPromise = new Promise<void>((resolve, reject) => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
resolve();
|
resolve();
|
||||||
}, 100);
|
}, 100);
|
||||||
|
|
||||||
|
speechToText.on('wake_word', () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
reject(new Error('Wake word should not be detected'));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
speechToText.startWakeWordDetection();
|
||||||
|
await wakeWordPromise;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Audio Transcription', () => {
|
describe('Audio Transcription', () => {
|
||||||
const mockTranscriptionResult: TranscriptionResult = {
|
const mockTranscriptionResult = {
|
||||||
text: 'Hello world',
|
text: 'Test transcription',
|
||||||
segments: [{
|
segments: [{
|
||||||
text: 'Hello world',
|
text: 'Test transcription',
|
||||||
start: 0,
|
start: 0,
|
||||||
end: 1,
|
end: 1,
|
||||||
confidence: 0.95
|
confidence: 0.95
|
||||||
@@ -160,169 +152,100 @@ describe('SpeechToText', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
test('should transcribe audio successfully', async () => {
|
test('should transcribe audio successfully', async () => {
|
||||||
const mockProcess = {
|
// Setup mock process
|
||||||
stdout: new EventEmitter(),
|
|
||||||
stderr: new EventEmitter(),
|
|
||||||
on: (event: string, cb: (code: number) => void) => {
|
|
||||||
if (event === 'close') setTimeout(() => cb(0), 0);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
spawnMock.mockImplementation(() => mockProcess);
|
|
||||||
|
|
||||||
const transcriptionPromise = speechToText.transcribeAudio('/test/audio.wav');
|
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
mockProcess.stdout.emtest('data', Buffer.from(JSON.stringify(mockTranscriptionResult)));
|
mockProcess.stdout.emit('data', Buffer.from(JSON.stringify(mockTranscriptionResult)));
|
||||||
}, 0);
|
}, 0);
|
||||||
|
|
||||||
const result = await transcriptionPromise;
|
const result = await speechToText.transcribeAudio('/test/audio.wav');
|
||||||
expect(result).toEqual(mockTranscriptionResult);
|
expect(result).toEqual(mockTranscriptionResult);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should handle transcription errors', async () => {
|
test('should handle transcription errors', async () => {
|
||||||
const mockProcess = {
|
// Setup mock process
|
||||||
stdout: new EventEmitter(),
|
|
||||||
stderr: new EventEmitter(),
|
|
||||||
on: (event: string, cb: (code: number) => void) => {
|
|
||||||
if (event === 'close') setTimeout(() => cb(1), 0);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
spawnMock.mockImplementation(() => mockProcess);
|
|
||||||
|
|
||||||
const transcriptionPromise = speechToText.transcribeAudio('/test/audio.wav');
|
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
mockProcess.stderr.emtest('data', Buffer.from('Transcription failed'));
|
mockProcess.stderr.emit('data', Buffer.from('Transcription failed'));
|
||||||
}, 0);
|
}, 0);
|
||||||
|
|
||||||
await expect(transcriptionPromise).rejects.toThrow(TranscriptionError);
|
await expect(speechToText.transcribeAudio('/test/audio.wav')).rejects.toThrow(TranscriptionError);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should handle invalid JSON output', async () => {
|
test('should handle invalid JSON output', async () => {
|
||||||
const mockProcess = {
|
// Setup mock process
|
||||||
stdout: new EventEmitter(),
|
|
||||||
stderr: new EventEmitter(),
|
|
||||||
on: (event: string, cb: (code: number) => void) => {
|
|
||||||
if (event === 'close') setTimeout(() => cb(0), 0);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
spawnMock.mockImplementation(() => mockProcess);
|
|
||||||
|
|
||||||
const transcriptionPromise = speechToText.transcribeAudio('/test/audio.wav');
|
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
mockProcess.stdout.emtest('data', Buffer.from('Invalid JSON'));
|
mockProcess.stdout.emit('data', Buffer.from('Invalid JSON'));
|
||||||
}, 0);
|
}, 0);
|
||||||
|
|
||||||
await expect(transcriptionPromise).rejects.toThrow(TranscriptionError);
|
await expect(speechToText.transcribeAudio('/test/audio.wav')).rejects.toThrow(TranscriptionError);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should pass correct transcription options', async () => {
|
test('should pass correct transcription options', async () => {
|
||||||
const options: TranscriptionOptions = {
|
const options: TranscriptionOptions = {
|
||||||
model: 'large-v2',
|
model: 'base.en',
|
||||||
language: 'en',
|
language: 'en',
|
||||||
temperature: 0.5,
|
temperature: 0,
|
||||||
beamSize: 3,
|
beamSize: 5,
|
||||||
patience: 2,
|
patience: 1,
|
||||||
device: 'cuda'
|
device: 'cpu'
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockProcess = {
|
await speechToText.transcribeAudio('/test/audio.wav', options);
|
||||||
stdout: new EventEmitter(),
|
|
||||||
stderr: new EventEmitter(),
|
|
||||||
on: (event: string, cb: (code: number) => void) => {
|
|
||||||
if (event === 'close') setTimeout(() => cb(0), 0);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
spawnMock.mockImplementation(() => mockProcess);
|
|
||||||
|
|
||||||
const transcriptionPromise = speechToText.transcribeAudio('/test/audio.wav', options);
|
const spawnArgs = spawnMock.mock.calls[0]?.args[1] || [];
|
||||||
|
expect(spawnArgs).toContain('--model');
|
||||||
const expectedArgs = [
|
expect(spawnArgs).toContain(options.model);
|
||||||
'exec',
|
expect(spawnArgs).toContain('--language');
|
||||||
mockConfig.containerName,
|
expect(spawnArgs).toContain(options.language);
|
||||||
'fast-whisper',
|
expect(spawnArgs).toContain('--temperature');
|
||||||
'--model', options.model,
|
expect(spawnArgs).toContain(options.temperature?.toString());
|
||||||
'--language', options.language,
|
expect(spawnArgs).toContain('--beam-size');
|
||||||
'--temperature', String(options.temperature ?? 0),
|
expect(spawnArgs).toContain(options.beamSize?.toString());
|
||||||
'--beam-size', String(options.beamSize ?? 5),
|
expect(spawnArgs).toContain('--patience');
|
||||||
'--patience', String(options.patience ?? 1),
|
expect(spawnArgs).toContain(options.patience?.toString());
|
||||||
'--device', options.device
|
expect(spawnArgs).toContain('--device');
|
||||||
].filter((arg): arg is string => arg !== undefined);
|
expect(spawnArgs).toContain(options.device);
|
||||||
|
|
||||||
const mockCalls = spawnMock.mock.calls;
|
|
||||||
expect(mockCalls.length).toBe(1);
|
|
||||||
const [cmd, args] = mockCalls[0].args;
|
|
||||||
expect(cmd).toBe('docker');
|
|
||||||
expect(expectedArgs.every(arg => args.includes(arg))).toBe(true);
|
|
||||||
|
|
||||||
await transcriptionPromise.catch(() => { });
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Event Handling', () => {
|
describe('Event Handling', () => {
|
||||||
test('should emit progress events', async () => {
|
test('should emit progress events', async () => {
|
||||||
const mockProcess = {
|
const progressPromise = new Promise<void>((resolve) => {
|
||||||
stdout: new EventEmitter(),
|
speechToText.on('progress', (progress) => {
|
||||||
stderr: new EventEmitter(),
|
expect(progress).toEqual({ type: 'stdout', data: 'Processing' });
|
||||||
on: (event: string, cb: (code: number) => void) => {
|
|
||||||
if (event === 'close') setTimeout(() => cb(0), 0);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
spawnMock.mockImplementation(() => mockProcess);
|
|
||||||
|
|
||||||
return new Promise<void>((resolve) => {
|
|
||||||
const progressEvents: any[] = [];
|
|
||||||
speechToText.on('progress', (event) => {
|
|
||||||
progressEvents.push(event);
|
|
||||||
if (progressEvents.length === 2) {
|
|
||||||
expect(progressEvents).toEqual([
|
|
||||||
{ type: 'stdout', data: 'Processing' },
|
|
||||||
{ type: 'stderr', data: 'Loading model' }
|
|
||||||
]);
|
|
||||||
resolve();
|
resolve();
|
||||||
}
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
void speechToText.transcribeAudio('/test/audio.wav');
|
const transcribePromise = speechToText.transcribeAudio('/test/audio.wav');
|
||||||
|
mockProcess.stdout.emit('data', Buffer.from('Processing'));
|
||||||
mockProcess.stdout.emtest('data', Buffer.from('Processing'));
|
await Promise.all([transcribePromise.catch(() => { }), progressPromise]);
|
||||||
mockProcess.stderr.emtest('data', Buffer.from('Loading model'));
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should emit error events', async () => {
|
test('should emit error events', async () => {
|
||||||
return new Promise<void>((resolve) => {
|
const errorPromise = new Promise<void>((resolve) => {
|
||||||
speechToText.on('error', (error) => {
|
speechToText.on('error', (error) => {
|
||||||
expect(error instanceof Error).toBe(true);
|
expect(error instanceof Error).toBe(true);
|
||||||
expect(error.message).toBe('Test error');
|
expect(error.message).toBe('Test error');
|
||||||
resolve();
|
resolve();
|
||||||
});
|
});
|
||||||
|
|
||||||
speechToText.emtest('error', new Error('Test error'));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
speechToText.emit('error', new Error('Test error'));
|
||||||
|
await errorPromise;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Cleanup', () => {
|
describe('Cleanup', () => {
|
||||||
test('should stop wake word detection', () => {
|
test('should stop wake word detection', () => {
|
||||||
speechToText.startWakeWordDetection(testAudioDir);
|
speechToText.startWakeWordDetection();
|
||||||
speechToText.stopWakeWordDetection();
|
speechToText.stopWakeWordDetection();
|
||||||
// Verify no more file watching events are processed
|
expect(mockProcess.kill.mock.calls.length).toBe(1);
|
||||||
const testFile = path.join(testAudioDir, 'wake_word_test_123456.wav');
|
|
||||||
let eventEmitted = false;
|
|
||||||
speechToText.on('wake_word', () => {
|
|
||||||
eventEmitted = true;
|
|
||||||
});
|
|
||||||
fs.writeFileSync(testFile, 'test audio content');
|
|
||||||
expect(eventEmitted).toBe(false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should clean up resources on shutdown', async () => {
|
test('should clean up resources on shutdown', async () => {
|
||||||
await speechToText.initialize();
|
await speechToText.initialize();
|
||||||
const shutdownSpy = spyOn(speechToText, 'shutdown');
|
|
||||||
await speechToText.shutdown();
|
await speechToText.shutdown();
|
||||||
expect(shutdownSpy).toHaveBeenCalled();
|
expect(mockProcess.kill.mock.calls.length).toBe(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,120 +1,181 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test, beforeEach, afterEach, mock } from "bun:test";
|
||||||
import { jest, describe, it, expect, beforeEach, afterEach } from '@jest/globals';
|
import { EventEmitter } from "events";
|
||||||
import { HassWebSocketClient } from '../../src/websocket/client.js';
|
import { HassWebSocketClient } from "../../src/websocket/client";
|
||||||
import WebSocket from 'ws';
|
import type { MessageEvent, ErrorEvent } from "ws";
|
||||||
import { EventEmitter } from 'events';
|
|
||||||
import * as HomeAssistant from '../../src/types/hass.js';
|
|
||||||
|
|
||||||
// Mock WebSocket
|
|
||||||
// // jest.mock('ws');
|
|
||||||
|
|
||||||
describe('WebSocket Event Handling', () => {
|
describe('WebSocket Event Handling', () => {
|
||||||
let client: HassWebSocketClient;
|
let client: HassWebSocketClient;
|
||||||
let mockWebSocket: jest.Mocked<WebSocket>;
|
|
||||||
let eventEmitter: EventEmitter;
|
let eventEmitter: EventEmitter;
|
||||||
|
let mockWebSocket: any;
|
||||||
|
let onOpenCallback: () => void;
|
||||||
|
let onCloseCallback: () => void;
|
||||||
|
let onErrorCallback: (event: any) => void;
|
||||||
|
let onMessageCallback: (event: any) => void;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// Clear all mocks
|
|
||||||
jest.clearAllMocks();
|
|
||||||
|
|
||||||
// Create event emitter for mocking WebSocket events
|
|
||||||
eventEmitter = new EventEmitter();
|
eventEmitter = new EventEmitter();
|
||||||
|
|
||||||
// Create mock WebSocket instance
|
|
||||||
mockWebSocket = {
|
mockWebSocket = {
|
||||||
on: jest.fn((event: string, listener: (...args: any[]) => void) => {
|
|
||||||
eventEmitter.on(event, listener);
|
|
||||||
return mockWebSocket;
|
|
||||||
}),
|
|
||||||
send: mock(),
|
send: mock(),
|
||||||
close: mock(),
|
close: mock(),
|
||||||
readyState: WebSocket.OPEN,
|
readyState: 1,
|
||||||
removeAllListeners: mock(),
|
OPEN: 1
|
||||||
// Add required WebSocket properties
|
};
|
||||||
binaryType: 'arraybuffer',
|
|
||||||
bufferedAmount: 0,
|
|
||||||
extensions: '',
|
|
||||||
protocol: '',
|
|
||||||
url: 'ws://test.com',
|
|
||||||
isPaused: () => false,
|
|
||||||
ping: mock(),
|
|
||||||
pong: mock(),
|
|
||||||
terminate: mock()
|
|
||||||
} as unknown as jest.Mocked<WebSocket>;
|
|
||||||
|
|
||||||
// Mock WebSocket constructor
|
// Initialize callback storage
|
||||||
(WebSocket as unknown as jest.Mock).mockImplementation(() => mockWebSocket);
|
let storedOnOpen: () => void;
|
||||||
|
let storedOnClose: () => void;
|
||||||
|
let storedOnError: (event: any) => void;
|
||||||
|
let storedOnMessage: (event: any) => void;
|
||||||
|
|
||||||
// Create client instance
|
// Define setters that store the callbacks
|
||||||
client = new HassWebSocketClient('ws://test.com', 'test-token');
|
Object.defineProperties(mockWebSocket, {
|
||||||
|
onopen: {
|
||||||
|
set(callback: () => void) {
|
||||||
|
storedOnOpen = callback;
|
||||||
|
onOpenCallback = () => storedOnOpen?.();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onclose: {
|
||||||
|
set(callback: () => void) {
|
||||||
|
storedOnClose = callback;
|
||||||
|
onCloseCallback = () => storedOnClose?.();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onerror: {
|
||||||
|
set(callback: (event: any) => void) {
|
||||||
|
storedOnError = callback;
|
||||||
|
onErrorCallback = (event: any) => storedOnError?.(event);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onmessage: {
|
||||||
|
set(callback: (event: any) => void) {
|
||||||
|
storedOnMessage = callback;
|
||||||
|
onMessageCallback = (event: any) => storedOnMessage?.(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// @ts-expect-error - Mock WebSocket implementation
|
||||||
|
global.WebSocket = mock(() => mockWebSocket);
|
||||||
|
|
||||||
|
client = new HassWebSocketClient('ws://localhost:8123/api/websocket', 'test-token');
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
eventEmitter.removeAllListeners();
|
eventEmitter.removeAllListeners();
|
||||||
|
if (client) {
|
||||||
client.disconnect();
|
client.disconnect();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should handle connection events', () => {
|
test('should handle connection events', async () => {
|
||||||
// Simulate open event
|
const connectPromise = client.connect();
|
||||||
eventEmitter.emtest('open');
|
onOpenCallback();
|
||||||
|
await connectPromise;
|
||||||
// Verify authentication message was sent
|
expect(client.isConnected()).toBe(true);
|
||||||
expect(mockWebSocket.send).toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining('"type":"auth"')
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should handle authentication response', () => {
|
test('should handle authentication response', async () => {
|
||||||
// Simulate auth_ok message
|
const connectPromise = client.connect();
|
||||||
eventEmitter.emtest('message', JSON.stringify({ type: 'auth_ok' }));
|
onOpenCallback();
|
||||||
|
|
||||||
// Verify client is ready for commands
|
onMessageCallback({
|
||||||
expect(mockWebSocket.readyState).toBe(WebSocket.OPEN);
|
data: JSON.stringify({
|
||||||
|
type: 'auth_required'
|
||||||
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should handle auth failure', () => {
|
onMessageCallback({
|
||||||
// Simulate auth_invalid message
|
data: JSON.stringify({
|
||||||
eventEmitter.emtest('message', JSON.stringify({
|
type: 'auth_ok'
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
await connectPromise;
|
||||||
|
expect(client.isAuthenticated()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle auth failure', async () => {
|
||||||
|
const connectPromise = client.connect();
|
||||||
|
onOpenCallback();
|
||||||
|
|
||||||
|
onMessageCallback({
|
||||||
|
data: JSON.stringify({
|
||||||
|
type: 'auth_required'
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
onMessageCallback({
|
||||||
|
data: JSON.stringify({
|
||||||
type: 'auth_invalid',
|
type: 'auth_invalid',
|
||||||
message: 'Invalid token'
|
message: 'Invalid password'
|
||||||
}));
|
})
|
||||||
|
|
||||||
// Verify client attempts to close connection
|
|
||||||
expect(mockWebSocket.close).toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should handle connection errors', () => {
|
await expect(connectPromise).rejects.toThrow('Authentication failed');
|
||||||
// Create error spy
|
expect(client.isAuthenticated()).toBe(false);
|
||||||
const errorSpy = mock();
|
|
||||||
client.on('error', errorSpy);
|
|
||||||
|
|
||||||
// Simulate error
|
|
||||||
const testError = new Error('Test error');
|
|
||||||
eventEmitter.emtest('error', testError);
|
|
||||||
|
|
||||||
// Verify error was handled
|
|
||||||
expect(errorSpy).toHaveBeenCalledWith(testError);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should handle disconnection', () => {
|
test('should handle connection errors', async () => {
|
||||||
// Create close spy
|
const errorPromise = new Promise((resolve) => {
|
||||||
const closeSpy = mock();
|
client.on('error', resolve);
|
||||||
client.on('close', closeSpy);
|
|
||||||
|
|
||||||
// Simulate close
|
|
||||||
eventEmitter.emtest('close');
|
|
||||||
|
|
||||||
// Verify close was handled
|
|
||||||
expect(closeSpy).toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should handle event messages', () => {
|
const connectPromise = client.connect();
|
||||||
// Create event spy
|
onOpenCallback();
|
||||||
const eventSpy = mock();
|
|
||||||
client.on('event', eventSpy);
|
const errorEvent = {
|
||||||
|
error: new Error('Connection failed'),
|
||||||
|
message: 'Connection failed',
|
||||||
|
target: mockWebSocket
|
||||||
|
};
|
||||||
|
|
||||||
|
onErrorCallback(errorEvent);
|
||||||
|
|
||||||
|
const error = await errorPromise;
|
||||||
|
expect(error).toBeDefined();
|
||||||
|
expect((error as Error).message).toBe('Connection failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle disconnection', async () => {
|
||||||
|
const connectPromise = client.connect();
|
||||||
|
onOpenCallback();
|
||||||
|
await connectPromise;
|
||||||
|
|
||||||
|
const disconnectPromise = new Promise((resolve) => {
|
||||||
|
client.on('disconnected', resolve);
|
||||||
|
});
|
||||||
|
|
||||||
|
onCloseCallback();
|
||||||
|
|
||||||
|
await disconnectPromise;
|
||||||
|
expect(client.isConnected()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle event messages', async () => {
|
||||||
|
const connectPromise = client.connect();
|
||||||
|
onOpenCallback();
|
||||||
|
|
||||||
|
onMessageCallback({
|
||||||
|
data: JSON.stringify({
|
||||||
|
type: 'auth_required'
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
onMessageCallback({
|
||||||
|
data: JSON.stringify({
|
||||||
|
type: 'auth_ok'
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
await connectPromise;
|
||||||
|
|
||||||
|
const eventPromise = new Promise((resolve) => {
|
||||||
|
client.on('state_changed', resolve);
|
||||||
|
});
|
||||||
|
|
||||||
// Simulate event message
|
|
||||||
const eventData = {
|
const eventData = {
|
||||||
|
id: 1,
|
||||||
type: 'event',
|
type: 'event',
|
||||||
event: {
|
event: {
|
||||||
event_type: 'state_changed',
|
event_type: 'state_changed',
|
||||||
@@ -124,217 +185,67 @@ describe('WebSocket Event Handling', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
eventEmitter.emtest('message', JSON.stringify(eventData));
|
|
||||||
|
|
||||||
// Verify event was handled
|
onMessageCallback({
|
||||||
expect(eventSpy).toHaveBeenCalledWith(eventData.event);
|
data: JSON.stringify(eventData)
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Connection Events', () => {
|
const receivedEvent = await eventPromise;
|
||||||
test('should handle successful connection', (done) => {
|
expect(receivedEvent).toEqual(eventData.event.data);
|
||||||
client.on('open', () => {
|
|
||||||
expect(mockWebSocket.send).toHaveBeenCalled();
|
|
||||||
done();
|
|
||||||
});
|
|
||||||
|
|
||||||
eventEmitter.emtest('open');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should handle connection errors', (done) => {
|
|
||||||
const error = new Error('Connection failed');
|
|
||||||
client.on('error', (err: Error) => {
|
|
||||||
expect(err).toBe(error);
|
|
||||||
done();
|
|
||||||
});
|
|
||||||
|
|
||||||
eventEmitter.emtest('error', error);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should handle connection close', (done) => {
|
|
||||||
client.on('disconnected', () => {
|
|
||||||
expect(mockWebSocket.close).toHaveBeenCalled();
|
|
||||||
done();
|
|
||||||
});
|
|
||||||
|
|
||||||
eventEmitter.emtest('close');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Authentication', () => {
|
|
||||||
test('should send authentication message on connect', () => {
|
|
||||||
const authMessage: HomeAssistant.AuthMessage = {
|
|
||||||
type: 'auth',
|
|
||||||
access_token: 'test_token'
|
|
||||||
};
|
|
||||||
|
|
||||||
client.connect();
|
|
||||||
expect(mockWebSocket.send).toHaveBeenCalledWith(JSON.stringify(authMessage));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should handle successful authentication', (done) => {
|
|
||||||
client.on('auth_ok', () => {
|
|
||||||
done();
|
|
||||||
});
|
|
||||||
|
|
||||||
client.connect();
|
|
||||||
eventEmitter.emtest('message', JSON.stringify({ type: 'auth_ok' }));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should handle authentication failure', (done) => {
|
|
||||||
client.on('auth_invalid', () => {
|
|
||||||
done();
|
|
||||||
});
|
|
||||||
|
|
||||||
client.connect();
|
|
||||||
eventEmitter.emtest('message', JSON.stringify({ type: 'auth_invalid' }));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Event Subscription', () => {
|
|
||||||
test('should handle state changed events', (done) => {
|
|
||||||
const stateEvent: HomeAssistant.StateChangedEvent = {
|
|
||||||
event_type: 'state_changed',
|
|
||||||
data: {
|
|
||||||
entity_id: 'light.living_room',
|
|
||||||
new_state: {
|
|
||||||
entity_id: 'light.living_room',
|
|
||||||
state: 'on',
|
|
||||||
attributes: { brightness: 255 },
|
|
||||||
last_changed: '2024-01-01T00:00:00Z',
|
|
||||||
last_updated: '2024-01-01T00:00:00Z',
|
|
||||||
context: {
|
|
||||||
id: '123',
|
|
||||||
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: '122',
|
|
||||||
parent_id: null,
|
|
||||||
user_id: null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
origin: 'LOCAL',
|
|
||||||
time_fired: '2024-01-01T00:00:00Z',
|
|
||||||
context: {
|
|
||||||
id: '123',
|
|
||||||
parent_id: null,
|
|
||||||
user_id: null
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
client.on('event', (event) => {
|
|
||||||
expect(event.data.entity_id).toBe('light.living_room');
|
|
||||||
expect(event.data.new_state.state).toBe('on');
|
|
||||||
expect(event.data.old_state.state).toBe('off');
|
|
||||||
done();
|
|
||||||
});
|
|
||||||
|
|
||||||
eventEmitter.emtest('message', JSON.stringify({ type: 'event', event: stateEvent }));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should subscribe to specific events', async () => {
|
test('should subscribe to specific events', async () => {
|
||||||
const subscriptionId = 1;
|
const connectPromise = client.connect();
|
||||||
const callback = mock();
|
onOpenCallback();
|
||||||
|
|
||||||
// Mock successful subscription
|
onMessageCallback({
|
||||||
const subscribePromise = client.subscribeEvents('state_changed', callback);
|
data: JSON.stringify({
|
||||||
eventEmitter.emtest('message', JSON.stringify({
|
type: 'auth_required'
|
||||||
id: 1,
|
})
|
||||||
type: 'result',
|
});
|
||||||
success: true
|
|
||||||
}));
|
|
||||||
|
|
||||||
await expect(subscribePromise).resolves.toBe(subscriptionId);
|
onMessageCallback({
|
||||||
|
data: JSON.stringify({
|
||||||
|
type: 'auth_ok'
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
// Test event handling
|
await connectPromise;
|
||||||
const eventData = {
|
|
||||||
entity_id: 'light.living_room',
|
|
||||||
state: 'on'
|
|
||||||
};
|
|
||||||
eventEmitter.emtest('message', JSON.stringify({
|
|
||||||
type: 'event',
|
|
||||||
event: {
|
|
||||||
event_type: 'state_changed',
|
|
||||||
data: eventData
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
expect(callback).toHaveBeenCalledWith(eventData);
|
const subscriptionId = await client.subscribeEvents('state_changed', (data) => {
|
||||||
|
// Empty callback for type satisfaction
|
||||||
|
});
|
||||||
|
expect(mockWebSocket.send).toHaveBeenCalledWith(
|
||||||
|
expect.stringMatching(/"type":"subscribe_events"/)
|
||||||
|
);
|
||||||
|
expect(subscriptionId).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should unsubscribe from events', async () => {
|
test('should unsubscribe from events', async () => {
|
||||||
// First subscribe
|
const connectPromise = client.connect();
|
||||||
const subscriptionId = await client.subscribeEvents('state_changed', () => { });
|
onOpenCallback();
|
||||||
|
|
||||||
// Then unsubscribe
|
onMessageCallback({
|
||||||
const unsubscribePromise = client.unsubscribeEvents(subscriptionId);
|
data: JSON.stringify({
|
||||||
eventEmitter.emtest('message', JSON.stringify({
|
type: 'auth_required'
|
||||||
id: 2,
|
})
|
||||||
type: 'result',
|
|
||||||
success: true
|
|
||||||
}));
|
|
||||||
|
|
||||||
await expect(unsubscribePromise).resolves.toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Message Handling', () => {
|
onMessageCallback({
|
||||||
test('should handle malformed messages', (done) => {
|
data: JSON.stringify({
|
||||||
client.on('error', (error: Error) => {
|
type: 'auth_ok'
|
||||||
expect(error.message).toContain('Unexpected token');
|
})
|
||||||
done();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
eventEmitter.emtest('message', 'invalid json');
|
await connectPromise;
|
||||||
});
|
|
||||||
|
|
||||||
test('should handle unknown message types', (done) => {
|
const subscriptionId = await client.subscribeEvents('state_changed', (data) => {
|
||||||
const unknownMessage = {
|
// Empty callback for type satisfaction
|
||||||
type: 'unknown_type',
|
|
||||||
data: {}
|
|
||||||
};
|
|
||||||
|
|
||||||
client.on('error', (error: Error) => {
|
|
||||||
expect(error.message).toContain('Unknown message type');
|
|
||||||
done();
|
|
||||||
});
|
});
|
||||||
|
await client.unsubscribeEvents(subscriptionId);
|
||||||
|
|
||||||
eventEmitter.emtest('message', JSON.stringify(unknownMessage));
|
expect(mockWebSocket.send).toHaveBeenCalledWith(
|
||||||
});
|
expect.stringMatching(/"type":"unsubscribe_events"/)
|
||||||
});
|
);
|
||||||
|
|
||||||
describe('Reconnection', () => {
|
|
||||||
test('should attempt to reconnect on connection loss', (done) => {
|
|
||||||
let reconnectAttempts = 0;
|
|
||||||
client.on('disconnected', () => {
|
|
||||||
reconnectAttempts++;
|
|
||||||
if (reconnectAttempts === 1) {
|
|
||||||
expect(WebSocket).toHaveBeenCalledTimes(2);
|
|
||||||
done();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
eventEmitter.emtest('close');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should re-authenticate after reconnection', (done) => {
|
|
||||||
client.connect();
|
|
||||||
|
|
||||||
client.on('auth_ok', () => {
|
|
||||||
done();
|
|
||||||
});
|
|
||||||
|
|
||||||
eventEmitter.emtest('close');
|
|
||||||
eventEmitter.emtest('open');
|
|
||||||
eventEmitter.emtest('message', JSON.stringify({ type: 'auth_ok' }));
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -55,7 +55,8 @@
|
|||||||
"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"
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
test audio content
|
|
||||||
@@ -1,183 +1,256 @@
|
|||||||
import WebSocket from "ws";
|
import WebSocket from "ws";
|
||||||
import { EventEmitter } from "events";
|
import { EventEmitter } from "events";
|
||||||
|
|
||||||
|
interface HassMessage {
|
||||||
|
type: string;
|
||||||
|
id?: number;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HassAuthMessage extends HassMessage {
|
||||||
|
type: "auth";
|
||||||
|
access_token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HassEventMessage extends HassMessage {
|
||||||
|
type: "event";
|
||||||
|
event: {
|
||||||
|
event_type: string;
|
||||||
|
data: any;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HassSubscribeMessage extends HassMessage {
|
||||||
|
type: "subscribe_events";
|
||||||
|
event_type?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HassUnsubscribeMessage extends HassMessage {
|
||||||
|
type: "unsubscribe_events";
|
||||||
|
subscription: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HassResultMessage extends HassMessage {
|
||||||
|
type: "result";
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export class HassWebSocketClient extends EventEmitter {
|
export class HassWebSocketClient extends EventEmitter {
|
||||||
private ws: WebSocket | null = null;
|
private ws: WebSocket | null = null;
|
||||||
private messageId = 1;
|
|
||||||
private authenticated = false;
|
private authenticated = false;
|
||||||
|
private messageId = 1;
|
||||||
|
private subscriptions = new Map<number, (data: any) => void>();
|
||||||
|
private url: string;
|
||||||
|
private token: string;
|
||||||
private reconnectAttempts = 0;
|
private reconnectAttempts = 0;
|
||||||
private maxReconnectAttempts = 5;
|
private maxReconnectAttempts = 3;
|
||||||
private reconnectDelay = 1000;
|
|
||||||
private subscriptions = new Map<string, (data: any) => void>();
|
|
||||||
|
|
||||||
constructor(
|
constructor(url: string, token: string) {
|
||||||
private url: string,
|
|
||||||
private token: string,
|
|
||||||
private options: {
|
|
||||||
autoReconnect?: boolean;
|
|
||||||
maxReconnectAttempts?: number;
|
|
||||||
reconnectDelay?: number;
|
|
||||||
} = {},
|
|
||||||
) {
|
|
||||||
super();
|
super();
|
||||||
this.maxReconnectAttempts = options.maxReconnectAttempts || 5;
|
this.url = url;
|
||||||
this.reconnectDelay = options.reconnectDelay || 1000;
|
this.token = token;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async connect(): Promise<void> {
|
public async connect(): Promise<void> {
|
||||||
|
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
try {
|
try {
|
||||||
this.ws = new WebSocket(this.url);
|
this.ws = new WebSocket(this.url);
|
||||||
|
|
||||||
this.ws.on("open", () => {
|
this.ws.onopen = () => {
|
||||||
|
this.emit('connect');
|
||||||
this.authenticate();
|
this.authenticate();
|
||||||
});
|
|
||||||
|
|
||||||
this.ws.on("message", (data: string) => {
|
|
||||||
const message = JSON.parse(data);
|
|
||||||
this.handleMessage(message);
|
|
||||||
});
|
|
||||||
|
|
||||||
this.ws.on("close", () => {
|
|
||||||
this.handleDisconnect();
|
|
||||||
});
|
|
||||||
|
|
||||||
this.ws.on("error", (error) => {
|
|
||||||
this.emit("error", error);
|
|
||||||
reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
this.once("auth_ok", () => {
|
|
||||||
this.authenticated = true;
|
|
||||||
this.reconnectAttempts = 0;
|
|
||||||
resolve();
|
resolve();
|
||||||
});
|
};
|
||||||
|
|
||||||
this.once("auth_invalid", () => {
|
this.ws.onclose = () => {
|
||||||
reject(new Error("Authentication failed"));
|
this.authenticated = false;
|
||||||
});
|
this.emit('disconnect');
|
||||||
|
this.handleReconnect();
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onerror = (event: WebSocket.ErrorEvent) => {
|
||||||
|
this.emit('error', event);
|
||||||
|
reject(event);
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onmessage = (event: WebSocket.MessageEvent) => {
|
||||||
|
if (typeof event.data === 'string') {
|
||||||
|
this.handleMessage(event.data);
|
||||||
|
}
|
||||||
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
reject(error);
|
reject(error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private authenticate(): void {
|
public isConnected(): boolean {
|
||||||
this.send({
|
return this.ws !== null && this.ws.readyState === WebSocket.OPEN;
|
||||||
type: "auth",
|
|
||||||
access_token: this.token,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleMessage(message: any): void {
|
public isAuthenticated(): boolean {
|
||||||
switch (message.type) {
|
return this.authenticated;
|
||||||
case "auth_required":
|
|
||||||
this.authenticate();
|
|
||||||
break;
|
|
||||||
case "auth_ok":
|
|
||||||
this.emit("auth_ok");
|
|
||||||
break;
|
|
||||||
case "auth_invalid":
|
|
||||||
this.emit("auth_invalid");
|
|
||||||
break;
|
|
||||||
case "event":
|
|
||||||
this.handleEvent(message);
|
|
||||||
break;
|
|
||||||
case "result":
|
|
||||||
this.emit(`result_${message.id}`, message);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleEvent(message: any): void {
|
|
||||||
const subscription = this.subscriptions.get(message.event.event_type);
|
|
||||||
if (subscription) {
|
|
||||||
subscription(message.event.data);
|
|
||||||
}
|
|
||||||
this.emit("event", message.event);
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleDisconnect(): void {
|
|
||||||
this.authenticated = false;
|
|
||||||
this.emit("disconnected");
|
|
||||||
|
|
||||||
if (
|
|
||||||
this.options.autoReconnect &&
|
|
||||||
this.reconnectAttempts < this.maxReconnectAttempts
|
|
||||||
) {
|
|
||||||
setTimeout(
|
|
||||||
() => {
|
|
||||||
this.reconnectAttempts++;
|
|
||||||
this.connect().catch((error) => {
|
|
||||||
this.emit("error", error);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
this.reconnectDelay * Math.pow(2, this.reconnectAttempts),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async subscribeEvents(
|
|
||||||
eventType: string,
|
|
||||||
callback: (data: any) => void,
|
|
||||||
): Promise<number> {
|
|
||||||
if (!this.authenticated) {
|
|
||||||
throw new Error("Not authenticated");
|
|
||||||
}
|
|
||||||
|
|
||||||
const id = this.messageId++;
|
|
||||||
this.subscriptions.set(eventType, callback);
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
this.send({
|
|
||||||
id,
|
|
||||||
type: "subscribe_events",
|
|
||||||
event_type: eventType,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.once(`result_${id}`, (message) => {
|
|
||||||
if (message.success) {
|
|
||||||
resolve(id);
|
|
||||||
} else {
|
|
||||||
reject(new Error(message.error?.message || "Subscription failed"));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public async unsubscribeEvents(subscription: number): Promise<void> {
|
|
||||||
if (!this.authenticated) {
|
|
||||||
throw new Error("Not authenticated");
|
|
||||||
}
|
|
||||||
|
|
||||||
const id = this.messageId++;
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
this.send({
|
|
||||||
id,
|
|
||||||
type: "unsubscribe_events",
|
|
||||||
subscription,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.once(`result_${id}`, (message) => {
|
|
||||||
if (message.success) {
|
|
||||||
resolve();
|
|
||||||
} else {
|
|
||||||
reject(new Error(message.error?.message || "Unsubscribe failed"));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private send(message: any): void {
|
|
||||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
||||||
this.ws.send(JSON.stringify(message));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public disconnect(): void {
|
public disconnect(): void {
|
||||||
if (this.ws) {
|
if (this.ws) {
|
||||||
this.ws.close();
|
this.ws.close();
|
||||||
this.ws = null;
|
this.ws = null;
|
||||||
|
this.authenticated = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private authenticate(): void {
|
||||||
|
const authMessage: HassAuthMessage = {
|
||||||
|
type: "auth",
|
||||||
|
access_token: this.token
|
||||||
|
};
|
||||||
|
this.send(authMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleMessage(data: string): void {
|
||||||
|
try {
|
||||||
|
const message = JSON.parse(data) as HassMessage;
|
||||||
|
|
||||||
|
switch (message.type) {
|
||||||
|
case "auth_ok":
|
||||||
|
this.authenticated = true;
|
||||||
|
this.emit('authenticated', message);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "auth_invalid":
|
||||||
|
this.authenticated = false;
|
||||||
|
this.emit('auth_failed', message);
|
||||||
|
this.disconnect();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "event":
|
||||||
|
this.handleEvent(message as HassEventMessage);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "result": {
|
||||||
|
const resultMessage = message as HassResultMessage;
|
||||||
|
if (resultMessage.success) {
|
||||||
|
this.emit('result', resultMessage);
|
||||||
|
} else {
|
||||||
|
this.emit('error', new Error(resultMessage.error || 'Unknown error'));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
this.emit('error', new Error(`Unknown message type: ${message.type}`));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.emit('error', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleEvent(message: HassEventMessage): void {
|
||||||
|
this.emit('event', message.event);
|
||||||
|
const callback = this.subscriptions.get(message.id || 0);
|
||||||
|
if (callback) {
|
||||||
|
callback(message.event.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async subscribeEvents(eventType: string | undefined, callback: (data: any) => void): Promise<number> {
|
||||||
|
if (!this.authenticated) {
|
||||||
|
throw new Error('Not authenticated');
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = this.messageId++;
|
||||||
|
const message: HassSubscribeMessage = {
|
||||||
|
id,
|
||||||
|
type: "subscribe_events",
|
||||||
|
event_type: eventType
|
||||||
|
};
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const handleResult = (result: HassResultMessage) => {
|
||||||
|
if (result.id === id) {
|
||||||
|
this.removeListener('result', handleResult);
|
||||||
|
this.removeListener('error', handleError);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
this.subscriptions.set(id, callback);
|
||||||
|
resolve(id);
|
||||||
|
} else {
|
||||||
|
reject(new Error(result.error || 'Failed to subscribe'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleError = (error: Error) => {
|
||||||
|
this.removeListener('result', handleResult);
|
||||||
|
this.removeListener('error', handleError);
|
||||||
|
reject(error);
|
||||||
|
};
|
||||||
|
|
||||||
|
this.on('result', handleResult);
|
||||||
|
this.on('error', handleError);
|
||||||
|
|
||||||
|
this.send(message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async unsubscribeEvents(subscription: number): Promise<boolean> {
|
||||||
|
if (!this.authenticated) {
|
||||||
|
throw new Error('Not authenticated');
|
||||||
|
}
|
||||||
|
|
||||||
|
const message: HassUnsubscribeMessage = {
|
||||||
|
id: this.messageId++,
|
||||||
|
type: "unsubscribe_events",
|
||||||
|
subscription
|
||||||
|
};
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const handleResult = (result: HassResultMessage) => {
|
||||||
|
if (result.id === message.id) {
|
||||||
|
this.removeListener('result', handleResult);
|
||||||
|
this.removeListener('error', handleError);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
this.subscriptions.delete(subscription);
|
||||||
|
resolve(true);
|
||||||
|
} else {
|
||||||
|
reject(new Error(result.error || 'Failed to unsubscribe'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleError = (error: Error) => {
|
||||||
|
this.removeListener('result', handleResult);
|
||||||
|
this.removeListener('error', handleError);
|
||||||
|
reject(error);
|
||||||
|
};
|
||||||
|
|
||||||
|
this.on('result', handleResult);
|
||||||
|
this.on('error', handleError);
|
||||||
|
|
||||||
|
this.send(message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private send(message: HassMessage): void {
|
||||||
|
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
||||||
|
throw new Error('WebSocket is not connected');
|
||||||
|
}
|
||||||
|
this.ws.send(JSON.stringify(message));
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleReconnect(): void {
|
||||||
|
if (this.reconnectAttempts < this.maxReconnectAttempts) {
|
||||||
|
this.reconnectAttempts++;
|
||||||
|
setTimeout(() => {
|
||||||
|
this.connect().catch(() => { });
|
||||||
|
}, 1000 * Math.pow(2, this.reconnectAttempts));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user