UNPKG

@homebridge-plugins/homebridge-govee

Version:

Homebridge plugin to integrate Govee devices into HomeKit.

639 lines (569 loc) 18.7 kB
import axios from 'axios' import mqtt from 'mqtt' import { k2rgb } from '../utils/colour.js' import { parseError } from '../utils/functions.js' const BASE_URL = 'https://openapi.api.govee.com' function normalizeOptions(options = []) { return Array.isArray(options) ? options.filter(option => option && Object.hasOwn(option, 'value')) : [] } function capabilityLookup(capabilities = []) { const byInstance = {} capabilities.forEach((capability) => { if (capability?.instance) { byInstance[capability.instance] = capability } }) return byInstance } function findRange(capability) { const range = capability?.parameters?.range || capability?.range || capability?.options?.range || capability?.struct?.range if (!range) { return null } const min = Number(range.min) const max = Number(range.max) return Number.isFinite(min) && Number.isFinite(max) ? { min, max } : null } function intToRgb(value) { const rgb = Number(value) if (!Number.isFinite(rgb)) { return null } return { r: (rgb >> 16) & 0xFF, g: (rgb >> 8) & 0xFF, b: rgb & 0xFF, } } function rgbDistance(a, b) { if (!a || !b) { return Number.POSITIVE_INFINITY } return Math.abs(a.r - b[0]) + Math.abs(a.g - b[1]) + Math.abs(a.b - b[2]) } function inferKelvinFromRgb(rgb) { let bestKelvin = null let bestDistance = Number.POSITIVE_INFINITY for (let kelvin = 2000; kelvin <= 7100; kelvin += 100) { const candidate = k2rgb(kelvin) const distance = rgbDistance(rgb, candidate) if (distance < bestDistance) { bestDistance = distance bestKelvin = kelvin } } // Only treat RGB as white temperature if it is very close to one of the known // Kelvin RGB values. This avoids reclassifying obvious colors like red/blue. return bestDistance <= 40 ? bestKelvin : null } function getDevicesFromPayload(data) { return data?.payload?.devices || data?.data?.devices || (Array.isArray(data?.data) ? data.data : null) || data?.devices || (Array.isArray(data) ? data : null) || [] } function getCapabilitiesFromPayload(data) { return data?.payload?.capabilities || data?.payload?.state?.capabilities || data?.data?.capabilities || data?.capabilities || [] } function getCapabilityValue(capability) { if (capability?.state && Object.hasOwn(capability.state, 'value')) { return capability.state.value } return capability?.value } function getCapabilityOptions(capability) { return normalizeOptions(capability?.parameters?.options) } function cloneCapabilities(capabilities = []) { return capabilities.map(capability => ({ ...capability, parameters: capability?.parameters ? { ...capability.parameters, options: normalizeOptions(capability.parameters.options), } : capability?.parameters, })) } function capabilityHasSceneOptions(capability) { return getCapabilityOptions(capability).length > 0 } function mergeSceneCapabilities(baseCapabilities = [], sceneCapabilities = []) { if (!Array.isArray(sceneCapabilities) || sceneCapabilities.length === 0) { return baseCapabilities } const scenesByInstance = {} sceneCapabilities.forEach((sceneCapability) => { if (sceneCapability?.instance) { scenesByInstance[sceneCapability.instance] = sceneCapability } }) return baseCapabilities.map((capability) => { const sceneCapability = scenesByInstance[capability?.instance] if (!sceneCapability) { return capability } return { ...capability, parameters: { ...(capability?.parameters || {}), ...(sceneCapability?.parameters || {}), options: normalizeOptions(sceneCapability?.parameters?.options), }, } }) } function findOptionByName(capability, name) { const lowered = `${name}`.trim().toLowerCase() return getCapabilityOptions(capability).find(option => `${option?.name || ''}`.trim().toLowerCase() === lowered) } function validateRangeValue(capability, value, cmd) { const numeric = Number(value) if (!Number.isFinite(numeric)) { throw new TypeError(`invalid numeric value for ${cmd}`) } const range = findRange(capability) if (!range) { return numeric } const clamped = Math.max(range.min, Math.min(range.max, numeric)) if (range.precision && range.precision >= 1) { return Math.round(clamped / range.precision) * range.precision } return clamped } export default class { constructor(platform) { this.apiKey = platform.config.apiKey this.log = platform.log this.platform = platform } async request({ method, path, data }) { try { const res = await axios({ url: `${BASE_URL}${path}`, method, headers: this.headers(), data, timeout: 30000, }) if (Number(res?.data?.code) && Number(res.data.code) !== 200) { throw new Error(res?.data?.message || res?.data?.msg || `OpenAPI code ${res.data.code}`) } return res.data } catch (err) { throw new Error(parseError(err)) } } headers() { return { 'Content-Type': 'application/json', 'Govee-API-Key': this.apiKey, } } async getScenes(sku, device) { const data = await this.request({ method: 'post', path: '/router/api/v1/device/scenes', data: { requestId: `hb-scenes-${Date.now()}`, payload: { sku, device }, }, }) return getCapabilitiesFromPayload(data) } async getDevices() { const data = await this.request({ method: 'get', path: '/router/api/v1/user/devices', }) const devices = getDevicesFromPayload(data) const parsedDevices = await Promise.all( devices .filter(device => device?.sku && device?.device) .map(async (device) => { let capabilities = cloneCapabilities(Array.isArray(device.capabilities) ? device.capabilities : []) if (capabilities.some(capability => capability?.type === 'devices.capabilities.dynamic_scene' && !capabilityHasSceneOptions(capability))) { try { const sceneCapabilities = await this.getScenes(device.sku, device.device) capabilities = mergeSceneCapabilities(capabilities, sceneCapabilities) } catch (err) { this.log.debug?.(`[OPENAPI] could not fetch scenes for ${device.device}: ${parseError(err)}`) } } const byInstance = capabilityLookup(capabilities) const colorTempRange = findRange(byInstance.colorTemperatureK) const brightnessRange = findRange(byInstance.brightness) const properties = {} const supportCmds = [] if (byInstance.powerSwitch) { supportCmds.push('turn') } if (brightnessRange) { supportCmds.push('bright') properties.bright = { range: brightnessRange } } if (byInstance.colorRgb) { supportCmds.push('colour') } if (colorTempRange) { supportCmds.push('colorTem') properties.colorTem = { range: colorTempRange } } return { device: device.device, deviceName: device.deviceName || device.name || device.device, model: device.sku, openApiInfo: { capabilities, byInstance, category: device.category || device.type || null, image: device.skuUrl || null, }, properties, supportCmds, } }), ) return parsedDevices } async requestUpdate(accessory) { const state = await this.getState(accessory.context.gvModel, accessory.context.gvDeviceId) this.platform.receiveUpdateOpenAPI(accessory.context.gvDeviceId, state) } async getState(sku, device) { const data = await this.request({ method: 'post', path: '/router/api/v1/device/state', data: { requestId: `hb-state-${Date.now()}`, payload: { sku, device }, }, }) const capabilities = getCapabilitiesFromPayload(data) const normalized = {} let colorRgb = null let colorTemInKelvin = null capabilities.forEach((capability) => { const value = getCapabilityValue(capability) switch (capability?.instance) { case 'online': normalized.online = !!value break case 'powerSwitch': normalized.onOff = Number(value) break case 'brightness': normalized.brightness = Number(value) break case 'colorRgb': { const rgb = intToRgb(value) if (rgb) { colorRgb = rgb } break } case 'colorTemperatureK': colorTemInKelvin = Number(value) break case 'workMode': normalized.workMode = value break case 'sensorTemperature': normalized.sensorTemperature = Number(value) break case 'sensorHumidity': normalized.sensorHumidity = Number(value) break case 'humidity': normalized.targetHumidity = Number(value) break case 'targetTemperature': case 'sliderTemperature': normalized.targetTemperature = value break default: if (capability?.instance?.endsWith('Toggle')) { normalized.toggles = normalized.toggles || {} normalized.toggles[capability.instance] = Number(value) === 1 } break } }) if (Number.isFinite(colorTemInKelvin) && colorTemInKelvin > 0) { normalized.colorTemInKelvin = colorTemInKelvin } if (colorRgb) { if (Number.isFinite(colorTemInKelvin) && colorTemInKelvin > 0) { const kelvinRgb = k2rgb(colorTemInKelvin) // OpenAPI reports both RGB and Kelvin even in white mode, so only treat RGB as active // when it differs materially from the RGB equivalent of the reported Kelvin. if (rgbDistance(colorRgb, kelvinRgb) > 30) { normalized.color = colorRgb delete normalized.colorTemInKelvin } } else { const inferredKelvin = inferKelvinFromRgb(colorRgb) if (inferredKelvin) { normalized.colorTemInKelvin = inferredKelvin } else { normalized.color = colorRgb } } } return normalized } getCapability(accessory, instance, fallbackType) { const lookup = accessory.context.openApiCapabilities || {} const capability = lookup[instance] if (capability?.type && capability?.instance) { return capability } return { type: fallbackType, instance, parameters: {}, } } buildCapabilityPayload(accessory, params) { let capability switch (params.cmd) { case 'state': { const base = this.getCapability(accessory, 'powerSwitch', 'devices.capabilities.on_off') capability = { type: base.type, instance: base.instance, value: params.value === 'on' ? 1 : 0, } break } case 'brightness': { const base = this.getCapability(accessory, 'brightness', 'devices.capabilities.range') capability = { type: base.type, instance: base.instance, value: validateRangeValue(base, params.value, params.cmd), } break } case 'color': { const base = this.getCapability(accessory, 'colorRgb', 'devices.capabilities.color_setting') capability = { type: base.type, instance: base.instance, value: (params.value.r << 16) + (params.value.g << 8) + params.value.b, } break } case 'colorTem': { const base = this.getCapability(accessory, 'colorTemperatureK', 'devices.capabilities.color_setting') capability = { type: base.type, instance: base.instance, value: validateRangeValue(base, params.value, params.cmd), } break } case 'stateOutlet': { // stateOutlet receives 'on'/'off' strings const base = this.getCapability(accessory, 'powerSwitch', 'devices.capabilities.on_off') capability = { type: base.type, instance: base.instance, value: params.value === 'on' ? 1 : 0, } break } case 'stateHumi': case 'statePuri': case 'stateHeat': { // These receive numeric (1/0) or boolean values const base = this.getCapability(accessory, 'powerSwitch', 'devices.capabilities.on_off') capability = { type: base.type, instance: base.instance, value: params.value ? 1 : 0, } break } case 'stateDual': { const base = this.getCapability(accessory, 'powerSwitch', 'devices.capabilities.on_off') capability = { type: base.type, instance: base.instance, value: params.value, } break } case 'lightScene': case 'diyScene': case 'scene': { const instance = params.instance || (params.cmd === 'diyScene' ? 'diyScene' : 'lightScene') const base = this.getCapability(accessory, instance, 'devices.capabilities.dynamic_scene') let value = params.value if (typeof value === 'string') { const matched = findOptionByName(base, value) if (!matched) { throw new Error(`scene not available via OpenAPI [${value}]`) } value = matched.value } capability = { type: base.type, instance: base.instance, value, } break } case 'openApi': { const base = this.getCapability(accessory, params.instance, params.capabilityType || 'devices.capabilities.work_mode') capability = { type: base.type, instance: base.instance, value: params.value, } break } default: throw new Error(`command not supported via OpenAPI [${params.cmd}]`) } return capability } async updateDevice(accessory, params) { const capability = this.buildCapabilityPayload(accessory, params) await this.request({ method: 'post', path: '/router/api/v1/device/control', data: { requestId: `hb-control-${Date.now()}`, payload: { sku: accessory.context.gvModel, device: accessory.context.gvDeviceId, capability, }, }, }) } async connectMQTT() { if (this.mqttClient) { return } const topic = `GA/${this.apiKey}` try { this.mqttClient = await mqtt.connectAsync('mqtts://mqtt.openapi.govee.com:8883', { username: this.apiKey, password: this.apiKey, reconnectPeriod: 5000, connectTimeout: 30000, }) this.mqttClient.on('close', () => { this.log.debug('[OPENAPI MQTT] connection closed.') this.mqttConnected = false }) this.mqttClient.on('reconnect', () => { this.log.debug('[OPENAPI MQTT] reconnecting...') }) this.mqttClient.on('offline', () => { this.log.debug('[OPENAPI MQTT] offline.') this.mqttConnected = false }) this.mqttClient.on('error', (err) => { this.log.debug('[OPENAPI MQTT] error: %s.', parseError(err)) this.mqttConnected = false }) this.mqttClient.on('message', (receivedTopic, payload) => { try { const message = JSON.parse(payload.toString()) this.log.debug('[OPENAPI MQTT] message: %s', JSON.stringify(message)) if (!message?.device || !Array.isArray(message?.capabilities)) { return } // Normalize capabilities into state format matching getState() output const normalized = {} message.capabilities.forEach((capability) => { const value = getCapabilityValue(capability) switch (capability?.instance) { case 'online': normalized.online = !!value break case 'powerSwitch': normalized.onOff = Number(value) break case 'brightness': normalized.brightness = Number(value) break case 'colorRgb': { const rgb = intToRgb(value) if (rgb) { normalized.color = rgb } break } case 'colorTemperatureK': if (Number.isFinite(Number(value)) && Number(value) > 0) { normalized.colorTemInKelvin = Number(value) } break case 'workMode': normalized.workMode = value break case 'sensorTemperature': normalized.sensorTemperature = Number(value) break case 'sensorHumidity': normalized.sensorHumidity = Number(value) break case 'humidity': normalized.targetHumidity = Number(value) break case 'targetTemperature': case 'sliderTemperature': normalized.targetTemperature = value break default: if (capability?.instance?.endsWith('Toggle')) { normalized.toggles = normalized.toggles || {} normalized.toggles[capability.instance] = Number(value) === 1 } break } }) if (Object.keys(normalized).length > 0) { this.platform.receiveUpdateOpenAPI(message.device, normalized) } } catch (err) { this.log.debug('[OPENAPI MQTT] failed to parse message: %s.', parseError(err)) } }) await this.mqttClient.subscribeAsync(topic) this.mqttConnected = true this.log('[OPENAPI MQTT] connected and subscribed to %s.', topic) } catch (err) { this.log.warn('[OPENAPI MQTT] failed to connect: %s.', parseError(err)) this.mqttClient = null this.mqttConnected = false } } async disconnectMQTT() { if (this.mqttClient) { try { await this.mqttClient.endAsync() } catch { // Ignore errors during shutdown } this.mqttClient = null this.mqttConnected = false } } }