Skip to content

Instantly share code, notes, and snippets.

@jtp10181
Created April 9, 2022 20:16
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save jtp10181/fcf4317912898277a02fcf58fd314359 to your computer and use it in GitHub Desktop.
Save jtp10181/fcf4317912898277a02fcf58fd314359 to your computer and use it in GitHub Desktop.
TuyaOpenCloudAPI fixes for setting level on RGB light strip
/**
* MIT License
* Copyright 2020 Jonathan Bradshaw (jb@nrgup.net)
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import com.hubitat.app.exception.UnknownDeviceTypeException
import com.hubitat.app.ChildDeviceWrapper
import com.hubitat.app.DeviceWrapper
import groovy.json.JsonOutput
import groovy.json.JsonSlurper
import groovy.transform.Field
import java.security.MessageDigest
import java.util.concurrent.ConcurrentHashMap
import javax.crypto.spec.SecretKeySpec
import javax.crypto.Cipher
import javax.crypto.Mac
import hubitat.helper.HexUtils
import hubitat.scheduling.AsyncResponse
/*
* Changelog:
* 10/06/21 - 0.1 - Initial release
* 10/08/21 - 0.1.1 - added Scene Switch TS004F productKey:xabckq1v
* 10/09/21 - 0.1.2 - added Scene Switch TS0044 productKey:vp6clf9d; added battery reports (when the virtual driver supports it)
* 10/10/21 - 0.1.3 - brightness, temperature, humidity, CO2 sensors
* 10/11/21 - 0.1.4 - door contact, water, smoke, co, pir sensors, fan
* 10/13/21 - 0.1.5 - fix ternary use error for colors and levels
* 10/14/21 - 0.1.6 - smart plug, vibration sensor; brightness and temperature sensors scaling bug fix
* 10/17/21 - 0.1.7 - switched API to use device specification request to get both functions and status ranges
* 10/25/21 - 0.1.8 - Added support for Window Shade using a custom component driver
* 10/26/21 - 0.1.9 - Add support for heating devices with custom component driver
* 10/27/21 - 0.2.0 - Created country to datacenter map (https://developer.tuya.com/en/docs/iot/oem-app-data-center-distributed?id=Kafi0ku9l07qb)
* 12/02/21 - 0.2.1 - Added support for power strips and triggering Tuya scenes
* 12/03/21 - 0.2.1 - Added basic support for pet feeder manual feeding button
* 12/08/21 - 0.2.2 - Added support for additional types of sockets and switches
* 12/26/21 - 0.2.3 - Added more types of sockets
* 01/06/22 - 0.2.4 - Added humidifier support (by simon)
* 01/07/22 - 0.2.5 - Added check for expired tokens
* 03/09/22 - 0.3.0 - Optimized device state parsing to remove duplicatation
*
* Custom component drivers located at https://github.com/bradsjm/hubitat-drivers/tree/master/Component
*/
metadata {
definition(name: 'Tuya IoT Platform (Cloud)', namespace: 'tuya', author: 'Jonathan Bradshaw',
importUrl: 'https://raw.githubusercontent.com/bradsjm/hubitat-drivers/main/Tuya/TuyaOpenCloudAPI.groovy') {
capability 'Initialize'
capability 'Refresh'
command 'removeDevices'
attribute 'deviceCount', 'number'
attribute 'state', 'enum', [
'not configured',
'error',
'authenticating',
'authenticated',
'connected',
'disconnected',
'ready'
]
}
preferences {
section {
input name: 'access_id',
type: 'text',
title: 'Tuya API Access/Client Id',
required: true
input name: 'access_key',
type: 'password',
title: 'Tuya API Access/Client Secret',
required: true
input name: 'appSchema',
title: 'Tuya Application',
type: 'enum',
required: true,
defaultValue: 'tuyaSmart',
options: [
'tuyaSmart': 'Tuya Smart Life App',
'smartlife': 'Smart Life App'
]
input name: 'username',
type: 'text',
title: 'Tuya Application Login',
required: true
input name: 'password',
type: 'password',
title: 'Tuya Application Password',
required: true
input name: 'appCountry',
title: 'Tuya Application Country',
type: 'enum',
required: true,
defaultValue: 'United States',
options: tuyaCountries.country
input name: 'logEnable',
type: 'bool',
title: 'Enable debug logging',
required: false,
defaultValue: true
input name: 'txtEnable',
type: 'bool',
title: 'Enable descriptionText logging',
required: false,
defaultValue: true
}
}
}
// Tuya Function Categories
@Field static final Map<String, List<String>> tuyaFunctions = [
'battery' : [ 'battery_percentage', 'va_battery' ],
'brightness' : [ 'bright_value', 'bright_value_v2', 'bright_value_1' ],
'co' : [ 'co_state' ],
'co2' : [ 'co2_value' ],
'colour' : [ 'colour_data', 'colour_data_v2' ],
'contact' : [ 'doorcontact_state' ],
'ct' : [ 'temp_value', 'temp_value_v2' ],
'control' : [ 'control' ],
'fanSpeed' : [ 'fan_speed' ],
'light' : [ 'switch_led', 'switch_led_1', 'light' ],
'humiditySet' : [ 'dehumidify_set_value' ], /* Inserted by SJB */
'humiditySpeed' : [ 'fan_speed_enum' ],
'humidity' : [ 'temp_indoor', 'swing', 'child_lock', 'fan_speed_enum', 'dehumidify_set_value', 'humidity_indoor', 'switch' ],
'meteringSwitch' : [ 'countdown_1' , 'add_ele' , 'cur_current', 'cur_power', 'cur_voltage' , 'relay_status', 'light_mode' ],
'omniSensor' : [ 'bright_value', 'humidity_value', 'va_humidity', 'bright_sensitivity', 'shock_state', 'inactive_state', 'sensitivity' ],
'pir' : [ 'pir' ],
'power' : [ 'Power', 'power', 'switch', 'switch_1', 'switch_2', 'switch_3', 'switch_4', 'switch_5', 'switch_6', 'switch_usb1', 'switch_usb2', 'switch_usb3', 'switch_usb4', 'switch_usb5', 'switch_usb6' ],
'percentControl' : [ 'percent_control', 'fan_speed_percent' ],
'push' : [ 'manual_feed' ],
'sceneSwitch' : [ 'switch1_value', 'switch2_value', 'switch3_value', 'switch4_value', 'switch_mode2', 'switch_mode3', 'switch_mode4' ],
'smoke' : [ 'smoke_sensor_status' ],
'temperatureSet' : [ 'temp_set' ],
'temperature' : [ 'temp_current', 'va_temperature' ],
'water' : [ 'watersensor_state' ],
'workMode' : [ 'work_mode' ],
'workState' : [ 'work_state' ]
].asImmutable()
// Tuya -> Hubitat attributes mappings
// TS004F productKey:xabckq1v TS0044 productKey:vp6clf9d
@Field static final Map<String, String> sceneSwitchAction = [
'single_click' : 'pushed', // TS004F
'double_click' : 'doubleTapped',
'long_press' : 'held',
'click' : 'pushed', // TS0044
'press' : 'held'
].asImmutable()
@Field static final Map<String, String> sceneSwitchKeyNumbers = [
'switch_mode2' : '2', // TS0044
'switch_mode3' : '3',
'switch_mode4' : '4',
'switch1_value' : '4', // '4'for TS004F and '1' for TS0044 !
'switch2_value' : '3', // TS004F - match the key numbering as in Hubitat built-in TS0044 driver
'switch3_value' : '1',
'switch4_value' : '2',
].asImmutable()
// Constants
@Field static final Integer maxMireds = 500 // 2000K
@Field static final Integer minMireds = 153 // 6536K
// Json Parsing Cache
@Field static final Map<String, Map> jsonCache = new ConcurrentHashMap<>()
// Track for dimming operations
@Field static final Map<String, Integer> levelChanges = new ConcurrentHashMap<>()
// Random number generator
@Field static final Random random = new Random()
// Json Parser
@Field static final JsonSlurper jsonParser = new JsonSlurper()
// Cipher Cache
@Field static final Map<String, Cipher> cipherCache = new ConcurrentHashMap<>()
/*
* Tuya default attributes used if missing from device details
*/
@Field static final Map defaults = [
'battery_percentage': [ min: 0, max: 100, scale: 0, step: 1, unit: '%', type: 'Integer' ],
'bright_value': [ min: 0, max: 100, scale: 0, step: 1, type: 'Integer' ],
'bright_value_v2': [ min: 0, max: 100, scale: 0, step: 1, type: 'Integer' ],
'co2_value': [ min: 0, max: 1000, scale: 1, step: 1, type: 'Integer' ],
'fan_speed': [ min: 1, max: 100, scale: 0, step: 1, type: 'Integer' ],
'fan_speed_percent': [ min: 1, max: 100, scale: 0, step: 1, type: 'Integer' ],
'temp_value': [ min: 0, max: 100, scale: 0, step: 1, type: 'Integer' ],
'temp_value_v2': [ min: 0, max: 100, scale: 0, step: 1, type: 'Integer' ],
'colour_data': [
h: [ min: 1, scale: 0, max: 360, step: 1, type: 'Integer' ],
s: [ min: 1, scale: 0, max: 255, step: 1, type: 'Integer' ],
v: [ min: 1, scale: 0, max: 255, step: 1, type: 'Integer' ]
],
'colour_data_v2': [
h: [ min: 1, scale: 0, max: 360, step: 1, type: 'Integer' ],
s: [ min: 1, scale: 0, max: 1000, step: 1, type: 'Integer' ],
v: [ min: 1, scale: 0, max: 1000, step: 1, type: 'Integer' ]
],
'humidity_value': [ min: 0, max: 100, scale: 0, step: 1, type: 'Integer' ],
'temp_current': [ min: -400, max: 2000, scale: 0, step: 1, unit: '°C', type: 'Integer' ],
'temp_set': [ min: -400, max: 2000, scale: 0, step: 1, unit: '°C', type: 'Integer' ],
'va_humidity': [ min: 0, max: 1000, scale: 1, step: 1, type: 'Integer' ],
'va_temperature': [ min: 0, max: 1000, scale: 1, step: 1, type: 'Integer' ],
'manual_feed': [ min: 1, max: 50, scale:0, step: 1, type: 'Integer' ]
].asImmutable()
/* -------------------------------------------------------
* Implementation of component commands from child devices
*/
// Component command to close device
void componentClose(DeviceWrapper dw) {
Map<String, Map> functions = getFunctions(dw)
String code = getFunctionCode(functions, tuyaFunctions.control)
if (code != null) {
if (txtEnable) { LOG.info "Closing ${dw}" }
tuyaSendDeviceCommandsAsync(dw.getDataValue('id'), [ 'code': code, 'value': 'close' ])
} else {
LOG.error "Unable to determine close function code in ${functions}"
}
}
// Component command to cycle fan speed
void componentCycleSpeed(DeviceWrapper dw) {
switch (dw.currentValue('speed')) {
case 'low':
case 'medium-low':
componentSetSpeed(dw, 'medium')
break
case 'medium':
case 'medium-high':
componentSetSpeed(dw, 'high')
break
case 'high':
componentSetSpeed(dw, 'low')
break
}
}
// Component command to lock device
void componentLock(DeviceWrapper dw) {
LOG.warn "componentLock not yet supported for ${dw}"
}
// Component command to turn on device
void componentOn(DeviceWrapper dw) {
Map<String, Map> functions = getFunctions(dw)
String code = getFunctionCode(functions, tuyaFunctions.light + tuyaFunctions.power)
if (code != null) {
if (txtEnable) { LOG.info "Turning ${dw} on" }
tuyaSendDeviceCommandsAsync(dw.getDataValue('id'), [ 'code': code, 'value': true ])
} else {
String homeId = dw.getDataValue('homeId')
String sceneId = dw.getDataValue('sceneId')
if (sceneId && homeId) {
if (txtEnable) { LOG.info "Triggering ${dw} automation" }
tuyaTriggerScene(homeId, sceneId)
} else {
LOG.error "Unable to determine off function code in ${functions}"
}
}
}
// Component command to turn off device
void componentOff(DeviceWrapper dw) {
Map<String, Map> functions = getFunctions(dw)
String code = getFunctionCode(functions, tuyaFunctions.light + tuyaFunctions.power)
if (code != null) {
if (txtEnable) { LOG.info "Turning ${dw} off" }
tuyaSendDeviceCommandsAsync(dw.getDataValue('id'), [ 'code': code, 'value': false ])
} else {
LOG.error "Unable to determine off function code in ${functions}"
}
}
// Component command to open device
void componentOpen(DeviceWrapper dw) {
Map<String, Map> functions = getFunctions(dw)
String code = getFunctionCode(functions, tuyaFunctions.control)
if (code != null) {
if (txtEnable) { LOG.info "Opening ${dw}" }
tuyaSendDeviceCommandsAsync(dw.getDataValue('id'), [ 'code': code, 'value': 'open' ])
} else {
LOG.error "Unable to determine open function code in ${functions}"
}
}
// Component command to turn on device
void componentPush(DeviceWrapper dw, BigDecimal button) {
Map<String, Map> functions = getFunctions(dw)
String code = getFunctionCode(functions, tuyaFunctions.push)
if (code != null) {
if (txtEnable) { LOG.info "Pushing ${dw} button ${button}" }
tuyaSendDeviceCommandsAsync(dw.getDataValue('id'), [ 'code': code, 'value': button ])
} else {
String homeId = dw.getDataValue('homeId')
String sceneId = dw.getDataValue('sceneId')
if (sceneId && homeId) {
tuyaTriggerScene(homeId, sceneId)
} else {
LOG.error "Unable to determine push function code in ${functions}"
}
}
}
// Component command to refresh device
void componentRefresh(DeviceWrapper dw) {
String id = dw.getDataValue('id')
if (id != null && dw.getDataValue('functions')) {
LOG.info "Refreshing ${dw} (${id})"
tuyaGetStateAsync(id)
}
}
// Component command to set color
void componentSetColor(DeviceWrapper dw, Map colorMap) {
Map<String, Map> functions = getFunctions(dw)
String code = getFunctionCode(functions, tuyaFunctions.colour)
if (code != null) {
Map color = functions[code] ?: defaults[code]
// An oddity and workaround for mapping brightness values
Map bright = getFunction(functions, functions.brightness) ?: color.v
Map value = [
h: remap(colorMap.hue, 0, 100, (int)color.h.min, (int)color.h.max),
s: remap(colorMap.saturation, 0, 100, (int)color.s.min, (int)color.s.max),
v: remap(colorMap.level, 0, 100, (int)bright.min, (int)bright.max)
]
if (txtEnable) { LOG.info "Setting ${dw} color to ${colorMap}" }
tuyaSendDeviceCommandsAsync(dw.getDataValue('id'),
[ 'code': code, 'value': value ],
[ 'code': 'work_mode', 'value': 'colour']
)
} else {
LOG.error "Unable to determine set color function code in ${functions}"
}
}
// Component command to set color temperature
void componentSetColorTemperature(DeviceWrapper dw, BigDecimal kelvin,
BigDecimal level = null, BigDecimal duration = null) {
Map<String, Map> functions = getFunctions(dw) << getStatusSet(dw)
String code = getFunctionCode(functions, tuyaFunctions.ct)
if (code != null) {
Map temp = functions[code] ?: defaults[code]
Integer value = (int)temp.max - Math.ceil(remap(1000000 / kelvin, minMireds, maxMireds, (int)temp.min, (int)temp.max))
if (txtEnable) { LOG.info "Setting ${dw} color temperature to ${kelvin}K" }
tuyaSendDeviceCommandsAsync(dw.getDataValue('id'),
[ 'code': code, 'value': value ],
[ 'code': 'work_mode', 'value': 'white']
)
} else {
LOG.error "Unable to determine color temperature function code in ${functions}"
}
if (level != null && dw.currentValue('level') != level) {
componentSetLevel(dw, level, duration)
}
}
// Component command to set effect
void componentSetEffect(DeviceWrapper dw, BigDecimal index) {
Map value = ["scene_num":index]
if (txtEnable) { LOG.info "Setting ${dw} [work_mode:scene]" }
//if (txtEnable) { LOG.info "Setting ${dw} effects to ${value}" }
tuyaSendDeviceCommandsAsync(dw.getDataValue('id'),
[ 'code': 'work_mode', 'value': 'scene']
//[ 'code': 'scene_data', 'value': value ], //This Does not Work (but it should...)
)
}
// Component command to set heating setpoint
void componentSetHeatingSetpoint(DeviceWrapper dw, BigDecimal temperature) {
Map<String, Map> functions = getFunctions(dw)
String code = getFunctionCode(functions, tuyaFunctions.temperatureSet)
if (code != null) {
int value = toCelcius(temperature)
if (txtEnable) { LOG.info "Setting ${dw} heating set point to ${value}" }
tuyaSendDeviceCommandsAsync(dw.getDataValue('id'), [ 'code': code, 'value': value ])
} else {
LOG.error "Unable to determine heating setpoint function code in ${functions}"
}
}
// Component command to set humidity setpoint
void componentSetHumiditySetpoint(DeviceWrapper dw, BigDecimal humidityNeeded) {
Map<String, Map> functions = getFunctions(dw)
String code = getFunctionCode(functions, tuyaFunctions.humiditySet)
if (code != null) {
if (txtEnable) { LOG.info "Setting ${dw} humidity set point to ${humidityNeeded}" }
int setHumidity = humidityNeeded
tuyaSendDeviceCommandsAsync(dw.getDataValue('id'), [ 'code': code, 'value': setHumidity ])
}
}
// Component command to set dehumidifier speed
void componentSetHumidifierSpeed(DeviceWrapper dw, BigDecimal speedNeeded) {
Map<String, Map> functions = getFunctions(dw)
String code = getFunctionCode(functions, tuyaFunctions.humiditySpeed)
if (code != null) {
if (txtEnable) { LOG.info "Setting ${dw} dehumidifier speed to ${speedNeeded}" }
tuyaSendDeviceCommandsAsync(dw.getDataValue('id'), [ 'code': code, 'value': (int)speedNeeded ])
}
}
// Component command to set hue
void componentSetHue(DeviceWrapper dw, BigDecimal hue) {
componentSetColor(dw, [
hue: hue,
saturation: dw.currentValue('saturation'),
level: dw.currentValue('level')
])
}
// Component command to set level
/* groovylint-disable-next-line UnusedMethodParameter */
void componentSetLevel(DeviceWrapper dw, BigDecimal level, BigDecimal duration = 0) {
String colorMode = dw.currentValue('colorMode') ?: 'CT'
if (colorMode == 'CT') {
Map<String, Map> functions = getFunctions(dw)
String code = getFunctionCode(functions, tuyaFunctions.brightness)
if (code != null) {
Map bright = functions[code] ?: defaults[code]
int value = Math.ceil(remap((int)level, 0, 100, (int)bright.min, (int)bright.max))
if (txtEnable) { LOG.info "Setting ${dw} level to ${level}%" }
tuyaSendDeviceCommandsAsync(dw.getDataValue('id'), [ 'code': code, 'value': value ])
} else {
LOG.error "Unable to determine set level function code in ${functions}"
}
} else {
componentSetColor(dw, [
hue: dw.currentValue('hue'),
saturation: dw.currentValue('saturation'),
level: level
])
}
}
void componentSetNextEffect(DeviceWrapper dw) {
LOG.warn "Set next effect command not supported for ${dw}"
}
void componentSetPreviousEffect(DeviceWrapper dw) {
LOG.warn "Set previous effect command not supported for ${dw}"
}
// Component command to set position
void componentSetPosition(DeviceWrapper dw, BigDecimal position) {
Map<String, Map> functions = getFunctions(dw)
String code = getFunctionCode(functions, tuyaFunctions.percentControl)
if (code != null) {
if (txtEnable) { LOG.info "Setting ${dw} position to ${position}" }
tuyaSendDeviceCommandsAsync(dw.getDataValue('id'), [ 'code': code, 'value': (int)position ])
} else {
LOG.error "Unable to determine set position function code in ${functions}"
}
}
// Component command to set saturation
void componentSetSaturation(DeviceWrapper dw, BigDecimal saturation) {
componentSetColor(dw, [
hue: dw.currentValue('hue'),
saturation: saturation,
level: dw.currentValue('level')
])
}
// Component command to set fan speed
void componentSetSpeed(DeviceWrapper dw, String speed) {
Map<String, Map> functions = getFunctions(dw)
String fanSpeedCode = getFunctionCode(functions, tuyaFunctions.fanSpeed)
String fanSpeedPercent = getFunctionCode(functions, tuyaFunctions.percentControl)
String id = dw.getDataValue('id')
if (fanSpeedCode != null) {
if (txtEnable) { LOG.info "Setting speed to ${speed}" }
switch (speed) {
case 'on':
tuyaSendDeviceCommandsAsync(id, [ 'code': 'switch', 'value': true ])
break
case 'off':
tuyaSendDeviceCommandsAsync(id, [ 'code': 'switch', 'value': false ])
break
case 'auto':
LOG.warn 'Speed level auto is not supported'
break
default:
Map speedFunc = functions[fanSpeedCode] ?: defaults[fanSpeedCode]
int speedVal = ['low', 'medium-low', 'medium', 'medium-high', 'high'].indexOf(speed)
String value
switch (speedFunc.type) {
case 'Enum':
value = speedFunc.range[remap(speedVal, 0, 4, 0, speedFunc.range.size() - 1)]
break
case 'Integer':
value = remap(speedVal, 0, 4, (int)speedFunc.min, (int)speedFunc.max)
break
default:
LOG.warn "Unknown fan speed function type ${speedFunc}"
return
}
tuyaSendDeviceCommandsAsync(id, [ 'code': fanSpeedCode, 'value': value ])
break
}
} else if (fanSpeedPercent) {
Map speedFunc = functions[fanSpeedPercent] ?: defaults[fanSpeedPercent]
int speedVal = ['low', 'medium-low', 'medium', 'medium-high', 'high'].indexOf(speed)
int value = remap(speedVal, 0, 4, (int)speedFunc.min, (int)speedFunc.max)
tuyaSendDeviceCommandsAsync(id, [ 'code': fanSpeedPercent, 'value': value ])
} else {
LOG.error "Unable to determine set speed function code in ${functions}"
}
}
// Component command to start level change (up or down)
void componentStartLevelChange(DeviceWrapper dw, String direction) {
levelChanges[dw.deviceNetworkId] = (direction == 'down') ? -10 : 10
if (txtEnable) { LOG.info "Starting level change ${direction} for ${dw}" }
runInMillis(1000, 'doLevelChange')
}
// Component command to stop level change
void componentStopLevelChange(DeviceWrapper dw) {
if (txtEnable) { LOG.info "Stopping level change for ${dw}" }
levelChanges.remove(dw.deviceNetworkId)
}
// Component command to set position direction
void componentStartPositionChange(DeviceWrapper dw, String direction) {
switch (direction) {
case 'open': componentOpen(dw); break
case 'close': componentClose(dw); break
default:
LOG.warn "Unknown position change direction ${direction} for ${dw}"
break
}
}
// Component command to stop position change
void componentStopPositionChange(DeviceWrapper dw) {
Map<String, Map> functions = getFunctions(dw)
String code = getFunctionCode(functions, tuyaFunctions.control)
if (code != null) {
if (txtEnable) { LOG.info "Stopping ${dw}" }
tuyaSendDeviceCommandsAsync(dw.getDataValue('id'), [ 'code': code, 'value': 'stop' ])
} else {
LOG.error "Unable to determine stop position change function code in ${functions}"
}
}
// Component command to unlock device
void componentUnlock(DeviceWrapper dw) {
LOG.warn "componentUnlock not yet supported for ${dw}"
}
// Utility function to handle multiple level changes
void doLevelChange() {
List active = levelChanges.collect() // copy list locally
active.each { kv ->
ChildDeviceWrapper dw = getChildDevice(kv.key)
if (dw != null) {
int newLevel = (int)dw.currentValue('level') + kv.value
if (newLevel < 0) { newLevel = 0 }
if (newLevel > 100) { newLevel = 100 }
componentSetLevel(dw, newLevel)
if (newLevel <= 0 && newLevel >= 100) {
componentStopLevelChange(device)
}
} else {
levelChanges.remove(kv.key)
}
}
if (!levelChanges.isEmpty()) {
runInMillis(1000, 'doLevelChange')
}
}
// Called when the device is started
void initialize() {
String version = '0.3.0'
LOG.info "Driver v${version} initializing"
state.clear()
unschedule()
state.with {
tokenInfo = [ access_token: '', expire: now() ] // initialize token
uuid = state?.uuid ?: UUID.randomUUID().toString()
driver_version = version
lang = 'en'
}
sendEvent([ name: 'deviceCount', value: 0 ])
Map datacenter = tuyaCountries.find { c -> c.country == settings.appCountry }
if (datacenter != null) {
LOG.info "Setting ${settings.appCountry} datacenter ${datacenter}"
state.endPoint = datacenter.endpoint
state.countryCode = datacenter.countryCode
} else {
LOG.error 'Country not set in configuration, please update settings'
sendEvent([ name: 'state', value: 'error', descriptionText: 'Country not set in configuration'])
return
}
tuyaAuthenticateAsync()
}
// Called when the device is first created
void installed() {
LOG.info 'Driver installed'
}
// Called when the device is removed
void uninstalled() {
LOG.info 'Driver uninstalled'
}
// Called when the settings are updated
void updated() {
LOG.info 'Driver configuration updated'
LOG.debug settings
if (settings.logEnable == true) { runIn(1800, 'logsOff') }
initialize()
}
// Called to parse received MQTT data
void parse(String data) {
Map payload = jsonParser.parseText(interfaces.mqtt.parseMessage(data).payload)
try {
Cipher cipher = tuyaGetCipher(Cipher.DECRYPT_MODE)
Map result = jsonParser.parse(cipher.doFinal(payload.data.decodeBase64()), 'UTF-8')
if (result.status != null && (result.id != null || result.devId != null)) {
updateMultiDeviceStatus(result)
} else if (result.bizCode != null && result.bizData != null) {
parseBizData(result.bizCode, result.bizData)
} else {
LOG.warn "Unsupported mqtt packet: ${result}"
}
} catch (javax.crypto.BadPaddingException e) {
LOG.warn "Decryption error: ${e}"
sendEvent([ name: 'state', value: 'error', descriptionText: e.message])
runIn(15 + (3 * random.nextInt(3)), initialize)
}
}
// Called to parse MQTT client status changes
void mqttClientStatus(String status) {
switch (status) {
case 'Status: Connection succeeded':
sendEvent([ name: 'state', value: 'connected', descriptionText: 'Connected to Tuya MQTT hub'])
runIn(1, 'tuyaHubSubscribeAsync')
break
default:
LOG.error 'MQTT connection error: ' + status
sendEvent([ name: 'state', value: 'disconnected', descriptionText: 'Disconnected from Tuya MQTT hub'])
runIn(15 + (3 * random.nextInt(3)), initialize)
break
}
}
// Command to refresh all devices
void refresh() {
LOG.info 'Refreshing devices and scenes'
tuyaGetDevicesAsync()
tuyaGetHomesAsync()
}
// Command to remove all the child devices
void removeDevices() {
LOG.info 'Removing all child devices'
childDevices.each { device -> deleteChildDevice(device.deviceNetworkId) }
}
/**
* Tuya Standard Instruction Set Category Mapping to Hubitat Drivers
* https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq
*/
private static Map mapTuyaCategory(Map d) {
Map switches = [
'switch': [ suffix: 'Switch', driver: 'Generic Component Switch' ],
'switch_1': [ suffix: 'Socket 1', driver: 'Generic Component Switch' ],
'switch_2': [ suffix: 'Socket 2', driver: 'Generic Component Switch' ],
'switch_3': [ suffix: 'Socket 3', driver: 'Generic Component Switch' ],
'switch_4': [ suffix: 'Socket 4', driver: 'Generic Component Switch' ],
'switch_5': [ suffix: 'Socket 5', driver: 'Generic Component Switch' ],
'switch_6': [ suffix: 'Socket 6', driver: 'Generic Component Switch' ],
'switch_usb1': [ suffix: 'USB 1', driver: 'Generic Component Switch' ],
'switch_usb2': [ suffix: 'USB 2', driver: 'Generic Component Switch' ],
'switch_usb3': [ suffix: 'USB 3', driver: 'Generic Component Switch' ],
'switch_usb4': [ suffix: 'USB 4', driver: 'Generic Component Switch' ],
'switch_usb5': [ suffix: 'USB 5', driver: 'Generic Component Switch' ],
'switch_usb6': [ suffix: 'USB 6', driver: 'Generic Component Switch' ]
]
switch (d.category) {
// Lighting
case 'dc': // String Lights
case 'dd': // Strip Lights
case 'dj': // Light
case 'tgq': // Dimmer Light
case 'tyndj': // Solar Light
case 'qjdcz': // Night Light
case 'xdd': // Ceiling Light
case 'ykq': // Remote Control
if (getFunctionCode(d.statusSet, tuyaFunctions.colour)) {
return [ driver: 'Generic Component RGBW', devices: switches ]
} else if (getFunctionCode(d.statusSet, tuyaFunctions.ct)) {
return [ driver: 'Generic Component CT', devices: switches ]
} else if (getFunctionCode(d.statusSet, tuyaFunctions.brightness)) {
return [ driver: 'Generic Component Dimmer', devices: switches ]
}
break
case 'fsd': // Ceiling Fan (with Light)
return [
driver: 'Generic Component Fan Control',
devices: [
'light': [ suffix: 'Light', driver: 'Generic Component Switch' ]
]
]
// Electrical
case 'tgkg': // Dimmer Switch
return [ driver: 'Generic Component Dimmer' ]
case 'wxkg': // Scene Switch (TS004F in 'Device trigger' mode only; TS0044)
return [ driver: 'Generic Component Central Scene Switch' ]
case 'cl': // Curtain Motor (uses custom driver)
case 'clkg':
return [ namespace: 'component', driver: 'Generic Component Window Shade' ]
case 'cwwsq': // Pet Feeder (https://developer.tuya.com/en/docs/iot/f?id=K9gf468bl11rj)
return [ driver: 'Generic Component Button Controller' ]
case 'cz': // Socket (https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s)
case 'kg': // Switch
case 'pc': // Power Strip (https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s)
if (getFunctionCode(d.statusSet, tuyaFunctions.colour)) {
return [ driver: 'Generic Component RGBW', devices: switches ]
} else if (getFunctionCode(d.statusSet, tuyaFunctions.brightness)) {
return [ driver: 'Generic Component Dimmer', devices: switches ]
}
return [ devices: switches ]
// Security & Sensors
case 'ms': // Lock
return [ driver: 'Generic Component Lock' ]
case 'ldcg': // Brightness, temperature, humidity, CO2 sensors
case 'wsdcg':
case 'zd': // Vibration sensor as motion
return [ driver: 'Generic Component Omni Sensor' ]
case 'mcs': // Contact Sensor
return [ driver: 'Generic Component Contact Sensor' ]
case 'sj': // Water Sensor
return [ driver: 'Generic Component Water Sensor' ]
case 'ywbj': // Smoke Detector
return [ driver: 'Generic Component Smoke Detector' ]
case 'cobj': // CO Detector
return [ driver: 'Generic Component Carbon Monoxide Detector' ]
case 'co2bj': // CO2 Sensor
return [ driver: 'Generic Component Carbon Dioxide Detector' ]
case 'pir': // Motion Sensor
return [ driver: 'Generic Component Motion Sensor' ]
// Large Home Appliances
// Small Home Appliances
case 'qn': // Heater
return [ namespace: 'component', driver: 'Generic Component Heating Device' ]
case 'cs': // DeHumidifer
return [ namespace: 'component', driver: 'Generic Component Dehumidifier' ]
case 'fs': // Fan
return [ driver: 'Generic Component Fan Control' ]
// Kitchen Appliances
}
return [ driver: 'Generic Component Switch' ]
}
private static Map<String, Map> getFunctions(DeviceWrapper dw) {
return jsonCache.computeIfAbsent(dw.getDataValue('functions') ?: '{}') {
k -> jsonParser.parseText(k)
}
}
private static Map getFunction(Map functions, List codes) {
return functions.find { f -> f.key in codes }?.value
}
private static String getFunctionCode(Map functions, List codes) {
return codes.find { c -> functions.containsKey(c) }
}
private static Map<String, Map> getStatusSet(DeviceWrapper dw) {
return jsonCache.computeIfAbsent(dw.getDataValue('statusSet') ?: '{}') {
k -> jsonParser.parseText(k)
}
}
private static BigDecimal remap(BigDecimal oldValue, BigDecimal oldMin, BigDecimal oldMax,
BigDecimal newMin, BigDecimal newMax) {
BigDecimal value = oldValue
if (value < oldMin) { value = oldMin }
if (value > oldMax) { value = oldMax }
BigDecimal newValue = ((value - oldMin) / (oldMax - oldMin)) * (newMax - newMin) + newMin
return newValue.setScale(1, BigDecimal.ROUND_HALF_UP)
}
private static BigDecimal scale(BigDecimal value, Integer scale) {
return value / Math.pow(10, scale ?: 0)
}
private static String translateColorName(Integer hue, Integer saturation) {
if (saturation < 1) {
return 'White'
}
switch (hue * 3.6 as int) {
case 0..15: return 'Red'
case 16..45: return 'Orange'
case 46..75: return 'Yellow'
case 76..105: return 'Chartreuse'
case 106..135: return 'Green'
case 136..165: return 'Spring'
case 166..195: return 'Cyan'
case 196..225: return 'Azure'
case 226..255: return 'Blue'
case 256..285: return 'Violet'
case 286..315: return 'Magenta'
case 316..345: return 'Rose'
case 346..360: return 'Red'
}
return ''
}
// define country map
private static Map country(String country, String countryCode, String endpoint = 'https://openapi.tuyaus.com') {
return [ country: country, countryCode: countryCode, endpoint: endpoint ]
}
@Field static final List<Map> tuyaCountries = [
country('Afghanistan', '93', 'https://openapi.tuyaeu.com'),
country('Albania', '355', 'https://openapi.tuyaeu.com'),
country('Algeria', '213', 'https://openapi.tuyaeu.com'),
country('American Samoa', '1-684', 'https://openapi.tuyaeu.com'),
country('Andorra', '376', 'https://openapi.tuyaeu.com'),
country('Angola', '244', 'https://openapi.tuyaeu.com'),
country('Anguilla', '1-264', 'https://openapi.tuyaeu.com'),
country('Antarctica', '672', 'https://openapi.tuyaus.com'),
country('Antigua and Barbuda', '1-268', 'https://openapi.tuyaeu.com'),
country('Argentina', '54', 'https://openapi.tuyaus.com'),
country('Armenia', '374', 'https://openapi.tuyaeu.com'),
country('Aruba', '297', 'https://openapi.tuyaeu.com'),
country('Australia', '61', 'https://openapi.tuyaeu.com'),
country('Austria', '43', 'https://openapi.tuyaeu.com'),
country('Azerbaijan', '994', 'https://openapi.tuyaeu.com'),
country('Bahamas', '1-242', 'https://openapi.tuyaeu.com'),
country('Bahrain', '973', 'https://openapi.tuyaeu.com'),
country('Bangladesh', '880', 'https://openapi.tuyaeu.com'),
country('Barbados', '1-246', 'https://openapi.tuyaeu.com'),
country('Belarus', '375', 'https://openapi.tuyaeu.com'),
country('Belgium', '32', 'https://openapi.tuyaeu.com'),
country('Belize', '501', 'https://openapi.tuyaeu.com'),
country('Benin', '229', 'https://openapi.tuyaeu.com'),
country('Bermuda', '1-441', 'https://openapi.tuyaeu.com'),
country('Bhutan', '975', 'https://openapi.tuyaeu.com'),
country('Bolivia', '591', 'https://openapi.tuyaus.com'),
country('Bosnia and Herzegovina', '387', 'https://openapi.tuyaeu.com'),
country('Botswana', '267', 'https://openapi.tuyaeu.com'),
country('Brazil', '55', 'https://openapi.tuyaus.com'),
country('British Indian Ocean Territory', '246', 'https://openapi.tuyaus.com'),
country('British Virgin Islands', '1-284', 'https://openapi.tuyaeu.com'),
country('Brunei', '673', 'https://openapi.tuyaeu.com'),
country('Bulgaria', '359', 'https://openapi.tuyaeu.com'),
country('Burkina Faso', '226', 'https://openapi.tuyaeu.com'),
country('Burundi', '257', 'https://openapi.tuyaeu.com'),
country('Cambodia', '855', 'https://openapi.tuyaeu.com'),
country('Cameroon', '237', 'https://openapi.tuyaeu.com'),
country('Canada', '1', 'https://openapi.tuyaus.com'),
country('Capo Verde', '238', 'https://openapi.tuyaeu.com'),
country('Cayman Islands', '1-345', 'https://openapi.tuyaeu.com'),
country('Central African Republic', '236', 'https://openapi.tuyaeu.com'),
country('Chad', '235', 'https://openapi.tuyaeu.com'),
country('Chile', '56', 'https://openapi.tuyaus.com'),
country('China', '86', 'https://openapi.tuyacn.com'),
country('Christmas Island', '61'),
country('Cocos Islands', '61'),
country('Colombia', '57', 'https://openapi.tuyaus.com'),
country('Comoros', '269', 'https://openapi.tuyaeu.com'),
country('Cook Islands', '682', 'https://openapi.tuyaus.com'),
country('Costa Rica', '506', 'https://openapi.tuyaeu.com'),
country('Croatia', '385', 'https://openapi.tuyaeu.com'),
country('Cuba', '53'),
country('Curacao', '599', 'https://openapi.tuyaus.com'),
country('Cyprus', '357', 'https://openapi.tuyaeu.com'),
country('Czech Republic', '420', 'https://openapi.tuyaeu.com'),
country('Democratic Republic of the Congo', '243', 'https://openapi.tuyaeu.com'),
country('Denmark', '45', 'https://openapi.tuyaeu.com'),
country('Djibouti', '253', 'https://openapi.tuyaeu.com'),
country('Dominica', '1-767', 'https://openapi.tuyaeu.com'),
country('Dominican Republic', '1-809', 'https://openapi.tuyaus.com'),
country('East Timor', '670', 'https://openapi.tuyaus.com'),
country('Ecuador', '593', 'https://openapi.tuyaus.com'),
country('Egypt', '20', 'https://openapi.tuyaeu.com'),
country('El Salvador', '503', 'https://openapi.tuyaeu.com'),
country('Equatorial Guinea', '240', 'https://openapi.tuyaeu.com'),
country('Eritrea', '291', 'https://openapi.tuyaeu.com'),
country('Estonia', '372', 'https://openapi.tuyaeu.com'),
country('Ethiopia', '251', 'https://openapi.tuyaeu.com'),
country('Falkland Islands', '500', 'https://openapi.tuyaus.com'),
country('Faroe Islands', '298', 'https://openapi.tuyaeu.com'),
country('Fiji', '679', 'https://openapi.tuyaeu.com'),
country('Finland', '358', 'https://openapi.tuyaeu.com'),
country('France', '33', 'https://openapi.tuyaeu.com'),
country('French Polynesia', '689', 'https://openapi.tuyaeu.com'),
country('Gabon', '241', 'https://openapi.tuyaeu.com'),
country('Gambia', '220', 'https://openapi.tuyaeu.com'),
country('Georgia', '995', 'https://openapi.tuyaeu.com'),
country('Germany', '49', 'https://openapi.tuyaeu.com'),
country('Ghana', '233', 'https://openapi.tuyaeu.com'),
country('Gibraltar', '350', 'https://openapi.tuyaeu.com'),
country('Greece', '30', 'https://openapi.tuyaeu.com'),
country('Greenland', '299', 'https://openapi.tuyaeu.com'),
country('Grenada', '1-473', 'https://openapi.tuyaeu.com'),
country('Guam', '1-671', 'https://openapi.tuyaeu.com'),
country('Guatemala', '502', 'https://openapi.tuyaus.com'),
country('Guernsey', '44-1481'),
country('Guinea', '224'),
country('Guinea-Bissau', '245', 'https://openapi.tuyaus.com'),
country('Guyana', '592', 'https://openapi.tuyaeu.com'),
country('Haiti', '509', 'https://openapi.tuyaeu.com'),
country('Honduras', '504', 'https://openapi.tuyaeu.com'),
country('Hong Kong', '852', 'https://openapi.tuyaus.com'),
country('Hungary', '36', 'https://openapi.tuyaeu.com'),
country('Iceland', '354', 'https://openapi.tuyaeu.com'),
country('India', '91', 'https://openapi.tuyain.com'),
country('Indonesia', '62', 'https://openapi.tuyaus.com'),
country('Iran', '98'),
country('Iraq', '964', 'https://openapi.tuyaeu.com'),
country('Ireland', '353', 'https://openapi.tuyaeu.com'),
country('Isle of Man', '44-1624'),
country('Israel', '972', 'https://openapi.tuyaeu.com'),
country('Italy', '39', 'https://openapi.tuyaeu.com'),
country('Ivory Coast', '225', 'https://openapi.tuyaeu.com'),
country('Jamaica', '1-876', 'https://openapi.tuyaeu.com'),
country('Japan', '81', 'https://openapi.tuyaus.com'),
country('Jersey', '44-1534'),
country('Jordan', '962', 'https://openapi.tuyaeu.com'),
country('Kazakhstan', '7', 'https://openapi.tuyaeu.com'),
country('Kenya', '254', 'https://openapi.tuyaeu.com'),
country('Kiribati', '686', 'https://openapi.tuyaus.com'),
country('Kosovo', '383'),
country('Kuwait', '965', 'https://openapi.tuyaeu.com'),
country('Kyrgyzstan', '996', 'https://openapi.tuyaeu.com'),
country('Laos', '856', 'https://openapi.tuyaeu.com'),
country('Latvia', '371', 'https://openapi.tuyaeu.com'),
country('Lebanon', '961', 'https://openapi.tuyaeu.com'),
country('Lesotho', '266', 'https://openapi.tuyaeu.com'),
country('Liberia', '231', 'https://openapi.tuyaeu.com'),
country('Libya', '218', 'https://openapi.tuyaeu.com'),
country('Liechtenstein', '423', 'https://openapi.tuyaeu.com'),
country('Lithuania', '370', 'https://openapi.tuyaeu.com'),
country('Luxembourg', '352', 'https://openapi.tuyaeu.com'),
country('Macao', '853', 'https://openapi.tuyaus.com'),
country('Macedonia', '389', 'https://openapi.tuyaeu.com'),
country('Madagascar', '261', 'https://openapi.tuyaeu.com'),
country('Malawi', '265', 'https://openapi.tuyaeu.com'),
country('Malaysia', '60', 'https://openapi.tuyaus.com'),
country('Maldives', '960', 'https://openapi.tuyaeu.com'),
country('Mali', '223', 'https://openapi.tuyaeu.com'),
country('Malta', '356', 'https://openapi.tuyaeu.com'),
country('Marshall Islands', '692', 'https://openapi.tuyaeu.com'),
country('Mauritania', '222', 'https://openapi.tuyaeu.com'),
country('Mauritius', '230', 'https://openapi.tuyaeu.com'),
country('Mayotte', '262', 'https://openapi.tuyaeu.com'),
country('Mexico', '52', 'https://openapi.tuyaus.com'),
country('Micronesia', '691', 'https://openapi.tuyaeu.com'),
country('Moldova', '373', 'https://openapi.tuyaeu.com'),
country('Monaco', '377', 'https://openapi.tuyaeu.com'),
country('Mongolia', '976', 'https://openapi.tuyaeu.com'),
country('Montenegro', '382', 'https://openapi.tuyaeu.com'),
country('Montserrat', '1-664', 'https://openapi.tuyaeu.com'),
country('Morocco', '212', 'https://openapi.tuyaeu.com'),
country('Mozambique', '258', 'https://openapi.tuyaeu.com'),
country('Myanmar', '95', 'https://openapi.tuyaus.com'),
country('Namibia', '264', 'https://openapi.tuyaeu.com'),
country('Nauru', '674', 'https://openapi.tuyaus.com'),
country('Nepal', '977', 'https://openapi.tuyaeu.com'),
country('Netherlands', '31', 'https://openapi.tuyaeu.com'),
country('Netherlands Antilles', '599'),
country('New Caledonia', '687', 'https://openapi.tuyaeu.com'),
country('New Zealand', '64', 'https://openapi.tuyaus.com'),
country('Nicaragua', '505', 'https://openapi.tuyaeu.com'),
country('Niger', '227', 'https://openapi.tuyaeu.com'),
country('Nigeria', '234', 'https://openapi.tuyaeu.com'),
country('Niue', '683', 'https://openapi.tuyaus.com'),
country('North Korea', '850'),
country('Northern Mariana Islands', '1-670', 'https://openapi.tuyaeu.com'),
country('Norway', '47', 'https://openapi.tuyaeu.com'),
country('Oman', '968', 'https://openapi.tuyaeu.com'),
country('Pakistan', '92', 'https://openapi.tuyaeu.com'),
country('Palau', '680', 'https://openapi.tuyaeu.com'),
country('Palestine', '970', 'https://openapi.tuyaus.com'),
country('Panama', '507', 'https://openapi.tuyaeu.com'),
country('Papua New Guinea', '675', 'https://openapi.tuyaus.com'),
country('Paraguay', '595', 'https://openapi.tuyaus.com'),
country('Peru', '51', 'https://openapi.tuyaus.com'),
country('Philippines', '63', 'https://openapi.tuyaus.com'),
country('Pitcairn', '64'),
country('Poland', '48', 'https://openapi.tuyaeu.com'),
country('Portugal', '351', 'https://openapi.tuyaeu.com'),
country('Puerto Rico', '1-787, 1-939', 'https://openapi.tuyaus.com'),
country('Qatar', '974', 'https://openapi.tuyaeu.com'),
country('Republic of the Congo', '242', 'https://openapi.tuyaeu.com'),
country('Reunion', '262', 'https://openapi.tuyaeu.com'),
country('Romania', '40', 'https://openapi.tuyaeu.com'),
country('Russia', '7', 'https://openapi.tuyaeu.com'),
country('Rwanda', '250', 'https://openapi.tuyaeu.com'),
country('Saint Barthelemy', '590', 'https://openapi.tuyaeu.com'),
country('Saint Helena', '290'),
country('Saint Kitts and Nevis', '1-869', 'https://openapi.tuyaeu.com'),
country('Saint Lucia', '1-758', 'https://openapi.tuyaeu.com'),
country('Saint Martin', '590', 'https://openapi.tuyaeu.com'),
country('Saint Pierre and Miquelon', '508', 'https://openapi.tuyaeu.com'),
country('Saint Vincent and the Grenadines', '1-784', 'https://openapi.tuyaeu.com'),
country('Samoa', '685', 'https://openapi.tuyaeu.com'),
country('San Marino', '378', 'https://openapi.tuyaeu.com'),
country('Sao Tome and Principe', '239', 'https://openapi.tuyaus.com'),
country('Saudi Arabia', '966', 'https://openapi.tuyaeu.com'),
country('Senegal', '221', 'https://openapi.tuyaeu.com'),
country('Serbia', '381', 'https://openapi.tuyaeu.com'),
country('Seychelles', '248', 'https://openapi.tuyaeu.com'),
country('Sierra Leone', '232', 'https://openapi.tuyaeu.com'),
country('Singapore', '65', 'https://openapi.tuyaeu.com'),
country('Sint Maarten', '1-721', 'https://openapi.tuyaus.com'),
country('Slovakia', '421', 'https://openapi.tuyaeu.com'),
country('Slovenia', '386', 'https://openapi.tuyaeu.com'),
country('Solomon Islands', '677', 'https://openapi.tuyaus.com'),
country('Somalia', '252', 'https://openapi.tuyaeu.com'),
country('South Africa', '27', 'https://openapi.tuyaeu.com'),
country('South Korea', '82', 'https://openapi.tuyaus.com'),
country('South Sudan', '211'),
country('Spain', '34', 'https://openapi.tuyaeu.com'),
country('Sri Lanka', '94', 'https://openapi.tuyaeu.com'),
country('Sudan', '249'),
country('Suriname', '597', 'https://openapi.tuyaus.com'),
country('Svalbard and Jan Mayen', '4779', 'https://openapi.tuyaus.com'),
country('Swaziland', '268', 'https://openapi.tuyaeu.com'),
country('Sweden', '46', 'https://openapi.tuyaeu.com'),
country('Switzerland', '41', 'https://openapi.tuyaeu.com'),
country('Syria', '963'),
country('Taiwan', '886', 'https://openapi.tuyaus.com'),
country('Tajikistan', '992', 'https://openapi.tuyaeu.com'),
country('Tanzania', '255', 'https://openapi.tuyaeu.com'),
country('Thailand', '66', 'https://openapi.tuyaus.com'),
country('Togo', '228', 'https://openapi.tuyaeu.com'),
country('Tokelau', '690', 'https://openapi.tuyaus.com'),
country('Tonga', '676', 'https://openapi.tuyaeu.com'),
country('Trinidad and Tobago', '1-868', 'https://openapi.tuyaeu.com'),
country('Tunisia', '216', 'https://openapi.tuyaeu.com'),
country('Turkey', '90', 'https://openapi.tuyaeu.com'),
country('Turkmenistan', '993', 'https://openapi.tuyaeu.com'),
country('Turks and Caicos Islands', '1-649', 'https://openapi.tuyaeu.com'),
country('Tuvalu', '688', 'https://openapi.tuyaeu.com'),
country('U.S. Virgin Islands', '1-340', 'https://openapi.tuyaeu.com'),
country('Uganda', '256', 'https://openapi.tuyaeu.com'),
country('Ukraine', '380', 'https://openapi.tuyaeu.com'),
country('United Arab Emirates', '971', 'https://openapi.tuyaeu.com'),
country('United Kingdom', '44', 'https://openapi.tuyaeu.com'),
country('United States', '1', 'https://openapi.tuyaus.com'),
country('Uruguay', '598', 'https://openapi.tuyaus.com'),
country('Uzbekistan', '998', 'https://openapi.tuyaeu.com'),
country('Vanuatu', '678', 'https://openapi.tuyaus.com'),
country('Vatican', '379', 'https://openapi.tuyaeu.com'),
country('Venezuela', '58', 'https://openapi.tuyaus.com'),
country('Vietnam', '84', 'https://openapi.tuyaus.com'),
country('Wallis and Futuna', '681', 'https://openapi.tuyaeu.com'),
country('Western Sahara', '212', 'https://openapi.tuyaeu.com'),
country('Yemen', '967', 'https://openapi.tuyaeu.com'),
country('Zambia', '260', 'https://openapi.tuyaeu.com'),
country('Zimbabwe', '263', 'https://openapi.tuyaeu.com')
]
/**
* Driver Capabilities Implementation
*/
private boolean createChildDevices(Map d) {
Map mapping = mapTuyaCategory(d)
LOG.debug "Tuya category ${d.category} driver ${mapping}"
if (mapping.driver != null) {
createChildDevice("${device.id}-${d.id}", mapping, d)
}
if (mapping.devices == null) { return false }
// Tuya Device to Multiple Hubitat Devices
String baseName = d.name
Map baseFunctions = d.functions
Map baseStatusSet = d.statusSet
Map subdevices = mapping.devices.findAll { entry -> entry.key in baseFunctions.keySet() }
subdevices.each { code, submap ->
d.name = "${baseName} ${submap.suffix ?: code}"
d.functions = [ (code): baseFunctions[(code)] ]
d.statusSet = [ (code): baseStatusSet[(code)] ]
createChildDevice("${device.id}-${d.id}-${code}", [
namespace: submap.namespace ?: mapping.namespace,
driver: submap.driver ?: mapping.driver
], d)
}
return true
}
private ChildDeviceWrapper createChildDevice(String dni, Map mapping, Map d) {
ChildDeviceWrapper dw = getChildDevice(dni)
if (dw == null) {
LOG.info "Creating device ${d.name} using ${mapping.driver} driver"
try {
dw = addChildDevice(mapping.namespace ?: 'hubitat', mapping.driver, dni,
[
name: d.product_name,
label: d.name,
]
)
} catch (UnknownDeviceTypeException e) {
if (mapping.namespace == 'component') {
LOG.error "${d.name} driver not found, try downloading from " +
"https://raw.githubusercontent.com/bradsjm/hubitat-drivers/main/Component/${mapping.driver}"
} else {
LOG.exception("${d.name} device creation failed", e)
}
}
}
String functionJson = JsonOutput.toJson(d.functions)
jsonCache.put(functionJson, d.functions)
dw?.with {
label = label ?: d.name
updateDataValue 'id', d.id
updateDataValue 'local_key', d.local_key
updateDataValue 'product_id', d.product_id
updateDataValue 'category', d.category
updateDataValue 'functions', functionJson
updateDataValue 'statusSet', JsonOutput.toJson(d.statusSet)
updateDataValue 'online', d.online as String
}
return dw
}
private ChildDeviceWrapper createSceneDevice(Integer homeId, Map scene) {
String dni = "${device.id}-${scene.scene_id}"
ChildDeviceWrapper dw = getChildDevice(dni)
if (dw == null) {
LOG.info "Creating scene device ${scene.name}"
try {
String name = scene.name.replace('\"', '')
dw = addChildDevice('hubitat', 'Generic Component Switch', dni,
[
name: name,
label: name
]
)
} catch (UnknownDeviceTypeException e) {
LOG.exception("${driver.name} device creation failed", e)
}
}
dw?.with {
updateDataValue 'homeId', homeId as String
updateDataValue 'sceneId', scene.scene_id
}
return dw
}
/* groovylint-disable-next-line UnusedPrivateMethod */
private void logsOff() {
device.updateSetting('logEnable', [value: 'false', type: 'bool'])
LOG.info 'Debug logging disabled'
}
private void parseBizData(String bizCode, Map bizData) {
LOG.debug "${bizCode} ${bizData}"
switch (bizCode) {
case 'nameUpdate':
case 'online':
case 'offline':
case 'bindUser':
refresh()
break
}
}
private void updateMultiDeviceStatus(Map d) {
String base = "${device.id}-${d.id ?: d.devId}"
Map<String, ChildDeviceWrapper> children = getChildDevices().collectEntries { child -> [ (child.deviceNetworkId): child] }
Map groups = d.status.groupBy { s -> children["${base}-${s.code}"] ?: children[base] }
LOG.debug "Groups: ${groups}"
groups.each { dw, states -> dw.parse(createEvents(dw, states)) }
}
/* ---------------------------------------------------
* Convert Tuya device values to Hubitat device events
*/
/* groovylint-disable-next-line MethodSize */
private List<Map> createEvents(DeviceWrapper dw, List<Map> statusList) {
String workMode = ''
Map<String, Map> deviceStatusSet = getStatusSet(dw) ?: getFunctions(dw)
statusList.each { status ->
if (status.code in tuyaFunctions.workMode) {
workMode = status.value
}
}
LOG.debug "${dw} workMode ${workMode}"
return statusList.collectMany { status ->
LOG.debug "${dw} status ${status}"
if (status.code in tuyaFunctions.battery) {
if (status.code == 'battery_percentage' || status.code == 'va_battery') {
if (txtEnable) { LOG.info "${dw} battery is ${status.value}%" }
return [ [ name: 'battery', value: status.value, descriptionText: "battery is ${status.value}%", unit: '%' ] ]
}
}
if (status.code in tuyaFunctions.brightness && workMode != 'colour') {
Map bright = deviceStatusSet[status.code] ?: defaults[status.code]
if (bright != null) {
Integer value = Math.floor(remap((int)status.value, (int)bright.min, (int)bright.max, 0, 100))
if (txtEnable) { LOG.info "${dw} level is ${value}%" }
return [ [ name: 'level', value: value, unit: '%', descriptionText: "level is ${value}%" ] ]
}
}
if (status.code in tuyaFunctions.co) {
String value = status.value == 'alarm' ? 'detected' : 'clear'
if (txtEnable) { LOG.info "${dw} carbon monoxide is ${value}" }
return [ [ name: 'carbonMonoxide', value: value, descriptionText: "carbon monoxide is ${value}" ] ]
}
if (status.code in tuyaFunctions.co2) {
Map co2 = deviceStatusSet[status.code] ?: defaults[status.code]
int value = scale((int)status.value, (int)co2.scale)
if (txtEnable) { LOG.info "${dw} carbon dioxide level is ${value}" }
return [ [ name: 'carbonDioxide', value: value, unit: 'ppm', descriptionText: "carbon dioxide level is ${value}" ] ]
}
if (status.code in tuyaFunctions.control + tuyaFunctions.workState) {
String value
switch (status.value) {
case 'open': value = 'open'; break
case 'opening': value = 'opening'; break
case 'close': value = 'closed'; break
case 'closing': value = 'closing'; break
case 'stop': value = 'unknown'; break
}
if (value) {
if (txtEnable) { LOG.info "${dw} control is ${value}" }
return [ [ name: 'windowShade', value: value, descriptionText: "window shade is ${value}" ] ]
}
}
if (status.code in tuyaFunctions.ct) {
Map temperature = deviceStatusSet[status.code] ?: defaults[status.code]
Integer value = Math.floor(1000000 / remap((int)temperature.max - (int)status.value,
(int)temperature.min, (int)temperature.max, minMireds, maxMireds))
if (txtEnable) { LOG.info "${dw} color temperature is ${value}K" }
return [ [ name: 'colorTemperature', value: value, unit: 'K',
descriptionText: "color temperature is ${value}K" ] ]
}
if (status.code in tuyaFunctions.colour) {
Map colour = deviceStatusSet[status.code] ?: defaults[status.code]
Map bright = getFunction(deviceStatusSet, tuyaFunctions.brightness) ?: colour.v
Map value = status.value == '' ? [h: 100.0, s: 100.0, v: 100.0] :
jsonCache.computeIfAbsent(status.value) { k -> jsonParser.parseText(k) }
Integer hue = Math.floor(remap((int)value.h, (int)colour.h.min, (int)colour.h.max, 0, 100))
Integer saturation = Math.floor(remap((int)value.s, (int)colour.s.min, (int)colour.s.max, 0, 100))
Integer level = Math.floor(remap((int)value.v, (int)bright.min, (int)bright.max, 0, 100))
String colorName = translateColorName(hue, saturation)
if (txtEnable) { LOG.info "${dw} color is h:${hue} s:${saturation} (${colorName})" }
List<Map> events = [
[ name: 'hue', value: hue, descriptionText: "hue is ${hue}" ],
[ name: 'saturation', value: saturation, descriptionText: "saturation is ${saturation}" ],
[ name: 'colorName', value: colorName, descriptionText: "color name is ${colorName}" ]
]
if (workMode in ['colour', 'scene']) {
if (txtEnable) { LOG.info "${dw} level is ${level}%" }
events << [ name: 'level', value: level, unit: '%', descriptionText: "level is ${level}%" ]
}
return events
}
if (status.code in tuyaFunctions.contact) {
String value = status.value ? 'open' : 'closed'
if (txtEnable) { LOG.info "${dw} contact is ${value}" }
return [ [ name: 'contact', value: value, descriptionText: "contact is ${value}" ] ]
}
if (status.code in tuyaFunctions.fanSpeed) {
Map speed = deviceStatusSet[status.code] ?: defaults[status.code]
int value
if (statusList['switch']) {
switch (speed.type) {
case 'Enum':
value = remap(speed.range.indexOf(status.value), 0, speed.range.size() - 1, 0, 4)
break
case 'Integer':
int min = (speed.min == null) ? 1 : speed.min
int max = (speed.max == null) ? 100 : speed.max
value = remap((int)status.value, min, max, 0, 4)
break
}
String level = ['low', 'medium-low', 'medium', 'medium-high', 'high'].get(value)
if (txtEnable) { LOG.info "${dw} speed is ${level}" }
return [
[ name: 'speed', value: level, descriptionText: "speed is ${level}" ],
[ name: 'switch', value: 'on', descriptionText: 'fan is on' ]
]
}
if (txtEnable) { LOG.info "${dw} speed is off" }
return [
[ name: 'speed', value: 'off', descriptionText: 'speed is off' ],
[ name: 'switch', value: 'off', descriptionText: 'fan is off' ]
]
}
if (status.code in tuyaFunctions.light || status.code in tuyaFunctions.power) {
String value = status.value ? 'on' : 'off'
if (txtEnable) { LOG.info "${dw} switch is ${value}" }
return [ [ name: 'switch', value: value, descriptionText: "switch is ${value}" ] ]
}
if (status.code in tuyaFunctions.meteringSwitch) {
Map code = deviceStatusSet[status.code] ?: defaults[status.code]
String name
String value
String unit = ''
switch (status.code) {
case 'cur_power':
name = 'power'
value = scale(status.value, (int)code.scale)
unit = 'W'
break
case 'cur_voltage':
case 'cur_current':
case 'relay_status':
case 'light_mode':
case 'add_ele':
case 'countdown_1':
break
default:
LOG.warn "${dw} unsupported meteringSwitch status.code ${status.code}"
}
if (name != null && value != null) {
if (txtEnable) { LOG.info "${dw} ${name} is ${value} ${unit}" }
return [ [ name: name, value: value, descriptionText: "${dw} ${name} is ${value} ${unit}", unit: unit ] ]
}
}
if (status.code in tuyaFunctions.omniSensor) {
Map code = deviceStatusSet[status.code] ?: defaults[status.code]
String name
String value
String unit = ''
switch (status.code) {
case 'bright_value':
name = 'illuminance'
value = scale(status.value, (int)code.scale)
unit = 'Lux'
break
case 'humidity_value':
case 'va_humidity':
value = status.value
if (status.code == 'humidity_value') {
value = scale(status.value, (int)code.scale)
}
name = 'humidity'
unit = 'RH%'
break
case 'bright_sensitivity':
case 'sensitivity':
name = 'sensitivity'
value = scale(status.value, (int)code.scale)
unit = '%'
break
case 'shock_state': // vibration sensor TS0210
name = 'motion' // simulated motion
value = 'active' // no 'inactive' state!
unit = ''
status.code = 'inactive_state'
runIn(5, 'updateMultiDeviceStatus', [data: d])
break
case 'inactive_state': // vibration sensor
name = 'motion' // simulated motion
value = 'inactive' // simulated 'inactive' state!
unit = ''
break
default:
LOG.warn "${dw} unsupported omniSensor status.code ${status.code}"
}
if (name != null && value != null) {
if (txtEnable) { LOG.info "${dw} ${name} is ${value} ${unit}" }
return [ [ name: name, value: value, descriptionText: "${name} is ${value} ${unit}", unit: unit ] ]
}
}
if (status.code in tuyaFunctions.pir) {
String value = status.value == 'pir' ? 'active' : 'inactive'
if (txtEnable) { LOG.info "${dw} motion is ${value}" }
return [ [ name: 'motion', value: value, descriptionText: "motion is ${value}" ] ]
}
if (status.code in tuyaFunctions.percent_control) {
if (txtEnable) { LOG.info "${dw} position is ${status.value}%" }
return [ [ name: 'position', value: status.value, descriptionText: "position is ${status.value}%", unit: '%' ] ]
}
if (status.code in tuyaFunctions.sceneSwitch) {
String action
if (status.value in sceneSwitchAction) {
action = sceneSwitchAction[status.value]
} else {
LOG.warn "${dw} sceneSwitch: unknown status.value ${status.value}"
}
String value
if (status.code in sceneSwitchKeyNumbers) {
value = sceneSwitchKeyNumbers[status.code]
if (d.productKey == 'vp6clf9d' && status.code == 'switch1_value') {
value = '1' // correction for TS0044 key #1
}
} else {
LOG.warn "${dw} sceneSwitch: unknown status.code ${status.code}"
}
if (value != null && action != null) {
if (txtEnable) { LOG.info "${dw} buttons ${value} is ${action}" }
return [ [ name: action, value: value, descriptionText: "button ${value} is ${action}", isStateChange: true ] ]
}
LOG.warn "${dw} sceneSwitch: unknown name ${action} or value ${value}"
}
if (status.code in tuyaFunctions.smoke) {
String value = status.value == 'alarm' ? 'detected' : 'clear'
if (txtEnable) { LOG.info "${dw} smoke is ${value}" }
return [ [ name: 'smoke', value: value, descriptionText: "smoke is ${value}" ] ]
}
if (status.code in tuyaFunctions.temperature) {
Map set = deviceStatusSet[status.code] ?: defaults[status.code]
String value = fromCelcius(scale(status.value, (int)set.scale))
String unit = location.temperatureScale
if (txtEnable) { LOG.info "${dw} temperature is ${value}${unit} (${status})" }
return [ [ name: 'temperature', value: value, unit: unit, descriptionText: "temperature is ${value}${unit} (${status})" ] ]
}
if (status.code in tuyaFunctions.temperatureSet) {
Map set = deviceStatusSet[status.code] ?: defaults[status.code]
String value = fromCelcius(scale(status.value, (int)set.scale))
String unit = location.temperatureScale
if (txtEnable) { LOG.info "${dw} heating set point is ${value}${unit} (${status})" }
return [ [ name: 'heatingSetpoint', value: value, unit: unit, descriptionText: "heating set point is ${value}${unit} (${status})" ] ]
}
if (status.code in tuyaFunctions.water) {
String value = status.value == 'alarm' ? 'wet' : 'dry'
if (txtEnable) { LOG.info "${dw} water is ${value}" }
return [ [ name: 'water', value: value, descriptionText: "water is ${value}" ] ]
}
if (status.code in tuyaFunctions.workMode) {
switch (status.value) {
case 'white':
case 'light_white':
if (txtEnable) { LOG.info "${dw} color mode is CT" }
return [ [ name: 'colorMode', value: 'CT', descriptionText: 'color mode is CT' ] ]
case 'colour':
if (txtEnable) { LOG.info "${dw} color mode is RGB" }
return [ [ name: 'colorMode', value: 'RGB', descriptionText: 'color mode is RGB' ] ]
case 'scene':
if (txtEnable) { LOG.info "${dw} color mode is EFFECTS" }
return [ [ name: 'colorMode', value: 'EFFECTS', descriptionText: 'color mode is EFFECTS' ] ]
}
}
if (status.code in tuyaFunctions.humidity) {
Map set = deviceStatusSet[status.code] ?: defaults[status.code]
String name
String value
String unit = ''
switch (status.code) {
case 'temp_indoor':
name = 'temperature'
value = scale(status.value, (int)set.scale)
unit = set.unit
break
case 'swing':
name = 'swing'
value = status.value
unit = ''
break
case 'child_lock':
name = 'child_lock'
value = status.value
unit = ''
break
case 'fan_speed_enum':
name = 'speed'
value = status.value
unit = ''
break
case 'dehumidify_set_value':
name = 'humiditySetpoint'
value = scale(status.value, (int)set.scale)
unit = 'RH%'
break
case 'humidity_indoor':
name = 'humidity'
value = scale(status.value, (int)set.scale)
unit = 'RH%'
break
default:
LOG.warn "${dw} unsupported Dehumidifier status.code ${status.code}"
}
if (name != null && value != null) {
if (txtEnable) { LOG.info "${dw} ${name} is ${value} ${unit}" }
return [ [ name: name, value: value, descriptionText: "${name} is ${value} ${unit}", unit: unit ] ]
}
}
return []
}
}
// Convert value to celcius only if we Hubitat is using F scale
private BigDecimal toCelcius(BigDecimal temperature) {
return (location.temperatureScale == 'F' ? fahrenheitToCelsius(temperature) : temperature).setScale(1, BigDecimal.ROUND_HALF_UP)
}
// Convert value from celcius only if we Hubitat is using F scale
private BigDecimal fromCelcius(BigDecimal temperature) {
return (location.temperatureScale == 'F' ? celsiusToFahrenheit(temperature) : temperature).setScale(1, BigDecimal.ROUND_HALF_UP)
}
/**
* Tuya Open API Authentication
* https://developer.tuya.com/en/docs/cloud/
*/
private void tuyaAuthenticateAsync() {
unschedule('tuyaAuthenticateAsync')
if (settings.username && settings.password && settings.appSchema && state.countryCode) {
LOG.info "Starting Tuya cloud authentication for ${settings.username}"
MessageDigest digest = MessageDigest.getInstance('MD5')
String md5pwd = HexUtils.byteArrayToHexString(digest.digest(settings.password.bytes)).toLowerCase()
Map body = [
'country_code': state.countryCode,
'username': settings.username,
'password': md5pwd,
'schema': settings.appSchema
]
state.tokenInfo.access_token = ''
sendEvent([ name: 'state', value: 'authenticating', descriptionText: 'Authenticating to Tuya'])
tuyaPostAsync('/v1.0/iot-01/associated-users/actions/authorized-login', body, 'tuyaAuthenticateResponse')
} else {
sendEvent([ name: 'state', value: 'not configured', descriptionText: 'Driver not configured'])
LOG.error 'Driver must be configured before authentication is possible'
}
}
/* groovylint-disable-next-line UnusedPrivateMethod, UnusedPrivateMethodParameter */
private void tuyaAuthenticateResponse(AsyncResponse response, Map data) {
if (tuyaCheckResponse(response) == false) {
runIn(15 + (3 * random.nextInt(3)), initialize)
return
}
Map result = response.json.result
state.endPoint = result.platform_url
state.tokenInfo = [
access_token: result.access_token,
refresh_token: result.refresh_token,
uid: result.uid,
expire: result.expire_time * 1000 + now(),
]
LOG.info "Received Tuya access token (valid for ${result.expire_time}s)"
sendEvent([ name: 'state', value: 'authenticated', descriptionText: "Received access token ${result.access_token}" ])
// Schedule next authentication
runIn((int)(result.expire_time * 0.90), 'tuyaRefreshTokenAsync')
// Get MQTT details
tuyaGetHubConfigAsync()
// Get Home Scenes
tuyaGetHomesAsync()
}
/**
* Tuya Open API Device Management
* https://developer.tuya.com/en/docs/cloud/
*
* Attributes:
* id: Device id
* name: Device name
* local_key: Key
* category: Product category
* product_id: Product ID
* product_name: Product name
* sub: Determine whether it is a sub-device, true-> yes; false-> no
* uuid: The unique device identifier
* asset_id: asset id of the device
* online: Online status of the device
* icon: Device icon
* ip: Device external IP
* time_zone: device time zone
* active_time: The last pairing time of the device
* create_time: The first network pairing time of the device
* update_time: The update time of device status
* status: Status set of the device
*/
private Cipher tuyaGetCipher(int mode = Cipher.DECRYPT_MODE) {
return cipherCache.computeIfAbsent(device.deviceNetworkId) { k ->
Cipher cipher = Cipher.getInstance('AES/ECB/PKCS5Padding')
byte[] cipherKey = state.mqttInfo.password[8..23].bytes
cipher.init(mode, new SecretKeySpec(cipherKey, 'AES'))
return cipher
}
}
private void tuyaGetDevicesAsync(String lastRowKey = '', Map data = [:]) {
if (!jsonCache.empty) {
LOG.info 'Clearing json cache'
jsonCache.clear()
}
LOG.info 'Requesting cloud devices batch'
tuyaGetAsync('/v1.0/iot-01/associated-users/devices', [ 'last_row_key': lastRowKey ], 'tuyaGetDevicesResponse', data)
}
/* groovylint-disable-next-line UnusedPrivateMethod, UnusedPrivateMethodParameter */
private void tuyaGetDevicesResponse(AsyncResponse response, Map data) {
if (tuyaCheckResponse(response) == true) {
Map result = response.json.result
data.devices = (data.devices ?: []) + result.devices
LOG.info "Received ${result.devices.size()} cloud devices (has_more: ${result.has_more})"
if (result.has_more) {
pauseExecution(1000)
tuyaGetDevicesAsync(result.last_row_key, data)
return
}
}
sendEvent([ name: 'deviceCount', value: data.devices?.size() as String ])
data.devices.each { d ->
tuyaGetDeviceSpecificationsAsync(d.id, d)
}
}
// https://developer.tuya.com/en/docs/cloud/device-control?id=K95zu01ksols7#title-29-API%20address
private void tuyaGetDeviceSpecificationsAsync(String deviceID, Map data = [:]) {
LOG.info "Requesting cloud device specifications for ${deviceID}"
tuyaGetAsync("/v1.0/devices/${deviceID}/specifications", null, 'tuyaGetDeviceSpecificationsResponse', data)
}
/* groovylint-disable-next-line UnusedPrivateMethod, UnusedPrivateMethodParameter */
private void tuyaGetDeviceSpecificationsResponse(AsyncResponse response, Map data) {
if (tuyaCheckResponse(response) == true) {
Map result = response.json.result
data.category = result.category
if (result.functions != null) {
data.functions = result.functions.collectEntries { f ->
Map values = jsonParser.parseText(f.values ?: '{}')
values.type = f.type
return [ (f.code): values ]
}
} else {
data.functions = [:]
}
if (result.status != null) {
data.statusSet = result.status.collectEntries { f ->
Map values = jsonParser.parseText(f.values ?: '{}')
values.type = f.type
return [ (f.code): values ]
}
} else {
data.statusSet = [:]
}
//LOG.debug "Device Data: ${data}"
createChildDevices(data)
updateMultiDeviceStatus(data)
if (device.currentValue('state') != 'ready') {
sendEvent([ name: 'state', value: 'ready', descriptionText: 'Received device data from Tuya'])
}
}
}
private void tuyaGetHomesAsync() {
if (state.tokenInfo?.uid != null) {
LOG.info 'Requesting Tuya Home list'
tuyaGetAsync("/v1.0/users/${state.tokenInfo.uid}/homes", null, 'tuyaGetHomesResponse')
} else {
LOG.error "Unable to request homes (null uid token: ${state.tokenInfo})"
}
}
/* groovylint-disable-next-line UnusedPrivateMethod, UnusedPrivateMethodParameter */
private void tuyaGetHomesResponse(AsyncResponse response, Map data) {
if (tuyaCheckResponse(response) == false) { return }
List<Map> homes = response.json.result ?: []
homes.each { home ->
tuyaGetScenesAsync(home.home_id)
}
}
private void tuyaGetScenesAsync(Integer homeId) {
LOG.debug "Requesting scenes for home ${homeId}"
tuyaGetAsync("/v1.0/homes/${homeId}/scenes", null, 'tuyaGetScenesResponse', [ homeId: homeId ])
}
/* groovylint-disable-next-line UnusedPrivateMethod, UnusedPrivateMethodParameter */
private void tuyaGetScenesResponse(AsyncResponse response, Map data) {
if (tuyaCheckResponse(response) == false) {
log.warn 'Check you have the Smart Home Scene Linkage service enabled for your Tuya account'
return
}
state.scenes = state.scenes ?: [:]
List<Map> scenes = response.json.result ?: []
scenes.each { scene -> createSceneDevice(data.homeId, scene) }
}
private void tuyaGetStateAsync(String deviceID) {
LOG.debug "Requesting device ${deviceID} state"
tuyaGetAsync("/v1.0/devices/${deviceID}/status", null, 'tuyaGetStateResponse', [ id: deviceID ])
}
/* groovylint-disable-next-line UnusedPrivateMethod, UnusedPrivateMethodParameter */
private void tuyaGetStateResponse(AsyncResponse response, Map data) {
if (tuyaCheckResponse(response) == false) { return }
data.status = response.json.result
updateMultiDeviceStatus(data)
}
/* groovylint-disable-next-line UnusedPrivateMethod */
private void tuyaRefreshTokenAsync() {
unschedule('tuyaRefreshTokenAsync')
LOG.debug 'Refreshing authentication token'
tuyaGetAsync("/v1.0/token/${state.tokenInfo.refresh_token}", null, 'tuyaRefreshTokenResponse', null)
}
/* groovylint-disable-next-line UnusedPrivateMethod, UnusedPrivateMethodParameter */
private void tuyaRefreshTokenResponse(AsyncResponse response, Map data) {
if (tuyaCheckResponse(response) == false) {
tuyaAuthenticateAsync()
return
}
Map result = response.json.result
state.tokenInfo = [
access_token: result.access_token,
refresh_token: result.refresh_token,
uid: result.uid,
expire: result.expire_time * 1000 + now(),
]
LOG.info "Received Tuya access token (valid for ${result.expire_time}s)"
sendEvent([ name: 'state', value: 'authenticated', descriptionText: "Received refresh access token ${result.access_token}" ])
runIn((int)(result.expire_time * 0.90), 'tuyaRefreshTokenAsync')
tuyaGetHubConfigAsync()
}
private void tuyaTriggerScene(Integer homeId, String sceneId) {
LOG.debug "Triggering scene id ${sceneId}"
tuyaPostAsync("/v1.0/homes/${homeId}/scenes/${sceneId}/trigger", null, 'tuyaTriggerSceneResponse')
}
/* groovylint-disable-next-line UnusedPrivateMethod, UnusedPrivateMethodParameter */
private void tuyaTriggerSceneResponse(AsyncResponse response, Map data) {
tuyaCheckResponse(response)
}
private void tuyaSendDeviceCommandsAsync(String deviceID, Map...params) {
LOG.debug "Sending device ${deviceID} command ${params}"
if (!state?.tokenInfo?.access_token) {
LOG.error 'tuyaSendDeviceCommandsAsync Error - Access token is null'
sendEvent([ name: 'state', value: 'error', descriptionText: 'Access token not set (failed login?)'])
runIn(15 + (3 * random.nextInt(3)), initialize)
return
}
tuyaPostAsync("/v1.0/devices/${deviceID}/commands", [ 'commands': params ], 'tuyaSendDeviceCommandsResponse')
}
/* groovylint-disable-next-line UnusedPrivateMethod, UnusedPrivateMethodParameter */
private void tuyaSendDeviceCommandsResponse(AsyncResponse response, Map data) {
if (tuyaCheckResponse(response) == true) { return }
sendEvent([ name: 'state', value: 'error', descriptionText: 'Error sending device command'])
runIn(15 + (3 * random.nextInt(3)), initialize)
}
/**
* Tuya Open API MQTT Hub
* https://developer.tuya.com/en/docs/cloud/
*/
private void tuyaGetHubConfigAsync() {
LOG.info 'Requesting Tuya MQTT configuration'
Map body = [
'uid': state.tokenInfo.uid,
'link_id': state.uuid,
'link_type': 'mqtt',
'topics': 'device',
'msg_encrypted_version': '1.0'
]
tuyaPostAsync('/v1.0/iot-03/open-hub/access-config', body, 'tuyaGetHubConfigResponse')
}
/* groovylint-disable-next-line UnusedPrivateMethod, UnusedPrivateMethodParameter */
private void tuyaGetHubConfigResponse(AsyncResponse response, Map data) {
if (tuyaCheckResponse(response) == false) { return }
Map result = response.json.result
if (result.url) {
state.mqttInfo = result
tuyaHubConnectAsync()
} else {
LOG.warn "Hub response did not contain mqtt details: ${result}"
}
}
private void tuyaHubConnectAsync() {
LOG.info "Connecting to Tuya MQTT hub at ${state.mqttInfo.url}"
try {
interfaces.mqtt.connect(
state.mqttInfo.url,
state.mqttInfo.client_id,
state.mqttInfo.username,
state.mqttInfo.password)
} catch (e) {
LOG.exception 'MQTT connection error', e
sendEvent([ name: 'state', value: 'error', descriptionText: e.message])
runIn(15 + (3 * random.nextInt(3)), initialize)
}
}
/* groovylint-disable-next-line UnusedPrivateMethod */
private void tuyaHubSubscribeAsync() {
state.mqttInfo.source_topic.each { t ->
LOG.info "Subscribing to Tuya MQTT hub ${t.key} topic"
interfaces.mqtt.subscribe(t.value)
}
tuyaGetDevicesAsync()
}
/**
* Tuya Open API HTTP REST Implementation
* https://developer.tuya.com/en/docs/cloud/
*/
private void tuyaGetAsync(String path, Map query, String callback, Map data = [:]) {
tuyaRequestAsync('get', path, callback, query, null, data)
}
private void tuyaPostAsync(String path, Map body, String callback, Map data = [:]) {
tuyaRequestAsync('post', path, callback, null, body ?: [:], data)
}
/* groovylint-disable-next-line ParameterCount */
private void tuyaRequestAsync(String method, String path, String callback, Map query, Map body, Map data) {
String accessToken = state?.tokenInfo?.access_token ?: ''
String stringToSign = tuyaGetStringToSign(method, path, query, body)
if (path.startsWith('/v1.0/token/')) { accessToken = '' }
long now = now()
Map headers = [
't': now,
'nonce': state.uuid,
'client_id': access_id,
'Signature-Headers': 'client_id',
'sign': tuyaCalculateSignature(accessToken, now, stringToSign),
'sign_method': 'HMAC-SHA256',
'access_token': accessToken,
'lang': state.lang, // use zh for china
'dev_lang': 'groovy',
'dev_channel': 'hubitat',
'devVersion': state.driver_version
]
Map request = [
uri: state.endPoint,
path: path,
query: query,
contentType: 'application/json',
headers: headers,
body: JsonOutput.toJson(body),
timeout: 5
]
LOG.debug("API ${method.toUpperCase()} ${request}")
switch (method) {
case 'get': asynchttpGet(callback, request, data); break
case 'post': asynchttpPost(callback, request, data); break
}
}
private boolean tuyaCheckResponse(AsyncResponse response) {
if (response.hasError()) {
LOG.error "Cloud request error ${response.getErrorMessage()}"
sendEvent([ name: 'state', value: 'error', descriptionText: response.getErrorMessage() ])
return false
}
if (response.status != 200) {
LOG.error "Cloud request returned HTTP status ${response.status}"
sendEvent([ name: 'state', value: 'error', descriptionText: "Cloud HTTP response ${response.status}" ])
return false
}
if (response.json?.success == true) { return true }
LOG.error "Cloud API request failed: ${response.data}"
sendEvent([ name: 'state', value: 'error', descriptionText: "${response.json?.msg ?: response.data}" ])
switch (response.json?.code) {
case 1002: // token is null
case 1010: // token is expired
case 1011: // token invalid
case 1012: // token status invalid
case 1400: // token invalid
tuyaAuthenticateAsync()
break
}
return false
}
private String tuyaCalculateSignature(String accessToken, long timestamp, String stringToSign) {
String message = access_id + accessToken + timestamp.toString() + state.uuid + stringToSign
Mac sha256HMAC = Mac.getInstance('HmacSHA256')
sha256HMAC.init(new SecretKeySpec(access_key.bytes, 'HmacSHA256'))
return HexUtils.byteArrayToHexString(sha256HMAC.doFinal(message.bytes))
}
private String tuyaGetStringToSign(String method, String path, Map query, Map body) {
String url = query ? path + '?' + query.sort().collect { key, value -> "${key}=${value}" }.join('&') : path
String headers = 'client_id:' + access_id + '\n'
String bodyStream = (body == null) ? '' : JsonOutput.toJson(body)
MessageDigest sha256 = MessageDigest.getInstance('SHA-256')
String contentSHA256 = HexUtils.byteArrayToHexString(sha256.digest(bodyStream.bytes)).toLowerCase()
return method.toUpperCase() + '\n' + contentSHA256 + '\n' + headers + '\n' + url
}
@Field private final Map LOG = [
debug: { s -> if (settings.logEnable == true) { log.debug(s) } },
info: { s -> log.info(s) },
warn: { s -> log.warn(s) },
error: { s -> log.error(s) },
exception: { message, exception ->
List<StackTraceElement> relevantEntries = exception.stackTrace.findAll { entry -> entry.className.startsWith('user_app') }
Integer line = relevantEntries[0]?.lineNumber
String method = relevantEntries[0]?.methodName
log.error("${message}: ${exception} at line ${line} (${method})")
if (settings.logEnable) {
log.debug("App exception stack trace:\n${relevantEntries.join('\n')}")
}
}
].asImmutable()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment