@homebridge-plugins/homebridge-ewelink
Version:
Homebridge plugin to integrate eWeLink devices into HomeKit.
345 lines (311 loc) • 11.4 kB
JavaScript
import { Buffer } from 'node:buffer'
import {
createCipheriv,
createDecipheriv,
createHash,
randomBytes,
} from 'node:crypto'
import events from 'node:events'
import axios from 'axios'
import dnsSd from '../node-dns-sd/lib/dns-sd.js'
import platformConsts from '../utils/constants.js'
import { hasProperty, parseError, sleep } from '../utils/functions.js'
const emitter = new events()
export default class {
constructor(platform) {
// Set up variables from the platform
this.debug = platform.config.debug
this.ipOverride = platform.ipOverride
this.lang = platform.lang
this.log = platform.log
this.mode = platform.config.mode
// Set up other variables and libraries needed by this class
this.deviceMap = new Map()
}
async getHosts() {
// Create a template map with our device list from the HTTP client
Object.entries(this.ipOverride).forEach((entry) => {
const [deviceId, ip] = entry
// Add this device into the map with its user configured IP
this.deviceMap.set(deviceId, {
ip,
ipOverride: true,
})
})
// Perform an initial discovery of devices on the local network
if (this.debug) {
this.log('%s.', this.lang.lanStarting)
}
const res = await dnsSd.discover({ name: '_ewelink._tcp.local' })
if (this.debug) {
this.log('%s.', this.lang.lanStarted)
}
// Update the device map for each device found on the local network
res.forEach((device) => {
// Obtain the ewelink deviceId and check to see it is in our map
const deviceId = device.fqdn.replace('._ewelink._tcp.local', '').replace('eWeLink_', '')
// Do not override any overridden IPs
if (!this.deviceMap.has(deviceId)) {
this.deviceMap.set(deviceId, {
ip: device.address,
ipOverride: false,
})
}
})
return this.deviceMap
}
async startMonitor() {
// Function to parse DNS packets picked up by node-dns-sd
dnsSd.ondata = (packet) => {
try {
if (!packet.answers) {
return
}
packet.answers
.filter(value => value.name.includes('_ewelink._tcp.local'))
.filter(value => value.type === 'TXT')
.filter(value => this.deviceMap.has(value.rdata.id))
.filter(value => this.deviceMap.get(value.rdata.id).lanKey)
.forEach((value) => {
const { rdata } = value
// Check the packet relates to a device in our map
const deviceInfo = this.deviceMap.get(rdata.id)
// Skip the update if it's a duplicate
if (deviceInfo.lastIV === rdata.iv) {
return
}
deviceInfo.lastIV = rdata.iv
// Obtain the packet information
const data = rdata.data1 + (rdata.data2 || '') + (rdata.data3 || '') + (rdata.data4 || '')
const key = createHash('md5')
.update(Buffer.from(deviceInfo.lanKey, 'utf8'))
.digest()
const dText = createDecipheriv(
'aes-128-cbc',
key,
Buffer.from(rdata.iv, 'base64'),
)
const pText = Buffer.concat([
dText.update(Buffer.from(data, 'base64')),
dText.final(),
]).toString('utf8')
// Check to see if the IP address of the device has changed
if (packet.address !== deviceInfo.ip) {
deviceInfo.ip = deviceInfo.ipOverride ? deviceInfo.ip : packet.address
}
this.deviceMap.set(rdata.id, deviceInfo)
// Parse the deconstructed information from the packet
let params
try {
// Fix RF Bridge malformed JSON and strip trailing garbage bytes
// eslint-disable-next-line no-control-regex
const cleanText = pText.replace(/"\s*=\s*"/g, '":"').replace(/[\x00-\x1F]+$/g, '')
params = JSON.parse(cleanText)
} catch (err) {
this.log.warn(
'[%s] %s %s:\n%s',
rdata.id,
this.lang.cantReadPacket,
err.message,
pText,
)
return
}
// Remove any params we don't need
Object.keys(params).forEach((param) => {
if (!platformConsts.paramsToKeep.includes(param.replace(/\d/g, ''))) {
delete params[param]
}
})
// If any params are left then generate the template to be emitted
if (Object.keys(params).length > 0) {
if (packet.address !== deviceInfo.ip) {
params.ip = packet.address
}
params.online = true
params.updateSource = 'LAN'
const returnTemplate = {
deviceid: rdata.id,
params,
}
emitter.emit('update', returnTemplate)
}
})
} catch (err) {
this.log.warn('%s %s.', this.lang.cantParsePacket, parseError(err))
}
}
// Start the DNS packet monitoring
await dnsSd.startMonitoring()
this.log('%s.', this.lang.lanMonitor)
// Log LAN discovery summary after 10 seconds
setTimeout(() => {
const total = this.deviceMap.size
const withIp = [...this.deviceMap.values()].filter(d => d.ip).length
this.log('LAN discovery: %s/%s devices found locally.', withIp, total)
}, 10000)
}
async sendUpdate(json, retries = 0) {
try {
// Check this device exists in the map
if (!this.deviceMap.has(json.deviceid)) {
throw new Error(this.lang.devNotReachLAN)
}
const deviceInfo = this.deviceMap.get(json.deviceid)
// Check we have an IP address for the device
if (!deviceInfo.ip) {
throw new Error(this.lang.devNotReachLAN)
}
// Check we have the device lan key
if (!deviceInfo.lanKey || !deviceInfo.uiid || !deviceInfo.productModel) {
throw new Error(this.lang.devNoAPIKey)
}
const params = {}
let suffix
// Check the params to see which suffix and params we need
if (json.params.brightness) {
params.switch = 'on'
params.brightness = json.params.brightness
params.mode = 0
suffix = 'dimmable'
} else if (json.params.switches) {
const specialModels = ['iFan03', 'iFan', 'iFan04']
if (deviceInfo.uiid === 34 && specialModels.includes(deviceInfo.productModel)) {
// Special format for some iFan models
if (json.params.switches[0].outlet === 0) {
// Turn on/off the light for the iFan
params.light = json.params.switches[0].switch
suffix = 'light'
} else {
// Change the state or speed of the iFan
if (json.params.switches[0].switch === 'off') {
params.fan = 'off'
} else {
params.fan = 'on'
switch (json.params.switches[1].switch + json.params.switches[2].switch) {
case 'offoff':
params.speed = 1
break
case 'onoff':
params.speed = 2
break
case 'offon':
params.speed = 3
break
default:
// Should never happen
return 'ok'
}
}
suffix = 'fan'
}
} else {
params.switches = json.params.switches
if (json.params.operSide !== undefined) {
params.operSide = json.params.operSide
}
suffix = 'switches'
}
} else if (json.params.switch) {
params.switch = json.params.switch
if (deviceInfo.uiid === 15) {
// TH10/16 only supports LAN in normal mode
if (json.params.deviceType === 'normal') {
// Extra params for the TH10/16
params.mainSwitch = json.params.mainSwitch
params.deviceType = json.params.deviceType
} else {
throw new Error(this.lang.devNotConfLAN)
}
}
suffix = 'switch'
} else if (
hasProperty(json.params, 'location')
&& platformConsts.devices.switchMultiPower.includes(deviceInfo.uiid)
) {
// Use hasProperty has location can be INT 0
params.location = json.params.location
suffix = 'location'
} else if (hasProperty(json.params, 'sledOnline')) {
params.sledOnline = json.params.sledOnline ? 'on' : 'off'
suffix = 'switch'
} else if (json.params.uiActive && deviceInfo.uiid === 32) {
params.uiActive = json.params.uiActive
suffix = 'monitor'
} else if (json.params.uiActive && [126, 165].includes(deviceInfo.uiid)) {
// DualR3 uses statistics endpoint in LAN mode
suffix = 'statistics'
} else if (json.params.cmd) {
params.cmd = 'transmit'
params.rfChl = json.params.rfChl
suffix = 'transmit'
} else if (hasProperty(json.params, 'ltype')) {
params.ltype = json.params.ltype
if (hasProperty(json.params, 'white')) {
params.white = json.params.white
} else {
params.color = json.params.color
}
suffix = 'dimmable'
} else {
throw new Error(this.lang.devNotConfLAN)
}
// Generate the HTTP request
const key = createHash('md5')
.update(Buffer.from(deviceInfo.lanKey, 'utf8'))
.digest()
const iv = randomBytes(16)
const enc = createCipheriv('aes-128-cbc', key, iv)
const data = {
data: Buffer.concat([enc.update(JSON.stringify(params)), enc.final()]).toString('base64'),
deviceid: json.deviceid,
encrypt: true,
iv: iv.toString('base64'),
selfApikey: '123',
sequence: Date.now().toString(),
}
// Send the HTTP request
const res = await axios({
method: 'post',
url: `http://${deviceInfo.ip}:8081/zeroconf/${suffix}`,
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
},
data,
timeout: this.mode === 'lan' ? 9000 : 3000,
})
// Check for any errors in the response
if (!res.data || res.data.error !== 0) {
const error = res?.data?.error || this.lang.lanErr
throw new Error(error)
}
// This ok is needed by the plugin
return 'ok'
} catch (err) {
// Retry on ECONNRESET (device web server is single-threaded)
if (err.code === 'ECONNRESET' && retries < 10) {
await sleep(100)
return this.sendUpdate(json, retries + 1)
}
return parseError(err)
}
}
receiveUpdate(f) {
emitter.addListener('update', f)
}
addDeviceDetailsToMap(deviceId, context) {
const entry = this.deviceMap.get(deviceId) || { ip: this.ipOverride[deviceId] }
entry.lanKey = context.lanKey
entry.uiid = context.eweUIID
entry.productModel = context.eweModel
this.deviceMap.set(deviceId, entry)
}
async closeConnection() {
// This is called when Homebridge is shutdown
await dnsSd.stopMonitoring()
if (this.debug) {
this.log('%s.', this.lang.stoppedLAN)
}
}
}