@maxsaber/homebridge-govee
Version:
Homebridge plugin to integrate Govee devices into HomeKit.
1,279 lines (1,179 loc) • 71.9 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 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)