@homebridge-plugins/homebridge-govee
Version:
Homebridge plugin to integrate Govee devices into HomeKit.
1,302 lines (1,205 loc) • 69.4 kB
JavaScript
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