@homebridge-plugins/homebridge-ewelink
Version:
Homebridge plugin to integrate eWeLink devices into HomeKit.
321 lines (294 loc) • 10.6 kB
JavaScript
import { Buffer } from 'node:buffer'
import { createHmac, randomBytes } from 'node:crypto'
import axios from 'axios'
import platformConsts from '../utils/constants.js'
import { sleep } from '../utils/functions.js'
export default class {
constructor(platform) {
// Set up variables from the platform
this.appId = platform.config.appId || platformConsts.appId
this.appSecret = platform.config.appSecret || platformConsts.appSecret
this.countryCode = platform.config.countryCode
this.debug = platform.config.debug
this.homeList = []
this.httpHost = platform.config.httpHost
this.ignoredDevices = platform.ignoredDevices
this.ignoredHomes = platform.config.ignoredHomes.split(',')
this.lang = platform.lang
this.log = platform.log
this.mode = platform.config.mode
this.obstructSwitches = platform.obstructSwitches
this.password = platform.config.password
this.sensorSwitches = Object
.values(platform.deviceConf)
.filter(el => el.sensorId && ['garage', 'lock'].includes(el.showAs))
.map(el => el.sensorId)
this.triedBase64 = false
this.username = platform.config.username
// Set up axios interceptor for automatic 401 token refresh
this.axiosClient = axios.create()
this.axiosClient.interceptors.response.use(
res => res,
async (err) => {
if (err.response?.status === 401 && !err.config._retried) {
err.config._retried = true
try {
const authData = await this.login()
this.aToken = authData.aToken
err.config.headers.Authorization = `Bearer ${this.aToken}`
return this.axiosClient(err.config)
} catch (loginErr) {
throw err
}
}
throw err
},
)
}
async login() {
try {
// Used to log the user in and obtain the user api key and token
const data = {
countryCode: this.countryCode,
password: this.password,
}
// See if the user has provided an email or phone as username
if (this.username.includes('@')) {
data.email = this.username
} else {
data.phoneNumber = this.username
}
// Log the data depending on the debug setting
if (this.debug) {
this.log('%s.', this.lang.sendLogin)
}
// Set up the request signature
const dataToSign = createHmac('sha256', this.appSecret)
.update(JSON.stringify(data))
.digest('base64')
// Send the request
const res = await axios.post(`https://${this.httpHost}/v2/user/login`, data, {
headers: {
'Authorization': `Sign ${dataToSign}`,
'Content-Type': 'application/json',
'X-CK-Appid': this.appId,
'X-CK-Nonce': randomBytes(4).toString('hex'),
},
})
// Parse the response
const body = res.data
if (body.error === 10004 && body?.data?.region) {
// In this case the user has been given a different region so retry login
const givenRegion = body.data.region
// Check the new received region is valid
switch (givenRegion) {
case 'eu':
case 'us':
case 'as':
this.httpHost = `${givenRegion}-apia.coolkit.cc`
break
case 'cn':
this.httpHost = 'cn-apia.coolkit.cn'
break
default:
throw new Error(`${this.lang.noRegionRec} - [${givenRegion}].`)
}
// Log the new http host if appropriate
if (this.debug) {
this.log('%s [%s].', this.lang.newRegionRec, this.httpHost)
}
// Retry the login with the new http host
return await this.login()
}
if ([10001, 10014].includes(body.error) && !this.triedBase64) {
// In this case the password is incorrect so try base64 decoding just once
this.triedBase64 = true
this.password = Buffer.from(this.password, 'base64')
.toString('utf8')
.replace(/\r\n|\n|\r/g, '')
.trim()
return await this.login()
}
if (body.data.at) {
// User api key and token received successfully
this.aToken = body.data.at
this.apiKey = body.data.user.apikey
return {
aToken: this.aToken,
apiKey: this.apiKey,
httpHost: this.httpHost,
password: this.password,
}
}
if (body.error === 500) {
// Retry if another attempt could be successful
this.log.warn('%s.', this.lang.eweError)
await sleep(30000)
return await this.login()
}
if (body.msg) {
throw new Error(body.msg + (body.error ? ` [${body.error}]` : ''))
} else {
throw new Error(`${this.lang.noAuthRec}.\n${JSON.stringify(body, null, 2)}`)
}
} catch (err) {
// Check to see if it's a eWeLink server problem, and we can retry
if (err.code && platformConsts.httpRetryCodes.includes(err.code)) {
// Retry if another attempt could be successful
this.log.warn('%s [login() - %s].', this.lang.httpRetry, err.code)
await sleep(30000)
return this.login()
}
// It's not a eWeLink problem so report the error back
this.log.warn('%s.', this.lang.errLogin)
if (err.message.includes('10003')) {
this.log.warn('%s', this.lang.httpLogin10003)
}
throw err
}
}
async getHomes() {
// Used to get a user's home list
try {
// Send the request
const res = await this.axiosClient.get(`https://${this.httpHost}/v2/family`, {
headers: {
'Authorization': `Bearer ${this.aToken}`,
'Content-Type': 'application/json',
'X-CK-Appid': this.appId,
'X-CK-Nonce': randomBytes(4).toString('hex'),
},
})
// Parse the response
const body = res.data
if (
!body.data
|| body.error !== 0
|| !body.data.familyList
|| !Array.isArray(body.data.familyList)
) {
throw new Error(JSON.stringify(body, null, 2))
}
// Add the home id to the global array of ids
body.data.familyList.forEach((home) => {
if (this.ignoredHomes.includes(home.id)) {
return
}
this.log('%s [%s] [%s].', this.lang.fetchHome, home.name, home.id)
this.homeList.push(home.id)
})
} catch (err) {
// Check to see if it's a eWeLink server problem, and we can retry
if (err.code && platformConsts.httpRetryCodes.includes(err.code)) {
// Retry if another attempt could be successful
this.log.warn('%s [getHomes() - %s].', this.lang.httpRetry, err.code)
await sleep(30000)
await this.getHomes()
} else {
// It's not a eWeLink problem so report the error back
this.log.warn('%s.', this.lang.errGetHomes)
throw err
}
}
}
async getDevices() {
// Used to get a user's device list
try {
// Send the request to get a device list for each of the homes
const fullDeviceList = []
for (const homeId of this.homeList) {
const res = await this.axiosClient.get(`https://${this.httpHost}/v2/device/thing`, {
headers: {
'Authorization': `Bearer ${this.aToken}`,
'Content-Type': 'application/json',
'X-CK-Appid': this.appId,
'X-CK-Nonce': Math.random()
.toString(36)
.substring(2, 10),
},
params: {
num: 0,
familyid: homeId,
},
})
// Parse the response
const body = res.data
if (!body.data || body.error !== 0) {
throw new Error(JSON.stringify(body, null, 2))
}
// The list also includes scenes, so we need to remove them
if (body.data?.thingList.length > 0) {
body.data.thingList.forEach(device => fullDeviceList.push(device))
}
}
// Now we have a device list from all the eWeLink user homes
const deviceList = []
const sensorList = []
const groupList = []
fullDeviceList.forEach((d) => {
// Check each item is a device and also remove any devices the user has ignored
if (d?.itemData?.extra?.uiid && !this.ignoredDevices.includes(d.itemData.deviceid)) {
// If in LAN mode then don't add to device list
if (this.mode === 'lan' && !platformConsts.devices.lan.includes(d.itemData.extra.uiid)) {
return
}
// Separate the sensors as these need to be set up last
const isObstructSwitch = this.obstructSwitches[d.itemData.deviceid]
const isSensorSwitch = this.sensorSwitches[d.itemData.deviceid]
if (
platformConsts.devices.garageSensors.includes(d.itemData.extra.uiid)
|| isObstructSwitch
|| isSensorSwitch
) {
sensorList.push(d.itemData)
} else {
deviceList.push(d.itemData)
}
} else if (d.itemType === 3) {
// Is a group
groupList.push(d.itemData)
}
})
// Sensors need to go last as they update garages that need to exist already
return {
httpDeviceList: deviceList.concat(sensorList),
httpGroupList: groupList,
}
} catch (err) {
// Check to see if it's a eWeLink server problem, and we can retry
if (err.code && platformConsts.httpRetryCodes.includes(err.code)) {
// Retry if another attempt could be successful
this.log.warn('%s [getDevices() - %s].', this.lang.httpRetry, err.code)
await sleep(30000)
return this.getDevices()
}
// It's not a eWeLink problem so report the error back
this.log.warn('%s.', this.lang.errGetDevices)
throw err
}
}
async updateGroup(groupId, params) {
// Used to get info about a specific device
const res = await this.axiosClient.post(
`https://${this.httpHost}/v2/device/thing/status`,
{
type: 2,
id: groupId,
params,
},
{
headers: {
'Authorization': `Bearer ${this.aToken}`,
'Content-Type': 'application/json',
'X-CK-Appid': this.appId,
'X-CK-Nonce': randomBytes(4).toString('hex'),
},
},
)
// Parse the response
const body = res.data
if (!body.data || body.error !== 0) {
throw new Error(JSON.stringify(body, null, 2))
}
}
}