homebridge-wemo
Version:
Homebridge plugin to integrate Wemo devices into HomeKit.
468 lines (402 loc) • 16.8 kB
JavaScript
import { parseStringPromise } from 'xml2js'
import {
hs2rgb,
rgb2hs,
rgb2xy,
xy2rgb,
} from '../utils/colour.js'
import platformConsts from '../utils/constants.js'
import { generateRandomString, parseError, sleep } from '../utils/functions.js'
import platformLang from '../utils/lang-en.js'
export default class {
constructor(platform, priAcc, accessory) {
// Set up variables from the platform
this.hapChar = platform.api.hap.Characteristic
this.hapErr = platform.api.hap.HapStatusError
this.hapServ = platform.api.hap.Service
this.platform = platform
// Set up variables from the accessory
this.accessory = accessory
this.priAcc = priAcc
// Set up variables from the device
this.deviceID = accessory.context.deviceId
// Set up custom variables for this device type
const deviceConf = platform.deviceConf[this.deviceID] || {}
this.brightStep = deviceConf.brightnessStep
? Math.min(deviceConf.brightnessStep, 100)
: platformConsts.defaultValues.brightnessStep
this.alShift = deviceConf.adaptiveLightingShift || platformConsts.defaultValues.adaptiveLightingShift
this.transitionTime = deviceConf.transitionTime || platformConsts.defaultValues.transitionTime
// Objects containing mapping info for the device capabilities
this.linkCodes = {
switch: '10006',
brightness: '10008',
color: '10300',
temperature: '30301',
}
this.linkCodesRev = {
10600: 'switch',
10008: 'brightness',
10300: 'color',
30301: 'temperature',
}
// Quick check variables for later use
this.hasBrightSupport = accessory.context.capabilities[this.linkCodes.brightness]
this.hasColourSupport = accessory.context.capabilities[this.linkCodes.color]
&& deviceConf?.enableColourControl
this.hasCTempSupport = accessory.context.capabilities[this.linkCodes.temperature]
// Add the lightbulb service if it doesn't already exist
this.service = this.accessory.getService(this.hapServ.Lightbulb)
|| this.accessory.addService(this.hapServ.Lightbulb)
// If adaptive lighting has just been disabled then remove and re-add service to hide AL icon
if (this.alShift === -1 && this.accessory.context.adaptiveLighting) {
this.accessory.removeService(this.service)
this.service = this.accessory.addService(this.hapServ.Lightbulb)
this.accessory.context.adaptiveLighting = false
}
// Add the set handler to the lightbulb on/off characteristic
this.service
.getCharacteristic(this.hapChar.On)
.removeOnSet()
.onSet(async value => this.internalStateUpdate(value))
// Add the set handler to the brightness characteristic if supported
if (this.hasBrightSupport) {
this.service
.getCharacteristic(this.hapChar.Brightness)
.setProps({ minStep: this.brightStep })
.onSet(async (value) => {
await this.internalBrightnessUpdate(value)
})
}
// Add the set handler to the colour temperature characteristic if supported
if (this.hasColourSupport) {
this.service.getCharacteristic(this.hapChar.Hue).onSet(async (value) => {
await this.internalColourUpdate(value)
})
this.cacheHue = this.service.getCharacteristic(this.hapChar.Hue).value
this.cacheSat = this.service.getCharacteristic(this.hapChar.Saturation).value
} else {
if (this.service.testCharacteristic(this.hapChar.Hue)) {
this.service.removeCharacteristic(this.service.getCharacteristic(this.hapChar.Hue))
}
if (this.service.testCharacteristic(this.hapChar.Saturation)) {
this.service.removeCharacteristic(this.service.getCharacteristic(this.hapChar.Saturation))
}
}
// Add the set handler to the colour temperature characteristic if supported
if (this.hasCTempSupport) {
this.service.getCharacteristic(this.hapChar.ColorTemperature).onSet(async (value) => {
await this.internalCTUpdate(value)
})
this.cacheMired = this.service.getCharacteristic(this.hapChar.ColorTemperature).value
// Add support for adaptive lighting if not disabled by user
if (this.alShift !== -1) {
this.alController = new platform.api.hap.AdaptiveLightingController(this.service, {
customTemperatureAdjustment: this.alShift,
})
this.accessory.configureController(this.alController)
}
}
// Output the customised options to the log
const opts = JSON.stringify({
adaptiveLightingShift: this.alShift,
brightnessStep: this.brightStep,
transitionTime: this.transitionTime,
})
platform.log('[%s] %s %s.', accessory.displayName, platformLang.devInitOpts, opts)
// Request a device update immediately
this.requestDeviceUpdate()
}
receiveDeviceUpdate(attribute) {
// Log the receiving update if debug is enabled
this.accessory.logDebug(`${platformLang.recUpd} [${this.linkCodesRev[attribute.name]}: ${attribute.value}]`)
// Check which attribute we are getting
switch (attribute.name) {
case this.linkCodes.switch:
// Need a HomeKit true/false value for the state update
this.externalStateUpdate(Number.parseInt(attribute.value, 10) !== 0)
break
case this.linkCodes.brightness:
// Need a HomeKit int value for the brightness update
this.externalBrightnessUpdate(Math.round(attribute.value.split(':').shift() / 2.55))
break
case this.linkCodes.color: {
if (this.hasColourSupport) {
// Need a HomeKit int values for the colour update
const xy = attribute.value.split(':')
this.externalColourUpdate(xy[0], xy[1])
}
break
}
case this.linkCodes.temperature:
// Need a HomeKit int value for the colour temperature update
this.externalCTUpdate(Math.round(attribute.value.split(':').shift()))
break
default:
}
}
async sendDeviceUpdate(capability, value) {
// Log the sending update if debug is enabled
this.accessory.logDebug(`${platformLang.senUpd} [${capability}: ${value}]`)
// Send the update
await this.priAcc.control.sendDeviceUpdate(
this.accessory.context.serialNumber,
capability,
value,
)
}
async requestDeviceUpdate() {
try {
// Request the update via the main (hidden) accessory
const data = await this.priAcc.control.requestDeviceUpdate(
this.accessory.context.serialNumber,
)
// Parse the response
const res = await parseStringPromise(data.DeviceStatusList, { explicitArray: false })
const deviceStatus = res.DeviceStatusList.DeviceStatus
const values = deviceStatus.CapabilityValue.split(',')
const caps = {}
deviceStatus.CapabilityID.split(',').forEach((val, index) => {
caps[val] = values[index]
})
// If no capability values received then device must be offline
if (!caps[this.linkCodes.switch] || !caps[this.linkCodes.switch].length) {
this.accessory.logWarn(platformLang.devOffline)
return
}
// Need a HomeKit true/false value for the state update
if (caps[this.linkCodes.switch]) {
this.externalStateUpdate(Number.parseInt(caps[this.linkCodes.switch], 10) !== 0)
}
// Need a HomeKit int value for the brightness update
if (caps[this.linkCodes.brightness] && this.hasBrightSupport) {
this.externalBrightnessUpdate(
Math.round(caps[this.linkCodes.brightness].split(':').shift() / 2.55),
)
}
// Need a HomeKit int value for the colour update
if (caps[this.linkCodes.color] && this.hasColourSupport) {
const xy = caps[this.linkCodes.color].split(':')
this.externalColourUpdate(xy[0], xy[1])
}
// Need a HomeKit int value for the colour temperature update
if (caps[this.linkCodes.temperature] && this.hasCTempSupport) {
this.externalCTUpdate(Math.round(caps[this.linkCodes.temperature].split(':').shift()))
}
} catch (err) {
const eText = parseError(err, [
platformLang.timeout,
platformLang.timeoutUnreach,
platformLang.noService,
])
this.accessory.logDebugWarn(`${platformLang.rduErr} ${eText}`)
}
}
async internalStateUpdate(value) {
try {
// Wait a longer time than the brightness so in scenes brightness is sent first
await sleep(500)
// Send the update
await this.sendDeviceUpdate(this.linkCodes.switch, value ? 1 : 0)
// Update the cache and log if appropriate
this.cacheState = value
this.accessory.log(`${platformLang.curState} [${value ? 'on' : 'off'}]`)
} catch (err) {
const eText = parseError(err, [platformLang.timeout, platformLang.timeoutUnreach])
this.accessory.logWarn(`${platformLang.cantCtl} ${eText}`)
// Throw a 'no response' error and set a timeout to revert this after 2 seconds
setTimeout(() => {
this.service.updateCharacteristic(this.hapChar.On, this.cacheState)
}, 2000)
throw new this.hapErr(-70402)
}
}
async internalBrightnessUpdate(value) {
try {
// Avoid multiple updates in quick succession
const updateKey = generateRandomString(5)
this.updateKeyBR = updateKey
await sleep(300)
if (updateKey !== this.updateKeyBR) {
return
}
// Don't continue if this value is same as before
if (this.cacheBright === value) {
return
}
// Send the update - value = brightness:transition_time
await this.sendDeviceUpdate(
this.linkCodes.brightness,
`${value * 2.55}:${this.transitionTime}`,
)
// Update the cache and log if appropriate
this.cacheBright = value
this.accessory.log(`${platformLang.curBright} [${this.cacheBright}%]`)
} catch (err) {
const eText = parseError(err, [platformLang.timeout, platformLang.timeoutUnreach])
this.accessory.logWarn(`${platformLang.cantCtl} ${eText}`)
// Throw a 'no response' error and set a timeout to revert this after 2 seconds
setTimeout(() => {
this.service.updateCharacteristic(this.hapChar.Brightness, this.cacheBright)
}, 2000)
throw new this.hapErr(-70402)
}
}
async internalColourUpdate(value) {
try {
// Avoid multiple updates in quick succession
const updateKey = generateRandomString(5)
this.updateKeyHue = updateKey
await sleep(400)
if (updateKey !== this.updateKeyHue) {
return
}
// Don't continue if this value is same as before
if (this.cacheHue === value) {
return
}
// First convert to RGB
const currentSat = this.service.getCharacteristic(this.hapChar.Saturation).value
const [r, g, b] = hs2rgb(value, currentSat)
// Then convert the RGB to the values needed for Wemo
const [x, y] = rgb2xy(r, g, b)
const X = Math.round(x * 65535)
const Y = Math.round(y * 65535)
// Send the update - value = ct:transition_time
await this.sendDeviceUpdate(this.linkCodes.color, `${X}:${Y}:${this.transitionTime}`)
// Update the cache and log if appropriate
this.cacheHue = value
this.cacheSat = currentSat
this.cacheMired = 0
this.accessory.log(`${platformLang.curColour} [X:${X} Y:${Y}]`)
} catch (err) {
const eText = parseError(err, [platformLang.timeout, platformLang.timeoutUnreach])
this.accessory.logWarn(`${platformLang.cantCtl} ${eText}`)
// Throw a 'no response' error and set a timeout to revert this after 2 seconds
setTimeout(() => {
this.service.updateCharacteristic(this.hapChar.Hue, this.cacheHue)
}, 2000)
throw new this.hapErr(-70402)
}
}
async internalCTUpdate(value) {
try {
// Avoid multiple updates in quick succession
const updateKey = generateRandomString(5)
this.updateKeyCT = updateKey
await sleep(400)
if (updateKey !== this.updateKeyCT) {
return
}
// Value needs to be between 170 and 370
value = Math.min(Math.max(value, 170), 370)
// Don't continue if this value is same as before
if (this.cacheMired === value) {
return
}
// Send the update - value = ct:transition_time
await this.sendDeviceUpdate(this.linkCodes.temperature, `${value}:${this.transitionTime}`)
// Update the cache and log if appropriate
this.cacheMired = value
this.cacheHue = 0
this.cacheSat = 0
// Convert mired value to kelvin for logging
const mToK = Math.round(1000000 / value)
if (this.alController?.isAdaptiveLightingActive()) {
this.accessory.log(`${platformLang.curCCT} [${mToK}K / ${value}M] ${platformLang.viaAL}`)
} else {
this.accessory.log(`${platformLang.curCCT} [${mToK}K / ${value}M]`)
}
} catch (err) {
const eText = parseError(err, [platformLang.timeout, platformLang.timeoutUnreach])
this.accessory.logWarn(`${platformLang.cantCtl} ${eText}`)
// Throw a 'no response' error and set a timeout to revert this after 2 seconds
setTimeout(() => {
this.service.updateCharacteristic(this.hapChar.ColorTemperature, this.cacheMired)
}, 2000)
throw new this.hapErr(-70402)
}
}
externalStateUpdate(value) {
try {
// Don't continue if the state is the same as before
if (value === this.cacheState) {
return
}
// Update the state HomeKit characteristic
this.service.updateCharacteristic(this.hapChar.On, value)
// Update the cache and log if appropriate
this.cacheState = value
this.accessory.log(`${platformLang.curState} [${value ? 'on' : 'off'}]`)
} catch (err) {
this.accessory.logWarn(`${platformLang.cantUpd} ${parseError(err)}`)
}
}
externalBrightnessUpdate(value) {
try {
// Don't continue if the brightness is the same as before
if (value === this.cacheBright) {
return
}
// Update the brightness HomeKit characteristic
this.service.updateCharacteristic(this.hapChar.Brightness, value)
// Update the cache and log if appropriate
this.cacheBright = value
this.accessory.log(`${platformLang.curBright} [${this.cacheBright}%]`)
} catch (err) {
this.accessory.logWarn(`${platformLang.cantUpd} ${parseError(err)}`)
}
}
externalColourUpdate(valueX, valueY) {
try {
// Convert the given values to RGB and hue/saturation
const [r, g, b] = xy2rgb(valueX / 65535, valueY / 65535)
const [h, s] = rgb2hs(r, g, b)
// Don't continue if the hue and saturation are the same as before
if (this.cacheHue !== h || this.cacheSat !== s) {
// Update the HomeKit characteristics
this.service.updateCharacteristic(this.hapChar.ColorTemperature, 140)
this.service.updateCharacteristic(this.hapChar.Hue, h)
this.service.updateCharacteristic(this.hapChar.Saturation, s)
// Update the cache values
this.cacheMired = 0
this.cacheHue = h
this.cacheSat = s
// Log the change if appropriate
this.accessory.log(`${platformLang.curColour} [X:${valueX} Y:${valueY}]`)
// Colour chosen externally so disable adaptive lighting
if (this.alController?.isAdaptiveLightingActive()) {
this.alController.disableAdaptiveLighting()
this.accessory.log(platformLang.alDisabled)
}
}
} catch (err) {
this.accessory.logWarn(`${platformLang.cantUpd} ${parseError(err)}`)
}
}
externalCTUpdate(value) {
try {
// Don't continue if the mired value is the same as before
if (value === this.cacheMired) {
return
}
// Update the mired HomeKit characteristic
this.service.updateCharacteristic(this.hapChar.ColorTemperature, value)
// Log the change if appropriate
const mToK = Math.round(1000000 / value)
this.accessory.log(`${platformLang.curCCT} [${mToK}K / ${value}M]`)
// If the difference is significant (>20) then disable adaptive lighting
if (!Number.isNaN(this.cacheMired)) {
const diff = Math.abs(value - this.cacheMired) > 20
if (this.alController?.isAdaptiveLightingActive() && diff) {
this.alController.disableAdaptiveLighting()
this.accessory.log(platformLang.alDisabled)
}
}
// Update the cache value after the adaptive lighting check
this.cacheMired = value
} catch (err) {
this.accessory.logWarn(`${platformLang.cantUpd} ${parseError(err)}`)
}
}
}