3.0.0-beta4

* Default to generic device
* Improved debugging granularity/increased categories
* Add heartbeat monitoring for availability
* Catch more failure cases with retry (still some missing I'd guess)
* Switch to MathJS evaluate for simple math transforms
* RGBTW: Switch base scale for all friendly topics to 100 (automatic conversion on backend)
* RGBTW: Add color temperature support
* RGBTW: Improve autodetection
* RGBTW: Improved white/color mode handling (still work to do here)
This commit is contained in:
tsightler
2020-10-12 16:14:22 -04:00
parent d5217ce237
commit 60b50c760e
9 changed files with 248 additions and 112 deletions

View File

@@ -1,5 +1,5 @@
const TuyaDevice = require('./tuya-device')
const debug = require('debug')('tuya-mqtt:tuya')
const debug = require('debug')('tuya-mqtt:device')
const utils = require('../lib/utils')
class GenericDevice extends TuyaDevice {

View File

@@ -1,5 +1,6 @@
const TuyaDevice = require('./tuya-device')
const debug = require('debug')('tuya-mqtt:tuya')
const debug = require('debug')('tuya-mqtt:device-detect')
const debugDiscovery = require('debug')('tuya-mqtt:discovery')
const utils = require('../lib/utils')
class RGBTWLight extends TuyaDevice {
@@ -12,9 +13,11 @@ class RGBTWLight extends TuyaDevice {
this.config.dpsWhiteValue = this.config.dpsWhiteValue ? this.config.dpsWhiteValue : this.guess.dpsWhiteValue
this.config.whiteValueScale = this.config.whiteValueScale ? this.config.whiteValueScale : this.guess.whiteValueScale
this.config.dpsColorTemp = this.config.dpsColorTemp ? this.config.dpsColorTemp : this.guess.dpsColorTemp
this.config.minColorTemp = this.config.minColorTemp ? this.config.minColorTemp : 165
this.config.maxColorTemp = this.config.maxColorTemp ? this.config.maxColorTemp : 375
this.config.colorTempScale = this.config.colorTempScale ? this.config.colorTempScale : this.guess.colorTempScale
this.config.dpsColor = this.config.dpsColor ? this.config.dpsColor : this.guess.dpsColor
this.config.colorType = this.config.colorType ? this.config.colorType : this.guess.colorType
this.config.colorType = 'hsb'
this.deviceData.mdl = 'RGBTW Light'
@@ -32,8 +35,8 @@ class RGBTWLight extends TuyaDevice {
min: 1,
max: 100,
scale: this.config.whiteValueScale,
stateMath: (this.config.whiteValueScale == 1000) ? '/10' : '/2.55',
commandMath: (this.config.whiteValueScale == 1000) ? '*10' : '*2.55'
stateMath: '/('+this.config.whiteValueScale+'/100)',
commandMath: '*('+this.config.whiteValueScale+'/100)'
},
hs_state: {
key: this.config.dpsColor,
@@ -56,6 +59,23 @@ class RGBTWLight extends TuyaDevice {
}
}
// If device supports Color Temperature add color temp device topic
if (this.config.dpsColorTemp) {
// Values used for tranform
const rangeFactor = (this.config.maxColorTemp-this.config.minColorTemp)/100
const scaleFactor = this.config.colorTempScale/100
const tuyaMaxColorTemp = this.config.maxColorTemp/rangeFactor*scaleFactor
this.deviceTopics.color_temp_state = {
key: this.config.dpsColorTemp,
type: 'int',
min: this.config.minColorTemp,
max: this.config.maxColorTemp,
stateMath: '/'+scaleFactor+'*-'+rangeFactor+'+'+this.config.maxColorTemp,
commandMath: '/'+rangeFactor+'*-'+scaleFactor+'+'+tuyaMaxColorTemp
}
}
// Send home assistant discovery data and give it a second before sending state updates
this.initDiscovery()
await utils.sleep(1)
@@ -83,44 +103,46 @@ class RGBTWLight extends TuyaDevice {
device: this.deviceData
}
debug('Home Assistant config topic: '+configTopic)
debug(discoveryData)
if (this.config.dpsColorTemp) {
discoveryData.color_temp_state_topic = this.baseTopic+'color_temp_state'
discoveryData.color_temp_command_topic = this.baseTopic+'color_temp_command'
discoveryData.min_mireds = this.config.minColorTemp
discoveryData.max_mireds = this.config.maxColorTemp
}
debugDiscovery('Home Assistant config topic: '+configTopic)
debugDiscovery(discoveryData)
this.publishMqtt(configTopic, JSON.stringify(discoveryData))
}
async guessLightInfo() {
this.guess = new Object()
debug('Attempting to detect light capabilites and DPS values...')
debug('Querying DPS 2 for white/color mode setting...')
let mode = await this.device.get({"dps": 2})
if (mode && (mode === 'white' || mode === 'colour')) {
this.guess.dpsPower = 1
this.guess.dpsMode = 2
this.guess.dpsWhiteValue = 3
this.guess.whiteValueScale = 255
const colorTemp = await this.device.get({"dps": 4})
if (colorTemp) {
this.guess.dpsColorTemp = 4
} else {
this.guess.dpsColorTemp = 0
}
this.guess.dpsColor = 5
const color = await this.device.get({"dps": this.guess.dpsColor})
this.guess.colorType = (color && color.length === 14) ? 'hsbhex' : 'hsb'
if (mode && (mode === 'white' || mode === 'colour' || mode.toString().includes('scene'))) {
debug('Detected probably Tuya color bulb at DPS 1-5, checking more details...')
this.guess = {'dpsPower': 1, 'dpsMode': 2, 'dpsWhiteValue': 3, 'whiteValueScale': 255, 'dpsColorTemp': 4, 'colorTempScale': 255, 'dpsColor': 5}
} else {
mode = await this.device.get({"dps": 20})
this.guess.dpsPower = 20
this.guess.dpsMode = 21
this.guess.dpsWhiteValue = 22
this.guess.whiteValueScale = 1000
const colorTemp = await this.device.get({"dps": 23})
if (colorTemp) {
this.guess.dpsColorTemp = 23
debug('Detected likely Tuya color bulb at DPS 20-24, checking more details...')
this.guess = {'dpsPower': 20, 'dpsMode': 21, 'dpsWhiteValue': 22, 'whiteValueScale': 1000, 'dpsColorTemp': 23, 'colorTempScale': 1000, 'dpsColor': 24}
}
if (this.guess.dpsPower) {
debug('Attempting to detect if bulb supports color temperature...')
const colorTemp = await this.device.get({"dps": this.guess.dpsColorTemp})
if (colorTemp !== '' && colorTemp >= 0 && colorTemp <= this.guess.colorTempScale) {
debug('Detected likely color temerature support')
} else {
debug('No color temperature support detected')
this.guess.dpsColorTemp = 0
}
this.guess.dpsColor = 24
debug('Attempting to detect Tuya color format used by device...')
const color = await this.device.get({"dps": this.guess.dpsColor})
this.guess.colorType = (color && color.length === 12) ? 'hsb' : 'hsbhex'
}
debug ('Detected Tuya color format '+this.guess.colorType.toUpperCase())
} else {
debug('No Tuya color bulb detected, if this device is definitely a Tuya bulb please manually specify settings.')
}
}
}

View File

@@ -1,5 +1,6 @@
const TuyaDevice = require('./tuya-device')
const debug = require('debug')('tuya-mqtt:tuya')
const debug = require('debug')('tuya-mqtt:device')
const debugDiscovery = require('debug')('tuya-mqtt:discovery')
const utils = require('../lib/utils')
class SimpleDimmer extends TuyaDevice {
@@ -47,8 +48,8 @@ class SimpleDimmer extends TuyaDevice {
device: this.deviceData
}
debug('Home Assistant config topic: '+configTopic)
debug(discoveryData)
debugDiscovery('Home Assistant config topic: '+configTopic)
debugDiscovery(discoveryData)
this.publishMqtt(configTopic, JSON.stringify(discoveryData))
}
}

View File

@@ -1,5 +1,6 @@
const TuyaDevice = require('./tuya-device')
const debug = require('debug')('tuya-mqtt:tuya')
const debug = require('debug')('tuya-mqtt:device')
const debugDiscovery = require('debug')('tuya-mqtt:discovery')
const utils = require('../lib/utils')
class SimpleSwitch extends TuyaDevice {
@@ -36,8 +37,8 @@ class SimpleSwitch extends TuyaDevice {
device: this.deviceData
}
debug('Home Assistant config topic: '+configTopic)
debug(discoveryData)
debugDiscovery('Home Assistant config topic: '+configTopic)
debugDiscovery(discoveryData)
this.publishMqtt(configTopic, JSON.stringify(discoveryData))
}
}

View File

@@ -1,7 +1,10 @@
const TuyAPI = require('tuyapi')
const { evaluate } = require('mathjs')
const utils = require('../lib/utils')
const debug = require('debug')('tuya-mqtt:tuya')
const debugMqtt = require('debug')('tuya-mqtt:mqtt')
const { msSleep } = require('../lib/utils')
const debug = require('debug')('tuya-mqtt:tuyapi')
const debugState = require('debug')('tuya-mqtt:state')
const debugCommand = require('debug')('tuya-mqtt:command')
const debugError = require('debug')('tuya-mqtt:error')
class TuyaDevice {
@@ -39,15 +42,15 @@ class TuyaDevice {
"color": {'h': 0, 's': 0, 'b': 0}
}
// Property to hold friendly topics template
this.deviceTopics = {}
this.deviceTopics = {} // Property to hold friendly topics template
this.heartbeatsMissed = 0 // Used to monitor heartbeat status to detect offline device
// Build the MQTT topic for this device (friendly name or device id)
if (this.options.name) {
this.baseTopic = this.topic + this.options.name + '/'
} else {
this.baseTopic = this.topic + this.options.id + '/'
}
}
// Create the new Tuya Device
this.device = new TuyAPI(JSON.parse(JSON.stringify(this.options)))
@@ -55,7 +58,7 @@ class TuyaDevice {
// Listen for device data and call update DPS function if valid
this.device.on('data', (data) => {
if (typeof data === 'object') {
debug('Received JSON data from device '+this.options.id+' ->', data.dps)
debug('Received JSON data from device '+this.options.id+' ->', JSON.stringify(data.dps))
this.updateState(data)
} else {
if (data !== 'json obj data unvalid') {
@@ -64,36 +67,46 @@ class TuyaDevice {
}
})
// Find device on network
debug('Search for device id '+this.options.id)
this.device.find().then(() => {
debug('Found device id '+this.options.id)
// Attempt connection to device
this.device.connect()
})
// Attempt to find/connect to device and start heartbeat monitor
this.connectDevice()
this.monitorHeartbeat()
// On connect perform device specific init
this.device.on('connected', () => {
debug('Connected to device ' + this.toString())
this.init()
this.device.on('connected', async () => {
// Sometimes TuyAPI reports connection even on socket error
// Wait one second to check if device is really connected before initializing
await utils.sleep(1)
if (this.device.isConnected()) {
debug('Connected to device ' + this.toString())
this.heartbeatsMissed = 0
this.publishMqtt(this.baseTopic+'status', 'online')
this.init()
}
})
// On disconnect perform device specific disconnect
this.device.on('disconnected', () => {
this.connected = false
this.publishMqtt(this.baseTopic+'status', 'offline')
debug('Disconnected from device ' + this.toString())
})
// On connect error call reconnect
this.device.on('error', (err) => {
this.device.on('error', async (err) => {
debugError(err)
if (err.message === 'Error from socket') {
await utils.sleep(1)
if (!this.device.isConnected()) {
this.reconnect()
}
})
// On heartbeat reset heartbeat timer
this.device.on('heartbeat', () => {
this.heartbeatsMissed = 0
})
}
// Update dps properties with device data updates
// Update cached DPS states on data updates
updateState(data) {
if (typeof data.dps != 'undefined') {
// Update cached device state data
@@ -148,7 +161,7 @@ class TuyaDevice {
}
data = JSON.stringify(data)
const dpsStateTopic = dpsTopic + '/state'
debugMqtt('MQTT DPS JSON: ' + dpsStateTopic + ' -> ', data)
debugState('MQTT DPS JSON: ' + dpsStateTopic + ' -> ', data)
this.publishMqtt(dpsStateTopic, data, false)
// Publish dps/<#>/state value for each device DPS
@@ -156,7 +169,7 @@ class TuyaDevice {
if (this.state.dps[key].updated) {
const dpsKeyTopic = dpsTopic + '/' + key + '/state'
const data = this.state.dps.hasOwnProperty(key) ? this.state.dps[key].val.toString() : 'None'
debugMqtt('MQTT DPS'+key+': '+dpsKeyTopic+' -> ', data)
debugState('MQTT DPS'+key+': '+dpsKeyTopic+' -> ', data)
this.publishMqtt(dpsKeyTopic, data, false)
this.state.dps[key].updated = false
}
@@ -193,7 +206,7 @@ class TuyaDevice {
return state
}
// Parse the received state value based on deviceTopic config
// Parse the received state numeric value based on deviceTopic rules
parseStateNumber(value, deviceTopic) {
// Check if it's a number and it's not outside of defined range
if (isNaN(value)) {
@@ -204,14 +217,14 @@ class TuyaDevice {
switch (deviceTopic.type) {
case 'int':
if (deviceTopic.stateMath) {
value = parseInt(Math.round(eval(value+deviceTopic.stateMath)))
value = parseInt(Math.round(evaluate(value+deviceTopic.stateMath)))
} else {
value = parseInt(value)
}
break;
case 'float':
if (deviceTopic.stateMath) {
value = parseFloat(eval(value+deviceTopic.stateMath))
value = parseFloat(evaluate(value+deviceTopic.stateMath))
} else {
value = parseFloat(value)
}
@@ -222,12 +235,12 @@ class TuyaDevice {
}
// Process MQTT commands for all command topics at device level
async processCommand(message, commandTopic) {
processCommand(message, commandTopic) {
const command = this.getCommandFromMessage(message)
if (commandTopic === 'command' && command === 'get-states') {
// Handle "get-states" command to update device state
debug('Received command: ', command)
await this.getStates()
debugCommand('Received command: ', command)
this.getStates()
} else {
// Call device specific command topic handler
this.processDeviceCommand(message, commandTopic)
@@ -241,14 +254,14 @@ class TuyaDevice {
const deviceTopic = this.deviceTopics.hasOwnProperty(stateTopic) ? this.deviceTopics[stateTopic] : ''
if (deviceTopic) {
debug('Device '+this.options.id+' received command topic: '+commandTopic+', message: '+message)
debugCommand('Device '+this.options.id+' received command topic: '+commandTopic+', message: '+message)
const command = this.getCommandFromMessage(message)
let commandResult = this.sendTuyaCommand(command, deviceTopic)
if (!commandResult) {
debug('Command topic '+this.baseTopic+commandTopic+' received invalid value: '+command)
debugCommand('Command topic '+this.baseTopic+commandTopic+' received invalid value: '+command)
}
} else {
debug('Invalid command topic '+this.baseTopic+commandTopic+' for device: '+this.config.name)
debugCommand('Invalid command topic '+this.baseTopic+commandTopic+' for device: '+this.config.name)
return
}
}
@@ -258,7 +271,7 @@ class TuyaDevice {
let command
if (message != '1' && message != '0' && utils.isJsonString(message)) {
debugMqtt('MQTT message is JSON')
debugCommand('MQTT message is JSON')
command = JSON.parse(message);
} else {
switch(message.toLowerCase()) {
@@ -284,20 +297,20 @@ class TuyaDevice {
processDpsCommand(message) {
if (utils.isJsonString(message)) {
const tuyaCommand = this.getCommandFromMessage(message)
debugMqtt('Received command: '+tuyaCommand)
debugCommand('Received command: '+tuyaCommand)
this.set(tuyaCommand)
} else {
debugError('DPS command topic requires Tuya style JSON value')
debugCommand('DPS command topic requires Tuya style JSON value')
}
}
// Process text base Tuya command via DPS key command topics
processDpsKeyCommand(message, dpsKey) {
if (utils.isJsonString(message)) {
debugError('Individual DPS command topics do not accept JSON values')
debugCommand('Individual DPS command topics do not accept JSON values')
} else {
const dpsMessage = this.parseDpsMessage(message)
debugMqtt('Received command for DPS'+dpsKey+': ', message)
debugCommand('Received command for DPS'+dpsKey+': ', message)
const tuyaCommand = {
dps: dpsKey,
set: dpsMessage
@@ -325,7 +338,13 @@ class TuyaDevice {
this.connected = false
for (let topic in this.deviceTopics) {
const key = this.deviceTopics[topic].key
const result = await this.device.get({"dps": key})
try {
const result = await this.device.get({"dps": key})
this.state.dps[key].val = result
this.state.dps[key].updated = true
} catch {
debugError('Could not get value for device DPS key '+key)
}
}
this.connected = true
// Force topic update now that all states are fully in sync
@@ -390,14 +409,14 @@ class TuyaDevice {
switch (deviceTopic.type) {
case 'int':
if (deviceTopic.commandMath) {
value = parseInt(Math.round(eval(command+deviceTopic.commandMath)))
value = parseInt(Math.round(evaluate(command+deviceTopic.commandMath)))
} else {
value = parseInt(command)
}
break;
case 'float':
if (deviceTopic.commandMath) {
value = parseFloat(eval(command+deviceTopic.commandMath))
value = parseFloat(evaluate(command+deviceTopic.commandMath))
} else {
value = parseFloat(command)
}
@@ -421,7 +440,7 @@ class TuyaDevice {
// Convert from Hex to Decimal and cache values
this.state.color.h = parseInt(h, 16)
this.state.color.s = Math.round(parseInt(s, 16) / 10) // Convert saturation to 100 Scale
this.state.color.b = Math.round(parseInt(b, 16) / 10) // Convert brightness to 1000 scale
this.state.color.b = Math.round(parseInt(b, 16) / 10) // Convert brightness to 100 scale
}
// Initialize the set color values for first time. Used to conflicts
@@ -491,12 +510,16 @@ class TuyaDevice {
// Set white/colour mode based on
async setLight(topic, command) {
const currentMode = this.state.dps[this.config.dpsMode].val
let targetMode = undefined
const currentMode = this.config.dpsMode.val
if (topic.key === this.config.dpsWhiteValue || topic.key === this.config.dpsColorTemp) {
// If setting white level or color temperature, light should be in white mode
targetMode = 'white'
} else if (topic.key === this.config.dpsColor) {
// Short sleep for cases where mulitple updates occur quickly
await msSleep(100)
if (this.state.setColor.s < 10) {
// If saturation is < 10 then white mode
targetMode = 'white'
@@ -505,17 +528,18 @@ class TuyaDevice {
targetMode = 'colour'
}
}
// If mode change required, add it to the set command
if (targetMode && currentMode !== targetMode) {
command = {
multiple: true,
data: {
[command.dps]: command.set,
[this.config.dpsMode]: targetMode
}
}
}
// Set the proper value
this.set(command)
// Put the bulb in the correct mode
if (targetMode) {
command = {
dps: this.config.dpsMode,
set: targetMode
}
this.set(command)
}
}
// Simple function to help debug output
@@ -532,9 +556,27 @@ class TuyaDevice {
})
}
connectDevice() {
// Find device on network
debug('Search for device id '+this.options.id)
this.device.find().then(() => {
debug('Found device id '+this.options.id)
// Attempt connection to device
this.device.connect().catch((error) => {
debugError(error.message)
this.reconnect()
})
}).catch(async (error) => {
debugError(error.message)
debugError('Will attempt to find device again in 60 seconds')
await utils.sleep(60)
this.connectDevice()
})
}
// Retry connection every 10 seconds if unable to connect
async reconnect() {
debug('Error connecting to device id '+this.options.id+'...retry in 10 seconds.')
debugError('Error connecting to device id '+this.options.id+'...retry in 10 seconds.')
await utils.sleep(10)
if (this.connected) { return }
debug('Search for device id '+this.options.id)
@@ -545,10 +587,27 @@ class TuyaDevice {
})
}
// Simple function to monitor heartbeats to determine if
monitorHeartbeat() {
setInterval(async () => {
if (this.connected) {
if (this.heartbeatsMissed > 3) {
debugError('Device id '+this.options.id+' not responding to heartbeats...disconnecting')
this.device.disconnect()
await utils.sleep(1)
this.connectDevice()
} else if (this.heartbeatsMissed > 0) {
const errMessage = this.heartbeatsMissed > 1 ? " consecutive heartbeats" : " heartbeat"
debugError('Device id '+this.options.id+' has missed '+this.heartbeatsMissed+errMessage)
}
this.heartbeatsMissed++
}
}, 10000)
}
// Publish MQTT
publishMqtt(topic, message, isDebug) {
if (isDebug) { debugMqtt(topic, message) }
if (isDebug) { debugState(topic, message) }
this.mqttClient.publish(topic, message, { qos: 1 });
}
}