/** * Date: 2021-03-31 */ import hubitat.helper.HexUtils metadata { definition (name: "Zigbee - Tuya TRV", namespace: "Mark-C-uk", author: "MarkC") { capability "Configuration" capability "TemperatureMeasurement" capability "Thermostat" capability "ThermostatHeatingSetpoint" capability "ThermostatCoolingSetpoint" capability "ThermostatSetpoint" capability "Refresh" capability "Battery" attribute "valve", "String" attribute "WindowOpenDetection","String" attribute "autolock","String" attribute "childLock","String" fingerprint endpointId: "01", profileId: "0104", inClusters: "0000,0004,0005,EF00", outClusters: "0019,000A", manufacturer: "_TZE200_ckud7u2l", model: "TS0601", deviceJoinName: "Zigbee - Tuya TRV" // Moes TRV fingerprint endpointId: "01", profileId: "0104", inClusters: "0000,000A,0004,0005,EF00", outClusters: "0019", manufacturer: "_TZE200_zion52ef", model: "TS0601", deviceJoinName: "Zigbee - Tuya TRV" } preferences { //input("lock", "enum", title: "Do you want to lock your thermostat's physical keypad?", options: ["No", "Yes"], defaultValue: "No", required: false, displayDuringSetup: false) input name: "infoLogging", type: "bool", title: "Enable info logging", defaultValue: true } } ArrayList parse(String description) { //log.debug "parse $description" ArrayList cmd = [] Map msgMap = null if(description.indexOf('encoding: 42') >= 0) { List values = description.split("value: ")[1].split("(?<=\\G..)") String fullValue = values.join() Integer zeroIndex = values.indexOf("01") if(zeroIndex > -1) { msgMap = zigbee.parseDescriptionAsMap(description.replace(fullValue, values.take(zeroIndex).join())) values = values.drop(zeroIndex + 3) msgMap["additionalAttrs"] = [ ["encoding": "41", "value": parseXiaomiStruct(values.join(), isFCC0=false, hasLength=true)] ] log.warn "encoding: 42 parse 37 IF true" } else { msgMap = zigbee.parseDescriptionAsMap(description) //modle name log.warn "encoding: 42 parse 51 ELSE true" } } else { msgMap = zigbee.parseDescriptionAsMap(description) } if(msgMap.containsKey("encoding") && msgMap.containsKey("value") && msgMap["encoding"] != "41" && msgMap["encoding"] != "42") { log.warn "pase lin 59 used - ${description}" msgMap["valueParsed"] = zigbee_generic_decodeZigbeeData(msgMap["value"], msgMap["encoding"]) } if(msgMap == [:] && description.indexOf("zone") == 0) { msgMap["type"] = "zone" java.util.regex.Matcher zoneMatcher = description =~ /.*zone.*status.*0x(?([0-9a-fA-F][0-9a-fA-F])+).*extended.*status.*0x(?([0-9a-fA-F][0-9a-fA-F])+).*/ if(zoneMatcher.matches()) { msgMap["parsed"] = true msgMap["status"] = zoneMatcher.group("status") msgMap["statusInt"] = Integer.parseInt(msgMap["status"], 16) msgMap["statusExtended"] = zoneMatcher.group("statusExtended") msgMap["statusExtendedInt"] = Integer.parseInt(msgMap["statusExtended"], 16) } else { msgMap["parsed"] = false } log.warn "line 64 section used" } switch(msgMap["cluster"] + '_' + msgMap["attrId"]) { case "0000_0001": log.trace("Application ID Received") if(msgMap['value']) { updateDataValue("application", msgMap['value']) } break case "0000_0004": log.trace("Manufacturer Name Received ${msgMap['value']}") if(msgMap['value']) { updateDataValue("manufacturer", msgMap['value']) } break case "0000_0005": log.trace("Model Name Received") if(msgMap['value']) { updateDataValue('model', msgMap['value']) } break default: //log.debug " ${msgMap["cluster"]} ${msgMap["attrId"]} $msgMap" switch(msgMap["clusterId"]) { case "0013": logging("MULTISTATE CLUSTER EVENT") break case "8021": logging("BIND RESPONSE CLUSTER EVENT") break case "8001": logging("GENERAL CLUSTER EVENT") break case "8004": log.trace("Simple Descriptor Information Received - description:${description} | parseMap:${msgMap}") updateDataFromSimpleDescriptorData(msgMap["data"]) break case "8031": logging("Link Quality Cluster Event - description:${description} | parseMap:${msgMap}") break case "8032": logging("Routing Table Cluster Event - description:${description} | parseMap:${msgMap}") break case "8021": case "8038": logging("GENERAL CATCHALL (0x${msgMap["clusterId"]}") break ///////////TUYA TRV messages//////////// case "EF00": //log.debug "clutsInt= ${msgMap[clusterInt]} ,att ID ${msgMap["attrId"]}, cluster ${msgMap["clusterId"]} -- ${msgMap}" List data = msgMap['data'] if (data[2] && data[3]){ String commandType = data[2] + data[3] switch(commandType){ //set point temp case "0202": //set point temp String SetPoint = HexUtils.hexStringToInt("${data[-2]}${data[-1]}") / 10 logging("${device.displayName} Temp Set Point ${SetPoint}, data ${msgMap["data"]}") sendEvent(name: "heatingSetpoint", value: SetPoint.toFloat(), unit: "C") sendEvent(name: "thermostatSetpoint", value: SetPoint.toFloat(), unit: "C") if (device.currentValue("thermostatMode") != "off" && SetPoint.toFloat() > device.currentValue("temperature").toFloat()) { sendEvent(name: "thermostatOperatingState", value: "heating")} else { sendEvent(name: "thermostatOperatingState", value: "idle")} break case "1002": // Moes setpoint String SetPoint = HexUtils.hexStringToInt("${data[-1]}") / 2 logging("${device.displayName} Temp Set Point ${SetPoint}, data ${msgMap["data"]}") sendEvent(name: "heatingSetpoint", value: SetPoint.toFloat(), unit: "C") sendEvent(name: "thermostatSetpoint", value: SetPoint.toFloat(), unit: "C") break //7202 away preset temperature case "0702": //0x7202 away/off preset temperature String SetPoint = HexUtils.hexStringToInt(data[9]) / 10 logging("${device.displayName} AWAY Temp Set Point ${commandType}, data9 ${SetPoint}") break //0302 Temperature case '0302': //Temperature String temperature = HexUtils.hexStringToInt("${data[-2]}${data[-1]}") / 10 logging("${device.displayName} Temp ${temperature}, data ${msgMap["data"]}") sendEvent(name: "temperature", value: temperature, unit: "C" ) if (device.currentValue("thermostatMode") != "off" && temperature.toFloat() < device.currentValue("thermostatSetpoint").toFloat()) { sendEvent(name: "thermostatOperatingState", value: "heating")} else { sendEvent(name: "thermostatOperatingState", value: "idle")} break case '1802': //Moes Temperature String temperature = HexUtils.hexStringToInt("${data[-2]}${data[-1]}") / 10 logging("${device.displayName} Temp ${temperature}, data ${msgMap["data"]}") sendEvent(name: "temperature", value: temperature, unit: "C" ) break // Mode case '0404': // Mode String mode = HexUtils.hexStringToInt(data[6]) logging("${device.displayName} mode Code=${mode}") switch (mode){ case '0': sendEvent(name: "thermostatMode", value: "off" ) break case '1': sendEvent(name: "thermostatMode", value: "auto" , descriptionText:"internal programming of device") break case '2': sendEvent(name: "thermostatMode", value: "heat" ) break } break case "0204": //Moes Mode String mode = HexUtils.hexStringToInt(data[6]) logging("${device.displayName} mode Code=${mode}") switch (mode){ case '2': sendEvent(name: "thermostatMode", value: "off" , descriptionText:"Holiday Mode") break case '0': sendEvent(name: "thermostatMode", value: "auto" , descriptionText:"Using internally programmed schedule") break case '1': sendEvent(name: "thermostatMode", value: "heat" , descriptionText:"Manual Mode") break } break // battery --- DEV case '1502': //String values = data.collect{c -> HexUtils.hexStringToInt(c)} String batt = HexUtils.hexStringToInt(data[-1]) state.batdev = batt //dv to see it it is ever peported logging("${device.displayName} battery ${batt}") sendEvent(name: "battery", value: batt, unit:"%", descriptionText: "reported from 1502") break case '6702': //String values = data.collect{c -> HexUtils.hexStringToInt(c)} String batt = (HexUtils.hexStringToInt(data[-1]).toFloat() /3) *10 logging("${device.displayName} battery ${batt}") sendEvent(name: "battery", value: batt, unit:"%", descriptionText: "reported from 6702" ) // 6702 - [68, 13, 103, 2, 0, 4, 0, 0, 0, 35] ?3.5 volt? // 6702 - [126, 162, 103, 2, 0, 4, 0, 0, 0, 30] ?3 volt? break case '0D05': // battery low warning String battminmax = HexUtils.hexStringToInt(data[-1]) logging("${device.displayName} battery $data") if (battminmax == "0"){ if (device.currentValue("battery") == null || device.currentValue("battery") == 0 ) { sendEvent(name: "battery", value: 100, unit:"%" ) } } else if (battminmax == "10" ||battminmax == "16"){//10 for low sendEvent(name: "battery", value: 0, unit:"%", descriptionText: "battery value $battminmax" ) } break case "2202": //Moes Low battery warning? -- Dev logging("${device.displayName} Unknown Moes ${commandType} , data ${msgMap["data"]}") break case '6D02': // Valve position String valve = HexUtils.hexStringToInt(data[-1]) logging("${device.displayName} valve position ${valve}") sendEvent(name: "valve", value: valve, unit: "%", descriptionText: "Valve open ${valve}%") break //// Temperature correction reporting ---DEV case '2C02': //Temperature correction reporting String temperatureCorr = HexUtils.hexStringToInt(data[9])/ 10 logging("${device.displayName} Temp correction reporting DEV STILL, ${temperatureCorr}, data ${msgMap["data"]}") break // Child lock --- DEV case '0701': // Child lock String locked = HexUtils.hexStringToInt(data[6]) logging("${device.displayName} child lock ${commandType}, ${locked} 1 - is locked 0 is unlocked") switch (locked){ case '0': sendEvent(name: "childLock", value: "off" ) break case '1': sendEvent(name: "childLock", value: "on") break } break case '7401': // auto lock setting A3 String autolock = HexUtils.hexStringToInt(data[6]) switch (autolock){ case '0': logging("${device.displayName} Auto lock A3 Off") sendEvent(name: "autolock", value: "off") break case '1': logging("${device.displayName} Auto lock A3 On ather 10min") sendEvent(name: "autolock", value: "on") break } break case '6800': //window open detection String WinTemp = HexUtils.hexStringToInt(data[7]) String WinMink = HexUtils.hexStringToInt(data[8]) logging("${device.displayName} window open detection ${WinTemp}deg in ${WinMin}min will trigger shutdown") sendEvent(name: "WindowOpenDetection", value: "${WinTemp}deg in ${WinMin}min") break case '6902': //boost -- Dev String values = data.collect{c -> HexUtils.hexStringToInt(c)} logging("${device.displayName} boost ${values}") break case '7000': // schedule setting aka Auto mode -- Dev values = data.collect{c -> HexUtils.hexStringToInt(c)} logging("${device.displayName} schedual P1 ${data[6]}:${data[7]} = ${data[8]}deg , ${data[9]}:${data[10]} = ${data[11]}deg ,more ${data} ") state.SchduleP1 = "${values[6]}:${values[7]} = ${values[8]}deg , ${values[9]}:${values[10]} = ${values[11]}deg ,more ${values}" break case '7001': // schedule setting aka Auto mode -- Dev values = data.collect{c -> HexUtils.hexStringToInt(c)} logging("${device.displayName} schedual P2 ${data[6]}:${data[7]} = ${data[8]}deg , ${data[9]}:${data[10]} = ${data[11]}deg ,more ${data} ") state.SchduleP2 = "${values[6]}:${values[7]} = ${values[8]}deg , ${values[9]}:${values[10]} = ${values[11]}deg ,more ${values}" break case '7100': // schedule setting aka Auto mode -- Dev values = data.collect{c -> HexUtils.hexStringToInt(c)} logging("${device.displayName} schedual P3? ${data[6]}:${data[7]} = ${data[8]}deg , ${data[9]}:${data[10]} = ${data[11]}deg ,more ${data} ") state.SchduleP3 = "${values[6]}:${values[7]} = ${values[8]}deg , ${values[9]}:${values[10]} = ${values[11]}deg ,more ${values}" break // 0x7502 away preset number of days case '7502': logging("${device.displayName} away preset number of days ${HexUtils.hexStringToInt(data[-1])} ") break default: String values = data.collect{c -> HexUtils.hexStringToInt(c)} log.debug "${device.displayName} other EF00 cluster - ${commandType} - ${values} ${data}" break } } else { // found data in map of, data:[02, 19]], data:[00, 00]] //logging("other cluster EF00 but map null- ${data}") } break /////////////////////////////////////////////////////////////////////////////////////////mc default: //log.debug "Unhandled Event IGNORE THIS - description:${description} | msgMap:${msgMap}" break } break } msgMap = null return cmd } def logsOff(){ log.warn "debug logging disabled..." device.updateSetting("logEnable",[value:"false",type:"bool"]) } // from markus toolbox driver Map unpackStructInMap(Map msgMap, String originalEncoding="4C") { msgMap['encoding'] = originalEncoding List values = msgMap['value'].split("(?<=\\G..)") logging("unpackStructInMap() values=$values", 1) Integer numElements = Integer.parseInt(values.take(2).reverse().join(), 16) values = values.drop(2) List r = [] Integer cType = null List ret = null while(values != []) { cType = Integer.parseInt(values.take(1)[0], 16) values = values.drop(1) ret = zigbee_generic_convertStructValueToList(values, cType) r += ret[0] values = ret[1] } if(r.size() != numElements) throw new Exception("The STRUCT specifies $numElements elements, found ${r.size()}!") msgMap['value'] = r return msgMap } def zigbee_generic_decodeZigbeeData(String value, String cTypeStr, boolean reverseBytes=true) { List values = value.split("(?<=\\G..)") values = reverseBytes == true ? values.reverse() : values Integer cType = Integer.parseInt(cTypeStr, 16) Map rMap = [:] rMap['raw'] = [:] List ret = zigbee_generic_convertStructValue(rMap, values, cType, "NA", "NA") return ret[0]["NA"] } // BEGIN:getLoggingFunction() private boolean logging(message) { boolean didLogging = false Integer logLevelLocal = 0 if (infoLogging == null || infoLogging == true) { log.info "$message" didLogging = true } return didLogging } // END: getLoggingFunction() //end markus toobox //////////////////// boolean isMoesModel(String manufacturer=null) { manufacturer = manufacturer != null ? manufacturer : getDeviceDataByName('manufacturer') switch(manufacturer) { case "_TZE200_zion52ef": return true break default: return false } } //////////////////////////////////////////////////////////////////////////// def refresh() { def dp = "0302" def fn = "0" def data = "00" // ?? log.debug "refresh" zigbee.readAttribute(0 , 0 ) //zigbee.readAttribute(0, 0, 770 ) //zigbee.readAttribute(0x0000, CLUSTER_TUYA) //get setting but not temparture //zigbee.readAttribute(0x0302, CLUSTER_TUYA) //zigbee.configureReporting(0x0000, CLUSTER_TUYA) //zigbee.readAttribute(CLUSTER_TUYA, 0x0402) //configureReporting(java.lang.Integer, java.lang.Integer, java.lang.Integer, java.lang.Integer, java.lang.Integer), //zigbee.readAttribute(0x0000, 0x0402) // zigbee.readAttribute(0x0000, 0x0005) //encoding: 42 parse 51 ELSE true Model Name Received 0000_0005 // zigbee.readAttribute(0x0000, 0x0004) //zigbee.readAttribute(0x0000, 0x0000) // 0000 0000 // nothing zigbee.readAttribute(0x0000, CLUSTER_TUYA, [:] ) // nothing zigbee.readAttribute(0x0402,0x0000) // error zigbee.readAttribute(0x0000) // nothing zigbee.readAttribute(0x0000, 0x0021) //sendTuyaCommand(dp,fn,data) // ????????private getPOWER_ATTR_BATTERY_PERCENTAGE_REMAINING() { 0x0021 } // return [ // "he rattr 0x${device.deviceNetworkId} 0x${device.endpointId} 0x0000 0x0005 {}","delay 600", // "he rattr 0x${device.deviceNetworkId} 0x${device.endpointId} 0x0000, 0x0004 {}" // ] } ///////////// commands /////////////// private sendTuyaCommand(dp, fn, data) { // everything goes through here //log.info "sending ${zigbee.convertToHexString(rand(256), 2)}=${dp},${fn},${data}" zigbee.command(CLUSTER_TUYA, SETDATA, "00" + zigbee.convertToHexString(rand(256), 2) + dp + fn + data) } private getCLUSTER_TUYA() { 0xEF00 } private getSETDATA() { 0x00 } private rand(n) { return (new Random().nextInt(n))} def off() { // this is away setting heat point def dp = "0404" def fn = "0001" def data = "00" // off if(isMoesModel() == true) { dp = "0204" data = "02" // off } log.info "Off command, this is away setting heat point" sendTuyaCommand(dp,fn,data) } def heat() { //manual and hubitat control def dp = "0404" def fn = "0001" def data = "02" // on if(isMoesModel() == true) { dp = "0204" data = "01" // on } log.info "On/heat/manual command" sendTuyaCommand(dp,fn,data) } def auto() { if(isMoesModel() == true) { log.info "Auto mode is not available for Moes TRV. => Defaulting to heat mode instead." heat() } else{ def dp = "0404" def fn = "0001" def data = "01" // auto mode, internal schdual log.info "auto mode, internal schdual command ${zigbee.convertToHexString(rand(256), 2)}${dp}${fn}${data}" sendTuyaCommand(dp,fn,data) } } def setHeatingSetpoint(preciseDegrees) { if (preciseDegrees != null) { def dp = "0202" def fn = "00" def SP = preciseDegrees *10 def X = (SP / 256).intValue() def Y = SP.intValue() % 256 if(isMoesModel() == true) { dp = "1002" SP = preciseDegrees *2 log.info "Moes Model" } def data = "040000" + zigbee.convertToHexString(X.intValue(), 2) + zigbee.convertToHexString(Y.intValue(), 2) log.info "heating setpoint to ${preciseDegrees}" sendTuyaCommand(dp,fn,data) } } def setThermostatMode(String value) { switch (value) { case "heat": case "emergency heat": return heat() case "eco": case "cool": return eco() case "auto": return auto() default: return off() } } def updated() { sendEvent(name: "supportedThermostatFanModes", value: [""]) sendEvent(name: "supportedThermostatModes", value: ["off", "heat", "auto"] ) sendEvent(name: "thermostatFanMode", value: "off") log.info "${device.displayName} updated..." } def installed() { sendEvent(name: "supportedThermostatFanModes", value: [""]) sendEvent(name: "supportedThermostatModes", value: ["off", "heat", "auto"] ) sendEvent(name: "thermostatFanMode", value: "auto") } def configure(){ log.warn "configure..." runIn(1800,logsOff) //binding to Thermostat cluster" // Set unused default values (for Google Home Integration) sendEvent(name: "coolingSetpoint", value: "30") sendEvent(name: "thermostatFanMode", value:"auto") updateDataValue("lastRunningMode", "heat") // heat is the only compatible mode for this device updated() } void updateDataFromSimpleDescriptorData(List data) { Map sdi = parseSimpleDescriptorData(data) if(sdi != [:]) { updateDataValue("endpointId", sdi['endpointId']) updateDataValue("profileId", sdi['profileId']) updateDataValue("inClusters", sdi['inClusters']) updateDataValue("outClusters", sdi['outClusters']) getInfo(true, sdi) } else { log.warn("No VALID Simple Descriptor Data received!") } sdi = null } //unused commands and redirected def eco() { //holiday log.info "eco mode is not available for this device. => Defaulting to off mode instead." off() } def cool() { log.info "cool mode is not available for this device. => Defaulting to eco mode instead." eco() } def emergencyHeat() { log.info "emergencyHeat mode is not available for this device. => Defaulting to heat mode instead." heat() } def setCoolingSetpoint(degrees) { log.info "setCoolingSetpoint is not available for this device" } def fanAuto() { log.info "fanAuto mode is not available for this device" } def fanCirculate(){ log.info "fanCirculate mode is not available for this device" } def fanOn(){ log.info "fanOn mode is not available for this device" } def setSchedule(JSON_OBJECT){ log.info "setSchedule is not available for this device" } def setThermostatFanMode(fanmode){ log.info "setThermostatFanMode is not available for this device" } List zigbee_generic_convertStructValueToList(List values, Integer cType) { Map rMap = [:] rMap['raw'] = [:] List ret = zigbee_generic_convertStructValue(rMap, values, cType, "NA", "NA") return [ret[0]["NA"], ret[1]] } List zigbee_generic_convertStructValue(Map r, List values, Integer cType, String cKey, String cTag) { String cTypeStr = cType != null ? integerToHexString(cType, 1) : null switch(cType) { case 0x10: r["raw"][cKey] = values.take(1)[0] r[cKey] = Integer.parseInt(r["raw"][cKey], 16) != 0 values = values.drop(1) break case 0x18: case 0x20: r["raw"][cKey] = values.take(1)[0] r[cKey] = Integer.parseInt(r["raw"][cKey], 16) values = values.drop(1) break case 0x19: case 0x21: r["raw"][cKey] = values.take(2).reverse().join() r[cKey] = Integer.parseInt(r["raw"][cKey], 16) values = values.drop(2) break case 0x1A: case 0x22: r["raw"][cKey] = values.take(3).reverse().join() r[cKey] = Integer.parseInt(r["raw"][cKey], 16) values = values.drop(3) break case 0x1B: case 0x23: r["raw"][cKey] = values.take(4).reverse().join() r[cKey] = Long.parseLong(r["raw"][cKey], 16) values = values.drop(4) break case 0x1C: case 0x24: r["raw"][cKey] = values.take(5).reverse().join() r[cKey] = Long.parseLong(r["raw"][cKey], 16) values = values.drop(5) break case 0x1D: case 0x25: r["raw"][cKey] = values.take(6).reverse().join() r[cKey] = Long.parseLong(r["raw"][cKey], 16) values = values.drop(6) break case 0x1E: case 0x26: r["raw"][cKey] = values.take(7).reverse().join() r[cKey] = Long.parseLong(r["raw"][cKey], 16) values = values.drop(7) break case 0x1F: case 0x27: r["raw"][cKey] = values.take(8).reverse().join() r[cKey] = new BigInteger(r["raw"][cKey], 16) values = values.drop(8) break case 0x28: r["raw"][cKey] = values.take(1).reverse().join() r[cKey] = convertToSignedInt8(Integer.parseInt(r["raw"][cKey], 16)) values = values.drop(1) break case 0x29: r["raw"][cKey] = values.take(2).reverse().join() r[cKey] = (Integer) (short) Integer.parseInt(r["raw"][cKey], 16) values = values.drop(2) break case 0x2B: r["raw"][cKey] = values.take(4).reverse().join() r[cKey] = (Integer) Long.parseLong(r["raw"][cKey], 16) values = values.drop(4) break case 0x30: r["raw"][cKey] = values.take(1)[0] r[cKey] = Integer.parseInt(r["raw"][cKey], 16) values = values.drop(1) break case 0x31: r["raw"][cKey] = values.take(2).reverse().join() r[cKey] = Integer.parseInt(r["raw"][cKey], 16) values = values.drop(2) break case 0x39: r["raw"][cKey] = values.take(4).reverse().join() r[cKey] = parseSingleHexToFloat(r["raw"][cKey]) values = values.drop(4) break case 0x42: Integer strLength = Integer.parseInt(values.take(1)[0], 16) values = values.drop(1) r["raw"][cKey] = values.take(strLength) r[cKey] = r["raw"][cKey].collect { (char)(int) Integer.parseInt(it, 16) }.join() values = values.drop(strLength) break default: throw new Exception("The Struct used an unrecognized type: $cTypeStr ($cType) for tag 0x$cTag with key $cKey (values: $values, map: $r)") } return [r, values] } String integerToHexString(BigDecimal value, Integer minBytes, boolean reverse=false) { return integerToHexString(value.intValue(), minBytes, reverse=reverse) } String integerToHexString(Integer value, Integer minBytes, boolean reverse=false) { if(reverse == true) { return HexUtils.integerToHexString(value, minBytes).split("(?<=\\G..)").reverse().join() } else { return HexUtils.integerToHexString(value, minBytes) } }