UNPKG

@felixgeelhaar/govee-api-client

Version:

Enterprise-grade TypeScript client library for the Govee Developer REST API

257 lines 11.7 kB
import axios from 'axios'; import { GoveeDevice } from '../domain/entities/GoveeDevice'; import { DeviceState, } from '../domain/entities/DeviceState'; import { ColorRgb, ColorTemperature, Brightness } from '../domain/value-objects'; import { GoveeApiError, InvalidApiKeyError, RateLimitError, NetworkError } from '../errors'; export class GoveeDeviceRepository { generateRequestId() { return crypto.randomUUID(); } constructor(config) { this.validateConfig(config); this.logger = config.logger; this.httpClient = axios.create({ baseURL: GoveeDeviceRepository.BASE_URL, timeout: config.timeout ?? 30000, headers: { 'Govee-API-Key': config.apiKey, 'Content-Type': 'application/json', }, }); this.setupInterceptors(); } validateConfig(config) { if (!config.apiKey || typeof config.apiKey !== 'string' || config.apiKey.trim().length === 0) { throw new Error('API key is required and must be a non-empty string'); } if (config.timeout !== undefined && (!Number.isInteger(config.timeout) || config.timeout <= 0)) { throw new Error('Timeout must be a positive integer'); } } setupInterceptors() { // Request interceptor for logging this.httpClient.interceptors.request.use(config => { this.logger?.debug({ method: config.method?.toUpperCase(), url: config.url, headers: { ...config.headers, 'Govee-API-Key': '[REDACTED]' }, }, 'Sending request to Govee API'); return config; }, error => { this.logger?.error(error, 'Request interceptor error'); return Promise.reject(error); }); // Response interceptor for logging and error handling this.httpClient.interceptors.response.use(response => { this.logger?.debug({ status: response.status, url: response.config.url, data: response.data, }, 'Received response from Govee API'); return response; }, error => this.handleHttpError(error)); } handleHttpError(error) { this.logger?.error(error, 'HTTP request failed'); if (error.code && (error.code === 'ECONNABORTED' || error.code.startsWith('ECONNR') || error.code.startsWith('ETIMEOUT') || error.code === 'ENOTFOUND')) { throw NetworkError.fromAxiosError(error); } if (!error.response) { throw new NetworkError('Network request failed without response', 'unknown', error); } const { status, data, headers } = error.response; if (status === 401) { const responseData = typeof data === 'object' && data !== null ? data : undefined; throw InvalidApiKeyError.fromUnauthorizedResponse(responseData); } if (status === 429) { throw RateLimitError.fromRateLimitResponse(headers); } const responseData = typeof data === 'string' ? data : data; throw GoveeApiError.fromResponse(status, responseData); } async findAll() { this.logger?.info('Fetching all devices'); try { const response = await this.httpClient.get('/router/api/v1/user/devices'); const apiResponse = response.data; if (apiResponse.code !== 200) { throw new GoveeApiError(`API returned error code ${apiResponse.code}: ${apiResponse.message}`, response.status, apiResponse.code, apiResponse.message); } const devices = apiResponse.data .filter(device => { if (!device.device || typeof device.device !== 'string' || device.device.trim().length === 0) { this.logger?.warn({ device: { ...device, device: '[INVALID]' } }, 'Filtering out device with invalid device ID'); return false; } if (!device.sku || typeof device.sku !== 'string' || device.sku.trim().length === 0) { this.logger?.warn({ device: { ...device, sku: '[INVALID]' } }, 'Filtering out device with invalid sku'); return false; } if (!device.deviceName || typeof device.deviceName !== 'string' || device.deviceName.trim().length === 0) { this.logger?.warn({ device: { ...device, deviceName: '[INVALID]' } }, 'Filtering out device with invalid device name'); return false; } if (!Array.isArray(device.capabilities)) { this.logger?.warn({ device: { ...device, capabilities: '[INVALID]' } }, 'Filtering out device with invalid capabilities'); return false; } for (const capability of device.capabilities) { if (!capability.type || typeof capability.type !== 'string' || capability.type.trim().length === 0) { this.logger?.warn({ device: { ...device, capabilities: '[INVALID]' } }, 'Filtering out device with invalid capability type'); return false; } } return true; }) .map(device => new GoveeDevice(device.device, device.sku, device.deviceName, device.capabilities)); const totalDevicesFromApi = apiResponse.data.length; const validDevices = devices.length; const filteredDevices = totalDevicesFromApi - validDevices; if (filteredDevices > 0) { this.logger?.info(`Successfully fetched ${validDevices} devices (filtered out ${filteredDevices} invalid devices from ${totalDevicesFromApi} total)`); } else { this.logger?.info(`Successfully fetched ${validDevices} devices`); } return devices; } catch (error) { this.logger?.error(error, 'Failed to fetch devices'); throw error; } } async findState(deviceId, sku) { this.validateDeviceParams(deviceId, sku); this.logger?.info({ deviceId, sku }, 'Fetching device state'); try { const requestBody = { requestId: this.generateRequestId(), payload: { sku: sku, device: deviceId, }, }; const response = await this.httpClient.post('/router/api/v1/device/state', requestBody); const apiResponse = response.data; if (apiResponse.code !== 200) { throw new GoveeApiError(`API returned error code ${apiResponse.code}: ${apiResponse.message}`, response.status, apiResponse.code, apiResponse.message); } // Parse capabilities into state properties const stateProperties = this.mapCapabilitiesToStateProperties(apiResponse.data.capabilities); const deviceState = new DeviceState(deviceId, sku, true, // Assume online if we get a response stateProperties); this.logger?.info({ deviceId, sku }, 'Successfully fetched device state'); return deviceState; } catch (error) { this.logger?.error(error, 'Failed to fetch device state'); throw error; } } async sendCommand(deviceId, sku, command) { this.validateDeviceParams(deviceId, sku); this.logger?.info({ deviceId, sku, command: command.toObject() }, 'Sending command to device'); const requestBody = { requestId: this.generateRequestId(), payload: { sku: sku, device: deviceId, capability: this.convertCommandToCapability(command), }, }; try { const response = await this.httpClient.post('/router/api/v1/device/control', requestBody); const apiResponse = response.data; if (apiResponse.code !== 200) { throw new GoveeApiError(`API returned error code ${apiResponse.code}: ${apiResponse.message}`, response.status, apiResponse.code, apiResponse.message); } this.logger?.info({ deviceId, sku }, 'Successfully sent command to device'); } catch (error) { this.logger?.error(error, 'Failed to send command to device'); throw error; } } validateDeviceParams(deviceId, sku) { if (!deviceId || typeof deviceId !== 'string' || deviceId.trim().length === 0) { throw new Error('Device ID must be a non-empty string'); } if (!sku || typeof sku !== 'string' || sku.trim().length === 0) { throw new Error('SKU must be a non-empty string'); } } convertCommandToCapability(command) { const cmdObj = command.toObject(); // Map command names to capability types const capabilityTypeMap = { turn: 'devices.capabilities.on_off', brightness: 'devices.capabilities.range', color: 'devices.capabilities.color_setting', colorTem: 'devices.capabilities.color_setting', }; // Map instances correctly according to Govee API v1 specification let instance; let value; if (cmdObj.name === 'turn') { // Power commands use 'powerSwitch' instance and numeric values instance = 'powerSwitch'; value = cmdObj.value === 'on' ? 1 : 0; // Convert string to number: on=1, off=0 } else if (cmdObj.name === 'color') { // Color commands use 'colorRgb' instance instance = 'colorRgb'; value = cmdObj.value; } else if (cmdObj.name === 'colorTem') { // Color temperature uses specific instance name instance = 'colorTemperatureK'; value = cmdObj.value; } else { // Other commands (brightness) use their name as instance instance = cmdObj.name; value = cmdObj.value; } return { type: capabilityTypeMap[cmdObj.name] || `devices.capabilities.${cmdObj.name}`, instance, value, }; } mapCapabilitiesToStateProperties(capabilities) { const result = {}; for (const capability of capabilities) { if (capability.type.includes('on_off')) { result.powerSwitch = { value: capability.state.value ? 'on' : 'off' }; } else if (capability.type.includes('range') && capability.instance === 'brightness') { result.brightness = { value: new Brightness(capability.state.value) }; } else if (capability.type.includes('color_setting')) { if (capability.instance === 'colorRgb') { result.color = { value: ColorRgb.fromObject(capability.state.value), }; } else if (capability.instance === 'colorTemperatureK') { result.colorTem = { value: new ColorTemperature(capability.state.value) }; } } } return result; } } GoveeDeviceRepository.BASE_URL = 'https://openapi.api.govee.com'; //# sourceMappingURL=GoveeDeviceRepository.js.map