UNPKG

@homebridge-plugins/homebridge-govee

Version:

Homebridge plugin to integrate Govee devices into HomeKit.

1,302 lines (1,205 loc) 69.4 kB
import { Buffer } from 'node:buffer' import { existsSync, mkdirSync, promises } from 'node:fs' import { createRequire } from 'node:module' import { join } from 'node:path' import process from 'node:process' import storage from 'node-persist' import PQueue from 'p-queue' import awsClient from './connection/aws.js' import httpClient from './connection/http.js' import lanClient from './connection/lan.js' import openApiClient from './connection/openapi.js' import deviceTypes from './device/index.js' import eveService from './fakegato/fakegato-history.js' import { buildCommand } from './utils/command-builder.js' import platformConsts from './utils/constants.js' import platformChars from './utils/custom-chars.js' import { getDeviceCapabilities } from './utils/device-capabilities.js' import eveChars from './utils/eve-chars.js' import { hasProperty, parseDeviceId, parseError, pfxToCertAndKey, } from './utils/functions.js' import platformLang from './utils/lang-en.js' import { parseDeviceUpdate } from './utils/response-parser.js' const require = createRequire(import.meta.url) const plugin = require('../package.json') const WHITESPACE_REGEX = /\s+/g const DEVICE_ID_FORMAT_REGEX = /([a-z0-9]{2})(?=[a-z0-9])/gi const devicesInHB = new Map() const awsDevices = [] const awsDevicesToPoll = [] const httpDevices = [] const lanDevices = [] const openApiDevices = [] export default class { constructor(log, config, api) { if (!log || !api) { return } // Begin plugin initialisation try { this.api = api this.log = log this.isBeta = process.argv.includes('-D') // Configuration objects for accessories this.deviceConf = {} this.ignoredDevices = [] // Make sure user is running Homebridge v1.5 or above if (!api.versionGreaterOrEqual?.('1.5.0')) { throw new Error(platformLang.hbVersionFail) } // Check the user has configured the plugin if (!config) { throw new Error(platformLang.pluginNotConf) } // Log some environment info for debugging this.log( '%s v%s | System %s | Node %s | HB v%s | HAPNodeJS v%s...', platformLang.initialising, plugin.version, process.platform, process.version, api.serverVersion, api.hap.HAPLibraryVersion(), ) // Apply the user's configuration this.config = platformConsts.defaultConfig this.applyUserConfig(config) // Set up empty clients this.bleClient = false this.httpClient = false this.lanClient = false this.openApiClient = false // Set up the Homebridge events this.api.on('didFinishLaunching', () => this.pluginSetup()) this.api.on('shutdown', () => this.pluginShutdown()) } catch (err) { // Catch any errors during initialisation log.warn('***** %s [v%s]. *****', platformLang.disabling, plugin.version) log.warn('***** %s. *****', parseError(err, [platformLang.hbVersionFail, platformLang.pluginNotConf])) } } applyUserConfig(config) { // These shorthand functions save line space during config parsing const logDefault = (k, def) => { this.log.warn('%s [%s] %s %s.', platformLang.cfgItem, k, platformLang.cfgDef, def) } const logDuplicate = (k) => { this.log.warn('%s [%s] %s.', platformLang.cfgItem, k, platformLang.cfgDup) } const logIgnore = (k) => { this.log.warn('%s [%s] %s.', platformLang.cfgItem, k, platformLang.cfgIgn) } const logIgnoreItem = (k) => { this.log.warn('%s [%s] %s.', platformLang.cfgItem, k, platformLang.cfgIgnItem) } const logIncrease = (k, min) => { this.log.warn('%s [%s] %s %s.', platformLang.cfgItem, k, platformLang.cfgLow, min) } const logQuotes = (k) => { this.log.warn('%s [%s] %s.', platformLang.cfgItem, k, platformLang.cfgQts) } const logRemove = (k) => { this.log.warn('%s [%s] %s.', platformLang.cfgItem, k, platformLang.cfgRmv) } // Begin applying the user's config Object.entries(config).forEach((entry) => { const [key, val] = entry switch (key) { case 'apiKey': case 'code': case 'password': case 'username': if (typeof val !== 'string' || val === '') { logIgnore(key) } else { this.config[key] = val } break case 'awsDisable': case 'bleDisable': case 'colourSafeMode': case 'disableDeviceLogging': case 'ignoreMatter': case 'lanDisable': if (typeof val === 'string') { logQuotes(key) } this.config[key] = val === 'false' ? false : !!val break case 'bleControlInterval': case 'bleRefreshTime': case 'httpRefreshTime': case 'lanRefreshTime': case 'lanScanInterval': { if (typeof val === 'string') { logQuotes(key) } const intVal = Number.parseInt(val, 10) if (Number.isNaN(intVal)) { logDefault(key, platformConsts.defaultValues[key]) this.config[key] = platformConsts.defaultValues[key] } else if (intVal < platformConsts.minValues[key]) { logIncrease(key, platformConsts.minValues[key]) this.config[key] = platformConsts.minValues[key] } else { this.config[key] = intVal } break } case 'dehumidifierDevices': case 'fanDevices': case 'heaterDevices': case 'humidifierDevices': case 'iceMakerDevices': case 'kettleDevices': case 'leakDevices': case 'lightDevices': case 'purifierDevices': case 'diffuserDevices': case 'switchDevices': case 'thermoDevices': if (Array.isArray(val) && val.length > 0) { val.forEach((x) => { if (!x.deviceId) { logIgnoreItem(key) return } const id = parseDeviceId(x.deviceId) if (Object.keys(this.deviceConf).includes(id)) { logDuplicate(`${key}.${id}`) return } const entries = Object.entries(x) if (entries.length === 1) { logRemove(`${key}.${id}`) return } this.deviceConf[id] = {} entries.forEach((subEntry) => { const [k, v] = subEntry switch (k) { case 'adaptiveLightingShift': case 'brightnessStep': case 'lowBattThreshold': { if (typeof v === 'string') { logQuotes(`${key}.${k}`) } const intVal = Number.parseInt(v, 10) if (Number.isNaN(intVal)) { logDefault(`${key}.${id}.${k}`, platformConsts.defaultValues[k]) this.deviceConf[id][k] = platformConsts.defaultValues[k] } else if (intVal < platformConsts.minValues[k]) { logIncrease(`${key}.${id}.${k}`, platformConsts.minValues[k]) this.deviceConf[id][k] = platformConsts.minValues[k] } else { this.deviceConf[id][k] = intVal } break } case 'hideLight': case 'hideModeGreenTea': case 'hideModeOolongTea': case 'hideModeCoffee': case 'hideModeBlackTea': case 'showCustomMode1': case 'showCustomMode2': case 'showExtraSwitch': case 'tempReporting': if (typeof v === 'string') { logQuotes(`${key}.${id}.${k}`) } this.deviceConf[id][k] = v === 'false' ? false : !!v break case 'awsColourMode': case 'showAs': { if (typeof v !== 'string' || !platformConsts.allowed[k].includes(v)) { logIgnore(`${key}.${id}.${k}`) } else { this.deviceConf[id][k] = v } break } case 'customAddress': case 'customIPAddress': if (typeof v !== 'string' || v === '') { logIgnore(`${key}.${id}.${k}`) } else { this.deviceConf[id][k] = v.replace(WHITESPACE_REGEX, '') } break case 'deviceId': break case 'diyMode': case 'diyModeTwo': case 'diyModeThree': case 'diyModeFour': case 'musicMode': case 'musicModeTwo': case 'scene': case 'sceneTwo': case 'sceneThree': case 'sceneFour': case 'segmented': case 'segmentedTwo': case 'segmentedThree': case 'segmentedFour': case 'temperatureSource': case 'videoMode': case 'videoModeTwo': { if (typeof v === 'string') { this.log.warn(`${key}.${id}.${k} incorrectly configured - please use the config screen to reconfigure this item:`) this.log.warn(`${key}.${id}.${k}: ${v}`) } if (typeof v === 'object') { // object - only allowed keys are 'sceneCode', 'bleCode' and 'showAs' const subEntries = Object.entries(v) if (subEntries.length > 0) { this.deviceConf[id][k] = {} subEntries.forEach((subSubEntry) => { const [k1, v1] = subSubEntry switch (k1) { case 'bleCode': case 'sceneCode': if (typeof v1 !== 'string' || v1 === '') { logIgnore(`${key}.${id}.${k}.${k1}`) } else { this.deviceConf[id][k][k1] = v1 } break case 'showAs': { if (typeof v1 !== 'string' || !['default', 'switch'].includes(v1)) { logIgnore(`${key}.${id}.${k}.${k1}`) } else { this.deviceConf[id][k][k1] = v1 } break } default: logIgnore(`${key}.${id}.${k}.${k1}`) break } }) } else { logIgnore(`${key}.${id}.${k}`) } } else { logIgnore(`${key}.${id}.${k}`) } break } case 'ignoreDevice': if (typeof v === 'string') { logQuotes(`${key}.${id}.${k}`) } if (!!v && v !== 'false') { this.ignoredDevices.push(id) } break case 'label': if (typeof v !== 'string' || v === '') { logIgnore(`${key}.${id}.${k}`) } else { this.deviceConf[id][k] = v } break default: logRemove(`${key}.${id}.${k}`) } }) }) } else { logIgnore(key) } break case 'name': case 'platform': break default: logRemove(key) break } }) } async pluginSetup() { // Plugin has finished initialising so now onto setup try { // Log that the plugin initialisation has been successful this.log('%s.', platformLang.initialised) // Sort out some logging functions if (this.isBeta) { this.log.debug = this.log this.log.debugWarn = this.log.warn } else { this.log.debug = () => {} this.log.debugWarn = () => {} } // Require any libraries that the plugin uses this.cusChar = new platformChars(this.api) this.eveChar = new eveChars(this.api) this.eveService = eveService(this.api) const cachePath = join(this.api.user.storagePath(), '/bwp91_cache') const persistPath = join(this.api.user.storagePath(), '/persist') // Create folders if they don't exist if (!existsSync(cachePath)) { mkdirSync(cachePath) } if (!existsSync(persistPath)) { mkdirSync(persistPath) } // Persist files are used to store device info that can be used by my other plugins try { this.storageData = storage.create({ dir: cachePath, forgiveParseErrors: true, }) await this.storageData.init() this.storageClientData = true } catch (err) { this.log.debugWarn('%s %s.', platformLang.storageSetupErr, parseError(err)) } // Set up the LAN client and perform an initial scan for devices try { if (this.config.lanDisable) { throw new Error(platformLang.disabledInConfig) } this.lanClient = new lanClient(this) const devices = await this.lanClient.getDevices() devices.forEach(device => lanDevices.push(device)) this.log('[LAN] %s.', platformLang.availableWithDevices(devices.length)) } catch (err) { this.log.warn('[LAN] %s %s.', platformLang.disableClient, parseError(err, [ platformLang.disabledInConfig, ])) this.lanClient = false Object.keys(this.deviceConf).forEach((id) => { delete this.deviceConf[id].customIPAddress }) } // Set up the OpenAPI client if an API key has been provided and not disabled try { if (this.config.openApiDisable) { throw new Error(platformLang.disabledInConfig) } if (!this.config.apiKey) { throw new Error(platformLang.openApiNoKey) } this.openApiClient = new openApiClient(this) const devices = await this.openApiClient.getDevices() devices.forEach(device => openApiDevices.push(device)) this.log('[OPENAPI] %s.', platformLang.availableWithDevices(devices.length)) } catch (err) { this.log.warn('[OPENAPI] %s %s.', platformLang.disableClient, parseError(err, [ platformLang.openApiNoKey, platformLang.disabledInConfig, ])) this.openApiClient = false } // Set up the HTTP client if Govee username and password have been provided try { if (!this.config.username || !this.config.password) { throw new Error(platformLang.noCreds) } const iotFile = join(persistPath, 'govee.pfx') const getDevices = async () => { const devices = await this.httpClient.getDevices() devices.forEach(device => httpDevices.push(device)) this.log('[HTTP] %s.', platformLang.availableWithDevices(devices.length)) } // Try and get access token from the cache to get a device list try { const storedData = await this.storageData.getItem('Govee_All_Devices_temp') const splitData = storedData?.split(':::') if (!Array.isArray(splitData) || splitData.length !== 7) { throw new Error(platformLang.accTokenNoExist) } if (splitData[2] !== this.config.username) { // Username has changed so throw error to generate new token throw new Error(platformLang.accTokenUserChange) } try { await promises.access(iotFile, 0) } catch (err) { throw new Error(platformLang.iotFileNoExist) } [ this.accountTopic, this.accountToken,, this.accountId, this.iotEndpoint, this.iotPass, this.accountTokenTTR, ] = splitData this.log.debug('[HTTP] %s.', platformLang.accTokenFromCache) this.httpClient = new httpClient(this) await getDevices() } catch (err) { this.log.warn('[HTTP] %s %s.', platformLang.accTokenFail, parseError(err, [ platformLang.accTokenUserChange, platformLang.accTokenNoExist, platformLang.iotFileNoExist, ])) this.httpClient = new httpClient(this) const data = await this.httpClient.login() this.accountId = data.accountId this.accountTopic = data.topic this.accountToken = data.token this.accountTokenTTR = data.tokenTTR this.clientId = data.client this.iotEndpoint = data.endpoint this.iotPass = data.iotPass // Save this to a file await promises.writeFile(iotFile, Buffer.from(data.iot, 'base64')) // Try and save these to the cache for future reference await this.persistAccountCache() await getDevices() } const iotFileData = await pfxToCertAndKey(iotFile, this.iotPass) if (this.config.awsDisable) { this.log.warn('[AWS] %s %s.', platformLang.disableClient, platformLang.disabledInConfig) } else { this.awsClient = new awsClient(this, iotFileData) this.log('[AWS] %s.', platformLang.available) } } catch (err) { if (err.message.includes('abnormal')) { err.message = platformLang.abnormalMessage } this.log.warn('[HTTP] %s %s.', platformLang.disableClient, parseError(err, [ platformLang.abnormalMessage, platformLang.noCreds, platformLang.twoFARequired, platformLang.twoFACodeInvalid, ])) if (err.message.includes('Could not find openssl')) { this.log.warn(platformLang.noOpenssl) } this.log.warn('[AWS] %s %s.', platformLang.disableClient, platformLang.needHTTPClient) this.httpClient = false this.awsClient = false } // Set up the BLE client, if enabled try { if (this.config.bleDisable) { throw new Error(platformLang.disabledInConfig) } // See if the bluetooth client is available /* Noble sends the plugin into a crash loop if there is no bluetooth adapter available This if statement follows the logic of Noble up to the offending socket.bindRaw(device) Put inside a try/catch now to check for error and disable ble control for rest of plugin */ if (['linux', 'freebsd', 'win32'].includes(process.platform)) { const { default: BluetoothHciSocket } = await import('@stoprocent/bluetooth-hci-socket') const socket = new BluetoothHciSocket() const device = process.env.NOBLE_HCI_DEVICE_ID ? Number.parseInt(process.env.NOBLE_HCI_DEVICE_ID, 10) : undefined socket.bindRaw(device) } try { await import('@stoprocent/noble') } catch (err) { throw new Error(platformLang.bleNoPackage) } const { default: BLEConnection } = await import('./connection/ble.js') this.bleClient = new BLEConnection(this) this.log('[BLE] %s.', platformLang.available) } catch (err) { // This error thrown from bluetooth-hci-socket does not contain an 'err.message' if (err.code === 'ERR_DLOPEN_FAILED') { err.message = 'ERR_DLOPEN_FAILED' } this.log.warn('[BLE] %s %s.', platformLang.disableClient, parseError(err, [ platformLang.bleNoPackage, platformLang.disabledInConfig, 'ENODEV, No such device', 'ERR_DLOPEN_FAILED', ])) this.bleClient = false Object.keys(this.deviceConf).forEach((id) => { delete this.deviceConf[id].customAddress }) } // Config changed from milliseconds to seconds, so convert if needed this.config.bleControlInterval = this.config.bleControlInterval >= 500 ? this.config.bleControlInterval / 1000 : this.config.bleControlInterval this.queue = new PQueue({ concurrency: 1, interval: this.config.bleControlInterval * 1000, intervalCap: 1, timeout: 10000, throwOnTimeout: true, }) // Initialise the devices let bleSyncNeeded = false let httpSyncNeeded = false let lanDevicesWereInitialised = false let httpDevicesWereInitialised = false let openApiDevicesWereInitialised = false if (httpDevices && httpDevices.length > 0) { // We have some devices from HTTP client httpDevices.forEach((httpDevice) => { // Format device id if (!httpDevice.device.includes(':')) { // Eg converts abcd1234abcd1234 to AB:CD:12:34:AB:CD:12:34 // For sensors with an add-on sensor like H5178 // Eg converts abcd1234abcd1234_1 to AB:CD:12:34:AB:CD:12:34_1 httpDevice.device = httpDevice.device.replace(DEVICE_ID_FORMAT_REGEX, '$&:').toUpperCase() } // Check it's not a user-ignored device if (this.ignoredDevices.includes(httpDevice.device)) { return } // Check it's not a matter-ignored device, if the config has been set if (platformConsts.matterModels.includes(httpDevice.sku) && this.config.ignoreMatter) { return } // Sets the flag to see if we need to set up the BLE/HTTP syncs if (platformConsts.models.sensorLeak.includes(httpDevice.sku)) { httpSyncNeeded = true } if (platformConsts.models.sensorThermo.includes(httpDevice.sku)) { bleSyncNeeded = true httpSyncNeeded = true } // Find any matching device from the LAN client const lanDevice = lanDevices.find(el => el.device === httpDevice.device) // Find any matching device from the OpenAPI client to merge capabilities const matchingOpenApiDevice = openApiDevices.find(el => el.device === httpDevice.device) const openApiMerge = matchingOpenApiDevice ? { openApiInfo: matchingOpenApiDevice.openApiInfo, properties: matchingOpenApiDevice.properties, supportCmds: matchingOpenApiDevice.supportCmds, } : {} if (lanDevice) { // Device exists in LAN data so add the http info to the object and initialise this.initialiseDevice({ ...lanDevice, httpInfo: httpDevice, model: httpDevice.sku, deviceName: httpDevice.deviceName, isLanDevice: true, ...openApiMerge, }) lanDevicesWereInitialised = true lanDevice.initialised = true } else { // Device doesn't exist in LAN data, but try to initialise as could be other device type this.initialiseDevice({ device: httpDevice.device, deviceName: httpDevice.deviceName, model: httpDevice.sku, httpInfo: httpDevice, ...openApiMerge, }) } httpDevicesWereInitialised = true }) } if (openApiDevices && openApiDevices.length > 0) { openApiDevices.forEach((openApiDevice) => { if (this.ignoredDevices.includes(openApiDevice.device)) { return } if (platformConsts.matterModels.includes(openApiDevice.model) && this.config.ignoreMatter) { return } if (httpDevices.some(httpDevice => httpDevice.device === openApiDevice.device)) { return } const lanDevice = lanDevices.find(el => el.device === openApiDevice.device) if (lanDevice) { this.initialiseDevice({ ...lanDevice, deviceName: openApiDevice.deviceName, model: openApiDevice.model, openApiInfo: openApiDevice.openApiInfo, properties: openApiDevice.properties, supportCmds: openApiDevice.supportCmds, isLanDevice: true, }) lanDevicesWereInitialised = true lanDevice.initialised = true } else { this.initialiseDevice(openApiDevice) } openApiDevicesWereInitialised = true }) } // Some LAN devices may exist outside the HTTP client const pendingLANDevices = lanDevices.filter(el => !el.initialised) if (pendingLANDevices.length > 0) { // No devices from HTTP client, but LAN devices exist pendingLANDevices.forEach((lanDevice) => { // Check it's not a user-ignored device if (this.ignoredDevices.includes(lanDevice.device)) { return } // Initialise the device into Homebridge // Since LAN does not provide a name, we will use the configured label or device id this.initialiseDevice({ device: lanDevice.device, deviceName: this.deviceConf?.[lanDevice.device]?.label || lanDevice.device.replaceAll(':', ''), model: lanDevice.sku || 'HXXXX', // In case the model is not provided isLanDevice: true, isLanOnly: true, }) lanDevicesWereInitialised = true }) } if (!lanDevicesWereInitialised && !httpDevicesWereInitialised && !openApiDevicesWereInitialised) { // No devices either from HTTP client nor LAN client throw new Error(platformLang.noDevs) } // Check for redundant Homebridge accessories devicesInHB.forEach((accessory) => { // If the accessory doesn't exist in Govee then remove it if ( (!httpDevices.some(el => el.device === accessory.context.gvDeviceId) && !lanDevices.some(el => el.device === accessory.context.gvDeviceId) && !openApiDevices.some(el => el.device === accessory.context.gvDeviceId)) || this.ignoredDevices.includes(accessory.context.gvDeviceId) ) { this.removeAccessory(accessory) } }) // Set up the ble client sync needed for thermo sensor devices if (bleSyncNeeded) { try { // Check BLE is available if (!this.bleClient) { throw new Error(platformLang.bleNoPackage) } this.log('[BLE] enabling sync for thermo sensor devices.') this.refreshBLEInterval = setInterval(async () => { try { await this.goveeBLESync() } catch (err) { this.log.warn('[BLE] sync failed: %s', parseError(err)) } }, this.config.bleRefreshTime * 1000) } catch (err) { this.log.warn('[BLE] %s %s.', platformLang.bleScanDisabled, parseError(err, [platformLang.bleNoPackage])) } } // Set up the http client sync needed for leak and thermo sensor devices if (this.httpClient && httpSyncNeeded) { this.goveeHTTPSync() this.refreshHTTPInterval = setInterval( () => this.goveeHTTPSync(), this.config.httpRefreshTime * 1000, ) } if (this.openApiClient && (openApiDevicesWereInitialised || httpDevicesWereInitialised)) { const openApiRefresh = (this.config.openApiRefreshTime || this.config.httpRefreshTime) * 1000 this.goveeOpenApiSync() this.refreshOpenApiInterval = setInterval( () => this.goveeOpenApiSync(), openApiRefresh, ) // Connect to OpenAPI MQTT for real-time event push try { await this.openApiClient.connectMQTT() } catch (err) { this.log.warn('[OPENAPI MQTT] %s.', parseError(err)) } } // Set up the AWS client sync if there are any compatible devices if (this.awsClient && awsDevices.length > 0) { // Set up the AWS client await this.awsClient.connect() // No need for await as catches its own errors, we poll specific models that need it this.goveeAWSSync(true) this.refreshAWSInterval = setInterval( () => this.goveeAWSSync(), 60000, ) } // Set up the LAN client device scanning and device status polling if (lanDevicesWereInitialised) { this.lanClient.startDevicesPolling() this.lanClient.startStatusPolling() } // Access a list of scene codes from the HTTP client if (this.httpClient) { try { const scenes = await this.httpClient.getTapToRuns() // If the TTR token had to be refreshed (eg. it was missing or expired // in the cache), write the new one back so the next startup is fixed if (this.httpClient.tokenTTRRefreshed) { this.accountTokenTTR = this.httpClient.tokenTTR this.httpClient.tokenTTRRefreshed = false await this.persistAccountCache() this.log.debug('[HTTP] refreshed TTR token saved to cache.') } scenes.forEach((scene) => { if (scene.oneClicks) { scene.oneClicks.forEach((oneClick) => { if (oneClick.iotRules) { oneClick.iotRules.forEach((iotRule) => { if (iotRule?.deviceObj?.sku) { if (platformConsts.models.rgb.includes(iotRule.deviceObj.sku)) { iotRule.rule.forEach((rule) => { this.log.debugWarn(`[%s] [%s] ttr rule debug: ${JSON.stringify(rule)}.`, iotRule.deviceObj.name, oneClick.name) try { if (rule.iotMsg) { const iotMsg = JSON.parse(rule.iotMsg) if (iotMsg.msg?.cmd === 'ptReal') { this.log('[%s] [%s] [AWS] %s', iotRule.deviceObj.name, oneClick.name, iotMsg.msg.data.command.join(',')) } } if (rule.blueMsg) { const bleMsg = JSON.parse(rule.blueMsg) if (bleMsg.type === 'scene') { this.log('[%s] [%s] [BLE] %s', iotRule.deviceObj.name, oneClick.name, bleMsg.modeCmd) } } } catch { // Ignore malformed rule messages } }) } } }) } }) } }) } catch (err) { this.log.warn('%s %s.', 'Could not retrieve TTRs as', parseError(err)) } } else { this.log.debug('Skipping TTR retrieval as HTTP client not available') } // Log connection summary this.logConnectionSummary() // Setup successful this.log('%s. %s', platformLang.complete, platformLang.welcome) } catch (err) { // Catch any errors during setup this.log.warn('***** %s [v%s]. *****', platformLang.disabling, plugin.version) this.log.warn('***** %s. *****', parseError(err, [platformLang.noDevs])) this.pluginShutdown() } } logConnectionSummary() { const connections = [] const realtime = [] const polling = [] if (this.lanClient) { connections.push('LAN') } if (this.awsClient) { connections.push('AWS') } if (this.openApiClient) { connections.push('OpenAPI') } if (this.bleClient) { connections.push('BLE') } if (connections.length === 0) { return } // Count devices per connection type let lanCount = 0 let awsCount = 0 let openApiCount = 0 let bleCount = 0 let openApiPollCount = 0 devicesInHB.forEach((accessory) => { if (accessory.context.useLanControl) { lanCount++ } if (accessory.context.useAwsControl) { awsCount++ } if (accessory.context.useOpenApiControl) { openApiCount++ } if (accessory.context.useBleControl) { bleCount++ } // OpenAPI polling only for devices without AWS if (accessory.context.useOpenApiControl && !accessory.context.useAwsControl) { openApiPollCount++ } }) // Real-time channels if (this.awsClient) { realtime.push(`AWS IoT MQTT (${awsCount} devices)`) } if (this.openApiClient?.mqttConnected) { realtime.push(`OpenAPI MQTT (${openApiCount} devices)`) } if (this.lanClient && lanCount > 0) { realtime.push(`LAN UDP (${lanCount} devices)`) } // Polling intervals if (this.refreshAWSInterval) { polling.push(`AWS every 60s (${awsCount} devices)`) } if (this.refreshHTTPInterval) { polling.push(`HTTP every ${this.config.httpRefreshTime}s (sensors)`) } if (this.refreshBLEInterval) { polling.push(`BLE every ${this.config.bleRefreshTime}s (${bleCount} sensors)`) } if (this.refreshOpenApiInterval && openApiPollCount > 0) { const openApiRefresh = this.config.openApiRefreshTime || this.config.httpRefreshTime const dailyEstimate = Math.round((86400 / openApiRefresh) * openApiPollCount) polling.push(`OpenAPI every ${openApiRefresh}s (${openApiPollCount} devices without AWS) ~${dailyEstimate} req/day`) } this.log('---- Connection Summary ----') this.log('Configured: %s', connections.join(', ')) this.log('Send priority: %s', connections.join(' > ')) if (realtime.length > 0) { this.log('Incoming (real-time): %s', realtime.join(', ')) } if (polling.length > 0) { this.log('Incoming (polling): %s', polling.join(', ')) } this.log('----------------------------') } async persistAccountCache() { // Persist the account details to the cache for future startups, so we can // skip a full login. Kept in one place so the TTR self-heal path can also // re-save once it has refreshed the (separate, shorter-lived) TTR token. try { await this.storageData.setItem( 'Govee_All_Devices_temp', `${this.accountTopic}:::${this.accountToken}:::${this.config.username}:::${this.accountId}:::${this.iotEndpoint}:::${this.iotPass}:::${this.accountTokenTTR}`, ) } catch (err) { this.log.warn('[HTTP] %s %s.', platformLang.accTokenStoreErr, parseError(err)) } } pluginShutdown() { // A function that is called when the plugin fails to load or Homebridge restarts try { // Stop the refresh intervals if (this.refreshBLEInterval) { clearInterval(this.refreshBLEInterval) this.log('[BLE] refresh interval stopped.') } if (this.refreshHTTPInterval) { clearInterval(this.refreshHTTPInterval) this.log('[HTTP] refresh interval stopped.') // No need to await this since it catches its own errors this.httpClient.logout() this.log('[HTTP] logged out from session.') } if (this.refreshOpenApiInterval) { clearInterval(this.refreshOpenApiInterval) this.log('[OPENAPI] refresh interval stopped.') } if (this.openApiClient?.disconnectMQTT) { this.openApiClient.disconnectMQTT() this.log('[OPENAPI MQTT] disconnected.') } if (this.refreshAWSInterval) { clearInterval(this.refreshAWSInterval) this.log('[AWS] refresh interval stopped.') } // Close the LAN client if (this.lanClient?.close) { this.lanClient.close() this.log('[LAN] client closed.') } // Stop BLE operations immediately if the BLE client is running if (this.bleClient) { this.bleClient.shutdown() this.log('[BLE] stopped all BLE operations.') } } catch (err) { this.log.error('***** %s. *****', parseError(err)) } } applyAccessoryLogging(accessory) { if (this.isBeta) { accessory.log = msg => this.log('[%s] %s.', accessory.displayName, msg) accessory.logWarn = msg => this.log.warn('[%s] %s.', accessory.displayName, msg) accessory.logDebug = msg => this.log('[%s] %s.', accessory.displayName, msg) accessory.logDebugWarn = msg => this.log.warn('[%s] %s.', accessory.displayName, msg) } else { if (this.config.disableDeviceLogging) { accessory.log = () => {} accessory.logWarn = () => {} } else { accessory.log = msg => this.log('[%s] %s.', accessory.displayName, msg) accessory.logWarn = msg => this.log.warn('[%s] %s.', accessory.displayName, msg) } accessory.logDebug = () => {} accessory.logDebugWarn = () => {} } } initialiseDevice(device) { // Get the correct device type instance for the device try { const deviceConf = this.deviceConf[device.device.toUpperCase()] || {} const uuid = this.api.hap.uuid.generate(device.device) let accessory let devInstance let doAWSPolling = false if (platformConsts.models.rgb.includes(device.model)) { // Device is an LED strip/bulb devInstance = deviceConf.showAs === 'switch' ? deviceTypes.deviceLightSwitch : deviceTypes.deviceLight accessory = devicesInHB.get(uuid) || this.addAccessory(device) } else if (platformConsts.models.switchSingle.includes(device.model)) { // Device is a cloud enabled Wi-Fi switch switch (deviceConf.showAs || platformConsts.defaultValues.showAs) { case 'audio': { if (devicesInHB.get(uuid)) { this.removeAccessory(devicesInHB.get(uuid)) } devInstance = deviceTypes.deviceTVSingle accessory = this.addExternalAccessory(device, 34) break } case 'box': { if (devicesInHB.get(uuid)) { this.removeAccessory(devicesInHB.get(uuid)) } devInstance = deviceTypes.deviceTVSingle accessory = this.addExternalAccessory(device, 35) break } case 'stick': { if (devicesInHB.get(uuid)) { this.removeAccessory(devicesInHB.get(uuid)) } devInstance = deviceTypes.deviceTVSingle accessory = this.addExternalAccessory(device, 36) break } case 'cooler': { if (!deviceConf.temperatureSource) { this.log.warn('[%s] %s.', device.deviceName, platformLang.heaterSimNoSensor) if (devicesInHB.has(uuid)) { this.removeAccessory(devicesInHB.get(uuid)) } return } devInstance = deviceTypes.deviceCoolerSingle accessory = devicesInHB.get(uuid) || this.addAccessory(device) break } case 'heater': { if (!deviceConf.temperatureSource) { this.log.warn('[%s] %s.', device.deviceName, platformLang.heaterSimNoSensor) if (devicesInHB.has(uuid)) { this.removeAccessory(devicesInHB.get(uuid)) } return } devInstance = deviceTypes.deviceHeater2Single accessory = devicesInHB.get(uuid) || this.addAccessory(device) break } case 'purifier': { devInstance = deviceTypes.devicePurifierSingle accessory = devicesInHB.get(uuid) || this.addAccessory(device) break } case 'switch': { devInstance = deviceTypes.deviceSwitchSingle accessory = devicesInHB.get(uuid) || this.addAccessory(device) break } case 'tap': { devInstance = deviceTypes.deviceTapSingle accessory = devicesInHB.get(uuid) || this.addAccessory(device) break } case 'valve': { devInstance = deviceTypes.deviceValveSingle accessory = devicesInHB.get(uuid) || this.addAccessory(device) break } default: devInstance = deviceTypes.deviceOutletSingle accessory = devicesInHB.get(uuid) || this.addAccessory(device) break } } else if (platformConsts.models.switchDouble.includes(device.model)) { // Device is an AWS enabled Wi-Fi double switch switch (deviceConf.showAs || platformConsts.defaultValues.showAs) { case 'switch': { devInstance = deviceTypes.deviceSwitchDouble accessory = devicesInHB.get(uuid) || this.addAccessory(device) break } default: { devInstance = deviceTypes.deviceOutletDouble accessory = devicesInHB.get(uuid) || this.addAccessory(device) break } } } else if (platformConsts.models.switchTriple.includes(device.model)) { // Device is an AWS enabled Wi-Fi double switch switch (deviceConf.showAs || platformConsts.defaultValues.showAs) { case 'switch': { devInstance = deviceTypes.deviceSwitchTriple accessory = devicesInHB.get(uuid) || this.addAccessory(device) break } default: { devInstance = deviceTypes.deviceOutletTriple accessory = devicesInHB.get(uuid) || this.addAccessory(device) break } } } else if (platformConsts.models.sensorLeak.includes(device.model)) { // Device is a leak sensor devInstance = deviceTypes.deviceSensorLeak accessory = devicesInHB.get(uuid) || this.addAccessory(device) } else if (platformConsts.models.sensorPresence.includes(device.model)) { // Device is a presence sensor devInstance = deviceTypes.deviceSensorPresence accessory = devicesInHB.get(uuid) || this.addAccessory(device) } else if (platformConsts.models.sensorThermo.includes(device.model)) { // Device is a thermo-hygrometer sensor devInstance = deviceConf.showExtraSwitch ? deviceTypes.deviceSensorThermoSwitch : deviceTypes.deviceSensorThermo accessory = devicesInHB.get(uuid) || this.addAccessory(device) } else if (platformConsts.models.sensorThermo4.includes(device.model)) { // Device is a thermo-hygrometer sensor with 4 prongs and AWS support devInstance = deviceTypes.deviceSensorThermo4 accessory = devicesInHB.get(uuid) || this.addAccessory(device) } else if (platformConsts.models.sensorMonitor.includes(device.model)) { devInstance = deviceTypes.deviceSensorMonitor doAWSPolling = true accessory = devicesInHB.get(uuid) || this.addAccessory(device) } else if (platformConsts.models.sensorCO2.includes(device.model)) { // Device is a CO2 + temperature + humidity monitor (H5140) devInstance = deviceTypes.deviceSensorCO2 doAWSPolling = true accessory = devicesInHB.get(uuid) || this.addAccessory(device) } else if (platformConsts.models.fan.includes(device.model)) { // Device is a fan devInstance = deviceTypes[`deviceFan${device.model}`] accessory = devicesInHB.get(uuid) || this.addAccessory(device) } else if (platformConsts.models.heater1.includes(device.model)) { // Device is a H7130 devInstance = deviceConf.tempReporting ? deviceTypes.deviceHeater1B : deviceTypes.deviceHeater1A doAWSPolling = true accessory = devicesInHB.get(uuid) || this.addAccessory(device) } else if (platformConsts.models.heater2.includes(device.model)) { // Device is a H7131/H7132 devInstance = deviceTypes.deviceHeater2 doAWSPolling = true accessory = devicesInHB.get(uuid) || this.addAccessory(device) } else if (platformConsts.models.humidifier.includes(device.model)) { // Device is a humidifier doAWSPolling = true devInstance = deviceTypes[`deviceHumidifier${device.model}`] accessory = devicesInHB.get(uuid) || this.addAccessory(device) } else if (platformConsts.models.dehumidifier.includes(device.model)) { // Device is a dehumidifier devInstance = deviceTypes[`deviceDehumidifier${device.model}`] doAWSPolling = true accessory = devicesInHB.get(uuid) || this.addAccessory(device) } else if (platformConsts.models.purifier.includes(device.model)) { // Device is a purifier devInstance = deviceTypes[`devicePurifier${device.model}`] doAWSPolling = true accessory = devicesInHB.get(uuid) || this.addAccessory(device) } else if (platformConsts.models.diffuser.includes(device.model)) { // Device is a diffuser devInstance = deviceTypes[`deviceDiffuser${device.model}`] doAWSPolling = true accessory = devicesInHB.get(uuid) || this.addAccessory(device) } else if (platformConsts.models.sensorButton.includes(device.model)) { // Device is a button devInstance = deviceTypes.deviceSensorButton accessory = devicesInHB.get(uuid) || this.addAccessory(device) } else if (platformConsts.models.sensorContact.includes(device.model)) { // Device is a contact sensor devInstance = deviceTypes.deviceSensorContact accessory = devicesInHB.get(uuid) || this.addAccessory(device) } else if (platformConsts.models.kettle.includes(device.model)) { // Device is a kettle devInstance = deviceTypes.deviceKettle accessory = devicesInHB.get(uuid) || this.addAccessory(device) } else if (platformConsts.models.iceMaker.includes(device.model)) { // Device is an ice maker devInstance = deviceTypes[`deviceIceMaker${device.model}`] accessory = devicesInHB.get(uuid) || this.addAccessory(device) } else if (platformConsts.models.template.includes(device.model)) { // Device is a work-in-progress devInstance = deviceTypes.deviceTemplate accessory = devicesInHB.get(uuid) || this.addAccessory(device) } else { // Device is not in any supported model list but could be implemented into the plugin this.log.warn( '[%s] %s:\n%s', device.deviceName, platformLang.devMaySupp, JSON.stringify(device), ) return } // Final check the accessory now exists in Homebridge if (!accessory) { throw new Error(platformLang.accNotFound) } // Set the logging level for this device this.applyAccessoryLogging(accessory) // Add the temperatureSource config to the context if exists if (deviceConf.temperatureSource) { accessory.context.temperatureSource = deviceConf.temperatureSource } // Get a supported command list if provided, with their options if (device.supportCmds && Array.isArray(device.supportCmds)) { accessory.context.supportedCmds = device.supportCmds accessory.context.supportedCmdsOpts = {} device.supportCmds.forEach((cmd) => { if (device?.properties?.[cmd]) { accessory.context.supportedCmdsOpts[cmd] = device.properties[cmd] } }) } // Add some initial context information which is changed later accessory.context.hasAwsControl = false accessory.context.useAwsControl = false accessory.context.hasOpenApiControl = false accessory.context.useOpenApiControl = false accessory.context.hasBleControl = false accessory.context.useBleControl = false accessory.context.hasLanControl = device.isLanDevice accessory.context.useLanControl = accessory.context.hasLanControl accessory.context.firmware = false accessory.context.hardware = false accessory.context.image = false // Overrides for when a custom IP is provided, for a light which is not BLE only if ( deviceConf.customIPAddress && accessory.context.hasLanControl && accessory.context.hasAwsControl && platformConsts.models.rgb.includes(device.model) ) { accessory.context.hasLanControl = true accessory.context.useLanControl = true } // If the device is LAN-only, then sync the display name with the label in the configuration if (device.isLanOnly) { accessory.displayName = device.deviceName } // See if we have extra HTTP client info for this device if (device.httpInfo) { // Save the hardware and firmware versions accessory.context.firmware = device.httpInfo.versionSoft accessory.context.hardware = device.httpInfo.versionHard // It's possible to show a nice