diff --git a/devicetypes/statusbits/radio-thermostat.src/radio-thermostat.groovy b/devicetypes/statusbits/radio-thermostat.src/radio-thermostat.groovy new file mode 100644 index 0000000..a192bca --- /dev/null +++ b/devicetypes/statusbits/radio-thermostat.src/radio-thermostat.groovy @@ -0,0 +1,779 @@ +/** + * Filtrete 3M-50 WiFi Thermostat. + * + * For more information, please visit: + * + * + * -------------------------------------------------------------------------- + * + * Copyright (c) 2014 Statusbits.com + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation, either version 3 of the License, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program. If not, see . + * + * -------------------------------------------------------------------------- + * + * Version 1.0.3 (07/20/2015) + */ + +import groovy.json.JsonSlurper + +preferences { + input("confIpAddr", "string", title:"Thermostat IP Address", + required:true, displayDuringSetup: true) + input("confTcpPort", "number", title:"Thermostat TCP Port", + required:true, displayDuringSetup:true) +} + +metadata { + definition (name:"Radio Thermostat", namespace:"statusbits", author:"geko@statusbits.com") { + capability "Thermostat" + capability "Temperature Measurement" + capability "Sensor" + capability "Refresh" + capability "Polling" + + // Custom attributes + attribute "fanState", "string" // Fan operating state. Values: "on", "off" + attribute "hold", "string" // Target temperature Hold status. Values: "on", "off" + + // Custom commands + command "heatLevelUp" + command "heatLevelDown" + command "coolLevelUp" + command "coolLevelDown" + command "holdOn" + command "holdOff" + } + + tiles { + valueTile("temperature", "device.temperature") { + state "temperature", label:'${currentValue}°', unit:"F", + backgroundColors:[ + [value: 31, color: "#153591"], + [value: 44, color: "#1e9cbb"], + [value: 59, color: "#90d2a7"], + [value: 74, color: "#44b621"], + [value: 84, color: "#f1d801"], + [value: 95, color: "#d04e00"], + [value: 96, color: "#bc2323"] + ] + } + + valueTile("heatingSetpoint", "device.heatingSetpoint", inactiveLabel:false) { + state "default", label:'${currentValue}°', unit:"F", + backgroundColors:[ + [value: 31, color: "#153591"], + [value: 44, color: "#1e9cbb"], + [value: 59, color: "#90d2a7"], + [value: 74, color: "#44b621"], + [value: 84, color: "#f1d801"], + [value: 95, color: "#d04e00"], + [value: 96, color: "#bc2323"] + ] + } + + valueTile("coolingSetpoint", "device.coolingSetpoint", inactiveLabel:false) { + state "default", label:'${currentValue}°', unit:"F", + backgroundColors:[ + [value: 31, color: "#153591"], + [value: 44, color: "#1e9cbb"], + [value: 59, color: "#90d2a7"], + [value: 74, color: "#44b621"], + [value: 84, color: "#f1d801"], + [value: 95, color: "#d04e00"], + [value: 96, color: "#bc2323"] + ] + } + + standardTile("heatLevelUp", "device.heatingSetpoint", inactiveLabel:false, decoration:"flat") { + state "default", label:'Heating', icon:"st.custom.buttons.add-icon", action:"heatLevelUp" + } + + standardTile("heatLevelDown", "device.heatingSetpoint", inactiveLabel:false, decoration:"flat") { + state "default", label:'Heating', icon:"st.custom.buttons.subtract-icon", action:"heatLevelDown" + } + + standardTile("coolLevelUp", "device.coolingSetpoint", inactiveLabel:false, decoration:"flat") { + state "default", label:'Cooling', icon:"st.custom.buttons.add-icon", action:"coolLevelUp" + } + + standardTile("coolLevelDown", "device.coolingSetpoint", inactiveLabel:false, decoration:"flat") { + state "default", label:'Cooling', icon:"st.custom.buttons.subtract-icon", action:"coolLevelDown" + } + + standardTile("operatingState", "device.thermostatOperatingState", inactiveLabel:false, decoration:"flat") { + state "default", label:'[State]' + state "idle", label:'', icon:"st.thermostat.heating-cooling-off" + state "heating", label:'', icon:"st.thermostat.heating" + state "cooling", label:'', icon:"st.thermostat.cooling" + } + + standardTile("fanState", "device.fanState", inactiveLabel:false, decoration:"flat") { + state "default", label:'[Fan State]' + state "on", label:'', icon:"st.thermostat.fan-on" + state "off", label:'', icon:"st.thermostat.fan-off" + } + + standardTile("mode", "device.thermostatMode", inactiveLabel:false) { + state "default", label:'[Mode]' + state "off", label:'', icon:"st.thermostat.heating-cooling-off", backgroundColor:"#FFFFFF", action:"thermostat.heat" + state "heat", label:'', icon:"st.thermostat.heat", backgroundColor:"#FFCC99", action:"thermostat.cool" + state "cool", label:'', icon:"st.thermostat.cool", backgroundColor:"#99CCFF", action:"thermostat.auto" + state "auto", label:'', icon:"st.thermostat.auto", backgroundColor:"#99FF99", action:"thermostat.off" + } + + standardTile("fanMode", "device.thermostatFanMode", inactiveLabel:false) { + state "default", label:'[Fan Mode]' + state "auto", label:'', icon:"st.thermostat.fan-auto", backgroundColor:"#A4FCA6", action:"thermostat.fanOn" + state "on", label:'', icon:"st.thermostat.fan-on", backgroundColor:"#FAFCA4", action:"thermostat.fanAuto" + } + + standardTile("hold", "device.hold", inactiveLabel:false) { + state "default", label:'[Hold]' + state "on", label:'Hold On', icon:"st.Weather.weather2", backgroundColor:"#FFDB94", action:"holdOff" + state "off", label:'Hold Off', icon:"st.Weather.weather2", backgroundColor:"#FFFFFF", action:"holdOn" + } + + standardTile("refresh", "device.thermostatMode", inactiveLabel:false, decoration:"flat") { + state "default", icon:"st.secondary.refresh", action:"refresh.refresh" + } + + main(["temperature"]) + + details(["temperature", "operatingState", "fanState", + "heatingSetpoint", "heatLevelDown", "heatLevelUp", + "coolingSetpoint", "coolLevelDown", "coolLevelUp", + "mode", "fanMode", "hold", "refresh"]) + } + + simulator { + status "Temperature 72.0": "simulator:true, temp:72.00" + status "Cooling Setpoint 76.0": "simulator:true, t_cool:76.00" + status "Heating Setpoint 68.0": "simulator:true, t_cool:68.00" + status "Thermostat Mode Off": "simulator:true, tmode:0" + status "Thermostat Mode Heat": "simulator:true, tmode:1" + status "Thermostat Mode Cool": "simulator:true, tmode:2" + status "Thermostat Mode Auto": "simulator:true, tmode:3" + status "Fan Mode Auto": "simulator:true, fmode:0" + status "Fan Mode Circulate": "simulator:true, fmode:1" + status "Fan Mode On": "simulator:true, fmode:2" + status "State Off": "simulator:true, tstate:0" + status "State Heat": "simulator:true, tstate:1" + status "State Cool": "simulator:true, tstate:2" + status "Fan State Off": "simulator:true, fstate:0" + status "Fan State On": "simulator:true, fstate:1" + status "Hold Disabled": "simulator:true, hold:0" + status "Hold Enabled": "simulator:true, hold:1" + } +} + +def updated() { + log.info "Radio Thermostat. ${textVersion()}. ${textCopyright()}" + LOG("$device.displayName updated with settings: ${settings.inspect()}") + + state.hostAddress = "${settings.confIpAddr}:${settings.confTcpPort}" + state.dni = createDNI(settings.confIpAddr, settings.confTcpPort) + + STATE() +} + +def parse(String message) { + LOG("parse(${message})") + + def msg = stringToMap(message) + + if (msg.headers) { + // parse HTTP response headers + def headers = new String(msg.headers.decodeBase64()) + def parsedHeaders = parseHttpHeaders(headers) + LOG("parsedHeaders: ${parsedHeaders}") + if (parsedHeaders.status != 200) { + log.error "Server error: ${parsedHeaders.reason}" + return null + } + + // parse HTTP response body + if (!msg.body) { + log.error "HTTP response has no body" + return null + } + + def body = new String(msg.body.decodeBase64()) + def slurper = new JsonSlurper() + def tstat = slurper.parseText(body) + + return parseTstatData(tstat) + } else if (msg.containsKey("simulator")) { + // simulator input + return parseTstatData(msg) + } + + return null +} + +// thermostat.setThermostatMode +def setThermostatMode(mode) { + LOG("setThermostatMode(${mode})") + + switch (mode) { + case "off": return off() + case "heat": return heat() + case "cool": return cool() + case "auto": return auto() + case "emergency heat": return emergencyHeat() + } + + log.error "Invalid thermostat mode: \'${mode}\'" +} + +// thermostat.off +def off() { + LOG("off()") + + if (device.currentValue("thermostatMode") == "off") { + return null + } + + sendEvent([name:"thermostatMode", value:"off"]) + return writeTstatValue('tmode', 0) +} + +// thermostat.heat +def heat() { + LOG("heat()") + + if (device.currentValue("thermostatMode") == "heat") { + return null + } + + sendEvent([name:"thermostatMode", value:"heat"]) + return writeTstatValue('tmode', 1) +} + +// thermostat.cool +def cool() { + LOG("cool()") + + if (device.currentValue("thermostatMode") == "cool") { + return null + } + + sendEvent([name:"thermostatMode", value:"cool"]) + return writeTstatValue('tmode', 2) +} + +// thermostat.auto +def auto() { + LOG("auto()") + + if (device.currentValue("thermostatMode") == "auto") { + return null + } + + sendEvent([name:"thermostatMode", value:"auto"]) + return writeTstatValue('tmode', 3) +} + +// thermostat.emergencyHeat +def emergencyHeat() { + LOG("emergencyHeat()") + log.warn "'emergency heat' mode is not supported" + return null +} + +// thermostat.setThermostatFanMode +def setThermostatFanMode(fanMode) { + LOG("setThermostatFanMode(${fanMode})") + + switch (fanMode) { + case "auto": return fanAuto() + case "circulate": return fanCirculate() + case "on": return fanOn() + } + + log.error "Invalid fan mode: \'${fanMode}\'" +} + +// thermostat.fanAuto +def fanAuto() { + LOG("fanAuto()") + + if (device.currentValue("thermostatFanMode") == "auto") { + return null + } + + sendEvent([name:"thermostatFanMode", value:"auto"]) + return writeTstatValue('fmode', 0) +} + +// thermostat.fanCirculate +def fanCirculate() { + LOG("fanCirculate()") + log.warn "Fan 'Circulate' mode is not supported" + return null +} + +// thermostat.fanOn +def fanOn() { + LOG("fanOn()") + + if (device.currentValue("thermostatFanMode") == "on") { + return null + } + + sendEvent([name:"thermostatFanMode", value:"on"]) + return writeTstatValue('fmode', 2) +} + +// thermostat.setHeatingSetpoint +def setHeatingSetpoint(tempHeat) { + LOG("setHeatingSetpoint(${tempHeat})") + + def ev = [ + name: "heatingSetpoint", + value: tempHeat, + unit: getTemperatureScale(), + ] + + sendEvent(ev) + + if (getTemperatureScale() == "C") { + tempHeat = temperatureCtoF(tempHeat) + } + + return writeTstatValue('it_heat', tempHeat) +} + +// thermostat.setCoolingSetpoint +def setCoolingSetpoint(tempCool) { + LOG("setCoolingSetpoint(${tempCool})") + + def ev = [ + name: "coolingSetpoint", + value: tempCool, + unit: getTemperatureScale(), + ] + + sendEvent(ev) + + if (getTemperatureScale() == "C") { + tempCool = temperatureCtoF(tempCool) + } + + return writeTstatValue('it_cool', tempCool) +} + +def heatLevelDown() { + LOG("heatLevelDown()") + + def currentT = device.currentValue("heatingSetpoint")?.toFloat() + if (!currentT) { + return + } + + def limit = 50 + def step = 1 + if (getTemperatureScale() == "C") { + limit = 10 + step = 0.5 + } + + if (currentT > limit) { + setHeatingSetpoint(currentT - step) + } +} + +def heatLevelUp() { + LOG("heatLevelUp()") + + def currentT = device.currentValue("heatingSetpoint")?.toFloat() + if (!currentT) { + return + } + + def limit = 95 + def step = 1 + if (getTemperatureScale() == "C") { + limit = 35 + step = 0.5 + } + + if (currentT < limit) { + setHeatingSetpoint(currentT + step) + } +} + +def coolLevelDown() { + LOG("coolLevelDown()") + + def currentT = device.currentValue("coolingSetpoint")?.toFloat() + if (!currentT) { + return + } + + def limit = 50 + def step = 1 + if (getTemperatureScale() == "C") { + limit = 10 + step = 0.5 + } + + if (currentT > limit) { + setCoolingSetpoint(currentT - step) + } +} + +def coolLevelUp() { + LOG("coolLevelUp()") + + def currentT = device.currentValue("coolingSetpoint")?.toFloat() + if (!currentT) { + return + } + + def limit = 95 + def step = 1 + if (getTemperatureScale() == "C") { + limit = 35 + step = 0.5 + } + + if (currentT < limit) { + setCoolingSetpoint(currentT + step) + } +} + +def holdOn() { + LOG("holdOn()") + + if (device.currentValue("hold") == "on") { + return null + } + + sendEvent([name:"hold", value:"on"]) + writeTstatValue("hold", 1) +} + +def holdOff() { + LOG("holdOff()") + + if (device.currentValue("hold") == "off") { + return null + } + + sendEvent([name:"hold", value:"off"]) + writeTstatValue("hold", 0) +} + +// polling.poll +def poll() { + LOG("poll()") + return refresh() +} + +// refresh.refresh +def refresh() { + LOG("refresh()") + //STATE() + return apiGet("/tstat") +} + +// Creates Device Network ID in 'AAAAAAAA:PPPP' format +private String createDNI(ipaddr, port) { + LOG("createDNI(${ipaddr}, ${port})") + + def hexIp = ipaddr.tokenize('.').collect { + String.format('%02X', it.toInteger()) + }.join() + + def hexPort = String.format('%04X', port.toInteger()) + + return "${hexIp}:${hexPort}" +} + +private updateDNI() { + if (device.deviceNetworkId != state.dni) { + device.deviceNetworkId = state.dni + } +} + +private apiGet(String path) { + LOG("apiGet(${path})") + + def headers = [ + HOST: state.hostAddress, + Accept: "*/*" + ] + + def httpRequest = [ + method: 'GET', + path: path, + headers: headers + ] + + updateDNI() + + return new physicalgraph.device.HubAction(httpRequest) +} + +private apiPost(String path, data) { + LOG("apiPost(${path}, ${data})") + + def headers = [ + HOST: state.hostAddress, + Accept: "*/*" + ] + + def httpRequest = [ + method: 'POST', + path: path, + headers: headers, + body: data + ] + + updateDNI() + + return new physicalgraph.device.HubAction(httpRequest) +} + +private def writeTstatValue(name, value) { + LOG("writeTstatValue(${name}, ${value})") + + def json = "{\"${name}\": ${value}}" + def hubActions = [ + apiPost("/tstat", json), + delayHubAction(2000), + apiGet("/tstat") + ] + + return hubActions +} + +private def delayHubAction(ms) { + return new physicalgraph.device.HubAction("delay ${ms}") +} + +private parseHttpHeaders(String headers) { + def lines = headers.readLines() + def status = lines.remove(0).split() + + def result = [ + protocol: status[0], + status: status[1].toInteger(), + reason: status[2] + ] + + return result +} + +private def parseTstatData(Map tstat) { + LOG("parseTstatData(${tstat})") + + def events = [] + if (tstat.containsKey("error_msg")) { + log.error "Thermostat error: ${tstat.error_msg}" + return null + } + + if (tstat.containsKey("success")) { + // this is POST response - ignore + return null + } + + if (tstat.containsKey("temp")) { + //Float temp = tstat.temp.toFloat() + def ev = [ + name: "temperature", + value: scaleTemperature(tstat.temp.toFloat()), + unit: getTemperatureScale(), + ] + + events << createEvent(ev) + } + + if (tstat.containsKey("t_cool")) { + def ev = [ + name: "coolingSetpoint", + value: scaleTemperature(tstat.t_cool.toFloat()), + unit: getTemperatureScale(), + ] + + events << createEvent(ev) + } + + if (tstat.containsKey("t_heat")) { + def ev = [ + name: "heatingSetpoint", + value: scaleTemperature(tstat.t_heat.toFloat()), + unit: getTemperatureScale(), + ] + + events << createEvent(ev) + } + + if (tstat.containsKey("tstate")) { + def value = parseThermostatState(tstat.tstate) + if (device.currentState("thermostatOperatingState")?.value != value) { + def ev = [ + name: "thermostatOperatingState", + value: value + ] + + events << createEvent(ev) + } + } + + if (tstat.containsKey("fstate")) { + def value = parseFanState(tstat.fstate) + if (device.currentState("fanState")?.value != value) { + def ev = [ + name: "fanState", + value: value + ] + + events << createEvent(ev) + } + } + + if (tstat.containsKey("tmode")) { + def value = parseThermostatMode(tstat.tmode) + if (device.currentState("thermostatMode")?.value != value) { + def ev = [ + name: "thermostatMode", + value: value + ] + + events << createEvent(ev) + } + } + + if (tstat.containsKey("fmode")) { + def value = parseFanMode(tstat.fmode) + if (device.currentState("thermostatFanMode")?.value != value) { + def ev = [ + name: "thermostatFanMode", + value: value + ] + + events << createEvent(ev) + } + } + + if (tstat.containsKey("hold")) { + def value = parseThermostatHold(tstat.hold) + if (device.currentState("hold")?.value != value) { + def ev = [ + name: "hold", + value: value + ] + + events << createEvent(ev) + } + } + + LOG("events: ${events}") + return events +} + +private def parseThermostatState(val) { + def values = [ + "idle", // 0 + "heating", // 1 + "cooling" // 2 + ] + + return values[val.toInteger()] +} + +private def parseFanState(val) { + def values = [ + "off", // 0 + "on" // 1 + ] + + return values[val.toInteger()] +} + +private def parseThermostatMode(val) { + def values = [ + "off", // 0 + "heat", // 1 + "cool", // 2 + "auto" // 3 + ] + + return values[val.toInteger()] +} + +private def parseFanMode(val) { + def values = [ + "auto", // 0 + "circulate",// 1 (not supported by CT30) + "on" // 2 + ] + + return values[val.toInteger()] +} + +private def parseThermostatHold(val) { + def values = [ + "off", // 0 + "on" // 1 + ] + + return values[val.toInteger()] +} + +private def scaleTemperature(Float temp) { + if (getTemperatureScale() == "C") { + return temperatureFtoC(temp) + } + + return temp.round(1) +} + +private def temperatureCtoF(Float tempC) { + Float t = (tempC * 1.8) + 32 + return t.round(1) +} + +private def temperatureFtoC(Float tempF) { + Float t = (tempF - 32) / 1.8 + return t.round(1) +} + +private def textVersion() { + return "Version 1.0.3 (08/25/2015)" +} + +private def textCopyright() { + return "Copyright (c) 2014 Statusbits.com" +} + +private def LOG(message) { + //log.trace message +} + +private def STATE() { + log.trace "state: ${state}" + log.trace "deviceNetworkId: ${device.deviceNetworkId}" + log.trace "temperature: ${device.currentValue("temperature")}" + log.trace "heatingSetpoint: ${device.currentValue("heatingSetpoint")}" + log.trace "coolingSetpoint: ${device.currentValue("coolingSetpoint")}" + log.trace "thermostatMode: ${device.currentValue("thermostatMode")}" + log.trace "thermostatFanMode: ${device.currentValue("thermostatFanMode")}" + log.trace "thermostatOperatingState: ${device.currentValue("thermostatOperatingState")}" + log.trace "fanState: ${device.currentValue("fanState")}" + log.trace "hold: ${device.currentValue("hold")}" +} \ No newline at end of file