UNPKG

@maxsaber/homebridge-govee

Version:

Homebridge plugin to integrate Govee devices into HomeKit.

1,279 lines (1,179 loc) 71.9 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 deviceTypes from './device/index.js' import eveService from './fakegato/fakegato-history.js' import { k2rgb } from './utils/colour.js' import platformConsts from './utils/constants.js' import platformChars from './utils/custom-chars.js' import eveChars from './utils/eve-chars.js' import { base64ToHex, hasProperty, parseDeviceId, parseError, pfxToCertAndKey, } from './utils/functions.js' import platformLang from './utils/lang-en.js' const require = createRequire(import.meta.url) const plugin = require('../package.json') const devicesInHB = new Map() const awsDevices = [] const awsDevicesToPoll = [] const httpDevices = [] const lanDevices = [] export default class { constructor(log, config, api) { if (!log || !api) { return } // Begin plugin initialisation try { this.api = api this.log = log this.isBeta = plugin.version.includes('beta') // 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 // 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 '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 'awsDisable': case 'bleDisable': case 'colourSafeMode': case 'disableDeviceLogging': case 'lanDisable': if (typeof val === 'string') { logQuotes(key) } this.config[key] = val === 'false' ? false : !!val 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 'awsBrightnessNoScale': case 'hideModeGreenTea': case 'hideModeOolongTea': case 'hideModeCoffee': case 'hideModeBlackTea': case 'showCustomMode1': case 'showCustomMode2': 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(/\s+/g, '') } 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 case 'password': case 'username': if (typeof val !== 'string' || val === '') { logIgnore(key) } else { this.config[key] = val } 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 // Log that using a beta will generate a lot of debug logs if (this.isBeta) { const divide = '*'.repeat(platformLang.beta.length + 1) // don't forget the full stop (+1!) this.log.warn(divide) this.log.warn(`${platformLang.beta}.`) this.log.warn(divide) } } 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 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 const accountToken = data.token const 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 try { await this.storageData.setItem( 'Govee_All_Devices_temp', `${this.accountTopic}:::${accountToken}:::${this.config.username}:::${this.accountId}:::${this.iotEndpoint}:::${this.iotPass}:::${accountTokenTTR}`, ) } catch (e) { this.log.warn('[HTTP] %s %s.', platformLang.accTokenStoreErr, parseError(e)) } 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, ])) 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) } const thisPlatform = process.platform // Bluetooth not supported on Mac if (thisPlatform === 'darwin') { throw new Error(platformLang.bleMacNoSupp) } // 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(thisPlatform)) { const { default: BluetoothHciSocket } = await import('@abandonware/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('@abandonware/noble') } catch (err) { throw new Error(platformLang.bleNoPackage) } const { default: bleClient } = await import('./connection/ble.js') this.bleClient = new bleClient(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.bleMacNoSupp, 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 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(/([a-z0-9]{2})(?=[a-z0-9])/gi, '$&:').toUpperCase() } // Check it's not a user-ignored device if (this.ignoredDevices.includes(httpDevice.device)) { 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) 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, }) 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, }) } httpDevicesWereInitialised = 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) { // 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)) || 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) } // Import the required modules const { debug: GoveeDebug, startDiscovery: sensorStartDiscovery, stopDiscovery: sensorStopDiscovery, } = await import('govee-bt-client') if (this.isBeta) { GoveeDebug(true) } this.sensorStartDiscovery = sensorStartDiscovery this.sensorStopDiscovery = sensorStopDiscovery this.refreshBLEInterval = setInterval( () => this.goveeBLESync(), 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, ) } // 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() 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) 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 (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') } // 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() } } 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) } if (this.refreshHTTPInterval) { clearInterval(this.refreshHTTPInterval) // No need to await this since it catches its own errors this.httpClient.logout() } if (this.refreshAWSInterval) { clearInterval(this.refreshAWSInterval) } // Close the LAN client this.lanClient.close() } catch (err) { // No need to show errors at this point } } 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 isLight = false let isJustBLE = false let doAWSPolling = false if (platformConsts.models.rgb.includes(device.model)) { // Device is a cloud-enabled (and maybe bluetooth) LED strip/bulb isLight = true devInstance = deviceConf.showAs === 'switch' ? deviceTypes.deviceLightSwitch : deviceTypes.deviceLight accessory = devicesInHB.get(uuid) || this.addAccessory(device) } else if (platformConsts.models.rgbBT.includes(device.model)) { // Device is a bluetooth-only LED strip/bulb if (this.config.bleDisable) { // BLE is disabled, so remove accessory if exists, log and return if (devicesInHB.has(uuid)) { this.removeAccessory(devicesInHB.get(uuid)) } this.log('[%s] %s.', device.deviceName, platformLang.devNoBlePackage) return } isLight = true isJustBLE = true devInstance = deviceConf.showAs === 'switch' ? deviceTypes.deviceLightSwitch : deviceTypes.deviceLight accessory = devicesInHB.get(uuid) || this.addAccessory(device) if (!this.bleClient) { this.log.warn('[%s] %s.', accessory.displayName, platformLang.bleNonControl) } } else if (platformConsts.models.switchSingle.includes(device.model)) { // Device is an 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 = 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.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.hasBLEControl = false accessory.context.useBLEControl = false accessory.context.firmware = false accessory.context.hardware = false accessory.context.image = false const modelHasLanControl = platformConsts.lanModels.includes(device.model) accessory.context.hasLANControl = modelHasLanControl && device.isLANDevice accessory.context.useLANControl = accessory.context.hasLANControl // Overrides for when a custom IP is provided, for a light which is not BLE only if (modelHasLanControl && deviceConf.customIPAddress && isLight && !isJustBLE) { 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 little icon of the device in the Homebridge UI if (device.httpInfo.deviceExt && device.httpInfo.deviceExt.extResources) { const parsed = JSON.parse(device.httpInfo.deviceExt.extResources) if (parsed && parsed.skuUrl) { accessory.context.image = parsed.skuUrl } } // HTTP info lets us see if AWS/BLE connection methods are available if (device.httpInfo.deviceExt && device.httpInfo.deviceExt.deviceSettings) { const parsed = JSON.parse(device.httpInfo.deviceExt.deviceSettings) // Check to see if AWS is possible if (parsed) { if (parsed.topic) { accessory.context.hasAWSControl = true accessory.context.awsTopic = parsed.topic if (this.awsClient) { accessory.context.useAWSControl = true accessory.context.awsBrightnessNoScale = deviceConf.awsBrightnessNoScale accessory.context.awsColourMode = deviceConf.awsColourMode || platformConsts.defaultValues.awsColourMode awsDevices.push(device.device) // Certain models need AWS polling if (doAWSPolling) { awsDevicesToPoll.push(device.device) } } } // Check to see if BLE is possible if (parsed.bleName) { const providedBle = parsed.address ? parsed.address.toLowerCase() : device.device.substring(6).toLowerCase() accessory.context.hasBLEControl = !!parsed.bleName accessory.context.bleAddress = deviceConf.customAddress ? deviceConf.customAddress.toLowerCase() : providedBle accessory.context.bleName = parsed.bleName if (this.bleClient) { accessory.context.useBLEControl = true } } // Get a min and max temperature/humidity range to show in the homebridge-ui if (hasProperty(parsed, 'temCali')) { accessory.context.minTemp = parsed.temMin / 100 accessory.context.maxTemp = parsed.temMax / 100 accessory.context.offTemp = parsed.temCali } if (hasProperty(parsed, 'humCali')) { accessory.context.minHumi = parsed.humMin / 100 accessory.context.maxHumi = parsed.humMax / 100 accessory.context.offHumi = parsed.humCali } } } } // Create the instance for this device type accessory.control = new devInstance(this, accessory) // Log the device initialisation this.log( '[%s] %s [%s] [%s].', accessory.displayName, platformLang.devInit, device.device, device.model, ) // Update any changes to the accessory to the platform this.api.updatePlatformAccessories([accessory]) devicesInHB.set(accessory.UUID, accessory) } catch (err) { // Catch any errors during device initialisation this.log.warn('[%s] %s %s.', device.deviceName, platformLang.devNotInit, parseError(err, [ platformLang.accNotFound, ])) } } async goveeAWSSync(allDevices = false) { const pollList = allDevices ? awsDevices : awsDevicesToPoll if (pollList.length === 0) { return } try { pollList.forEach(async (deviceId) => { // Generate the UUID from which we can match our Homebridge accessory const accessory = devicesInHB.get(this.api.hap.uuid.generate(deviceId)) try { await this.awsClient.requestUpdate(accessory) } catch (err) { accessory.logDebugWarn(`[LAN] ${platformLang.syncFail} ${parseError(err)}`) } }) } catch (err) { this.log.warn('[LAN] %s %s.', platformLang.syncFail, parseError(err)) } } async goveeBLESync() { try { await this.sensorStartDiscovery((goveeReading) => { const accessory = [...devicesInHB.values()].find(acc => acc.context.bleAddress === goveeReading.address) if (accessory && !platformConsts.models.sensorMonitor.includes(accessory.context.gvModel)) { this.receiveDeviceUpdate(accessory, { temperature: goveeReading.tempInC * 100, temperatureF: goveeReading.tempInF * 100, humidity: goveeReading.humidity * 100, battery: goveeReading.battery, source: 'BLE', }) } else { this.log.debugWarn('[BLE] %s [%s].', platformLang.bleScanUnknown, goveeReading.address) } }) // Stop scanning after 5 seconds setTimeout(async () => { try { await this.sensorStopDiscovery() } catch (err) { this.log.warn('[BLE] %s %s.', platformLang.bleScanNoStop, parseError(err)) } }, 5000) } catch (err) { this.log.warn('[BLE] %s %s.', platformLang.bleScanNoStart, parseError(err)) } } async goveeHTTPSync() { try { // Obtain a refreshed device list const devices = await this.httpClient.getDevices(true) // Filter those which are leak sensors devices .filter(device => [...platformConsts.models.sensorLeak, ...platformConsts.models.sensorThermo].includes(device.sku)) .forEach(async (device) => { try { // Reformat the device id if (!device.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 device.device = device.device.replace(/([a-z0-9]{2})(?=[a-z0-9])/gi, '$&:').toUpperCase() } // Generate the UIID from which we can match our Homebridge accessory const uiid = this.api.hap.uuid.generate(device.device) // Don't continue if the accessory doesn't exist if (!devicesInHB.has(uiid)) { return } // Retrieve the Homebridge accessory const accessory = devicesInHB.get(uiid) // Make sure the data we need for the device exists if (!device.deviceExt || !device.deviceExt.deviceSettings || !device.deviceExt.lastDeviceData) { return } // Parse the data received const parsedSettings = JSON.parse(device.deviceExt.deviceSettings) const parsedData = JSON.parse(device.deviceExt.lastDeviceData) const toReturn = { source: 'HTTP' } if (platformConsts.models.sensorLeak.includes(device.sku)) { accessory.logDebug(`raw data: ${JSON.stringify({ ...parsedData, ...parsedSettings })}`) // Leak Sensors - check to see of any warnings if the lastTime is above 0 let hasUnreadLeak = false if (parsedData.lastTime > 0) { // Obtain the leak warning messages for this device const msgs = await this.httpClient.getLeakDeviceWarning(device.device, device.sku)