@felixgeelhaar/govee-api-client
Version:
Enterprise-grade TypeScript client library for the Govee Developer REST API
257 lines • 11.7 kB
JavaScript
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