UNPKG

homebridge-tsvesync

Version:

Homebridge plugin for VeSync devices including Levoit air purifiers, humidifiers, and Etekcity smart outlets

771 lines (725 loc) 21.3 kB
import { Logger } from 'homebridge'; import { PluginLogger } from '../../utils/logger'; import { RetryManager } from '../../utils/retry'; import { VeSyncOutlet } from '../../types/device.types'; import { VeSync } from 'tsvesync'; import { VeSyncSwitch } from '../../types/device.types'; import { VeSyncBulb } from '../../types/device.types'; import { VeSyncFan } from '../../types/device.types'; /** * Creates a mock Logger instance for testing */ export const createMockLogger = (): jest.Mocked<Logger> => ({ debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn(), log: jest.fn(), prefix: undefined }); /** * Creates a mock PluginLogger instance for testing */ export const createMockPluginLogger = (log: Logger = createMockLogger()): jest.Mocked<PluginLogger> => { const logger = new PluginLogger(log, true); return { ...logger, debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn(), stateChange: jest.fn(), operationStart: jest.fn(), operationEnd: jest.fn(), pollingEvent: jest.fn(), formatMessage: jest.fn((message: string) => message), } as unknown as jest.Mocked<PluginLogger>; }; /** * Creates a mock RetryManager instance for testing */ export const createMockRetryManager = (): jest.Mocked<RetryManager> => { const manager = new RetryManager(createMockLogger(), { maxRetries: 3, }); return { ...manager, execute: jest.fn(), getRetryCount: jest.fn().mockReturnValue(0), } as unknown as jest.Mocked<RetryManager>; }; /** * Creates a mock service instance for testing */ export const createMockService = () => ({ getCharacteristic: jest.fn().mockReturnValue({ onSet: jest.fn().mockReturnThis(), onGet: jest.fn().mockReturnThis(), updateValue: jest.fn().mockReturnThis(), setProps: jest.fn().mockReturnThis(), }), setCharacteristic: jest.fn().mockReturnThis(), testCharacteristic: jest.fn().mockReturnValue(false), removeCharacteristic: jest.fn().mockReturnThis(), addCharacteristic: jest.fn().mockReturnValue({ onSet: jest.fn().mockReturnThis(), onGet: jest.fn().mockReturnThis(), updateValue: jest.fn().mockReturnThis(), setProps: jest.fn().mockReturnThis(), }), }); /** * Creates a mock info service instance for testing */ export const createMockInfoService = () => ({ setCharacteristic: jest.fn().mockReturnThis(), }); /** * Type for mock device configuration */ export interface MockDeviceConfig { deviceName?: string; uuid?: string; deviceType?: string; getDetails?: jest.Mock; } /** * Creates a mock device instance for testing */ export const createMockDevice = (config: MockDeviceConfig = {}) => ({ deviceName: config.deviceName || 'Test Device', uuid: config.uuid || '12345', deviceType: config.deviceType || 'outlet', getDetails: config.getDetails || jest.fn(), }); /** * Waits for all promises in the queue to resolve */ export const flushPromises = () => new Promise(resolve => setImmediate(resolve)); /** * Helper to run async tests with proper timeout and error handling */ export const runAsyncTest = async ( testFn: () => Promise<void>, timeout: number = 5000 ): Promise<void> => { return new Promise((resolve, reject) => { const timeoutId = setTimeout(() => { reject(new Error(`Test timed out after ${timeout}ms`)); }, timeout); testFn() .then(() => { clearTimeout(timeoutId); resolve(); }) .catch((error) => { clearTimeout(timeoutId); reject(error); }); }); }; /** * Simulates a network delay */ export const simulateNetworkDelay = (ms: number = 100): Promise<void> => new Promise(resolve => setTimeout(resolve, ms)); /** * Type for mock outlet configuration */ export interface MockOutletConfig { deviceName?: string; deviceType?: string; cid?: string; uuid?: string; power?: number; voltage?: number; energy?: number; current?: number; } /** * Creates a mock outlet instance for testing */ export const createMockOutlet = (config: MockOutletConfig = {}): jest.Mocked<VeSyncOutlet> => { const details = { power: config.power || 0, voltage: config.voltage || 120, energy: config.energy || 0, current: config.current || 0, }; return { deviceName: config.deviceName || 'Test Outlet', deviceType: config.deviceType || 'wifi-switch-1.3', cid: config.cid || 'test-cid', uuid: config.uuid || 'test-uuid', deviceStatus: 'on', subDeviceNo: 0, isSubDevice: false, deviceRegion: 'US', configModule: 'Outlet', macId: '00:11:22:33:44:55', deviceCategory: 'outlet', connectionStatus: 'online', getDetails: jest.fn().mockResolvedValue(details), setApiBaseUrl: jest.fn(), turnOn: jest.fn().mockResolvedValue(true), turnOff: jest.fn().mockResolvedValue(true), ...details, } as unknown as jest.Mocked<VeSyncOutlet>; }; /** * Creates a mock VeSync client for testing */ export const createMockVeSync = (): jest.Mocked<VeSync> => { return { login: jest.fn().mockResolvedValue(true), getDevices: jest.fn().mockResolvedValue(true), fans: [], outlets: [], switches: [], bulbs: [], humidifiers: [], purifiers: [], _debug: false, _redact: false, _energyUpdateInterval: 0, _energyCheck: false, username: 'test@example.com', password: 'test-password', token: 'test-token', accountID: 'test-account', apiKey: 'test-key', apiBase: 'test-base', timezone: 'UTC', debug: false, redact: false, traceSocket: false, apiUrl: 'test-url', initialized: true, setToken: jest.fn(), setAccountID: jest.fn(), setAPIKey: jest.fn(), setAPIBase: jest.fn(), setInitialized: jest.fn(), setDevices: jest.fn(), getDevicesByType: jest.fn(), getDeviceByUUID: jest.fn(), getDeviceByCid: jest.fn(), getDeviceByName: jest.fn(), getDevicesByCategory: jest.fn(), getDevicesByMacID: jest.fn(), getDevicesByDeviceType: jest.fn(), getDevicesByConfigModule: jest.fn(), getDevicesByDeviceRegion: jest.fn(), getDevicesByConnectionStatus: jest.fn(), } as unknown as jest.Mocked<VeSync>; }; /** * Type for mock switch configuration */ export interface MockSwitchConfig { deviceName?: string; deviceType?: string; cid?: string; uuid?: string; power?: boolean; } /** * Creates a mock switch instance for testing */ export const createMockSwitch = (config: MockSwitchConfig = {}): jest.Mocked<VeSyncSwitch> => { const state = { power: config.power || false, deviceStatus: config.power ? 'on' : 'off', }; const mockSwitch = { deviceName: config.deviceName || 'Test Switch', deviceType: config.deviceType || 'ESW01-EU', cid: config.cid || 'test-cid', uuid: config.uuid || 'test-uuid', deviceStatus: state.deviceStatus, power: state.power, subDeviceNo: 0, isSubDevice: false, deviceRegion: 'US', configModule: 'Switch', macId: '00:11:22:33:44:55', deviceCategory: 'switch', connectionStatus: 'online', getDetails: jest.fn().mockImplementation(async () => { return { deviceStatus: state.deviceStatus, power: state.power, }; }), setApiBaseUrl: jest.fn(), turnOn: jest.fn().mockImplementation(async () => { state.power = true; state.deviceStatus = 'on'; mockSwitch.power = state.power; mockSwitch.deviceStatus = state.deviceStatus; return true; }), turnOff: jest.fn().mockImplementation(async () => { state.power = false; state.deviceStatus = 'off'; mockSwitch.power = state.power; mockSwitch.deviceStatus = state.deviceStatus; return true; }), } as unknown as jest.Mocked<VeSyncSwitch>; return mockSwitch; }; export interface MockLightOptions { deviceName: string; deviceType: string; cid: string; uuid: string; power?: boolean; brightness?: number; colorTemp?: number; hue?: number; saturation?: number; subDeviceNo?: number; isSubDevice?: boolean; } export function createMockLight(options: MockLightOptions): VeSyncBulb { const state = { deviceStatus: options.power ? 'on' : 'off', brightness: options.brightness || 100, colorTemp: options.colorTemp || 140, hue: options.hue || 0, saturation: options.saturation || 0, }; const turnOn = jest.fn().mockImplementation(() => { state.deviceStatus = 'on'; mockLight.deviceStatus = state.deviceStatus; return Promise.resolve(true); }); const turnOff = jest.fn().mockImplementation(() => { state.deviceStatus = 'off'; mockLight.deviceStatus = state.deviceStatus; return Promise.resolve(true); }); const setBrightness = jest.fn().mockImplementation((brightness: number) => { state.brightness = brightness; mockLight.brightness = state.brightness; return Promise.resolve(true); }); const setColorTemperature = jest.fn().mockImplementation((colorTemp: number) => { state.colorTemp = colorTemp; mockLight.colorTemp = state.colorTemp; return Promise.resolve(true); }); const setColor = jest.fn().mockImplementation((hue: number, saturation: number) => { state.hue = hue; state.saturation = saturation; mockLight.hue = state.hue; mockLight.saturation = state.saturation; return Promise.resolve(true); }); const getDetails = jest.fn().mockImplementation(() => { return Promise.resolve({ deviceStatus: state.deviceStatus, brightness: state.brightness, colorTemp: state.colorTemp, hue: state.hue, saturation: state.saturation, }); }); const mockLight = { deviceName: options.deviceName, deviceType: options.deviceType, cid: options.cid, uuid: options.uuid, deviceStatus: state.deviceStatus, brightness: state.brightness, colorTemp: state.colorTemp, hue: state.hue, saturation: state.saturation, subDeviceNo: options.subDeviceNo || 0, isSubDevice: options.isSubDevice || false, deviceRegion: 'US', configModule: 'Light', macId: '00:11:22:33:44:55', deviceCategory: 'light', connectionStatus: 'online', setApiBaseUrl: jest.fn(), turnOn, turnOff, setBrightness, setColorTemperature, setColor, getDetails, } as VeSyncBulb; return mockLight; } /** * Type for mock fan configuration */ export interface MockFanConfig { deviceName?: string; deviceType?: string; cid?: string; uuid?: string; speed?: number; rotationDirection?: 'clockwise' | 'counterclockwise'; oscillationState?: boolean; childLock?: boolean; mode?: 'normal' | 'auto' | 'sleep' | 'turbo'; } /** * Creates a mock fan instance for testing */ export const createMockFan = (config: MockFanConfig = {}): jest.Mocked<VeSyncFan> => { const state = { deviceStatus: 'on', speed: config.speed || 3, rotationDirection: config.rotationDirection || 'clockwise', oscillationState: config.oscillationState || false, childLock: config.childLock || false, mode: config.mode || 'normal' }; const mockFan = { deviceName: config.deviceName || 'Test Fan', deviceType: config.deviceType || 'LTF-F422', cid: config.cid || 'test-cid', uuid: config.uuid || 'test-uuid', deviceStatus: state.deviceStatus, speed: state.speed, maxSpeed: 5, rotationDirection: state.rotationDirection, oscillationState: state.oscillationState, childLock: state.childLock, mode: state.mode, subDeviceNo: 0, isSubDevice: false, deviceRegion: 'US', configModule: 'Fan', macId: '00:11:22:33:44:55', deviceCategory: 'fan', connectionStatus: 'online', getDetails: jest.fn().mockImplementation(async () => { return { deviceStatus: state.deviceStatus, speed: state.speed, rotationDirection: state.rotationDirection, oscillationState: state.oscillationState, childLock: state.childLock, mode: state.mode }; }), setApiBaseUrl: jest.fn(), turnOn: jest.fn().mockImplementation(async () => { state.deviceStatus = 'on'; mockFan.deviceStatus = state.deviceStatus; return true; }), turnOff: jest.fn().mockImplementation(async () => { state.deviceStatus = 'off'; mockFan.deviceStatus = state.deviceStatus; return true; }), changeFanSpeed: jest.fn().mockImplementation(async (speed: number) => { state.speed = speed; mockFan.speed = state.speed; return true; }), setRotationDirection: jest.fn().mockImplementation(async (direction: 'clockwise' | 'counterclockwise') => { state.rotationDirection = direction; mockFan.rotationDirection = state.rotationDirection; return true; }), setOscillation: jest.fn().mockImplementation(async (enabled: boolean) => { state.oscillationState = enabled; mockFan.oscillationState = state.oscillationState; return true; }), setChildLock: jest.fn().mockImplementation(async (enabled: boolean) => { state.childLock = enabled; mockFan.childLock = state.childLock; return true; }), setMode: jest.fn().mockImplementation(async (mode: 'normal' | 'auto' | 'sleep' | 'turbo') => { state.mode = mode; mockFan.mode = state.mode; return true; }), setSwingMode: jest.fn().mockImplementation(async (enabled: boolean) => { state.oscillationState = enabled; mockFan.oscillationState = state.oscillationState; return true; }) } as unknown as jest.Mocked<VeSyncFan>; return mockFan; }; /** * Type for mock bulb configuration */ export interface MockBulbConfig { deviceName?: string; deviceType?: string; cid?: string; uuid?: string; brightness?: number; colorTemp?: number; hue?: number; saturation?: number; } /** * Creates a mock bulb instance for testing */ export const createMockBulb = (config: MockBulbConfig = {}): jest.Mocked<VeSyncBulb> => { const state = { brightness: config.brightness || 100, colorTemp: config.colorTemp || 200, hue: config.hue || 0, saturation: config.saturation || 0, deviceStatus: 'off', }; return { deviceName: config.deviceName || 'Test Bulb', deviceType: config.deviceType || 'ESL100MC', cid: config.cid || 'test-cid', uuid: config.uuid || 'test-uuid', deviceStatus: state.deviceStatus, brightness: state.brightness, colorTemp: state.colorTemp, hue: state.hue, saturation: state.saturation, subDeviceNo: 0, isSubDevice: false, deviceRegion: 'US', configModule: 'Bulb', macId: '00:11:22:33:44:55', deviceCategory: 'bulb', connectionStatus: 'online', getDetails: jest.fn().mockResolvedValue(true), setApiBaseUrl: jest.fn(), turnOn: jest.fn().mockResolvedValue(true), turnOff: jest.fn().mockResolvedValue(true), setBrightness: jest.fn().mockResolvedValue(true), setColorTemperature: jest.fn().mockResolvedValue(true), setColor: jest.fn().mockResolvedValue(true), } as unknown as jest.Mocked<VeSyncBulb>; }; /** * Type for mock air purifier configuration */ export interface MockAirPurifierConfig { deviceName?: string; deviceType?: string; cid?: string; uuid?: string; airQualityValue?: number; pm1?: number; pm10?: number; aqPercent?: number; filterLife?: number | { percent: number }; hasAirQuality?: boolean; hasFilter?: boolean; } /** * Creates a mock air purifier with air quality and filter features */ export const createMockAirPurifier = (config: MockAirPurifierConfig = {}): jest.Mocked<VeSyncFan> => { const state = { deviceStatus: 'on', speed: 3, mode: 'auto' as 'normal' | 'auto' | 'sleep' | 'turbo', airQualityValue: config.airQualityValue || 25, pm1: config.pm1 || 15, pm10: config.pm10 || 35, aqPercent: config.aqPercent || 80, filterLife: typeof config.filterLife === 'object' ? config.filterLife.percent : (config.filterLife || 75), }; const mockAirPurifier = { deviceName: config.deviceName || 'Test Air Purifier', deviceType: config.deviceType || 'Core300S', cid: config.cid || 'test-cid', uuid: config.uuid || 'test-uuid', deviceStatus: state.deviceStatus, speed: state.speed, mode: state.mode, maxSpeed: 4, rotationDirection: 'clockwise' as 'clockwise' | 'counterclockwise', oscillationState: false, childLock: false, airQualityValue: state.airQualityValue, pm1: state.pm1, pm10: state.pm10, aqPercent: state.aqPercent, filterLife: state.filterLife, subDeviceNo: 0, isSubDevice: false, deviceRegion: 'US', configModule: 'VeSyncAirBypass', macId: '00:11:22:33:44:55', deviceCategory: 'fan', connectionStatus: 'online', hasFeature: jest.fn().mockImplementation((feature: string) => { if (feature === 'air_quality') return config.hasAirQuality !== false; if (feature === 'filter_life') return config.hasFilter !== false; return true; }), getDetails: jest.fn().mockImplementation(async () => { return { deviceStatus: state.deviceStatus, speed: state.speed, mode: state.mode, air_quality_value: state.airQualityValue, pm1: state.pm1, pm10: state.pm10, aq_percent: state.aqPercent, filter_life: config.filterLife || state.filterLife }; }), setApiBaseUrl: jest.fn(), turnOn: jest.fn().mockImplementation(async () => { state.deviceStatus = 'on'; mockAirPurifier.deviceStatus = state.deviceStatus; return true; }), turnOff: jest.fn().mockImplementation(async () => { state.deviceStatus = 'off'; mockAirPurifier.deviceStatus = state.deviceStatus; return true; }), changeFanSpeed: jest.fn().mockImplementation(async (speed: number) => { state.speed = speed; mockAirPurifier.speed = state.speed; return true; }), changeMode: jest.fn().mockImplementation(async (mode: 'normal' | 'auto' | 'sleep' | 'turbo') => { state.mode = mode; mockAirPurifier.mode = state.mode; return true; }), setMode: jest.fn().mockImplementation(async (mode: 'normal' | 'auto' | 'sleep' | 'turbo') => { state.mode = mode; mockAirPurifier.mode = state.mode; return true; }), setRotationDirection: jest.fn().mockImplementation(async (direction: 'clockwise' | 'counterclockwise') => { mockAirPurifier.rotationDirection = direction; return true; }), setOscillation: jest.fn().mockImplementation(async (enabled: boolean) => { mockAirPurifier.oscillationState = enabled; return true; }), setChildLock: jest.fn().mockImplementation(async (enabled: boolean) => { mockAirPurifier.childLock = enabled; return true; }), setSwingMode: jest.fn().mockImplementation(async (enabled: boolean) => { mockAirPurifier.oscillationState = enabled; return true; }) } as unknown as jest.Mocked<VeSyncFan>; return mockAirPurifier; }; /** * Air quality test scenarios for different conditions */ export const airQualityScenarios = { excellent: { pm25: 8, pm1: 5, pm10: 12, level: 1, description: 'Excellent air quality' }, good: { pm25: 22, pm1: 15, pm10: 28, level: 2, description: 'Good air quality' }, fair: { pm25: 42, pm1: 30, pm10: 55, level: 3, description: 'Fair air quality' }, poor: { pm25: 85, pm1: 60, pm10: 120, level: 4, description: 'Poor air quality' }, veryPoor: { pm25: 200, pm1: 150, pm10: 300, level: 5, description: 'Very poor air quality' } }; /** * Filter life test scenarios */ export const filterLifeScenarios = { new: { percent: 100, needsReplacement: false, description: 'New filter' }, good: { percent: 75, needsReplacement: false, description: 'Good filter condition' }, fair: { percent: 50, needsReplacement: false, description: 'Fair filter condition' }, low: { percent: 15, needsReplacement: false, description: 'Low filter life' }, critical: { percent: 8, needsReplacement: true, description: 'Critical filter life' }, empty: { percent: 0, needsReplacement: true, description: 'Filter needs replacement' } }; /** * Creates air quality test data for device mocking */ export const createAirQualityTestData = (scenario: keyof typeof airQualityScenarios) => { const data = airQualityScenarios[scenario]; return { air_quality: data.level, air_quality_value: data.pm25, pm1: data.pm1, pm10: data.pm10, aq_percent: Math.max(0, 100 - data.pm25), // Rough approximation }; }; /** * Creates filter life test data for device mocking */ export const createFilterLifeTestData = (scenario: keyof typeof filterLifeScenarios, format: 'number' | 'object' = 'number') => { const data = filterLifeScenarios[scenario]; if (format === 'object') { return { filter_life: { percent: data.percent, replace_indicator: data.needsReplacement } }; } return { filter_life: data.percent }; };