UNPKG

@homebridge-plugins/homebridge-govee

Version:

Homebridge plugin to integrate Govee devices into HomeKit.

321 lines (287 loc) 10 kB
import { Buffer } from 'node:buffer' import axios from 'axios' import platformConsts from '../utils/constants.js' import { parseError, sleep } from '../utils/functions.js' import platformLang from '../utils/lang-en.js' const HYPHEN_REGEX = /-/g const WHITESPACE_REGEX = /\s+/g const NEWLINE_REGEX = /\r\n|\n|\r/g export default class { constructor(platform) { // Create variables usable by the class this.log = platform.log this.password = platform.config.password this.token = platform.accountToken this.tokenTTR = platform.accountTokenTTR this.username = platform.config.username this.code = platform.config.code // May need changing from time to time this.appVersion = '7.4.10' this.userAgent = `GoveeHome/${this.appVersion} (com.ihoment.GoVeeSensor; build:8; iOS 26.5.0) Alamofire/5.11.0` // Create a client id generated from Govee username which should remain constant let clientSuffix = platform.api.hap.uuid.generate(this.username).replace(HYPHEN_REGEX, '') // 32 chars clientSuffix = clientSuffix.substring(0, clientSuffix.length - 2) // 30 chars this.clientId = `hb${clientSuffix}` // 32 chars } async login() { try { // Perform the HTTP request const loginData = { email: this.username, password: this.password, client: this.clientId, } if (this.code) { loginData.code = this.code } const res = await axios({ url: 'https://app2.govee.com/account/rest/account/v2/login', method: 'post', data: loginData, headers: { 'appVersion': this.appVersion, 'clientId': this.clientId, 'clientType': 1, 'iotVersion': 0, 'timestamp': Date.now(), 'User-Agent': this.userAgent, }, timeout: 30000, }) // Check to see we got a response if (!res.data) { throw new Error(platformLang.noToken) } // Handle 2FA requirement (status 454) if (res.data.status === 454) { if (this.code) { throw new Error(platformLang.twoFACodeInvalid) } // Request a verification code to be sent to the user's email await axios({ url: 'https://app2.govee.com/account/rest/account/v1/verification', method: 'post', data: { type: 8, email: this.username, }, headers: { 'appVersion': this.appVersion, 'clientId': this.clientId, 'clientType': 1, 'iotVersion': 0, 'timestamp': Date.now(), 'User-Agent': this.userAgent, }, timeout: 30000, }) throw new Error(platformLang.twoFARequired) } // Check to see we got a needed response if (!res.data.client || !res.data.client.token) { if (res.data.message && res.data.message.replace(WHITESPACE_REGEX, '') === 'Incorrectpassword') { if (this.base64Tried) { throw new Error(res.data.message || platformLang.noToken) } else { this.base64Tried = true this.password = Buffer.from(this.password, 'base64') .toString('utf8') .replace(NEWLINE_REGEX, '') .trim() return await this.login() } } throw new Error(res.data.message || platformLang.noToken) } // Make the token available in other functions this.token = res.data.client.token // Also grab an access token specifically for the get-tap-to-run endpoint await this.loginTTR() // Mark this request complete if in debug mode this.log.debug('[HTTP] %s.', platformLang.loginSuccess) // Also grab the iot data const iotRes = await axios({ url: 'https://app2.govee.com/app/v1/account/iot/key', method: 'get', headers: { 'Authorization': `Bearer ${this.token}`, 'appVersion': this.appVersion, 'clientId': this.clientId, 'clientType': 1, 'iotVersion': 0, 'timestamp': Date.now(), 'User-Agent': this.userAgent, }, }) // Return the account token and topic for AWS return { accountId: res.data.client.accountId, client: this.clientId, endpoint: iotRes.data.data.endpoint, iot: iotRes.data.data.p12, iotPass: iotRes.data.data.p12Pass, token: res.data.client.token, tokenTTR: this.tokenTTR, topic: res.data.client.topic, } } catch (err) { if (err.code && platformConsts.httpRetryCodes.includes(err.code)) { if (this.loginRetryCount >= 3) { this.loginRetryCount = 0 throw err } this.loginRetryCount = (this.loginRetryCount || 0) + 1 this.log.warn('[HTTP] %s [login() - %s] (attempt %d/3).', platformLang.httpRetry, err.code, this.loginRetryCount) await sleep(30000) return this.login() } throw err } } async loginTTR() { // The tap-to-run endpoint uses a separate, shorter-lived token obtained from // a different login endpoint to the main Govee account token. This is split // out so it can be refreshed on its own without a full account re-login. const ttrRes = await axios({ url: 'https://community-api.govee.com/os/v1/login', method: 'post', data: { email: this.username, password: this.password, }, timeout: 30000, }) this.tokenTTR = ttrRes.data?.data?.token return this.tokenTTR } async logout() { try { await axios({ url: 'https://app2.govee.com/account/rest/account/v1/logout', method: 'post', headers: { 'Authorization': `Bearer ${this.token}`, 'appVersion': this.appVersion, 'clientId': this.clientId, 'clientType': 1, 'iotVersion': 0, 'timestamp': Date.now(), 'User-Agent': this.userAgent, }, }) } catch (err) { // Logout is only called on homebridge shutdown, so we can just log the error this.log.warn('[HTTP] %s %s.', platformLang.logoutFail, parseError(err)) } } async getDevices(isSync = true) { try { // Make sure we do have the account token if (!this.token) { throw new Error(platformLang.noTokenExists) } // Use the token received to get a device list const res = await axios({ url: 'https://app2.govee.com/bff-app/v1/device/list', method: 'get', headers: { 'Authorization': `Bearer ${this.token}`, 'appVersion': this.appVersion, 'clientId': this.clientId, 'clientType': 1, 'iotVersion': 0, 'timestamp': Date.now(), 'User-Agent': this.userAgent, }, timeout: 30000, }) // Check to see we got a response if (!res.data || !res.data.data || !res.data.data.devices) { throw new Error(platformLang.noDevices) } // Return the device list return res.data.data.devices || [] } catch (err) { if (!isSync && err.code && platformConsts.httpRetryCodes.includes(err.code)) { if (this.getDevicesRetryCount >= 3) { this.getDevicesRetryCount = 0 throw err } this.getDevicesRetryCount = (this.getDevicesRetryCount || 0) + 1 this.log.warn('[HTTP] %s [getDevices() - %s] (attempt %d/3).', platformLang.httpRetry, err.code, this.getDevicesRetryCount) await sleep(30000) return this.getDevices(isSync) } throw err } } async getTapToRuns(isRetry = false) { // The TTR token is separate to the main account token and shorter-lived. If // we don't have a usable one (eg. restored from an old cache where it was // never saved, saved as the literal string 'undefined', or expired) then // fetch a fresh one before continuing. This lets a user with a valid main // token but a bad TTR token self-heal without a full account re-login. if (!this.tokenTTR || this.tokenTTR === 'undefined') { await this.loginTTR() this.tokenTTRRefreshed = true } // Build and send the request const res = await axios({ url: 'https://app2.govee.com/bff-app/v1/exec-plat/home', method: 'get', headers: { 'Authorization': `Bearer ${this.tokenTTR}`, 'appVersion': this.appVersion, 'clientId': this.clientId, 'clientType': 1, 'iotVersion': 0, 'timestamp': Date.now(), 'User-Agent': this.userAgent, }, timeout: 10000, }) // Check to see we got a response if (!res?.data?.data?.components) { // The token may have expired since we obtained it - try one fresh TTR // login and retry once before giving up if (!isRetry) { await this.loginTTR() this.tokenTTRRefreshed = true return this.getTapToRuns(true) } throw new Error('not a valid response') } return res.data.data.components } async getLeakDeviceWarning(deviceId, deviceSku) { // Make sure we do have the account token if (!this.token) { throw new Error(platformLang.noTokenExists) } // Build and send the request const res = await axios({ url: 'https://app2.govee.com/leak/rest/device/v1/warnMessage', method: 'post', headers: { 'Authorization': `Bearer ${this.token}`, 'appVersion': this.appVersion, 'clientId': this.clientId, 'clientType': 1, 'iotVersion': 0, 'timestamp': Date.now(), 'User-Agent': this.userAgent, }, data: { device: deviceId.replaceAll(':', ''), limit: 50, sku: deviceSku, }, timeout: 10000, }) // Check to see we got a response if (!res?.data?.data) { throw new Error(platformLang.noDevices) } return res.data.data } }