mirror of
https://github.com/lehanspb/tuya-mqtt.git
synced 2025-12-16 09:44:36 +00:00
3.0.0-beta2
* Implement basic template model * Generic device can specify template in devices.conf * Simple switch, dimmer, and RGBTW specific support files
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,4 +1,4 @@
|
||||
devices/
|
||||
old/
|
||||
test/
|
||||
config.json
|
||||
|
||||
|
||||
30
devices/generic-device.js
Normal file
30
devices/generic-device.js
Normal file
@@ -0,0 +1,30 @@
|
||||
const TuyaDevice = require('./tuya-device')
|
||||
const debug = require('debug')('tuya-mqtt:tuya')
|
||||
const utils = require('../lib/utils')
|
||||
|
||||
class GenericDevice extends TuyaDevice {
|
||||
async init() {
|
||||
this.deviceData.mdl = 'Generic Device'
|
||||
|
||||
// Check if custom template in device config
|
||||
if (this.config.hasOwnProperty('template')) {
|
||||
// Map generic DPS topics to device specific topic names
|
||||
this.deviceTopics = this.config.template
|
||||
console.log(this.deviceTopics)
|
||||
} else {
|
||||
// Try to get schema to at least know what DPS keys to get initial update
|
||||
const result = await this.device.get({"schema": true})
|
||||
if (!utils.isJsonString(result)) {
|
||||
if (result === 'Schema for device not available') {
|
||||
debug('Device id '+this.config.id+' failed schema discovery and no custom template defined')
|
||||
debug('Cannot get initial DPS state data for device '+this.options.name+' but data updates will be publish')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get initial states and start publishing topics
|
||||
this.getStates()
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = GenericDevice
|
||||
80
devices/rgbtw-light.js
Normal file
80
devices/rgbtw-light.js
Normal file
@@ -0,0 +1,80 @@
|
||||
const TuyaDevice = require('./tuya-device')
|
||||
const debug = require('debug')('tuya-mqtt:tuya')
|
||||
const utils = require('../lib/utils')
|
||||
|
||||
class RGBTWLight extends TuyaDevice {
|
||||
async init() {
|
||||
// Set device specific variables
|
||||
this.config.dpsPower = this.config.dpsPower ? this.config.dpsPower : 1
|
||||
this.config.dpsMode = this.config.dpsMode ? this.config.dpsMode : 2
|
||||
this.config.dpsWhiteValue = this.config.dpsWhiteValue ? this.config.dpsWhiteValue : 3
|
||||
this.config.whiteValueScale = this.config.whiteValueScale ? this.config.whiteValueScale : 1000
|
||||
this.config.dpsColorTemp = this.config.dpsColorTemp ? this.config.dpsColorTemp : 4
|
||||
this.config.dpsColor = this.config.dpsColor ? this.config.dpsColor : 5
|
||||
this.config.colorType = this.config.colorType ? this.config.colorType : 'hsb'
|
||||
|
||||
this.deviceData.mdl = 'RGBTW Light'
|
||||
|
||||
// Map generic DPS topics to device specific topic names
|
||||
this.deviceTopics = {
|
||||
state: {
|
||||
key: this.config.dpsPower,
|
||||
type: 'bool'
|
||||
},
|
||||
white_value_state: {
|
||||
key: this.config.dpsWhiteValue,
|
||||
type: 'int',
|
||||
min: (this.config.whiteValueScale = 1000) ? 10 : 1,
|
||||
max: this.config.whiteValueScale,
|
||||
scale: this.config.whiteValueScale
|
||||
},
|
||||
hs_state: {
|
||||
key: this.config.dpsColor,
|
||||
type: this.config.colorType,
|
||||
components: 'h,s'
|
||||
},
|
||||
brightness_state: {
|
||||
key: this.config.dpsColor,
|
||||
type: this.config.colorType,
|
||||
components: 'b'
|
||||
},
|
||||
mode_state: {
|
||||
key: this.config.dpsMode,
|
||||
type: 'str'
|
||||
}
|
||||
}
|
||||
|
||||
// Send home assistant discovery data and give it a second before sending state updates
|
||||
this.initDiscovery()
|
||||
await utils.sleep(1)
|
||||
|
||||
// Get initial states and start publishing topics
|
||||
this.getStates()
|
||||
}
|
||||
|
||||
initDiscovery() {
|
||||
const configTopic = 'homeassistant/light/'+this.config.id+'/config'
|
||||
|
||||
const discoveryData = {
|
||||
name: (this.config.name) ? this.config.name : this.config.id,
|
||||
state_topic: this.baseTopic+'state',
|
||||
command_topic: this.baseTopic+'command',
|
||||
brightness_state_topic: this.baseTopic+'brightness_state',
|
||||
brightness_command_topic: this.baseTopic+'brightness_command',
|
||||
brightness_scale: 1000,
|
||||
hs_state_topic: this.baseTopic+'hs_state',
|
||||
hs_command_topic: this.baseTopic+'hs_command',
|
||||
white_value_state_topic: this.baseTopic+'white_value_state',
|
||||
white_value_command_topic: this.baseTopic+'white_value_command',
|
||||
white_value_scale: 1000,
|
||||
unique_id: this.config.id,
|
||||
device: this.deviceData
|
||||
}
|
||||
|
||||
debug('Home Assistant config topic: '+configTopic)
|
||||
debug(discoveryData)
|
||||
this.publishMqtt(configTopic, JSON.stringify(discoveryData))
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = RGBTWLight
|
||||
56
devices/simple-dimmer.js
Normal file
56
devices/simple-dimmer.js
Normal file
@@ -0,0 +1,56 @@
|
||||
const TuyaDevice = require('./tuya-device')
|
||||
const debug = require('debug')('tuya-mqtt:tuya')
|
||||
const utils = require('../lib/utils')
|
||||
|
||||
class SimpleDimmer extends TuyaDevice {
|
||||
async init() {
|
||||
// Set device specific variables
|
||||
this.config.dpsPower = this.config.dpsPower ? this.config.dpsPower : 1
|
||||
this.config.dpsBrightness = this.config.dpsBrightness ? this.config.dpsBrightness : 2
|
||||
this.config.brightnessScale = this.config.brightnessScale ? this.config.brightnessScale : 255
|
||||
|
||||
this.deviceData.mdl = 'Dimmer Switch'
|
||||
|
||||
// Map generic DPS topics to device specific topic names
|
||||
this.deviceTopics = {
|
||||
state: {
|
||||
key: this.config.dpsPower,
|
||||
type: 'bool'
|
||||
},
|
||||
brightness_state: {
|
||||
key: this.config.dpsBrightness,
|
||||
type: 'int',
|
||||
min: (this.config.brightnessScale = 1000) ? 10 : 1,
|
||||
max: this.config.brightnessScale,
|
||||
scale: this.config.brightnessScale
|
||||
}
|
||||
}
|
||||
|
||||
// Send home assistant discovery data and give it a second before sending state updates
|
||||
this.initDiscovery()
|
||||
await utils.sleep(1)
|
||||
|
||||
// Get initial states and start publishing topics
|
||||
this.getStates()
|
||||
}
|
||||
|
||||
initDiscovery() {
|
||||
const configTopic = 'homeassistant/light/'+this.config.id+'/config'
|
||||
|
||||
const discoveryData = {
|
||||
name: (this.config.name) ? this.config.name : this.config.id,
|
||||
state_topic: this.baseTopic+'state',
|
||||
command_topic: this.baseTopic+'command',
|
||||
brightness_state_topic: this.baseTopic+'brightness_state',
|
||||
brightness_command_topic: this.baseTopic+'brightness_command',
|
||||
unique_id: this.config.id,
|
||||
device: this.deviceData
|
||||
}
|
||||
|
||||
debug('Home Assistant config topic: '+configTopic)
|
||||
debug(discoveryData)
|
||||
this.publishMqtt(configTopic, JSON.stringify(discoveryData))
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = SimpleDimmer
|
||||
45
devices/simple-switch.js
Normal file
45
devices/simple-switch.js
Normal file
@@ -0,0 +1,45 @@
|
||||
const TuyaDevice = require('./tuya-device')
|
||||
const debug = require('debug')('tuya-mqtt:tuya')
|
||||
const utils = require('../lib/utils')
|
||||
|
||||
class SimpleSwitch extends TuyaDevice {
|
||||
async init() {
|
||||
// Set device specific variables
|
||||
this.config.dpsPower = this.config.dpsPower ? this.config.dpsPower : 1
|
||||
|
||||
this.deviceData.mdl = 'Switch/Socket'
|
||||
|
||||
// Map generic DPS topics to device specific topic names
|
||||
this.deviceTopics = {
|
||||
state: {
|
||||
key: this.config.dpsPower,
|
||||
type: 'bool'
|
||||
}
|
||||
}
|
||||
|
||||
// Send home assistant discovery data and give it a second before sending state updates
|
||||
this.initDiscovery()
|
||||
await utils.sleep(1)
|
||||
|
||||
// Get initial states and start publishing topics
|
||||
this.getStates()
|
||||
}
|
||||
|
||||
initDiscovery() {
|
||||
const configTopic = 'homeassistant/switch/'+this.config.id+'/config'
|
||||
|
||||
const discoveryData = {
|
||||
name: (this.config.name) ? this.config.name : this.config.id,
|
||||
state_topic: this.baseTopic+'state',
|
||||
command_topic: this.baseTopic+'command',
|
||||
unique_id: this.config.id,
|
||||
device: this.deviceData
|
||||
}
|
||||
|
||||
debug('Home Assistant config topic: '+configTopic)
|
||||
debug(discoveryData)
|
||||
this.publishMqtt(configTopic, JSON.stringify(discoveryData))
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = SimpleSwitch
|
||||
420
devices/tuya-device.js
Normal file
420
devices/tuya-device.js
Normal file
@@ -0,0 +1,420 @@
|
||||
const TuyAPI = require('tuyapi')
|
||||
const utils = require('../lib/utils')
|
||||
const debug = require('debug')('tuya-mqtt:tuya')
|
||||
const debugMqtt = require('debug')('tuya-mqtt:mqtt')
|
||||
const debugError = require('debug')('tuya-mqtt:error')
|
||||
|
||||
class TuyaDevice {
|
||||
constructor(deviceInfo) {
|
||||
this.config = deviceInfo.configDevice
|
||||
this.mqttClient = deviceInfo.mqttClient
|
||||
this.topic = deviceInfo.topic
|
||||
|
||||
// Build TuyAPI device options from device config info
|
||||
this.options = {
|
||||
id: this.config.id,
|
||||
key: this.config.key
|
||||
}
|
||||
if (this.config.name) { this.options.name = this.config.name.toLowerCase().replace(/ /g,'_') }
|
||||
if (this.config.ip) {
|
||||
this.options.ip = this.config.ip
|
||||
if (this.config.version) {
|
||||
this.options.version = this.config.version
|
||||
} else {
|
||||
this.options.version = '3.1'
|
||||
}
|
||||
}
|
||||
|
||||
// Set default device data for Home Assistant device registry
|
||||
// Values may be overridden by individual devices
|
||||
this.deviceData = {
|
||||
ids: [ this.config.id ],
|
||||
name: (this.config.name) ? this.config.name : this.config.id,
|
||||
mf: 'Tuya'
|
||||
}
|
||||
|
||||
this.dps = {} // This will hold dps state data for device
|
||||
this.prevDps = {} // This will hold previous dps value for device to avoid republish of non-changed states
|
||||
|
||||
// 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)))
|
||||
|
||||
// Listen for device data and call update DPS function if valid
|
||||
this.device.on('data', (data) => {
|
||||
if (typeof data == 'string') {
|
||||
debug('Data from device not encrypted:', data.replace(/[^a-zA-Z0-9 ]/g, ''))
|
||||
} else {
|
||||
if (!(data.dps['1'] === null && data.dps['2'] === null && data.dps['3'] === null && data.dps['101'] === null && data.dps['102'] === null && data.dps['103'] === null)) {
|
||||
debug('Data from device '+this.options.id+' ->', data.dps)
|
||||
this.updateDpsData(data)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 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()
|
||||
})
|
||||
|
||||
// On connect perform device specific init
|
||||
this.device.on('connected', () => {
|
||||
debug('Connected to device ' + this.toString())
|
||||
this.init()
|
||||
})
|
||||
|
||||
// On disconnect perform device specific disconnect
|
||||
this.device.on('disconnected', () => {
|
||||
this.connected = false
|
||||
debug('Disconnected from device ' + this.toString())
|
||||
})
|
||||
|
||||
// On connect error call reconnect
|
||||
this.device.on('error', (err) => {
|
||||
if (err !== 'json obj data unvalid') {
|
||||
debugError(err)
|
||||
}
|
||||
if (err.message === 'Error from socket') {
|
||||
this.reconnect()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Retry connection every 10 seconds if unable to connect
|
||||
async reconnect() {
|
||||
debug('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)
|
||||
this.device.find().then(() => {
|
||||
debug('Found device id '+this.options.id)
|
||||
// Attempt connection to device
|
||||
this.device.connect()
|
||||
})
|
||||
}
|
||||
|
||||
// Publish MQTT
|
||||
publishMqtt(topic, message, isDebug) {
|
||||
if (isDebug) { debugMqtt(topic, message) }
|
||||
this.mqttClient.publish(topic, message, { qos: 1 });
|
||||
}
|
||||
|
||||
// Publish device specific state topics
|
||||
publishTopics() {
|
||||
// Don't publish if device is not connected
|
||||
if (!this.connected) return
|
||||
|
||||
// Loop through and publish all device specific topics
|
||||
for (let topic in this.deviceTopics) {
|
||||
const state = this.getTopicState(topic)
|
||||
this.publishMqtt(this.baseTopic + topic, state, true)
|
||||
}
|
||||
|
||||
// Publish Generic Dps Topics
|
||||
this.publishDpsTopics()
|
||||
}
|
||||
|
||||
// Process MQTT commands for all command topics at device level
|
||||
processDeviceCommand(message, commandTopic) {
|
||||
// Determine state topic from command topic to find proper template
|
||||
const stateTopic = commandTopic.replace('command', 'state')
|
||||
const deviceTopic = this.deviceTopics.hasOwnProperty(stateTopic) ? this.deviceTopics[stateTopic] : ''
|
||||
|
||||
if (deviceTopic) {
|
||||
debug('Device '+this.options.id+' recieved command topic: '+commandTopic+', message: '+message)
|
||||
const command = this.getCommandFromMessage(message)
|
||||
let setResult = this.setState(command, deviceTopic)
|
||||
if (!setResult) {
|
||||
debug('Command topic '+this.baseTopic+commandTopic+' received invalid value: '+command)
|
||||
}
|
||||
} else {
|
||||
debug('Invalid command topic '+this.baseTopic+commandTopic+' for device: '+this.config.name)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Get and update state of all dps properties for device
|
||||
async getStates() {
|
||||
// Suppress topic updates while syncing state
|
||||
this.connected = false
|
||||
for (let topic in this.deviceTopics) {
|
||||
const key = this.deviceTopics[topic].key
|
||||
const result = await this.device.get({"dps": key})
|
||||
}
|
||||
this.connected = true
|
||||
// Force topic update now that all states are fully in sync
|
||||
this.publishTopics()
|
||||
}
|
||||
|
||||
// Update dps properties with device data updates
|
||||
updateDpsData(data) {
|
||||
try {
|
||||
if (typeof data.dps != 'undefined') {
|
||||
// Update device dps values
|
||||
for (let key in data.dps) {
|
||||
this.dps[key] = data.dps[key]
|
||||
}
|
||||
if (this.connected) {
|
||||
this.publishTopics()
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugError(e);
|
||||
}
|
||||
}
|
||||
|
||||
// Process MQTT commands for all command topics at device level
|
||||
async 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()
|
||||
} else {
|
||||
// Call device specific command topic handler
|
||||
this.processDeviceCommand(message, commandTopic)
|
||||
}
|
||||
}
|
||||
|
||||
// Publish all dps-values to topic
|
||||
publishDpsTopics() {
|
||||
try {
|
||||
const dpsTopic = this.baseTopic + 'dps'
|
||||
|
||||
// Publish DPS JSON data if not empty
|
||||
if (Object.keys(this.dps).length) {
|
||||
const data = JSON.stringify(this.dps)
|
||||
const dpsStateTopic = dpsTopic + '/state'
|
||||
debugMqtt('MQTT DPS JSON: ' + dpsStateTopic + ' -> ', data)
|
||||
this.publishMqtt(dpsStateTopic, data, false)
|
||||
}
|
||||
|
||||
// Publish dps/<#>/state value for each device DPS
|
||||
for (let key in this.dps) {
|
||||
const dpsKeyTopic = dpsTopic + '/' + key + '/state'
|
||||
const data = this.dps.hasOwnProperty(key) ? this.dps[key].toString() : 'None'
|
||||
debugMqtt('MQTT DPS'+key+': '+dpsKeyTopic+' -> ', data)
|
||||
this.publishMqtt(dpsKeyTopic, data, false)
|
||||
}
|
||||
} catch (e) {
|
||||
debugError(e);
|
||||
}
|
||||
}
|
||||
|
||||
getTopicState(topic) {
|
||||
const deviceTopic = this.deviceTopics[topic]
|
||||
const key = deviceTopic.key
|
||||
let state = null
|
||||
switch (deviceTopic.type) {
|
||||
case 'bool':
|
||||
state = this.dps[key] ? 'ON' : 'OFF'
|
||||
break;
|
||||
case 'int':
|
||||
state = this.dps[key] ? this.dps[key].toString() : 'None'
|
||||
break;
|
||||
case 'hsb':
|
||||
if (this.dps[key]) {
|
||||
state = this.getColorState(this.dps[key], topic)
|
||||
}
|
||||
break;
|
||||
case 'str':
|
||||
state = this.dps[key] ? this.dps[key] : ''
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
// Set state based on command topic
|
||||
setState(command, deviceTopic) {
|
||||
const tuyaCommand = new Object()
|
||||
tuyaCommand.dps = deviceTopic.key
|
||||
switch (deviceTopic.type) {
|
||||
case 'bool':
|
||||
if (command === 'toggle') {
|
||||
tuyaCommand.set = !this.dps[tuyaCommand.dps]
|
||||
} else {
|
||||
if (typeof command.set === 'boolean') {
|
||||
tuyaCommand.set = command.set
|
||||
} else {
|
||||
tuyaCommand.set = '!!!INVALID!!!'
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'int':
|
||||
if (isNaN(command)) {
|
||||
tuyaCommand.set = '!!!INVALID!!!'
|
||||
} else if (deviceTopic.hasOwnProperty('min') && deviceTopic.hasOwnProperty('max')) {
|
||||
tuyaCommand.set = (command >= deviceTopic.min && command <= deviceTopic.max ) ? parseInt(command) : '!!!INVALID!!!'
|
||||
} else {
|
||||
tuyaCommand.set = parseInt(command)
|
||||
}
|
||||
break;
|
||||
case 'hsb':
|
||||
tuyaCommand.set = this.getColorCommand(command, deviceTopic)
|
||||
this.setLightMode(deviceTopic)
|
||||
break;
|
||||
}
|
||||
if (tuyaCommand.set === '!!!INVALID!!!') {
|
||||
return false
|
||||
} else {
|
||||
if (this.config.dpsWhiteValue === deviceTopic.key) {
|
||||
this.setLightMode(deviceTopic)
|
||||
}
|
||||
this.set(tuyaCommand)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Converts message to TuyAPI JSON commands
|
||||
getCommandFromMessage(_message) {
|
||||
let command = _message
|
||||
|
||||
if (command != '1' && command != '0' && utils.isJsonString(command)) {
|
||||
debugMqtt('MQTT message is JSON');
|
||||
command = JSON.parse(command);
|
||||
} else {
|
||||
switch(command.toLowerCase()) {
|
||||
case 'on':
|
||||
case 'off':
|
||||
case '0':
|
||||
case '1':
|
||||
case 'true':
|
||||
case 'false':
|
||||
// convert simple commands (on, off, 1, 0) to TuyAPI-Commands
|
||||
const convertString = command.toLowerCase() === 'on' || command === '1' || command === 'true' || command === 1 ? true : false;
|
||||
command = {
|
||||
set: convertString
|
||||
}
|
||||
break;
|
||||
default:
|
||||
command = command.toLowerCase();
|
||||
}
|
||||
}
|
||||
return command;
|
||||
}
|
||||
|
||||
// Process Tuya JSON commands via DPS command topic
|
||||
processDpsCommand(message) {
|
||||
if (utils.isJsonString(message)) {
|
||||
const tuyaCommand = this.getCommandFromMessage(message)
|
||||
debugMqtt('Received command: '+tuyaCommand)
|
||||
this.set(tuyaCommand)
|
||||
} else {
|
||||
debugError('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')
|
||||
} else {
|
||||
const dpsMessage = this.parseDpsMessage(message)
|
||||
debugMqtt('Received command for DPS'+dpsKey+': ', message)
|
||||
const tuyaCommand = {
|
||||
dps: dpsKey,
|
||||
set: dpsMessage
|
||||
}
|
||||
this.set(tuyaCommand)
|
||||
}
|
||||
}
|
||||
|
||||
// Parse string message into boolean and number types
|
||||
parseDpsMessage(message) {
|
||||
if (typeof message === 'boolean' ) {
|
||||
return message;
|
||||
} else if (message === 'true' || message === 'false') {
|
||||
return (message === 'true') ? true : false
|
||||
} else if (!isNaN(message)) {
|
||||
return Number(message)
|
||||
} else {
|
||||
return message
|
||||
}
|
||||
}
|
||||
|
||||
// Simple function to help debug output
|
||||
toString() {
|
||||
return this.config.name+' (' +(this.options.ip ? this.options.ip+', ' : '')+this.options.id+', '+this.options.key+')'
|
||||
}
|
||||
|
||||
set(command) {
|
||||
debug('Set device '+this.options.id+' -> '+command)
|
||||
return new Promise((resolve, reject) => {
|
||||
this.device.set(command).then((result) => {
|
||||
debug(result)
|
||||
resolve(result)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Takes the current Tuya color and splits it into component parts
|
||||
// Returns decimal format comma delimeted string of components for selected topic
|
||||
getColorState(value, topic) {
|
||||
const [, h, s, b] = (value || '000003e803e8').match(/^([0-9a-f]{4})([0-9a-f]{4})([0-9a-f]{4})$/i) || [0, '0', '3e8', '3e8'];
|
||||
const decimalColor = {
|
||||
h: parseInt(h, 16),
|
||||
s: Math.round(parseInt(s, 16) / 10),
|
||||
b: parseInt(b, 16)
|
||||
}
|
||||
const color = new Array()
|
||||
const components = this.deviceTopics[topic].components.split(',')
|
||||
for (let i in components) {
|
||||
if (decimalColor.hasOwnProperty([components[i]])) {
|
||||
color.push(decimalColor[components[i]])
|
||||
}
|
||||
}
|
||||
return (color.join(','))
|
||||
}
|
||||
|
||||
// Takes provided decimal HSB components from MQTT topic, combine with existing
|
||||
// settings for unchanged values since brightness is sometimes sent separately
|
||||
// Convert to Tuya hex format and return value
|
||||
getColorCommand(value, topic) {
|
||||
const [, h, s, b] = (this.dps[topic.key] || '000003e803e8').match(/^([0-9a-f]{4})([0-9a-f]{4})([0-9a-f]{4})$/i) || [0, '0', '3e8', '3e8'];
|
||||
const decimalColor = {
|
||||
h: parseInt(h, 16),
|
||||
s: Math.round(parseInt(s, 16) / 10),
|
||||
b: parseInt(b, 16)
|
||||
}
|
||||
const components = topic.components.split(',')
|
||||
const values = value.split(',')
|
||||
for (let i in components) {
|
||||
decimalColor[components[i]] = Math.round(values[i])
|
||||
}
|
||||
const hexColor = decimalColor.h.toString(16).padStart(4, '0') + (10 * decimalColor.s).toString(16).padStart(4, '0') + (decimalColor.b).toString(16).padStart(4, '0')
|
||||
return hexColor
|
||||
}
|
||||
|
||||
// Set light mode based on received command
|
||||
async setLightMode(topic) {
|
||||
const currentMode = this.dps[this.config.dpsMode]
|
||||
let targetMode
|
||||
|
||||
if (this.config.dpsWhiteValue === topic.key) {
|
||||
// If setting white level, switch to white mode
|
||||
targetMode = 'white'
|
||||
} else if (this.config.dpsColor === topic.key) {
|
||||
// If setting an HSB value, switch to colour mode
|
||||
targetMode = 'colour'
|
||||
}
|
||||
|
||||
// Set the correct light mode
|
||||
if (targetMode && targetMode !== currentMode) {
|
||||
const tuyaCommand = {
|
||||
dps: this.config.dpsMode,
|
||||
set: targetMode
|
||||
}
|
||||
await this.set(tuyaCommand)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = TuyaDevice
|
||||
24
lib/utils.js
Normal file
24
lib/utils.js
Normal file
@@ -0,0 +1,24 @@
|
||||
class Utils
|
||||
{
|
||||
|
||||
// Check if data is JSON or not
|
||||
isJsonString(data) {
|
||||
try {
|
||||
const parsedData = JSON.parse(data)
|
||||
if (parsedData && typeof parsedData === "object") {
|
||||
return parsedData
|
||||
}
|
||||
}
|
||||
catch (e) { }
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// Simple sleep function for various required delays
|
||||
sleep(sec) {
|
||||
return new Promise(res => setTimeout(res, sec*1000))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = new Utils()
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "tuya-mqtt",
|
||||
"version": "3.0.0-beta1",
|
||||
"version": "3.0.0-beta2",
|
||||
"description": "Control Tuya devices locally via MQTT",
|
||||
"homepage": "https://github.com/TheAgentK/tuya-mqtt#readme",
|
||||
"main": "tuya-mqtt.js",
|
||||
@@ -16,7 +16,7 @@
|
||||
"color-convert": "^2.0.1",
|
||||
"debug": "^4.1.1",
|
||||
"mqtt": "^4.2.1",
|
||||
"tuyapi": "^5.3.2",
|
||||
"tuyapi": "github:tsightler/tuyAPI",
|
||||
"json5": "^2.1.3"
|
||||
},
|
||||
"repository": {
|
||||
|
||||
348
tuya-color.js
348
tuya-color.js
@@ -1,348 +0,0 @@
|
||||
const convert = require('color-convert');
|
||||
const debug = require('debug')('TuyaColor');
|
||||
|
||||
/**
|
||||
* Class to calculate settings for Tuya colors
|
||||
*/
|
||||
function TuyaColorLight() {
|
||||
|
||||
this.colorMode = 'white'; // or 'colour'
|
||||
this.brightness = 100; // percentage value use _convertValToPercentage functions below.
|
||||
|
||||
this.color = {
|
||||
H: 130,
|
||||
S: 100,
|
||||
L: 50
|
||||
};
|
||||
|
||||
this.hue = this.color.H;
|
||||
this.saturation = this.color.S;
|
||||
this.lightness = this.color.L;
|
||||
|
||||
this.colorTemperature = 255;
|
||||
this.colorTempMin = 153;
|
||||
this.colorTempMax = 500;
|
||||
|
||||
this.dps = {};
|
||||
}
|
||||
|
||||
/**
|
||||
* calculate color value from given brightness percentage
|
||||
* @param (Integer) percentage 0-100 percentage value
|
||||
* @returns (Integer) color value from 25 - 255
|
||||
* @private
|
||||
*/
|
||||
TuyaColorLight.prototype._convertBrightnessPercentageToVal = function(brt_percentage){
|
||||
// the brightness scale does not start at 0 but starts at 25 - 255
|
||||
// this linear equation is a better fit to the conversion to 255 scale
|
||||
var tmp = Math.round(2.3206*brt_percentage+22.56);
|
||||
debug('Converted brightness percentage ' + brt_percentage + ' to: ' + tmp);
|
||||
return tmp;
|
||||
}
|
||||
|
||||
/**
|
||||
* calculate percentage from brightness color value
|
||||
* @param brt_val 25 - 255 brightness color value
|
||||
* @returns {Integer} 0 - 100 integer percent
|
||||
* @private
|
||||
*/
|
||||
TuyaColorLight.prototype._convertValtoBrightnessPercentage = function(brt_val){
|
||||
var tmp = Math.round( (brt_val-22.56)/2.3206);
|
||||
debug('Converted brightness value ' + brt_val + ' to: ' + tmp);
|
||||
return tmp;
|
||||
}
|
||||
|
||||
/**
|
||||
* calculate color value from given saturation percentage OR color temperature percentage
|
||||
* @param (Integer) temp_percentage 0-100 percentage value
|
||||
* @returns {Integer} saturation or color temperature value from 0 - 255
|
||||
* @private
|
||||
*/
|
||||
TuyaColorLight.prototype._convertSATorColorTempPercentageToVal = function(temp_percentage){
|
||||
// the saturation OR temperature scale does start at 0 - 255
|
||||
// this is a perfect linear equation fit for the saturation OR temperature scale conversion
|
||||
var tmp = Math.round(((2.5498*temp_percentage)-0.4601));
|
||||
debug('Converted saturation OR temperature percentage ' + temp_percentage + ' to: ' + tmp);
|
||||
return tmp;
|
||||
}
|
||||
|
||||
/**
|
||||
* calculate percentage from saturation value OR color temperature value
|
||||
* @param temp_val 0 - 255 saturation or color temperature value
|
||||
* @returns {Integer} 0 - 100 integer percent
|
||||
* @private
|
||||
*/
|
||||
TuyaColorLight.prototype._convertValtoSATorColorTempPercentage = function(temp_val){
|
||||
var tmp = Math.round( (temp_val+0.4601/2.5498));
|
||||
debug('Converted saturation OR temperature value ' + temp_val + ' to: ' + tmp);
|
||||
return tmp;
|
||||
}
|
||||
|
||||
/**
|
||||
* calculate color value from given percentage
|
||||
* @param {Integer} percentage 0-100 percentage value
|
||||
* @returns {Integer} color value from 0-255
|
||||
*/
|
||||
TuyaColorLight.prototype._convertPercentageToVal = function (percentage) {
|
||||
var tmp = Math.round(255 * (percentage / 100));
|
||||
debug('Converted ' + percentage + ' to: ' + tmp);
|
||||
return tmp;
|
||||
};
|
||||
|
||||
/**
|
||||
* calculate percentage from color value
|
||||
* @param {Integer} val 0-255 color value
|
||||
* @returns {Integer} HK-Value
|
||||
*/
|
||||
TuyaColorLight.prototype._convertValToPercentage = function (val) {
|
||||
var tmp = Math.round((val / 255) * 100);
|
||||
debug('Converted ' + val + ' to: ' + tmp);
|
||||
return tmp;
|
||||
};
|
||||
|
||||
/**
|
||||
* converts color value to color temperature
|
||||
* @param {Integer} val
|
||||
* @returns {Integer} percentage from 0-100
|
||||
*/
|
||||
TuyaColorLight.prototype._convertColorTemperature = function (val) {
|
||||
var tmpRange = this.colorTempMax - this.colorTempMin;
|
||||
var tmpCalc = Math.round((val / this.colorTempMax) * 100);
|
||||
|
||||
debug('HK colorTemp Value: ' + val);
|
||||
debug('HK colorTemp scale min : ' + this.colorTempMin);
|
||||
debug('HK colorTemp scale max : ' + this.colorTempMax);
|
||||
debug('HK colorTemp range (tmpRange): ' + tmpRange);
|
||||
debug('HK colorTemp % tmpCalc: ' + tmpCalc);
|
||||
|
||||
var tuyaColorTemp = this._convertPercentageToVal(tmpCalc);
|
||||
|
||||
debug('HK tuyaColorTemp: ' + tuyaColorTemp);
|
||||
|
||||
return tuyaColorTemp;
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert color temperature to HK
|
||||
* @param {Integer} val
|
||||
* @returns {Integer} HK-Value
|
||||
*/
|
||||
TuyaColorLight.prototype._convertColorTemperatureToHK = function (val) {
|
||||
|
||||
var tuyaColorTempPercent = this._convertValToPercentage(this.colorTemperature);
|
||||
var tmpRange = this.colorTempMax - this.colorTempMin;
|
||||
var tmpCalc = Math.round((tmpRange * (tuyaColorTempPercent / 100)) + this.colorTempMin);
|
||||
var hkValue = Math.round(tmpCalc);
|
||||
|
||||
debug('Tuya color Temperature : ' + val);
|
||||
debug('Tuya color temp Percent of 255: ' + tuyaColorTempPercent + '%');
|
||||
|
||||
debug('HK colorTemp scale min : ' + this.colorTempMin);
|
||||
debug('HK colorTemp scale max : ' + this.colorTempMax);
|
||||
|
||||
debug('HK Color Temp Range: ' + tmpRange);
|
||||
debug('HK range %: ' + tuyaColorTempPercent);
|
||||
debug('HK Value: ' + hkValue);
|
||||
|
||||
return hkValue;
|
||||
};
|
||||
|
||||
/**
|
||||
* check if given String is HEX
|
||||
* @param {String} h
|
||||
* @returns {boolean}
|
||||
*/
|
||||
TuyaColorLight.prototype._ValIsHex = function (h) {
|
||||
debug("Check if value is hex", h);
|
||||
return /(^#[0-9A-F]{6}$)|(^#[0-9A-F]{3}$)/i.test(h)
|
||||
};
|
||||
|
||||
/**
|
||||
* get width Hex digits from given value
|
||||
* @param (Integer) value, decimal value to convert to hex string
|
||||
* @param (Integer) width, the number of hex digits to return
|
||||
* @returns {string} value as HEX containing (width) number of hex digits
|
||||
* @private
|
||||
*/
|
||||
TuyaColorLight.prototype._getHex = function (value,width){
|
||||
var hex = (value+Math.pow(16, width)).toString(16).slice(-width).toLowerCase();
|
||||
debug('value: ' + value + ' hex: ' + hex);
|
||||
return hex;
|
||||
}
|
||||
/**
|
||||
* get AlphaHex from percentage brightness
|
||||
* @param {Integer} brightness
|
||||
* @return {string} brightness as HEX value
|
||||
*/
|
||||
TuyaColorLight.prototype._getAlphaHex = function (brightness) {
|
||||
var i = brightness / 100;
|
||||
var alpha = Math.round(i * 255);
|
||||
var hex = (alpha + 0x10000).toString(16).substr(-2);
|
||||
var perc = Math.round(i * 100);
|
||||
|
||||
debug('alpha percent: ' + perc + '% hex: ' + hex + ' alpha: ' + alpha);
|
||||
return hex;
|
||||
};
|
||||
|
||||
/**
|
||||
* Set saturation from value
|
||||
* @param {Integer} value
|
||||
*/
|
||||
TuyaColorLight.prototype.setSaturation = function (value) {
|
||||
this.color.S = value;
|
||||
this.saturation = value;
|
||||
this.colorMode = 'colour';
|
||||
|
||||
debug('SET SATURATION: ' + value);
|
||||
};
|
||||
|
||||
/**
|
||||
* Set Brightness
|
||||
* @param {Integer} value
|
||||
*/
|
||||
TuyaColorLight.prototype.setBrightness = function (value) {
|
||||
this.brightness = value;
|
||||
//var newValue = this._convertPercentageToVal(value);
|
||||
var newValue = this._convertBrightnessPercentageToVal(value);
|
||||
debug("BRIGHTNESS from UI: " + value + ' Converted from 100 to 255 scale: ' + newValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {} value
|
||||
*/
|
||||
TuyaColorLight.prototype.setHue = function (value) {
|
||||
debug('SET HUE: ' + value);
|
||||
debug('Saturation Value: ' + this.color.S);
|
||||
this.color.H = value;
|
||||
|
||||
//check color and set colormode if necessary
|
||||
debug("colormode", value, this.color.S);
|
||||
if (value === 0 && this.color.S === 0) {
|
||||
this.colorMode = 'white';
|
||||
debug('SET Color Mode: \'white\'');
|
||||
} else {
|
||||
this.colorMode = 'colour';
|
||||
debug('SET Color Mode: \'colour\' -- dahhhhhh british spelling \'coulour\' really is annoying... why you gotta be special?');
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
color: this.color,
|
||||
colorMode: this.colorMode,
|
||||
hue: this.color.H,
|
||||
saturation: this.saturation
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Set HSL color
|
||||
* @param {Integer} hue
|
||||
* @param {Integer} saturation
|
||||
* @param {Integer} brightness
|
||||
*/
|
||||
TuyaColorLight.prototype.setHSL = function (hue, saturation, brightness) {
|
||||
this.setSaturation(saturation);
|
||||
this.setBrightness(brightness);
|
||||
this.setHue(hue);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set color from given string
|
||||
* @param {String} colorValue could be HEX or HSL color type
|
||||
* @returns {Object} dps settings for given color
|
||||
*/
|
||||
TuyaColorLight.prototype.setColor = function (colorValue) {
|
||||
debug("Recieved color", colorValue);
|
||||
|
||||
if (this._ValIsHex(colorValue)) {
|
||||
debug("Color is Hex");
|
||||
var color = convert.hex.hsl(colorValue);
|
||||
} else {
|
||||
debug("Color is HSL");
|
||||
var color = colorValue.split(",");
|
||||
// convert strings to numbers
|
||||
color.forEach(function (element, key) {
|
||||
color[key] = parseInt(element, 10);
|
||||
});
|
||||
}
|
||||
debug("Converted color as HSL", {
|
||||
0: color[0] + " - " + typeof color[0],
|
||||
1: color[1] + " - " + typeof color[1],
|
||||
2: color[2] + " - " + typeof color[2]
|
||||
})
|
||||
|
||||
this.setHSL(color[0], color[1], color[2]);
|
||||
return this.getDps();
|
||||
}
|
||||
|
||||
/**
|
||||
* get dps settings for current color
|
||||
* @returns {Object} dps settings
|
||||
*/
|
||||
TuyaColorLight.prototype.getDps = function () {
|
||||
var color = this.color;
|
||||
|
||||
var lightness = Math.round(this.brightness / 2);
|
||||
var brightness = this.brightness;
|
||||
//var apiBrightness = this._convertPercentageToVal(brightness);
|
||||
var apiBrightness = this._convertBrightnessPercentageToVal(brightness);
|
||||
|
||||
//var alphaBrightness = this._getAlphaHex(brightness);
|
||||
var alphaBrightness = this._getHex(apiBrightness,2);
|
||||
|
||||
var hexColor1 = convert.hsl.hex(color.H, color.S, lightness);
|
||||
|
||||
//var hexColor2 = convert.hsl.hex(0, 0, lightness);
|
||||
var hexColor2 = this._getHex(color.H,4);
|
||||
hexColor2 = hexColor2 + this._getHex(this._convertSATorColorTempPercentageToVal(color.S),2);
|
||||
|
||||
var colorTemperature = this.colorTemperature;
|
||||
|
||||
var lightColor = (hexColor1 + hexColor2 + alphaBrightness).toLowerCase();
|
||||
|
||||
//var temperature = (this.colorMode === 'colour') ? 255 : this._convertColorTemperature(colorTemperature);
|
||||
// color temperature percentage is at a fixed 51%
|
||||
var temperature = this._convertSATorColorTempPercentageToVal(51);
|
||||
|
||||
// if the bulb is in colour mode than the dps 3 and dps 4 are ignored by the bulb but if you set it now
|
||||
// some tuya bulbs will ignore dps 5 because you set dps 3 or dps 4
|
||||
// FOR colour mode the bulb looks at dps 1, dps 2, and dps 5.
|
||||
// DPS 5 is in the following format:
|
||||
// HSL to HEX format are the leftmost hex digits (hex digits 14 - 9)
|
||||
// hex digits 8 - 5 are the HSB/HSL Hue value in HEX format
|
||||
// hex digits 4 - 3 are the HSB/HSL Saturation percentage as a value (converted to 0-255 scale) in HEX format
|
||||
// hex digits 2 - 1 are the HSB Brightness percentage as a value (converted to 25-255 scale) in HEX format
|
||||
|
||||
if (this.colorMode === 'colour') {
|
||||
dpsTmp = {
|
||||
'1': true,
|
||||
'2': this.colorMode,
|
||||
//'3': apiBrightness,
|
||||
//'4': temperature,
|
||||
'5': lightColor
|
||||
// '6' : hexColor + hexColor + 'ff'
|
||||
};
|
||||
debug("dps", dpsTmp);
|
||||
return dpsTmp;
|
||||
}
|
||||
|
||||
// if the bulb is in white mode then the dps 5 value is ignored by the bulb but if you set dps 5 value now
|
||||
// you may not get a response back from the bulb on the dps values
|
||||
// FOR white mode the bulb looks at dps 1, dps 2, dps 3 and dps 4
|
||||
// DPS 3 is the HSB/HSL Brightness percentage converted to a value from 25 to 255 in decimal format
|
||||
// DPS 4 is the HSB/HSL Saturation percentage converted to a value from 0 to 255 in decimal format
|
||||
if (this.colorMode === 'white'){
|
||||
dpsTmp = {
|
||||
'1': true,
|
||||
'2': this.colorMode,
|
||||
'3': apiBrightness,
|
||||
'4': temperature,
|
||||
//'5': lightColor
|
||||
// '6' : hexColor + hexColor + 'ff'
|
||||
};
|
||||
debug("dps", dpsTmp);
|
||||
return dpsTmp;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = TuyaColorLight;
|
||||
270
tuya-device.js
270
tuya-device.js
@@ -1,270 +0,0 @@
|
||||
const TuyAPI = require('tuyapi');
|
||||
const TuyColor = require('./tuya-color');
|
||||
const debug = require('debug')('TuyAPI:device');
|
||||
const debugError = require('debug')('TuyAPI:device:error');
|
||||
const debugColor = require('debug')('TuyAPI:device:color');
|
||||
|
||||
/**
|
||||
*
|
||||
var device = new TuyaDevice({
|
||||
id: '03200240600194781244',
|
||||
key: 'b8bdebab418f5b55',
|
||||
ip: '192.168.178.45',
|
||||
version: "3.3",
|
||||
type: "<device_type>" <- "switch", "light", "dimmer", etc. Attempts autodetect if not defined
|
||||
});
|
||||
*/
|
||||
|
||||
var TuyaDevice = (function () {
|
||||
var devices = [];
|
||||
var events = {};
|
||||
|
||||
function checkExisiting(options) {
|
||||
var existing = false;
|
||||
// Check for existing instance
|
||||
devices.forEach(device => {
|
||||
if (device.topicLevel == options.topicLevel) {
|
||||
existing = device;
|
||||
}
|
||||
});
|
||||
return existing;
|
||||
}
|
||||
|
||||
function deleteDevice(id) {
|
||||
devices.forEach((device, key) => {
|
||||
if (device.hasOwnProperty("options")) {
|
||||
if (id === device.options.id) {
|
||||
debug("delete Device", devices[key].toString());
|
||||
delete devices[key];
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function TuyaDevice(options) {
|
||||
var device = this;
|
||||
|
||||
// Check for existing instance by matching topicLevel value
|
||||
if (existing = checkExisiting(options)) {
|
||||
return new Promise((resolve, reject) => {
|
||||
resolve({
|
||||
status: "connected",
|
||||
device: existing
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (!(this instanceof TuyaDevice)) {
|
||||
return new TuyaDevice(options);
|
||||
}
|
||||
|
||||
this.options = options;
|
||||
|
||||
if (this.options.name) {
|
||||
this.topicLevel = this.options.name.toLowerCase().replace(/ /g,"_");
|
||||
} else {
|
||||
this.topicLevel = this.options.id;
|
||||
}
|
||||
|
||||
Object.defineProperty(this, 'device', {
|
||||
value: new TuyAPI(JSON.parse(JSON.stringify(this.options)))
|
||||
});
|
||||
|
||||
this.device.on('data', data => {
|
||||
if (typeof data == "string") {
|
||||
debugError('Data from device not encrypted:', data.replace(/[^a-zA-Z0-9 ]/g, ""));
|
||||
} else {
|
||||
debug('Data from device:', data);
|
||||
device.triggerAll('data', data);
|
||||
}
|
||||
});
|
||||
|
||||
devices.push(this);
|
||||
|
||||
// Find device on network
|
||||
debug("Search device in network");
|
||||
this.find().then(() => {
|
||||
debug("Device found in network");
|
||||
// Connect to device
|
||||
this.device.connect();
|
||||
});
|
||||
|
||||
/**
|
||||
* @return Promise to wait for connection
|
||||
*/
|
||||
return new Promise((resolve, reject) => {
|
||||
this.device.on('connected', () => {
|
||||
device.triggerAll('connected');
|
||||
device.connected = true;
|
||||
debug('Connected to device.', device.toString());
|
||||
resolve({
|
||||
status: "connected",
|
||||
device: this
|
||||
});
|
||||
});
|
||||
this.device.on('disconnected', () => {
|
||||
device.triggerAll('disconnected');
|
||||
device.connected = false;
|
||||
debug('Disconnected from device.', device.toString());
|
||||
deleteDevice(options.id);
|
||||
return reject({
|
||||
status: "disconnect",
|
||||
device: null
|
||||
});
|
||||
});
|
||||
|
||||
this.device.on('error', (err) => {
|
||||
debugError(err);
|
||||
device.triggerAll('error', err);
|
||||
return reject({
|
||||
error: err,
|
||||
device: this
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
TuyaDevice.prototype.toString = function () {
|
||||
return this.name + " (" + this.options.ip + ", " + this.options.id + ", " + this.options.key + ")";
|
||||
}
|
||||
|
||||
TuyaDevice.prototype.triggerAll = function (name, argument) {
|
||||
var device = this;
|
||||
var e = events[name] || [];
|
||||
e.forEach(event => {
|
||||
event.call(device, argument);
|
||||
});
|
||||
}
|
||||
|
||||
TuyaDevice.prototype.on = function (name, callback) {
|
||||
if (!this.connected) return;
|
||||
var device = this;
|
||||
this.device.on(name, function () {
|
||||
callback.apply(device, arguments);
|
||||
});
|
||||
}
|
||||
|
||||
TuyaDevice.prototype.find = function () {
|
||||
return this.device.find();
|
||||
}
|
||||
|
||||
TuyaDevice.prototype.get = function () {
|
||||
return this.device.get();
|
||||
}
|
||||
|
||||
TuyaDevice.prototype.set = function (options) {
|
||||
debug('set:', options);
|
||||
return new Promise((resolve, reject) => {
|
||||
this.device.set(options).then((result) => {
|
||||
this.get().then(() => {
|
||||
debug("Set completed ");
|
||||
resolve(result);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
TuyaDevice.prototype.switch = function (newStatus, callback) {
|
||||
if (!this.connected) return;
|
||||
newStatus = newStatus.toLowerCase();
|
||||
if (newStatus == "on") {
|
||||
return this.switchOn(callback);
|
||||
}
|
||||
if (newStatus == "off") {
|
||||
return this.switchOff(callback);
|
||||
}
|
||||
if (newStatus == "toggle") {
|
||||
return this.toggle(callback);
|
||||
}
|
||||
}
|
||||
|
||||
TuyaDevice.prototype.switchOn = function () {
|
||||
if (!this.connected) return;
|
||||
debug("switch -> ON");
|
||||
|
||||
return this.set({
|
||||
set: true
|
||||
});
|
||||
}
|
||||
|
||||
TuyaDevice.prototype.switchOff = function () {
|
||||
if (!this.connected) return;
|
||||
debug("switch -> OFF");
|
||||
|
||||
return this.set({
|
||||
set: false
|
||||
});
|
||||
}
|
||||
|
||||
TuyaDevice.prototype.toggle = function () {
|
||||
if (!this.connected) return;
|
||||
return new Promise((resolve, reject) => {
|
||||
this.get().then((status) => {
|
||||
debug("toogle state", status);
|
||||
this.set({
|
||||
set: !status
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
TuyaDevice.prototype.schema = function(obj){
|
||||
return this.get(obj).then((status) => {
|
||||
debug("get", obj);
|
||||
});
|
||||
}
|
||||
|
||||
TuyaDevice.prototype.setColor = function (hexColor) {
|
||||
if (!this.connected) return;
|
||||
debugColor("Set color to: ", hexColor);
|
||||
var tuya = this.device;
|
||||
var color = new TuyColor(tuya);
|
||||
var dps = color.setColor(hexColor);
|
||||
debugColor("dps values:", dps);
|
||||
|
||||
return this.set({
|
||||
multiple: true,
|
||||
data: dps
|
||||
});
|
||||
}
|
||||
|
||||
TuyaDevice.prototype.connect = function (callback) {
|
||||
debug("Connect to TuyAPI Device");
|
||||
return this.device.connect(callback);
|
||||
}
|
||||
|
||||
TuyaDevice.prototype.disconnect = function (callback) {
|
||||
debug("Disconnect from TuyAPI Device");
|
||||
return this.device.disconnect(callback);
|
||||
}
|
||||
|
||||
Object.defineProperty(TuyaDevice, 'devices', {
|
||||
value: devices
|
||||
});
|
||||
|
||||
TuyaDevice.connectAll = function () {
|
||||
devices.forEach(device => {
|
||||
device.connect();
|
||||
});
|
||||
}
|
||||
|
||||
TuyaDevice.disconnectAll = function () {
|
||||
devices.forEach(device => {
|
||||
device.disconnect();
|
||||
});
|
||||
}
|
||||
|
||||
TuyaDevice.onAll = function (name, callback) {
|
||||
if (events[name] == undefined) {
|
||||
events[name] = [];
|
||||
}
|
||||
events[name].push(callback);
|
||||
devices.forEach(device => {
|
||||
device.triggerAll(name);
|
||||
});
|
||||
}
|
||||
|
||||
return TuyaDevice;
|
||||
}());
|
||||
|
||||
module.exports = TuyaDevice;
|
||||
460
tuya-mqtt.js
460
tuya-mqtt.js
@@ -1,426 +1,150 @@
|
||||
#!/usr/bin/env node
|
||||
const fs = require('fs')
|
||||
const mqtt = require('mqtt');
|
||||
const json5 = require('json5');
|
||||
const TuyaDevice = require('./tuya-device');
|
||||
const debug = require('debug')('TuyAPI:mqtt');
|
||||
const debugColor = require('debug')('TuyAPI:mqtt:color');
|
||||
const debugTuya = require('debug')('TuyAPI:mqtt:device');
|
||||
const debugError = require('debug')('TuyAPI:mqtt:error');
|
||||
const mqtt = require('mqtt')
|
||||
const json5 = require('json5')
|
||||
const debug = require('debug')('tuya-mqtt:mqtt')
|
||||
const debugError = require('debug')('tuya-mqtt:error')
|
||||
const SimpleSwitch = require('./devices/simple-switch')
|
||||
const SimpleDimmer = require('./devices/simple-dimmer')
|
||||
const RGBTWLight = require('./devices/rgbtw-light')
|
||||
const GenericDevice = require('./devices/generic-device')
|
||||
|
||||
var CONFIG = undefined;
|
||||
var mqtt_client = undefined;
|
||||
var CONFIG = undefined
|
||||
var tuyaDevices = new Array()
|
||||
|
||||
/*
|
||||
* Check if data is JSON or not
|
||||
*/
|
||||
function isJsonString (data){
|
||||
try {
|
||||
const parsedData = JSON.parse(data);
|
||||
if (parsedData && typeof parsedData === "object") {
|
||||
return parsedData;
|
||||
}
|
||||
function getDevice(configDevice, mqttClient) {
|
||||
const deviceInfo = {
|
||||
configDevice: configDevice,
|
||||
mqttClient: mqttClient,
|
||||
topic: CONFIG.topic
|
||||
}
|
||||
catch (e) { }
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* get command from mqtt message
|
||||
* converts message to TuyAPI JSON commands
|
||||
* @param {String} message
|
||||
* @returns {Object}
|
||||
*/
|
||||
function getCommandFromMessage(_message) {
|
||||
let command = _message
|
||||
|
||||
if (command != "1" && command != "0" && isJsonString(command)) {
|
||||
debug("Received command is JSON");
|
||||
command = JSON.parse(command);
|
||||
} else {
|
||||
switch(command.toLowerCase()) {
|
||||
case "on":
|
||||
case "off":
|
||||
case "0":
|
||||
case "1":
|
||||
// convert simple commands (on, off, 1, 0) to TuyAPI-Commands
|
||||
const convertString = command.toLowerCase() == "on" || command == "1" || command == 1 ? true : false;
|
||||
command = {
|
||||
set: convertString
|
||||
}
|
||||
break;
|
||||
default:
|
||||
command = command.toLowerCase();
|
||||
}
|
||||
switch (configDevice.type) {
|
||||
case 'SimpleSwitch':
|
||||
return new SimpleSwitch(deviceInfo)
|
||||
break;
|
||||
case 'SimpleDimmer':
|
||||
return new SimpleDimmer(deviceInfo)
|
||||
break;
|
||||
case 'RGBTWLight':
|
||||
return new RGBTWLight(deviceInfo)
|
||||
break;
|
||||
case 'GenericDevice':
|
||||
return new GenericDevice(deviceInfo)
|
||||
break;
|
||||
}
|
||||
return command;
|
||||
return null
|
||||
}
|
||||
|
||||
// Parse message
|
||||
function parseDpsMessage(message) {
|
||||
if (typeof message === "boolean" ) {
|
||||
return message;
|
||||
} else if (message === "true" || message === "false") {
|
||||
return (message === "true") ? true : false
|
||||
} else if (!isNaN(message)) {
|
||||
return Number(message)
|
||||
} else {
|
||||
return message
|
||||
}
|
||||
}
|
||||
|
||||
function publishMQTT(topic, data) {
|
||||
mqtt_client.publish(topic, data, {
|
||||
retain: CONFIG.retain,
|
||||
qos: CONFIG.qos
|
||||
});
|
||||
}
|
||||
|
||||
function guessDeviceType(device, dps) {
|
||||
keys = Object.keys(dps).length
|
||||
if (keys === 2) {
|
||||
if (typeof dps['1'] === "boolean" && dps['2'] >= 0 && dps['2'] <= 255) {
|
||||
// A "dimmer" is a switch/light with brightness control only
|
||||
device.options.type = "dimmer"
|
||||
device.options.template =
|
||||
{
|
||||
"state": { "dpsKey": 1, "dpsType": "bool" },
|
||||
"brightness_state": { "dpsKey": 2, "dpsType": "int", "minVal": 0, "maxVal": 255 }
|
||||
}
|
||||
}
|
||||
} else if (keys === 1) {
|
||||
if (typeof dps['1'] === "boolean") {
|
||||
// If it only has one value and it's a boolean, it's probably a switch/socket
|
||||
device.options.type = "switch"
|
||||
device.options.template =
|
||||
{
|
||||
"state": { "dpsKey": 1, "dpsType": "bool" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!device.options.type) {
|
||||
device.options.type = "unknown"
|
||||
device.options.template =
|
||||
{
|
||||
"state": { "dpsKey": 1, "dpsType": "bool" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function publishColorState(device, state) {
|
||||
|
||||
}
|
||||
|
||||
function publishDeviceTopics(device, dps) {
|
||||
if (!device.options.template) {
|
||||
debugTuya ("No device template found!")
|
||||
return
|
||||
}
|
||||
const baseTopic = CONFIG.topic + device.topicLevel + "/"
|
||||
for (let stateTopic in device.options.template) {
|
||||
const template = device.options.template[stateTopic]
|
||||
const topic = baseTopic + stateTopic
|
||||
let state
|
||||
// Only publish state updates for DPS values included in device data
|
||||
if (dps.hasOwnProperty(template.dpsType)) {
|
||||
switch (template.dpsType) {
|
||||
case "bool":
|
||||
state = (dps[template.dpsKey]) ? 'ON' : 'OFF';
|
||||
break;
|
||||
case "int":
|
||||
state = (dps[template.dpsKey])
|
||||
state = (state > template.minVal && state < template.maxVal) ? state.toString() : ""
|
||||
break;
|
||||
}
|
||||
if (state) {
|
||||
debugTuya("MQTT "+device.options.type+" "+topic+" -> ", state);
|
||||
publishMQTT(topic, state);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* publish all dps-values to topic
|
||||
* @param {TuyaDevice} device
|
||||
* @param {Object} dps
|
||||
*/
|
||||
function publishDPS(device, dps) {
|
||||
if (mqtt_client.connected == true) {
|
||||
try {
|
||||
if (!device.options.type) {
|
||||
guessDeviceType(device, dps)
|
||||
}
|
||||
|
||||
const baseTopic = CONFIG.topic + device.topicLevel + "/dps";
|
||||
const topic = baseTopic + "/state"
|
||||
const data = JSON.stringify(dps);
|
||||
|
||||
// Publish raw DPS JSON data
|
||||
debugTuya("MQTT DPS JSON (raw): " + topic + " -> ", data);
|
||||
publishMQTT(topic, data);
|
||||
|
||||
// Publish dps/<#>/state value for each DPS
|
||||
Object.keys(dps).forEach(function (key) {
|
||||
const topic = baseTopic + "/" + key + "/state";
|
||||
const data = JSON.stringify(dps[key]);
|
||||
debugTuya("MQTT DPS"+key+": "+topic+" -> ", data);
|
||||
publishMQTT(topic, data);
|
||||
});
|
||||
|
||||
publishDeviceTopics(device, dps)
|
||||
|
||||
} catch (e) {
|
||||
debugError(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* event fires if TuyaDevice sends data
|
||||
* @see TuyAPI (https://github.com/codetheweb/tuyapi)
|
||||
*/
|
||||
TuyaDevice.onAll('data', function (data) {
|
||||
try {
|
||||
if (typeof data.dps != "undefined") {
|
||||
debugTuya('Data from device Id ' + data.devId + ' ->', data.dps);
|
||||
publishDPS(this, data.dps);
|
||||
}
|
||||
} catch (e) {
|
||||
debugError(e);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Function call on script exit
|
||||
*/
|
||||
function onExit() {
|
||||
TuyaDevice.disconnectAll();
|
||||
};
|
||||
|
||||
// Simple sleep to pause in async functions
|
||||
function sleep(sec) {
|
||||
return new Promise(res => setTimeout(res, sec*1000));
|
||||
}
|
||||
|
||||
function initTuyaDevices(tuyaDevices) {
|
||||
for (let tuyaDevice of tuyaDevices) {
|
||||
let options = {
|
||||
id: tuyaDevice.id,
|
||||
key: tuyaDevice.key
|
||||
}
|
||||
if (tuyaDevice.name) { options.name = tuyaDevice.name }
|
||||
if (tuyaDevice.ip) {
|
||||
options.ip = tuyaDevice.ip
|
||||
if (tuyaDevice.version) {
|
||||
options.version = tuyaDevice.version
|
||||
} else {
|
||||
version = "3.1"
|
||||
}
|
||||
}
|
||||
new TuyaDevice(options);
|
||||
}
|
||||
}
|
||||
|
||||
// Process MQTT commands for all command topics at device level
|
||||
function processDeviceCommand(message, device, commandTopic) {
|
||||
let command = getCommandFromMessage(message);
|
||||
// If it's the color command topic handle it manually
|
||||
if (commandTopic === "color_command") {
|
||||
const color = message.toLowerCase();
|
||||
debugColor("Set color: ", color);
|
||||
device.setColor(color).then((data) => {
|
||||
debug("Set device color completed: ", data);
|
||||
});
|
||||
} else if (commandTopic === "command" && (command === "toggle" || command === "schema" )) {
|
||||
// Handle special commands "toggle" and "schema" to primary device command topic
|
||||
debug("Received command: ", command);
|
||||
switch(command) {
|
||||
case "toggle":
|
||||
device.switch(command).then((data) => {
|
||||
debug("Set device status completed: ", data);
|
||||
});
|
||||
break;
|
||||
case "schema":
|
||||
// Trigger device schema to update state
|
||||
device.schema(command).then((data) => {
|
||||
debug("Get schema status command complete.");
|
||||
});
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
// Recevied command on device topic level, check for matching device template
|
||||
// and process command accordingly
|
||||
const stateTopic = commandTopic.replace("command", "state")
|
||||
const template = device.options.template[stateTopic]
|
||||
if (template) {
|
||||
debug("Received device "+commandTopic.replace("_"," "), message);
|
||||
const tuyaCommand = new Object()
|
||||
tuyaCommand.dps = template.dpsKey
|
||||
switch (template.dpsType) {
|
||||
case "bool":
|
||||
if (command === "true") {
|
||||
tuyaCommand.set = true
|
||||
} else if (command === "false") {
|
||||
tuyaCommand.set = false
|
||||
} else if (typeof command.set === "boolean") {
|
||||
tuyaCommand.set = command.set
|
||||
} else {
|
||||
tuyaCommand.set = "!!!!!"
|
||||
}
|
||||
break;
|
||||
case "int":
|
||||
tuyaCommand.set = (command > template.minVal && command < template.maxVal ) ? parseInt(command) : "!!!!!"
|
||||
break;
|
||||
}
|
||||
if (tuyaCommand.set === "!!!!!") {
|
||||
debug("Received invalid value for ", commandTopic, ", value:", command)
|
||||
} else {
|
||||
device.set(tuyaCommand).then((data) => {
|
||||
debug("Set device "+commandTopic.replace("_"," ")+": ", data);
|
||||
});
|
||||
}
|
||||
function initDevices(configDevices, mqttClient) {
|
||||
for (let configDevice of configDevices) {
|
||||
if (!configDevice.type) {
|
||||
debug('Device type not specified, skipping creation of this device')
|
||||
} else {
|
||||
debug("Received unknown command topic for device: ", commandTopic)
|
||||
const newDevice = getDevice(configDevice, mqttClient)
|
||||
if (newDevice) {
|
||||
tuyaDevices.push(newDevice)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process raw Tuya JSON commands via DPS command topic
|
||||
function processDpsCommand(message, device) {
|
||||
if (isJsonString(message)) {
|
||||
const command = getCommandFromMessage(message);
|
||||
debug("Received command: ", command);
|
||||
device.set(command).then((data) => {
|
||||
debug("Set device status completed: ", data);
|
||||
});
|
||||
} else {
|
||||
debug("DPS command topic requires Tuya style JSON value")
|
||||
}
|
||||
}
|
||||
|
||||
// Process text base Tuya command via DPS key command topics
|
||||
function processDpsKeyCommand(message, device, dpsKey) {
|
||||
if (isJsonString(message)) {
|
||||
debug("Individual DPS command topics do not accept JSON values")
|
||||
} else {
|
||||
const dpsMessage = parseDpsMessage(message)
|
||||
debug("Received command for DPS"+dpsKey+": ", message);
|
||||
const command = {
|
||||
dps: dpsKey,
|
||||
set: dpsMessage
|
||||
}
|
||||
device.set(command).then((data) => {
|
||||
debug("Set device status completed: ", data);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Main code function
|
||||
const main = async() => {
|
||||
let tuyaDevices
|
||||
let configDevices
|
||||
let mqttClient
|
||||
|
||||
try {
|
||||
CONFIG = require("./config");
|
||||
CONFIG = require('./config')
|
||||
} catch (e) {
|
||||
console.error("Configuration file not found!")
|
||||
console.error('Configuration file not found!')
|
||||
debugError(e)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (typeof CONFIG.qos == "undefined") {
|
||||
CONFIG.qos = 2;
|
||||
if (typeof CONFIG.qos == 'undefined') {
|
||||
CONFIG.qos = 2
|
||||
}
|
||||
if (typeof CONFIG.retain == "undefined") {
|
||||
CONFIG.retain = false;
|
||||
if (typeof CONFIG.retain == 'undefined') {
|
||||
CONFIG.retain = false
|
||||
}
|
||||
|
||||
try {
|
||||
tuyaDevices = fs.readFileSync('./devices.conf', 'utf8');
|
||||
tuyaDevices = json5.parse(tuyaDevices)
|
||||
configDevices = fs.readFileSync('./devices.conf', 'utf8')
|
||||
configDevices = json5.parse(configDevices)
|
||||
} catch (e) {
|
||||
console.error("Devices file not found!")
|
||||
console.error('Devices file not found!')
|
||||
debugError(e)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (!tuyaDevices.length) {
|
||||
console.error("No devices found in devices file!")
|
||||
if (!configDevices.length) {
|
||||
console.error('No devices found in devices file!')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
mqtt_client = mqtt.connect({
|
||||
mqttClient = mqtt.connect({
|
||||
host: CONFIG.host,
|
||||
port: CONFIG.port,
|
||||
username: CONFIG.mqtt_user,
|
||||
password: CONFIG.mqtt_pass,
|
||||
});
|
||||
})
|
||||
|
||||
mqtt_client.on('connect', function (err) {
|
||||
debug("Connection established to MQTT server");
|
||||
let topic = CONFIG.topic + '#';
|
||||
mqtt_client.subscribe(topic, {
|
||||
mqttClient.on('connect', function (err) {
|
||||
debug('Connection established to MQTT server')
|
||||
let topic = CONFIG.topic + '#'
|
||||
mqttClient.subscribe(topic, {
|
||||
retain: CONFIG.retain,
|
||||
qos: CONFIG.qos
|
||||
});
|
||||
initTuyaDevices(tuyaDevices)
|
||||
});
|
||||
})
|
||||
initDevices(configDevices, mqttClient)
|
||||
})
|
||||
|
||||
mqtt_client.on("reconnect", function (error) {
|
||||
if (mqtt_client.connected) {
|
||||
debug("Connection to MQTT server lost. Attempting to reconnect...");
|
||||
mqttClient.on('reconnect', function (error) {
|
||||
if (mqttClient.connected) {
|
||||
debug('Connection to MQTT server lost. Attempting to reconnect...')
|
||||
} else {
|
||||
debug("Unable to connect to MQTT server");
|
||||
debug('Unable to connect to MQTT server')
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
mqtt_client.on("error", function (error) {
|
||||
debug("Unable to connect to MQTT server", error);
|
||||
});
|
||||
mqttClient.on('error', function (error) {
|
||||
debug('Unable to connect to MQTT server', error)
|
||||
})
|
||||
|
||||
mqtt_client.on('message', function (topic, message) {
|
||||
mqttClient.on('message', function (topic, message) {
|
||||
try {
|
||||
message = message.toString();
|
||||
const splitTopic = topic.split("/");
|
||||
message = message.toString()
|
||||
const splitTopic = topic.split('/')
|
||||
const topicLength = splitTopic.length
|
||||
const commandTopic = splitTopic[topicLength - 1];
|
||||
const options = {
|
||||
topicLevel: splitTopic[1]
|
||||
}
|
||||
const commandTopic = splitTopic[topicLength - 1]
|
||||
const deviceTopicLevel = splitTopic[1]
|
||||
|
||||
// If it looks like a valid command topic try to process it
|
||||
if (commandTopic.includes("command")) {
|
||||
debug("Receive settings", JSON.stringify({
|
||||
if (commandTopic.includes('command')) {
|
||||
debug('Received MQTT message -> ', JSON.stringify({
|
||||
topic: topic,
|
||||
message: message
|
||||
}));
|
||||
}))
|
||||
|
||||
// Uses device topic level to find matching device
|
||||
var device = new TuyaDevice(options);
|
||||
|
||||
device.then(function (params) {
|
||||
let device = params.device;
|
||||
switch (topicLength) {
|
||||
case 3:
|
||||
processDeviceCommand(message, device, commandTopic);
|
||||
break;
|
||||
case 4:
|
||||
processDpsCommand(message, device);
|
||||
break;
|
||||
case 5:
|
||||
const dpsKey = splitTopic[topicLength-2]
|
||||
processDpsKeyCommand(message, device, dpsKey);
|
||||
break;
|
||||
}
|
||||
}).catch((err) => {
|
||||
debugError(err);
|
||||
});
|
||||
// Use device topic level to find matching device
|
||||
const device = tuyaDevices.find(d => d.options.name === deviceTopicLevel || d.options.id === deviceTopicLevel)
|
||||
switch (topicLength) {
|
||||
case 3:
|
||||
device.processCommand(message, commandTopic)
|
||||
break;
|
||||
case 4:
|
||||
device.processDpsCommand(message)
|
||||
break;
|
||||
case 5:
|
||||
const dpsKey = splitTopic[topicLength-2]
|
||||
device.processDpsKeyCommand(message, dpsKey)
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugError(e);
|
||||
debugError(e)
|
||||
}
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
// Call the main code
|
||||
|
||||
Reference in New Issue
Block a user